Compare commits

...

283 Commits

Author SHA1 Message Date
Andreas Wrede 313bbd37ac version 5.3.8
Release / release (push) Successful in 42s
2026-05-30 15:06:46 -04:00
Andreas Wrede f7320644f3 fix: avoid SIGPIPE in changelog step by using grep -m 1
Replacing head -1 (and the broken head -2|tail -1 attempt) with grep -m 1
stops grep after the first match, eliminating the SIGPIPE that caused exit 141.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 15:06:19 -04:00
Andreas Wrede 76e11b92f2 version 5.3.7
Release / release (push) Failing after 47s
2026-05-30 14:48:43 -04:00
Andreas Wrede d39c0da5fe fix: use GITHUB_REF/GITHUB_OUTPUT in release workflow
Gitea Actions uses GitHub-compatible variable names, not GITEA_* variants.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 14:47:42 -04:00
Andreas Wrede 832b9d04d8 docs: use absolute URLs in wiki home page for Gitea wiki compatibility
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 13:59:08 -04:00
Andreas Wrede 44d5f15a67 docs: add wiki home page with overview and getting started guide
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 12:45:33 -04:00
Andreas Wrede 37b8e35a26 docs: add DARK_MODE.md
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 22:34:59 -04:00
Andreas Wrede fa317a3b78 feat: add dark mode with light/dark/auto theme setting
Theme preference stored in localStorage (auto follows the OS setting).
The chosen data-theme attribute is applied synchronously in <head> to
avoid any flash of unstyled content. CSS custom properties handle all
surface, text, border and input colours across every page. The
Appearance section on the profile page lets each user switch modes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 22:33:37 -04:00
Andreas Wrede 8729fe7038 feat: sort hosts, thresholds, and channels alphabetically on settings page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 13:01:47 -04:00
Andreas Wrede f4231dd5f3 fix: preserve log message order when replaying history on connect
Send history messages newest-first from the server, tagged with
history=True so the client appends rather than prepends them, avoiding
reverse-chronological display on initial load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 11:18:05 -04:00
andreas c47576637f feat: suppress alerts for unwatched hosts
Hosts with watch: false in config no longer appear in the Alerts page
or nav bar alert counts. Events still appear in the Log of Events.
Hosts without a config entry default to watch: false.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 14:54:53 -04:00
Andreas Wrede 2b9523ec28 finetune tabe and font sizes 2026-05-14 06:29:00 -04:00
Andreas Wrede 610ad0af30 feat: add UNKNOWN level filter to Log of Events
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 10:01:57 -04:00
Andreas Wrede 69b5b410ed feat: replace Dynamic DNS YAML editor with a web form
Adds structured form fields for nsupdate_bin, rndc_key, and dyndomains
(comma-separated list). Wires list-type editable fields through the
generic stageFormSection path and adds DNS support to
apply_structured_section in configio.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 07:12:44 -04:00
Andreas Wrede 8b2b0fd9d0 feat: add per-metric grace period input to thresholds settings page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 06:56:21 -04:00
Andreas Wrede 756b2323be version 5.3.6
Release / release (push) Successful in 5s
2026-05-13 06:42:31 -04:00
Andreas Wrede 6e7156b42d chore: remove redundant license classifier from pyproject.toml
The license expression field (PEP 639) supersedes the classifier.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 06:42:19 -04:00
Andreas Wrede 928035df50 fix: move dependencies back under [project] in pyproject.toml
The key had drifted below [project.urls], making setuptools interpret it
as a URL entry and failing validation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 06:37:14 -04:00
Andreas Wrede 0f90be659e fix: correct ZFS pool status threshold operator and add per-metric grace
The default zfs_monitor.*.status threshold used operator '>' with warning=1,
so a DEGRADED pool (status=1) never alerted (1 > 1 is false) and a FAULTED
pool (status=2) only triggered WARNING instead of CRITICAL.

Fix the operator to '>=' in THRESHOLD_DEFAULTS and the example config.

Also adds a per-metric grace period override (ThresholdConfig.grace) so
individual thresholds can bypass or shorten the global grace delay. Alerts
with grace=0 fire immediately on state change rather than waiting for a
second collection cycle. Sets grace=0 on zfs_monitor.*.status so pool
degradation alerts fire on the first data report after the event.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 06:33:06 -04:00
Andreas Wrede 4160e34a96 chore: remove commented-out step from release workflow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 00:02:24 -04:00
Andreas Wrede 6430d2ddf3 chore: add classifiers and project URL to pyproject.toml
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 00:00:30 -04:00
Andreas Wrede 4b87a90e76 chore: declare license-files in pyproject.toml
Associates LICENSE.md with the package for pip/PyPI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 23:58:25 -04:00
Andreas Wrede 450814daca chore: remove docs/superpowers from repo
Add to .gitignore to keep local copies untracked.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 23:56:56 -04:00
Andreas Wrede e7786ac5da chore: rename "CLAUDE. md" to CLAUDE.md
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 23:56:21 -04:00
Andreas Wrede fed71d97d6 chore: clean up dev scratch files from project root
- Remove rndc-key from tracking, add to .gitignore
- Move async_sms_send.py, demo_threshold.py, nagios_bad.sh to scripts/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 23:54:27 -04:00
Andreas Wrede ba96da9622 refactor: move loose test files out of project root
- tests/test_threshold.py: has proper pytest test functions
- scripts/test_*.py: manual run scripts with no test functions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 23:52:34 -04:00
Andreas Wrede 7f17ddc2ff chore: fix tox.ini to install dev deps from pyproject.toml
Replace the missing requirements-dev.txt reference with extras = dev,
which installs the [dev] optional dependencies declared in pyproject.toml.
Also remove skipsdist so tox installs the package before running tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 23:50:47 -04:00
Andreas Wrede 7750c5a303 chore: set author to Andreas Wrede in pyproject.toml
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 23:49:46 -04:00
Andreas Wrede e58530df7d docs: add MIT license
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 23:45:55 -04:00
Andreas Wrede fe7143759c docs: rewrite README from source code
Replace the previous README with documentation derived from reading
the actual code, including a new section covering the C client
(scripts/c/hbc_mini.c).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 23:42:24 -04:00
Andreas Wrede 236b40cfe4 fix: email and domain normalize 2026-05-12 17:02:02 -04:00
Andreas Wrede 4e5bafd26c version 5.3.4
Release / release (push) Successful in 5s
2026-05-12 15:06:24 -04:00
Andreas Wrede 817ae064af fix: run full reload after HTTP config publish, not just config.reload()
HTTP config-mutating endpoints (publish, rollback, channel CRUD, user
self-update) were calling config.reload() directly, which only refreshed
the in-memory config dict. This skipped re-applying host.dyn/host.watched
flags to live Host objects, so enabling dyndns via the UI had no effect
until a SIGHUP was sent.

Wire a reload_callback through http.start() that calls the same
reload_configuration() function used by the SIGHUP handler, ensuring
host attributes, notify module, users, and threshold checker are all
updated on every config publish.

Also fix unmatched quote in udp.py f-string log message.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 15:05:52 -04:00
Andreas Wrede a00282913b version 5.3.3
Release / release (push) Successful in 5s
2026-05-12 14:34:58 -04:00
Andreas Wrede d699a29fa9 refactor: remove dyndnshosts/drophosts legacy config keys, fix DNS event logging
- Remove dyndnshosts legacy list; dyndns is now set per-host in the hosts section
- Remove drophosts config key and load-time deletion loop
- Simplify get_dyndnshosts() to only read per-host dyndns attributes
- Fix dns_update_worker to call eventlog with correct (host, level, msg) signature
- Log INFO/ERROR events per domain on each DNS update instead of one batched message
- Add logger to dns.py (was missing, causing NameError on update failure)
- Update README and tests to reflect removed config keys

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:34:11 -04:00
Andreas Wrede 4ce7eacfdd fix: remove container max-width and stop stretching inputs on settings page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 11:42:54 -04:00
Andreas Wrede 1cefc2676e feat: replace YAML editor with form UI for threshold configurations
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 10:57:03 -04:00
Andreas Wrede 668a135e53 feat: replace multi-select fields with dual-panel picker on settings page
Replaces the 5 native <select multiple> fields (Managers, Monitors,
Threshold config, Channels in Hosts; Channels in Users) with a compact
picker widget: a truncated pill display with tooltip, and a click-to-open
panel split into Available / Selected columns for moving items between sides.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 10:10:18 -04:00
Andreas Wrede 59e256a042 feat: add nav bar button to publish pending config changes
Shows an orange "Publish Config" button to the left of the alert-pie
for admin users when there are staged config changes. Uses localStorage
to persist staged changes across page navigations so the button appears
on any page, not just settings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 09:32:32 -04:00
Andreas Wrede 708508157f feat: add host, level, and message filters to Log of Events
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 08:29:26 -04:00
Andreas Wrede f67fa9baff version 5.3.2
Release / release (push) Successful in 5s
2026-05-12 08:16:04 -04:00
Andreas Wrede 588eb2a792 feat: retry DNS resolution indefinitely and add -4/-6 flags in hbc and hbc_mini.c
Mirror the same changes from hbc_mini.py: retry host resolution with
exponential backoff (5s→60s) instead of exiting on DNS failure, and add
mutually exclusive -4 / -6 flags to restrict connections to IPv4 or IPv6.

In hbc (main.py) the retry sleep is interruptible via the shutdown_event.
In hbc_mini.c signal handlers are moved before the resolution loop so
SIGINT/SIGTERM can break the retry during startup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 08:15:53 -04:00
Andreas Wrede b907343e36 feat: retry DNS resolution indefinitely and add -4/-6 flags in hbc_mini
On startup, retry host resolution with exponential backoff (5s→60s) instead
of exiting when DNS fails. Add mutually exclusive -4 / -6 CLI flags to
restrict connections to IPv4 or IPv6 only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 08:07:54 -04:00
Andreas Wrede e50a3996ae fix: support list-valued threshold_config in hosts table
threshold_config in .hb.yaml can be a list (e.g. [local, zrepl]).
The hosts table was treating it as a single string, so the pre-selected
value never matched. Normalize to a list in settings.py, switch the
select to multiple, and fix the JS to collect all selected options.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 08:22:07 -04:00
Andreas Wrede e1056a0365 fix: derive hosts threshold config list from config file keys
Previously all_threshold_configs was built from the threshold_checker
object, which may not be populated at render time, leaving the select
empty. Read directly from config["threshold_configs"] instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 08:09:27 -04:00
Andreas Wrede 1dbe0f8e64 feat: replace YAML hosts editor with form-based CRUD table
Settings > Hosts now renders a table with per-column controls
(watch, dyndns, owner, managers/monitors multi-select, threshold
config, notification channels) instead of a raw YAML textarea.
Changes stage via the existing Publish flow like other form sections.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 07:57:28 -04:00
Andreas Wrede 12e8812070 docs: update notification channel and API docs for form-based management
- NOTIFICATIONS.md: document owner/private fields, channel visibility
  rules, and user-created channels; add troubleshooting note for
  private channel visibility
- HTTP_API.md: add notification channel API endpoints table and full
  endpoint reference (GET types, GET/POST/PUT/DELETE channels)
- USERS.md: add missing PUT /api/0/users/me endpoint documentation
  with all three update modes (identity, channels, password)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 07:45:30 -04:00
Andreas Wrede 9b5d8ac9b1 fix: replace channel checkboxes in Users table with multi-select
The per-user notification channel selector in the admin settings Users
section was a column of checkboxes; replaced with a <select multiple>
for consistency with the profile chip picker and to reduce table width.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 07:38:56 -04:00
Andreas Wrede 500d256d76 feat: replace YAML notification channel editor with form-based UI
Notification channels are now managed through a proper web form instead
of a raw YAML textarea. Any authenticated user can create channels; private
channels (owner-scoped) are hidden from other users. The user profile
channel selector becomes a tag/chip picker with a "My Channels" CRUD section.

- settings.py: add CHANNEL_TYPE_SCHEMAS for all 6 notifier types; channel
  section switches to section_mode="channels"; cards include owner/private/min_level
- configio.py: add apply_channel() and delete_channel() for per-entry CRUD
- notify.py: strip owner/private metadata before dispatching to drivers
- http.py: add GET/POST /api/0/notification_channels, PUT/DELETE /{name},
  GET /api/0/notification_channel_types; visibility helper filters private
  channels per user; PUT /api/0/users/me validates against visible channels
- settings.html: card grid with edit/delete per channel; add/edit modal
  with type dropdown and dynamically rendered type-specific fields
- profile.html: chip picker replaces checkbox list; My Channels section
  for creating/editing/deleting user-owned channels
- tests: update test_settings_sections, test_http_users_me; add
  test_notification_channels_api (16 new tests, 46 total passing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 07:34:26 -04:00
Andreas Wrede a7a45bf8c3 fix: support plugin-level enabled: false in threshold config
Setting enabled: false at the plugin level (e.g. memory_monitor: {enabled: false})
was silently ignored because the non-dict value was skipped by the metric parser,
leaving THRESHOLD_DEFAULTS entries active.

- _parse_plugin_thresholds: detect plugin-level enabled/enable flag and delete
  all matching entries from target_dict (covers legacy and default config paths)
- _parse_multi_config named configs: inject disabled stubs from effective_defaults
  into raw_overrides so the merge step overwrites inherited defaults
- Accept 'enable' as a tolerated alias for 'enabled' in both code paths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 17:40:29 -04:00
Andreas Wrede 3e9b052f71 fix: always populate glance-strip for all hosts on page load
fetchHostGlance was only called for the initially expanded host, leaving
all other hosts showing "—" until manually expanded. Now fetches glance
for every host-card on DOMContentLoaded and refreshes all (not just
expanded) on the 30s auto-refresh interval.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 14:13:10 -04:00
Andreas Wrede 7444262985 fix: fetch host info on initial page load
DOMContentLoaded was calling fetchHostGlance but not fetchHostInfo,
leaving the info-meta section stuck on "Loading…". Both the URL-hash
and default first-host paths now call fetchHostInfo and populate
infoCache on load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 14:08:37 -04:00
Andreas Wrede 3401cc0dbb version 5.3.1
Release / release (push) Successful in 6s
2026-05-10 14:03:58 -04:00
Andreas Wrede ab0132a38d fix: correct THRESHOLD_DEFAULTS metric keys and add missing defaults
- Rename memory_monitor threshold key from 'percent' to 'memory_percent'
  so it matches exactly rather than relying on suffix stripping, which was
  causing swap_percent to be evaluated against the memory threshold
- Add swap_percent default thresholds (warning: 40%, critical: 75%)
- Add zfs_monitor pool capacity default thresholds (warning: 80%, critical: 90%)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 14:03:44 -04:00
andreas 9e389736f8 feat: show suffix-matched metric coverage in host info threshold table
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 09:18:49 -04:00
andreas b64a2a9313 feat: move hbc_version and hbc_type out of os_info into host info section 2026-05-10 08:33:28 -04:00
andreas a52744a448 feat: fetch and render host info section on card expand 2026-05-10 08:31:32 -04:00
andreas 5e2b04b811 feat: add fetchHostInfo and renderInfoSection JS functions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 08:29:53 -04:00
andreas 8e07b09d7e feat: add host info section placeholder and CSS to plugins.html 2026-05-10 08:21:17 -04:00
andreas 653e018e4f feat: add GET /api/0/hosts/{hostname}/info endpoint 2026-05-10 08:18:49 -04:00
andreas c7326da7d9 feat: add _build_host_info helper for host info endpoint
Extracts host info assembly (owner, managers, hbc version/type,
last packet timestamp, threshold configs) into a testable module-level
helper, with 10 covering tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 08:17:47 -04:00
andreas 0426a75d8c docs: add implementation plan for host overview info section
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 08:09:47 -04:00
andreas 539f25d877 docs: add design spec for host overview info section
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 08:04:38 -04:00
andreas 3e3099fc6d version 5.3.0
Release / release (push) Successful in 5s
2026-05-09 12:16:09 -04:00
andreas c9f15a3f1c fix: correct grace comment in config defaults — additional wait time, not a multiplier 2026-05-09 12:14:47 -04:00
andreas 6e396ad760 fix: correct grace field label and description — it is additional wait time, not a multiplier 2026-05-09 12:13:11 -04:00
andreas 2800de0b4a fix: preserve .hb.yaml file permissions on backup and atomic write 2026-05-09 12:04:46 -04:00
andreas 15f7e6a64d feat: profile page self-service for identity, password, and notification channels
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 11:57:47 -04:00
andreas 9768d13b88 feat: settings page editor with form sections, YAML editors, stage/publish/rollback
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 11:55:10 -04:00
andreas 8640d731aa feat: add section_mode, api_section, editable flags and oauth section to settings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 11:49:41 -04:00
andreas de81751e59 fix: validate password body type and coerce notification_channels to strings in PUT /api/0/users/me 2026-05-09 11:46:58 -04:00
andreas 60c692cefc feat: add PUT /api/0/users/me for user self-service profile updates
Allows any authenticated user to update their own full_name, avatar,
notification_channels, and password via the config YAML write path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 11:45:09 -04:00
andreas 9a0baf3c78 fix: preserve oauth client_secret on roundtrip, harden rollback path validation, guard non-dict payload
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 11:43:14 -04:00
andreas 55bdb9593a feat: add config write API (POST /api/0/config, POST /api/0/config/rollback)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 11:35:45 -04:00
andreas 2009626fb4 fix: config read API error handling, consistent 403 messages, deduplicate key lists
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 11:33:18 -04:00
andreas 18769afd37 feat: add config read API (GET /api/0/config, /section/{name}, /backups)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 11:25:06 -04:00
andreas 31db5cf35e fix: configio thread safety, tmp cleanup, backup collision, dns empty handling
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 11:21:03 -04:00
andreas 326f53f23d feat: add configio module for comment-preserving YAML round-trip writes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 11:11:32 -04:00
andreas 4f9bc8c868 docs: add implementation plan for config editor feature
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 11:05:05 -04:00
andreas 259b4a3594 docs: design spec for config editor UI with per-user self-service 2026-05-09 10:42:42 -04:00
andreas 8646f68957 feat: log login/logout events to event log with auth source 2026-05-09 09:25:23 -04:00
andreas a4a6c1e3d9 fix: extend fetch_user error guard; escape HTML in login page
Move field-extraction inside the try/except in fetch_user so non-dict
responses from providers with empty profile_data_path (Gitea, GitHub)
raise OAuthError instead of an uncaught AttributeError. Apply
html.escape() to provider name, label, and logo URL in the login page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 08:57:25 -04:00
andreas 0e8250362e feat: multi-provider OAuth2 login page and generic routes
Replace hardcoded Gitea OAuth handlers with generic {name}-parameterized
routes and update the login page to render a button for each configured
provider via oauth_mod.get_providers().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 08:48:23 -04:00
andreas 2f5da9fc5e fix: coerce malformed profile JSON to OAuthError; add redirect_uri assertion
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 08:46:19 -04:00
andreas 87aeec5999 feat: generic build_auth_url/exchange_code/fetch_user for multi-provider OAuth2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 08:38:11 -04:00
andreas f24500a6b5 fix: copy field_map/profile_data_path in get_providers; improve caplog assertions 2026-05-09 08:34:48 -04:00
andreas a7bb183222 test: assert warning logged when get_providers skips invalid entries 2026-05-09 08:31:09 -04:00
andreas 8207cd7b5f feat: add PROVIDER_DEFS, ResolvedProvider, get_providers() to oauth.py
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 08:29:07 -04:00
andreas 11f1eefa8c docs: implementation plan for multi-provider OAuth2 2026-05-09 08:25:52 -04:00
andreas 62f496e9f8 docs: spec for multi-provider OAuth2 support
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 08:19:52 -04:00
andreas aef9e7769b fix: zfs_monitor alerts dropped on restart with wildcard pool thresholds
purge_stale_alerts used _find_threshold to validate alert state keys,
but _find_threshold has no wildcard matching. A threshold configured as
"zfs_monitor.*.status" never matched the concrete alert state key
"zfs_monitor.tank.status", so every restart silently purged active ZFS
pool alert states and reset the grace period from scratch.

Also fix _check_pending_or_renotify to set last_notification after the
grace-period notification fires, so the re-notification interval is
anchored to when the alert was actually sent rather than the next PLG cycle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 07:42:09 -04:00
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
andreas 74c89d098c version 5.1.17
Release / release (push) Successful in 5s
2026-05-04 08:04:01 -04:00
andreas 3301dbfe34 feat: owner Update/Delete buttons on Host Overview; purge stale alerts on reload
Host Overview (plugins.html): show Update and Delete buttons in the
host-right zone when the logged-in user is the host owner (or admin /
unauthenticated mode). Buttons link to /u?h=<host> and /d?h=<host>
with stopPropagation so they don't toggle the accordion; Delete prompts
for confirmation first.

ThresholdChecker.purge_stale_alerts(): removes alert states whose
metric_path has no matching threshold in the current config. Called
after startup pickle restore and after every SIGHUP config reload so
alerts orphaned by upgrades or config changes do not persist
indefinitely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 08:03:46 -04:00
andreas d00d903e7d fix: make Alerts page scrollable
Override the global style.css body height/overflow that locks all pages
to the viewport height (a remnant of the old drawer-menu layout).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 13:33:08 +02:00
andreas babb5d61aa docs: update README with changes since 917d6a4
- ZFS monitor plugin (zfs_monitor) added to plugin list and features
- nagios_runner: async execution, stderr capture, signal handling, path validation
- Threshold alerting: de-escalation suppression, short-duration suppression, ping_monitor thresholds
- Per-host watch flag and role-filtered dashboards
- HTTP API & Web UI: hostname links in Live View, Host Overview with ZFS renderer, alert pie chart in nav bar, Settings threshold viewer
- hbc connection retry: indefinite retry for IPv4; IPv6 dropped after 3 early startup failures
- hbc daemon mode: logs routed to syslog after daemonizing
- hbc_mini: noted zfs_monitor and IPv6 early-fail protection not available

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 12:46:35 +02:00
andreas 11d1c718b3 feat: retry AsyncConnection.open() indefinitely; drop IPv6 only on early startup failure
IPv4 connections are retried forever in heartbeat_sender if open() fails,
so a temporary network outage does not terminate the sender.

IPv6 connections that have never opened successfully are dropped after
IPV6_EARLY_FAIL_LIMIT (3) consecutive failures so that a network without
IPv6 support does not keep a dead sender running.

At startup all resolved connections are added to the list regardless of
whether the initial open() succeeds; the heartbeat_sender loop handles
the first real connection attempt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 12:29:35 +02:00
Andreas Wrede a99b6b54c7 feat: add alert pie chart to nav bar
Show a colour-coded pie chart (red=critical, yellow=warning, green=ok)
to the left of the clock in the nav bar. Backed by a new
GET /api/0/alert_summary endpoint that counts hosts per alert level
for the current user's visible hosts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 13:45:15 -04:00
Andreas Wrede 8da3d550eb version 5.1.16
Release / release (push) Successful in 5s
2026-05-03 06:08:14 -04:00
Andreas Wrede a76d0fc840 feat: generic ping_monitor thresholds; round RTT to nearest ms
- threshold.py: add _find_threshold() with suffix fallback so thresholds
  like ping_monitor.rtt_avg match ping_monitor.8_8_8_8_rtt_avg etc.;
  each pinged host keeps its own alert state
- hbdclass.py: format RTT as integer ms (round())
- live.html: JS RTT display rounded to nearest ms (Math.round)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-03 06:08:11 -04:00
Andreas Wrede 94cbb31c48 version 5.1.15
Release / release (push) Successful in 6s
2026-05-02 14:37:11 -04:00
Andreas Wrede ae60844a8a feat: link hostnames in Live Dashboard to Host Overview
Hostnames in the live dashboard table are now links to /plugins#hostname,
which expands and scrolls to that host's card in the Host Overview page.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-02 14:37:08 -04:00
Andreas Wrede 49fa310361 feat: add Threshold Configurations section to settings page
Reads threshold_configs (or legacy thresholds) from config and renders
per-named-config tables showing metric path, operator, warning/critical
values, hysteresis, and count. Disabled entries are dimmed.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-02 14:30:31 -04:00
Andreas Wrede 28e2180f7b fix: suppress notifications on alert de-escalation (e.g. CRITICAL→WARNING)
Only notify on worsening transitions (OK→WARNING, OK→CRITICAL,
WARNING→CRITICAL) and recovery (any→OK). De-escalation within alert
states no longer sends a duplicate notification since the metric never
recovered.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-02 14:27:18 -04:00
Andreas Wrede ce0590f015 fix: suppress recover messages for down durations under 4 seconds
Transient blips caused by hbc client restarts no longer generate
eventlog entries or notifications.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-02 14:18:58 -04:00
Andreas Wrede f50acca509 version 5.1.14
Release / release (push) Successful in 5s
2026-05-02 13:21:40 -04:00
Andreas Wrede 72fc82b91f feat: add ZFS pool renderer to Host Overview
Add renderZfsTables() to plugins.html with health/capacity/frag/dedup
table and cumulative I/O table; colour-code health and capacity thresholds;
add zfs_monitor to plugin_order and summary/render dispatch.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-02 13:21:28 -04:00
Andreas Wrede 46f8c32c0b version 5.1.13
Release / release (push) Successful in 5s
2026-05-02 12:43:06 -04:00
Andreas Wrede 691f62aa69 feat: host-level watch flag suppresses notifications; filter dashboard/overview by owner/manager; add ZFS monitor plugin
- watch: true (default) per host; watch: false suppresses all notifications
  for that host in udp.py and threshold.py
- Live Dashboard and Host Overview now show only hosts where the logged-in
  user is owner or manager (admins see all); WebSocket broadcasts filtered
  per-connection by the same rule
- Add hbd/client/plugins/zfs_monitor.py: collects per-pool health, capacity,
  fragmentation, dedup ratio, and cumulative I/O ops/bandwidth via zpool(8)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-02 12:42:35 -04:00
Andreas Wrede cffc9805f9 fix: mask api_password and access_token in settings page; add List to threshold imports
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-02 11:51:55 -04:00
Andreas Wrede 917d6a401b feat: composable threshold_config list for per-host threshold layering
threshold_config in the hosts section now accepts a list of named
configs applied left-to-right on top of the defaults, so focused
override profiles can be mixed without duplication. Single-string
and legacy host_threshold_mapping forms are unchanged.

- Add threshold_raw_configs to store per-config overrides separately
- Normalise threshold_config to list on parse (string or list)
- get_thresholds_for_host folds the list over the default base
- Update README and docs/THRESHOLD_ALERTING.md with examples

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-02 10:35:23 -04:00
Andreas Wrede 2bd3a9beb6 feat: restart on SIGHUP in hbc and hbc_mini
Sets dorestart and triggers a clean shutdown; os.execv re-execs
the process with the original arguments after cleanup.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-02 10:06:26 -04:00
Andreas Wrede 5523c60866 version 5.1.12
Release / release (push) Successful in 5s
2026-05-02 08:56:04 -04:00
Andreas Wrede ab37ac7194 undo last
Release / release (push) Failing after 5s
2026-05-02 08:51:12 -04:00
Andreas Wrede f811a19d80 Merge branch 'master' of git.wrede.ca:andreas/heartbeat 2026-05-02 08:50:40 -04:00
Andreas Wrede 6239825f43 allow manual release workflow 2026-05-02 08:50:37 -04:00
Andreas Wrede b56245bb23 Specify tag for workflow 2026-05-02 08:46:12 -04:00
Andreas Wrede 331c4e804d allow manual release workflow 2026-05-02 08:36:33 -04:00
Andreas Wrede 9fd945a481 fix install under docker 2026-05-02 08:32:14 -04:00
Andreas Wrede 26df08eeff version 5.1.11
Release / release (push) Failing after 5s
2026-05-02 07:55:27 -04:00
Andreas Wrede 5819dd6b25 cleanup install script 2026-05-02 07:55:18 -04:00
Andreas Wrede 6fb67f8615 version 5.1.10
Release / release (push) Successful in 5s
2026-05-01 13:50:15 -04:00
Andreas Wrede e70ae6f176 fix: change version in hbc_mini as well 2026-05-01 13:50:04 -04:00
Andreas Wrede a77f6d380c fix: install script should not copy over itself 2026-05-01 12:48:29 -04:00
Andreas Wrede 6aae2a1dab version 5.1.9
Release / release (push) Successful in 6s
2026-05-01 11:13:51 -04:00
Andreas Wrede 85ee0e1040 install hbc_mini via package or script 2026-05-01 11:13:33 -04:00
Andreas Wrede c4f09e9ced version 5.1.8
Release / release (push) Successful in 5s
- fix: matrix/sms_voipms notifications blocked the event loop on timeout;
  make send_notification async, dispatch all channel drivers as non-blocking
  tasks (asyncio.to_thread for sync drivers, asyncio.wait_for for async);
  update all call sites to fire-and-forget via create_task
- feat: add /about page with version, runtime, uptime counter, and repo link
- fix: hbc_mini plugin data format now matches full hbc client so Host
  Overview displays memory, disk, and network metrics correctly

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-01 05:33:27 -04:00
Andreas Wrede 64710fd4cd tweak h1 margins 2026-05-01 04:51:11 -04:00
Andreas Wrede 1f5e7465a3 fix nav bar position 2026-05-01 04:32:04 -04:00
Andreas Wrede b290b21e23 track hbc type and version 2026-04-30 18:22:35 -04:00
Andreas Wrede 65c4267847 version 5.1.7
Release / release (push) Successful in 5s
2026-04-30 17:50:46 -04:00
Andreas Wrede 462a445235 feat: add hbc_mini single-file client; drop dead connections on protocol error
- scripts/hbc_mini.py: self-contained hbc with no external deps; uses
  /proc for CPU/memory/network on Linux, df for disk, JSON config
- hbc + hbc_mini: mark connection _dead and stop sending on protocol error
- README: document hbc_mini usage, config, and plugin availability
- pyproject.toml: include hbc_mini.py in script-files

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 17:50:19 -04:00
Andreas Wrede 368e178f93 install the hb_install.sh script 2026-04-30 17:03:37 -04:00
Andreas Wrede 6905bf266a version 5.1.6
Release / release (push) Successful in 5s
2026-04-30 15:39:11 -04:00
Andreas Wrede b6dcce4f35 simplify eventlog usage, fix arguments 2026-04-30 15:38:46 -04:00
Andreas Wrede e6436fc236 version 5.1.5
Release / release (push) Successful in 5s
2026-04-30 13:55:21 -04:00
Andreas Wrede c5ce41762e feat: update hbc via hb_install.sh instead of code patching
Server now sends a bare UPD command; client runs hb_install.sh to
reinstall from the package registry, then restarts. hb_install.sh
also copies itself alongside hbc on client installs.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 13:55:15 -04:00
Andreas Wrede 26ca0c095f install.sh --> hb_innstall.sh 2026-04-30 09:54:48 -04:00
Andreas Wrede 1eecd67594 update docu 2026-04-30 09:19:11 -04:00
Andreas Wrede caf3c2c0ac don't error exit on pip insttalled test 2026-04-30 09:16:22 -04:00
Andreas Wrede 9af4006097 version 5.1.4
Release / release (push) Successful in 6s
2026-04-30 08:12:15 -04:00
Andreas Wrede ddf7067d13 feat: redesign Plugin Metrics page as Host Overview
Replace pill-tab plugin view with an accordion layout that shows key
metrics (CPU%, MEM%, top disk%, net delta, nagios status) at a glance
in each host card header. Plugin sections expand as structured tables.

- Rename page to "Host Overview" (URL /plugins unchanged)
- Three-wave parallel data loading: glance plugins on host expand,
  on-demand fetch for filesystem_info and extras
- Per-plugin table renderers with inline percent bars and threshold
  colour coding
- Add escHtml() for XSS-safe rendering of all field values
- Remove stale planning docs (REFACTORING.md, hbd/Plan.md)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 08:12:07 -04:00
andreas 505353a8a8 Update CLAUDE. md 2026-04-29 21:20:28 -04:00
andreas 0402d33c71 Add CLAUDE. md 2026-04-29 21:18:21 -04:00
andreas 7d8ca5d8db version 5.1.3
Release / release (push) Successful in 4s
2026-04-25 16:52:56 +02:00
andreas 56037a036d fix: remove unused pytest import in test_nagios_runner 2026-04-25 16:39:56 +02:00
andreas 65ceb31d8d fix: use os.path.exists check for /dev/log instead of dead-code OSError catch 2026-04-25 16:36:00 +02:00
andreas 1c9b6c1ca9 fix: reconfigure logging to syslog after daemonize() instead of no-op basicConfig
After daemonize() redirects stderr to /dev/null, the existing StreamHandler
writes to /dev/null. logging.basicConfig() is a no-op when handlers are
already configured, so log messages are silently lost.

Replace the daemon block to:
1. Call daemonize() first
2. Explicitly remove existing handlers (pointing to /dev/null)
3. Add SysLogHandler pointing to /dev/log with fallback to UDP localhost:514
4. Log startup message to the new syslog handler

Removes redundant syslog.openlog() call which is no longer needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:29:54 +02:00
andreas d7e6b478e1 fix: use shlex.split() in nagios_runner path validation to handle quoted paths 2026-04-25 16:28:32 +02:00
andreas 535dbda47d feat: validate absolute command paths at nagios_runner init 2026-04-25 16:24:33 +02:00
andreas c9567dddae fix: remove stale shell config key from NagiosRunnerPlugin docstring 2026-04-25 16:23:03 +02:00
andreas b5963badd6 feat: async subprocess in nagios_runner with stderr capture and signal handling
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:18:09 +02:00
andreas a76a39b4a0 fix: remove redundant no-commands log lines; fix skip_reason docstring style
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:15:44 +02:00
andreas 94e1597978 feat: set skip_reason on nagios_runner when no commands configured
When NagiosRunnerPlugin has no commands configured, set skip_reason before
returning False from initialize(). This allows PluginLoader to log INFO
(not WARNING) when the plugin is skipped.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:13:03 +02:00
andreas c9c2ed772f fix: document skip_reason in Plugin docstring; remove unused import in test 2026-04-25 16:10:35 +02:00
andreas aeb78dcb8e feat: add skip_reason to Plugin; improve PluginLoader init messaging 2026-04-25 16:08:07 +02:00
andreas 77b337e4dd Add implementation plan for plugin error checking and daemon logging fixes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:04:13 +02:00
andreas 293461f3f6 Add design spec for plugin error checking and daemon logging fixes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 15:49:09 +02:00
andreas c70a4807dc version 5.1.2
Release / release (push) Successful in 6s
2026-04-25 07:25:06 +02:00
andreas 1a470e7cfa Fix plugin config lookup shadowed by CLIENT_DEFAULTS plugins key
CLIENT_DEFAULTS seeds "plugins": {} so raw_config.get("plugins", raw_config)
always returned the empty subdict instead of falling back to the full config.
Plugins configured at top-level (e.g. nagios_runner: ...) were therefore
never found, resulting in "No Nagios commands configured".

Now checks the plugins subdict first, then top-level keys, so both
config layouts work correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 12:58:42 +02:00
andreas 990c658e65 Apply grace period to all threshold alerts before logging/notifying
Threshold alerts (plugin metrics, RTT) were firing immediately on the
first breach. Now every state transition to WARNING/CRITICAL starts a
grace-period timer (grace_seconds from the 'grace' config key). The
notification is deferred until the next heartbeat after grace_seconds
have elapsed. If the metric recovers within the grace window, both the
alert and the recovery are suppressed — no spurious pages for transient
spikes.

Two helper methods added to ThresholdChecker:
- _apply_grace: handles the state-change path (defer or suppress)
- _check_pending_or_renotify: handles the stable-alert path (fire
  deferred notification once grace expires, or fall through to reminders)

The overdue case is unchanged — on_overdue already fires only after
interval+grace seconds of silence, which is equivalent behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 12:00:40 +02:00
andreas b78d6ac0fe Fix RECOVER routing: use consistent level name and route via alerted channel
threshold.py was emitting level="RECOVERED" for metric recoveries, which
failed the is_recover check in send_notification (which only matched "RECOVER"),
bypassing _alerted_channels routing and the min_level bypass added in the
previous commit. Changed to "RECOVER" so all recovery paths are consistent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 11:29:04 +02:00
andreas afd5060f59 Fix early reminder notifications and lost recovery notifications
- AlertState.update() now resets last_notification when the alert level
  changes, so a WARNING→CRITICAL escalation restarts the reminder interval
  rather than inheriting a nearly-expired timer.
- _dispatch_to_channel() bypasses min_level for RECOVER, so recovery
  notifications are delivered even after a server restart when
  _alerted_channels is empty and the fallback dispatch path is used.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 18:11:22 +02:00
andreas f61f7aebc2 Use python3 consistently 2026-04-19 09:49:30 +02:00
Andreas Wrede 5c382d2b8d One more nit 2026-04-13 09:31:35 -04:00
Andreas Wrede 35bba451f5 Various formating nits 2026-04-13 09:27:51 -04:00
Andreas Wrede 80edfba0c0 fix inconsistencies in page layout, add swiss clock 2026-04-13 08:45:50 -04:00
Andreas Wrede 6bc8de192e fix non-alerting of overdue hosts 2026-04-12 18:44:36 -04:00
Andreas Wrede 2d8166d04a unse python3 -mpip instead of plain pip 2026-04-12 18:44:11 -04:00
Andreas Wrede ab33d81b30 catch syntax wanring when parsing version string 2026-04-12 16:39:51 -04:00
Andreas Wrede 2c0328f36d update install.sh to handle missing venv module 2026-04-12 16:39:14 -04:00
Andreas Wrede fb8e27825d make install.sh work on systems withou pip 2026-04-12 14:16:44 -04:00
Andreas Wrede 1366c69cdc version 5.1.1
Release / release (push) Successful in 5s
2026-04-12 13:06:30 -04:00
Andreas Wrede d0c8c186f4 Fix typo 2026-04-12 13:04:17 -04:00
Andreas Wrede 19f7c8312e Mkae columns sortabel agian, check hbc version, provide modile html pages 2026-04-12 12:53:00 -04:00
Andreas Wrede 24b0e362fb provide cli function stop, restart and reload for hbd
Thought for 1s
2026-04-12 12:06:07 -04:00
Andreas Wrede 3a030548c0 Fix profile not updating 2026-04-12 11:57:12 -04:00
Andreas Wrede 094cb7ed9d Merge branch 'master' of git.wrede.ca:andreas/heartbeat 2026-04-12 11:23:28 -04:00
Andreas Wrede 0199ca4693 re-factor notifications, add sms and matrix as channels 2026-04-12 11:21:21 -04:00
Andreas Wrede 75344ebbbd re-factor notifications, add sms and matrix as channels 2026-04-12 11:04:00 -04:00
Andreas Wrede 7f049a4e26 accept websocket connection on http:.../ws 2026-04-12 06:44:32 -04:00
Andreas Wrede 6559f5462c Merge branch 'master' of git.wrede.ca:andreas/heartbeat 2026-04-12 06:34:28 -04:00
Andreas Wrede 6556d35f97 Merge branch 'master' of git.wrede.ca:andreas/heartbeat 2026-04-12 06:32:52 -04:00
Andreas Wrede dec96a0da6 Merge branch 'master' of git.wrede.ca:andreas/heartbeat 2026-04-11 16:40:02 -04:00
Andreas Wrede 8d3de01117 Update install script 2026-04-11 16:36:20 -04:00
Andreas Wrede 5bedf026b1 Update install script 2026-04-11 16:19:41 -04:00
Andreas Wrede daf5277507 version 5.1.0
Release / release (push) Successful in 5s
2026-04-11 15:26:37 -04:00
Andreas Wrede ee3b72878f Add a ping monitor 2026-04-11 15:25:23 -04:00
Andreas Wrede 6217f7a124 fix bogus notification on new clients 2026-04-10 13:39:18 -04:00
Andreas Wrede 2468386f24 adjust default log, pick and config locations. renotify on critical only, make user sessions persistem 2026-04-10 13:24:57 -04:00
Andreas Wrede 2015195112 Grace interval on restart of hbd, fix SIGHUP processing 2026-04-10 12:58:38 -04:00
Andreas Wrede 3426185383 Set SO_TIMESTAMP correctly for the various platforms 2026-04-10 11:19:47 -04:00
Andreas Wrede 9eedbafe97 Show overdue in alerts instead of null 2026-04-10 09:20:28 -04:00
Andreas Wrede a5f31c5cb5 update picked data strucures 2026-04-10 09:18:38 -04:00
Andreas Wrede 2f72cf0118 typo 2026-04-10 09:17:57 -04:00
Andreas Wrede c56e77c2c1 Merge branch 'master' of git.wrede.ca:andreas/heartbeat 2026-04-10 08:20:40 -04:00
Andreas Wrede e9aa7a6f8b info only if no nagios command is defined 2026-04-10 08:19:59 -04:00
Andreas Wrede a75a8a4087 warn only if no nagios command is defined 2026-04-10 08:14:31 -04:00
Andreas Wrede ba27d2e300 Add count to rtt threshold 2026-04-10 08:07:50 -04:00
Andreas Wrede 381e37efce fix log-section height 2026-04-10 08:01:22 -04:00
Andreas Wrede 97dfc08f4d fix log level settiung 2026-04-10 08:00:51 -04:00
Andreas Wrede d281ac5a70 provide defaults for threshold_configs 2026-04-10 07:47:39 -04:00
Andreas Wrede 812bbf8555 Merge branch 'master' of git.wrede.ca:andreas/heartbeat 2026-04-09 13:02:17 -04:00
Andreas Wrede e6b7a1aa27 drop config file 2026-04-09 13:02:10 -04:00
Andreas Wrede 90f47ad018 drop config file 2026-04-09 13:00:07 -04:00
Andreas Wrede cc458e8972 update README 2026-04-09 08:33:25 -04:00
andreas 79bf00abfd version 5.0.12
Release / release (push) Successful in 6s
2026-04-08 16:47:12 -04:00
andreas d77277857f Add user management and a settings page 2026-04-08 16:21:55 -04:00
Andreas Wrede 3232239a85 version 5.0.11
Release / release (push) Successful in 5s
2026-04-07 14:19:46 -04:00
Andreas Wrede 014781de5e Merge branch 'master' of git.wrede.ca:andreas/heartbeat 2026-04-07 14:16:12 -04:00
Andreas Wrede 68b1c65384 version 5.0.10 2026-04-07 14:15:46 -04:00
Andreas Wrede e8bb553349 version 5.0.10
Release / release (push) Failing after 4s
2026-04-07 14:11:03 -04:00
Andreas Wrede e4ecb8723f release a pypi package on gitea 2026-04-07 14:10:07 -04:00
Andreas Wrede 5edbaacf81 version 5.0.9
Release / release (push) Successful in 15s
2026-04-07 11:02:19 -04:00
Andreas Wrede 8421f472f2 there is only one __version__ 2026-04-07 11:00:22 -04:00
Andreas Wrede 51f9bdc2b5 use SO_TIMESTAMP, works on Linux, FreeBSD and macOS 2026-04-07 10:46:54 -04:00
andreas 02bc42fbf0 get rtt time differently 2026-04-07 10:40:12 -04:00
andreas 832a8b0bda save state to pickle file, restart timers on restart 2026-04-06 17:24:59 -04:00
91 changed files with 16837 additions and 3749 deletions
+20
View File
@@ -0,0 +1,20 @@
{
"permissions": {
"allow": [
"Edit(*)",
"Bash(pytest *)",
"Bash(python *)",
"Bash(python3 *)",
"Bash(.venv/bin/pytest *)",
"Bash(npm *)",
"Bash(git *)",
"Bash(ls *)",
"Bash(cat *)",
"Bash(grep *)",
"Bash(find *)",
"Bash(mkdir *)",
"Bash(touch *)",
"Bash(uv *)"
]
}
}
+30 -11
View File
@@ -10,30 +10,49 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0
# - name: Set up Python
# uses: actions/setup-python@v5
# with:
# python-version: '3.11'
- name: Set up Python - name: Set up Python
# Use a generic run step for FreeBSD if actions/setup-python
# fails in restricted environments.
run: | run: |
python3 --version python3 --version
python3 -m ensurepip --upgrade python3 -m ensurepip --upgrade
- name: Install build tools - name: Install build tools
run: | run: |
python -m pip install --upgrade pip python3 -m venv .venv
# pip install build twine .venv/bin/pip install --upgrade pip
.venv/bin/pip install build twine
- name: Build package - name: Build package
run: python -m build run: .venv/bin/python -m build
- name: Extract version from tag - name: Extract version from tag
id: get_version id: get_version
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: Generate changelog
id: changelog
run: |
PREV_TAG=$(git tag --sort=-version:refname | grep -m 1 -v "^${GITHUB_REF#refs/tags/}$")
if [ -n "$PREV_TAG" ]; then
CHANGELOG=$(git log --pretty=format:"- %s" "${PREV_TAG}..HEAD")
else
CHANGELOG="Initial release"
fi
# Write multiline to output
{
echo "CHANGELOG<<EOF"
echo "$CHANGELOG"
echo "EOF"
} >> $GITHUB_OUTPUT
- name: Upload to Gitea PyPI registry
env:
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
run: |
.venv/bin/python3 -m twine upload --repository-url https://git.wrede.ca/api/packages/andreas/pypi dist/*
- name: Create release - name: Create release
uses: actions/gitea-release-action@v1 uses: actions/gitea-release-action@v1
with: with:
@@ -41,4 +60,4 @@ jobs:
dist/*.whl dist/*.whl
dist/*.tar.gz dist/*.tar.gz
title: "Release ${{ steps.get_version.outputs.VERSION }}" title: "Release ${{ steps.get_version.outputs.VERSION }}"
body: "Release version ${{ steps.get_version.outputs.VERSION }}" body: "${{ steps.changelog.outputs.CHANGELOG }}"
+4
View File
@@ -11,3 +11,7 @@ dist/
*.egg-info/ *.egg-info/
ssl/ ssl/
uv.lock uv.lock
.hb.yaml
.superpowers/
rndc-key
docs/superpowers/
-254
View File
@@ -1,254 +0,0 @@
#name: "w02"
hb_port: 50003
hbd_host: ''
#logfile: "/home/andreas/public_html/messages/andreas"
logfile: "/home/andreas/logs/heartbeat/heartbeat.log"
#logfile: "/Users/andreas/public_html/messages/andreas"
logfmt: "msg"
grace: 40
interval: 10
# Notification Channels - Define notification providers centrally
# Each channel has a type (pushover, email, signal, mattermost) and type-specific configuration
notification_channels:
pushover_standard:
type: pushover
token: ac7NLX2rPjXFareeDgLpXNoDf4iFmf
user: uDhH33UjQQDYtNzJb1ThRiWb9ingGK
signal_andreas:
type: signal
cli_path: /usr/local/bin/signal-cli
user: +14168226179
recipient: +14168226179
email_andreas:
type: email
recipients: [aew.hbd.notify@wrede.ca]
sender: aew.hbd@wrede.ca
smtp_server: smtp.fastmail.com
smtp_port: 587
smtp_user: andreas@wrede.ca
smtp_password: pvtvefyp5gbhnch2
# Example additional channels (commented out)
# pushover_urgent:
# type: pushover
# token: your-app-token
# user: your-user-key
#
mattermost_devops:
type: mattermost
host: mattermost.example.com
token: webhook-token
channel: devops-alerts
username: heartbeat-bot
icon: https://example.com/heartbeat-icon.png
# Default notification channels (used if host doesn't specify channels)
default_notification_channels: [pushover_standard]
# Host definitions - combines threshold mapping, watch status, DNS updates, and notifications
hosts:
wentworth:
threshold_config: default
watch: true
notification_channels: [pushover_standard]
dyndns: false
y:
threshold_config: default
watch: true
notification_channels: [pushover_standard]
dyndns: false
winter:
threshold_config: default
watch: true
notification_channels: [pushover_standard]
dyndns: false
wally:
threshold_config: freebsd_server
watch: false
notification_channels: [pushover_standard]
dyndns: false
eris:
threshold_config: truenas_server
watch: false
notification_channels: [pushover_standard]
dyndns: false
haschloss:
threshold_config: default
watch: false
dyndns: true
wayback:
threshold_config: default
watch: false
notification_channels: [pushover_standard]
dyndns: true
wertvoll:
threshold_config: default
watch: false
notification_channels: [pushover_standard]
dyndns: true
weekend:
threshold_config: freebsd_server
watch: false
notification_channels: [pushover_standard]
dyndns: true
cotgate:
threshold_config: default
watch: false
dyndns: true
rvgate:
threshold_config: default
watch: false
dyndns: true
draper:
threshold_config: default
watch: false
notification_channels: [pushover_standard]
dyndns: true
# Hosts to drop/ignore
drophosts: {"unknown", "wookie15", "wort"}
nsupdate_bin: "/usr/local/bin/nsupdate"
dyndomains: {"wrede.org"}
ws_port: 50005
# wss_port: 50006 # Commented out - use plain WebSocket instead of secure WSS
# cert_path: "/usr/local/etc/letsencrypt/live/hbd.wrede.ca/"
# cert_path: "test/"
# CERT_PATH = "./test/"
# wss_pem: "fullchain.pem"
# wss_key: "privkey.pem"
journal_enabled: true # Enable/disable journaling
journal_dir: /home/andreas/logs/heartbeat # Journal directory
journal_file: messages.journal # Base filename
journal_max_size: 104857600 # Max size (100MB default)
journal_max_backups: 10 # Number of backups to keep
threshold_configs:
default:
thresholds:
cpu_monitor:
cpu_percent:
warning: 80.0
critical: 90.0
memory_monitor:
percent:
warning: 85.0
critical: 95.0
disk_monitor:
partitions:
/:
percent:
warning: 85.0
critical: 90.0
rtt:
warning: 50
critical: 250.0
freebsd_server:
thresholds:
cpu_monitor:
cpu_percent:
warning: 80.0
critical: 90.0
memory_monitor:
memory_percent:
warning: 97.0
critical: 100.0
disk_monitor:
partitions:
/:
percent:
warning: 85.0
critical: 90.0
nagios_runner:
# overall_status_code:
# warning: 1
# critical: 2
# operator: ">="
load_status:
warning: WARNING
critical: CRITICAL
operator: "=="
ups_load:
display: "load to high: {ups_output}"
warning: 70
critical: 80
operator: ">="
ups_status_code:
display: "{ups_output}"
warning: 1
critical: 2
operator: ">="
nextcloud_apps_status_code:
display: "{nextcloud_apps_output}"
warning: 1
critical: 2
operator: ">="
rtt:
warning: 50
critical: 250.0
truenas_server:
thresholds:
cpu_monitor:
cpu_percent:
warning: 80.0
critical: 90.0
memory_monitor:
percent:
warning: 3.0
critical: 95.0
disk_monitor:
partitions:
/:
percent:
warning: 85.0
critical: 90.0
nagios_runner:
# overall_status_code:
# warning: 1
# critical: 2
# operator: ">="
load_status:
warning: WARNING
critical: CRITICAL
operator: "=="
ups_load:
display: "load to high: {ups_output}"
WARNING: 70
CRITICAL: 80
OPERATOR: ">="
ups_status_code:
DISPLAY: "{ups_output}"
warning: 1
critical: 2
operator: ">="
nextcloud_apps_status_code:
display: "{nextcloud_apps_output}"
warning: 1
critical: 2
operator: ">="
rtt:
warning: 120
critical: 250.0
+6 -5
View File
@@ -4,12 +4,13 @@
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "Python: Run hbd (module)", "name": "Python: Run hbd (module)",
"type": "debugpy", "type": "debugpy",
"request": "launch", "request": "launch",
"module": "hbd.server.cli", "module": "hbd.server.cli",
"args": ["-c", "/home/andreas/git/heartbeat/.hb.yaml", "-f", "-v", "-x", "-x", "-x", "-x"], "args": ["-c", "~/.hb.yaml", "-f", "-v"],
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}",
"env": { "env": {
"PYTHONPATH": "${workspaceFolder}" "PYTHONPATH": "${workspaceFolder}"
@@ -28,14 +29,14 @@
] ]
}, },
{ {
"name": "Python: Run hbd with debugpy (listen)", "name": "Python: Run hbc (module)",
"type": "debugpy", "type": "debugpy",
"request": "launch", "request": "launch",
"module": "debugpy", "module": "hbd.client.main",
"args": ["--listen", "5678", "--wait-for-client", "-m", "hbd.server.cli", "-c", ".hb.yaml", "-f", "-v"], "args": ["-c", "~/.hbc.yaml", "-v", "winter"],
"cwd": "${workspaceFolder}",
"env": { "PYTHONPATH": "${workspaceFolder}" }, "env": { "PYTHONPATH": "${workspaceFolder}" },
"console": "integratedTerminal", "console": "integratedTerminal",
"justMyCode": false
} }
] ]
} }
+4
View File
@@ -0,0 +1,4 @@
1. Don't assume. Don't hide confusion. Surface tradeoffs.
2. Minimum code that solves the problem. Nothing speculative.
3. Touch only what you must. Clean up only your own mess.
4. Define success criteria. Loop until verified.
+210
View File
@@ -0,0 +1,210 @@
# Heartbeat
Heartbeat is a lightweight host monitoring system built around a simple idea: each machine you want to monitor runs a small client (`hbc`) that sends a UDP "heartbeat" packet to a central server (`hbd`) on a regular interval. If a heartbeat stops arriving, you get notified. Alongside reachability, clients can ship system metrics — CPU, memory, disk, network — and the server will alert you when any of those cross a threshold.
## How it works
```
[ monitored host ] [ your server ]
┌─────────────┐ UDP 50003 ┌────────────────────────┐
│ hbc │ ────────────> │ hbd │
│ │ │ host state tracking │
│ plugins: │ <──────────── │ threshold alerting │
│ cpu, mem, │ ACK / CMD │ notifications │
│ disk, ... │ │ web dashboard + API │
└─────────────┘ └────────────────────────┘
```
- **hbd** — the server daemon. Tracks which hosts are alive, evaluates metric thresholds, fires notifications, serves the web dashboard and REST API.
- **hbc** — the client. Sends heartbeats and plugin data over UDP. Runs on any Linux/BSD/macOS host.
- **hbc_mini** — a zero-dependency single-file alternative (`hbc_mini.py` or `hbc_mini.c`) for hosts where you can't install Python packages.
Notifications can go to Pushover, email, Mattermost, Matrix, Signal, or VoIP.ms SMS. The dashboard shows host connectivity, RTT graphs, active alerts, and per-host plugin metrics in real time via WebSocket.
---
## Getting started
This tutorial sets up a server on one machine and a client on a second machine. You'll end up with a working dashboard and your first host being monitored.
### 1. Install the server
On the machine that will run `hbd`:
```bash
git clone https://git.wrede.ca/andreas/heartbeat.git
cd heartbeat
python3 -m venv .venv
source .venv/bin/activate
pip install .
```
Verify the install:
```bash
hbd --help
```
### 2. Create a server config
Create `~/.hb.yaml`:
```yaml
hb_port: 50003 # UDP port — clients send heartbeats here
hbd_port: 50004 # HTTP port — web dashboard and API
ws_port: 50005 # WebSocket port — live dashboard updates
interval: 20 # Expected heartbeat interval (seconds)
grace: 2 # Seconds of slack before a host is considered overdue
pickfile: ~/.hb.pick
pidfile: ~/.hb.pid
logfile: ~/.hb.log
```
That's enough to get started. No hosts, no users, no notifications needed yet — the server will accept any client that connects.
### 3. Start the server
```bash
hbd serve -c ~/.hb.yaml -f -v
```
`-f` keeps it in the foreground so you can watch the log. You should see:
```
Heartbeat daemon starting on UDP :50003, HTTP :50004, WS :50005
```
Open `http://your-server:50004/live` in a browser. The dashboard is empty for now.
### 4. Install the client on a host to monitor
On the machine you want to monitor (must be able to reach the server on UDP 50003):
```bash
pip install hbd # or: copy scripts/hbc_mini.py if you can't install packages
```
#### Quick start — no config file
```bash
hbc your-server.example.com
```
Within a few seconds the server log will show the host checking in, and it will appear on the dashboard.
#### With a config file
Create `~/.hbc.yaml` on the client host:
```yaml
hb_port: 50003
interval: 10 # Send a heartbeat every 10 seconds
plugins:
cpu_monitor:
interval: 60
memory_monitor:
interval: 60
disk_monitor:
interval: 60
```
Then start the client:
```bash
hbc -c ~/.hbc.yaml your-server.example.com
```
Send a boot message at startup so the server logs when the host came up:
```bash
hbc -b -c ~/.hbc.yaml your-server.example.com
```
Run as a daemon (logs go to syslog):
```bash
hbc -d -b -c ~/.hbc.yaml your-server.example.com
```
### 5. View the dashboard
Open `http://your-server:50004/live`. You'll see the monitored host, its last heartbeat time, and RTT. Click the host name to see plugin metrics.
Navigate to `/plugins/<hostname>` for CPU, memory, and disk graphs.
### 6. Add a notification channel (optional)
Edit `~/.hb.yaml` on the server:
```yaml
notification_channels:
pushover_ops:
type: pushover
token: YOUR_APP_TOKEN
user: YOUR_USER_KEY
users:
alice:
password: pbkdf2:sha256:... # generate: hbd passwd alice
admin: true
notification_channels: [pushover_ops]
default_owner: alice
```
Generate the password hash:
```bash
hbd passwd alice
```
Paste the output into the config, then reload:
```bash
hbd reload
```
Test the channel:
```bash
hbd notify
```
### 7. Set a threshold alert (optional)
Add to `~/.hb.yaml`:
```yaml
thresholds:
cpu_monitor:
cpu_percent:
warning: 80.0
critical: 90.0
disk_monitor:
partitions:
/:
percent:
warning: 80.0
critical: 90.0
```
Reload: `hbd reload`. The server will now alert when a monitored host crosses these values.
---
## What's next
| Topic | Where to look |
|---|---|
| Full server config reference | [README — Server](https://git.wrede.ca/andreas/heartbeat/src/branch/master/README.md#server-hbd) |
| Client options and all plugins | [README — Client](https://git.wrede.ca/andreas/heartbeat/src/branch/master/README.md#client-hbc) |
| Threshold alerting details | [THRESHOLD_ALERTING.md](https://git.wrede.ca/andreas/heartbeat/src/branch/master/docs/THRESHOLD_ALERTING.md) |
| Notification channels | [NOTIFICATIONS.md](https://git.wrede.ca/andreas/heartbeat/src/branch/master/docs/NOTIFICATIONS.md) |
| User accounts and roles | [USERS.md](https://git.wrede.ca/andreas/heartbeat/src/branch/master/docs/USERS.md) |
| Writing a custom plugin | [PLUGIN_DEVELOPMENT.md](https://git.wrede.ca/andreas/heartbeat/src/branch/master/docs/PLUGIN_DEVELOPMENT.md) |
| Nagios check integration | [NAGIOS_INTEGRATION.md](https://git.wrede.ca/andreas/heartbeat/src/branch/master/docs/NAGIOS_INTEGRATION.md) |
| REST API | [HTTP_API.md](https://git.wrede.ca/andreas/heartbeat/src/branch/master/docs/HTTP_API.md) |
| Zero-dependency client | [README — hbc_mini](https://git.wrede.ca/andreas/heartbeat/src/branch/master/README.md#hbc_mini--zero-dependency-client) |
+21
View File
@@ -0,0 +1,21 @@
# MIT License
Copyright (c) 2002 - 2026 Andreas Wrede
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+624 -433
View File
File diff suppressed because it is too large Load Diff
-234
View File
@@ -1,234 +0,0 @@
# HBD/HBC Separation Refactoring
## Overview
The heartbeat monitoring system has been refactored into a modular package structure with separate client and server components. This allows users to install only what they need and provides clear separation of concerns.
## New Package Structure
```
hbd/
├── __init__.py # Main package (minimal)
├── client/ # HBC - System monitoring client
│ ├── __init__.py
│ ├── main.py # Entry point (was hbc.py)
│ ├── config.py # Client-specific configuration
│ ├── plugin.py # Plugin framework
│ ├── threshold.py # Threshold checking
│ └── plugins/ # Monitoring plugins
│ ├── cpu_monitor.py
│ ├── disk_monitor.py
│ ├── memory_monitor.py
│ ├── network_monitor.py
│ ├── filesystem_info.py
│ ├── os_info.py
│ └── nagios_runner.py
├── server/ # HBD - Heartbeat daemon/server
│ ├── __init__.py
│ ├── main.py # Server runtime (was server.py)
│ ├── cli.py # Command-line interface
│ ├── config.py # Server-specific configuration
│ ├── http.py # HTTP/REST API
│ ├── ws.py # WebSocket server
│ ├── udp.py # UDP heartbeat listener
│ ├── dns.py # DNS update functionality
│ ├── notify.py # Notification handlers
│ ├── monitor.py # Host monitoring
│ ├── hbdclass.py # Host class definitions
│ ├── journal.py # Message journaling
│ ├── templates/ # Jinja2 web templates
│ └── static/ # Web UI assets
└── common/ # Shared utilities
├── __init__.py
├── proto.py # Protocol encoding/decoding
└── utils.py # Common utilities
## Configuration Files
### Client Configuration (hbd/client/config.py)
Client-specific defaults:
- `hb_port`: Port where hbd servers listen (default: 50003)
- `interval`: Heartbeat interval in seconds (default: 10)
- `plugins`: Per-plugin configuration
- `thresholds`: Threshold configuration for monitoring
### Server Configuration (hbd/server/config.py)
Server-specific defaults:
- `hb_port`: Port to listen for heartbeats (default: 50003)
- `hbd_port`: HTTP API port (default: 50004)
- `ws_port`: WebSocket port (default: 50005)
- `logfile`, `logfmt`: Logging configuration
- `pushsrv`, `pushover_token`, etc.: Notification settings
- `watchhosts`, `dyndnshosts`: Host monitoring
- `smtpserver`, etc.: Email settings
- `journal_*`: Message journaling settings
## Installation Options
### Install Core Only (minimal, PyYAML only)
```bash
pip install hbd
```
### Install Client Only (for monitoring)
```bash
pip install hbd[client]
# Installs: PyYAML, psutil
```
### Install Server Only (for daemon)
```bash
pip install hbd[server]
# Installs: PyYAML, websockets, mattermostdriver, aiohttp, Jinja2
```
### Install Everything
```bash
pip install hbd[all]
# Installs all dependencies for both client and server
```
### Development Installation
```bash
pip install -e ".[dev]"
# Includes all dependencies plus testing/linting tools
```
## Command-Line Interfaces
### HBC (Client)
```bash
hbc [options] host1 [host2 ...]
# Entry point: hbd.client.main:main
# Location: hbd/client/main.py
```
### HBD (Server)
```bash
hbd [options]
# Entry point: hbd.server.cli:main
# Location: hbd/server/cli.py → hbd/server/main.py
```
## Import Changes
### Client Code
```python
# Old imports
from .config import load_config
from .proto import dicttos, stodict
from .plugin import PluginRegistry
# New imports
from .config import load_config # Still in client/
from ..common.proto import dicttos # Moved to common/
from .plugin import PluginRegistry # Still in client/
```
### Server Code
```python
# Old imports
from .config import load_config
from .proto import stodict
from .threshold import AlertLevel
# New imports
from .config import load_config # Server-specific config
from ..common.proto import stodict # Moved to common/
from ..client.threshold import AlertLevel # Client module
```
### Plugin Code
```python
# Old import
from hbd.plugin import MonitorPlugin
# New import
from hbd.client.plugin import MonitorPlugin
```
## Benefits
1. **Modular Installation**: Install only what you need
- Client-only systems don't need web server dependencies
- Server-only systems don't need psutil
2. **Clearer Architecture**: Explicit separation of concerns
- Client: System monitoring and data collection
- Server: Heartbeat reception, web UI, notifications
- Common: Shared protocol and utilities
3. **Independent Evolution**: Client and server can evolve separately
- Different release cycles possible
- Clear API boundaries via common/
4. **Smaller Footprint**: Reduced dependency installation
- Client: ~1 dependency (psutil)
- Server: ~4 dependencies (websockets, aiohttp, Jinja2, mattermostdriver)
## Migration Guide
### For Existing Installations
1. **Reinstall the package**:
```bash
pip install -e ".[all]" # For development
# or
pip install hbd[all] # For production
```
2. **Configuration files remain unchanged**:
- Both client and server read from `~/.hb.yaml`
- All existing config keys are supported in both configs
- Server has additional keys (journal, websocket, email, etc.)
- Client has minimal keys (interval, plugins, thresholds)
3. **Commands remain the same**:
- `hbc` command works identically
- `hbd` command works identically
### For New Deployments
1. **Client-only system** (monitoring host):
```bash
pip install hbd[client]
hbc server1.example.com server2.example.com
```
2. **Server-only system** (monitoring daemon):
```bash
pip install hbd[server]
hbd -c /etc/hbd.yaml -f
```
3. **Combined system** (dev/test):
```bash
pip install hbd[all]
```
## Testing
All imports and entry points have been tested and validated:
- ✅ Package imports work correctly
- ✅ `hbc` command entry point functional
- ✅ `hbd` command entry point functional
- ✅ Optional dependencies properly configured
- ✅ All internal imports updated
## Files Archived
The following files were renamed to avoid conflicts:
- `hbd/config.py` → `hbd/config.py.old` (split into client/server configs)
- `hbd/hbc_old.py` → `hbd/hbc_old.py.bak` (backup file)
## Next Steps
1. Test client functionality with a monitoring host
2. Test server functionality with web UI and notifications
3. Update documentation (README.md) with new structure
4. Consider publishing to PyPI with new structure
5. Update any deployment scripts/Dockerfiles to use optional dependencies
-1
View File
@@ -81,7 +81,6 @@ The following settings **cannot** be reloaded and require a service restart:
- **Logging** - **Logging**
- `logfile` - Log file path - `logfile` - Log file path
- `logfmt` - Log format
- **Journal Settings** - **Journal Settings**
- `journal_enabled` - Enable/disable journaling - `journal_enabled` - Enable/disable journaling
+66
View File
@@ -0,0 +1,66 @@
# Dark Mode
Every page in the Heartbeat web UI supports light mode, dark mode, and automatic (follows the OS/browser setting). Each user picks their preference independently; it is stored in the browser and takes effect immediately without a page reload.
---
## Choosing a theme
Open your profile page (`/profile`) and scroll to the **Appearance** section. Click one of the three buttons:
| Button | Behaviour |
|--------|-----------|
| **Auto** | Follows the OS or browser dark-mode preference. Updates live if the system setting changes. |
| **Light** | Always light, regardless of system setting. |
| **Dark** | Always dark, regardless of system setting. |
The preference is stored in `localStorage` under the key `hbd_theme` and applies to the current browser only. Clearing browser storage resets it to **Auto**.
---
## Implementation notes
### No flash of unstyled content
A small synchronous `<script>` runs at the very top of `<head>`, before any CSS is parsed, and sets `data-theme="dark"` on `<html>` when the stored preference (or the system setting in auto mode) calls for dark. Because it runs before paint, there is no visible flicker on page load.
### CSS custom properties
All colours are expressed as CSS custom properties defined in `head.html`:
```
:root — light-mode values (default)
html[data-theme="dark"] — dark-mode overrides
```
Key variables:
| Variable | Purpose |
|----------|---------|
| `--bg` | Page background |
| `--surface` | Card / panel background |
| `--surface-2` / `--surface-3` | Slightly lighter/darker surfaces (table rows, hover states) |
| `--text` / `--text-sec` / `--text-muted` | Primary, secondary, muted text |
| `--border` / `--border-2``4` | Border shades from prominent to faint |
| `--link` | Hyperlink and interactive-element colour |
| `--nav-bg` | Navigation bar background |
| `--input-bg` / `--input-border` | Form control colours |
| `--shadow` / `--shadow-sm` | Box-shadow alphas |
A single global rule in `head.html` themes all `<input>`, `<select>`, and `<textarea>` elements across every page at once:
```css
html[data-theme="dark"] input:not([type=checkbox]):not([type=radio]),
html[data-theme="dark"] select,
html[data-theme="dark"] textarea { }
```
Each page template adds its own `html[data-theme="dark"]` block for page-specific elements (cards, tables, badges, etc.).
### Auto-mode live updates
A `matchMedia` change listener in `head.html` updates `data-theme` whenever the OS preference changes, so users in **Auto** mode see the theme switch without reloading.
### Semantic colours are unchanged
Alert colours (red for critical, orange for warning, green for ok) and status indicators are intentionally left as fixed values — they are semantic signals, not surface colours, and look correct on both light and dark backgrounds.
+211 -4
View File
@@ -15,12 +15,60 @@ Default port is `50004` (configurable via `hbd_port` in configuration).
--- ---
## Authentication
When [user accounts are configured](USERS.md), every request must be authenticated.
- **Browser requests** to HTML pages are redirected to `/login` automatically. JavaScript `fetch()` calls on the dashboards send the session cookie automatically — no JS changes are needed.
- **API / programmatic requests** must include the token in an `Authorization: Bearer <token>` header or an `X-Auth-Token` header.
Unauthenticated API requests receive `401 Unauthorized`. When no users are configured the server runs in unauthenticated mode and all endpoints are open.
### Login
```bash
TOKEN=$(curl -s -X POST http://localhost:50004/api/0/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"alice","password":"secret"}' | jq -r .token)
curl -H "Authorization: Bearer $TOKEN" http://localhost:50004/api/0/hosts
```
See [User Management](USERS.md) for full authentication documentation.
---
## API Endpoints ## API Endpoints
### Authentication
| Method | Path | Description | Auth required |
|--------|------|-------------|---------------|
| `POST` | `/api/0/auth/login` | Obtain session token | No |
| `POST` | `/api/0/auth/logout` | Invalidate session | Token |
### Users
| Method | Path | Description | Role |
|--------|------|-------------|------|
| `GET` | `/api/0/users` | List all users | Admin |
| `GET` | `/api/0/users/me` | Own profile | Authenticated |
| `PUT` | `/api/0/users/me` | Update own profile | Authenticated |
### Notification Channels
| Method | Path | Description | Role |
|--------|------|-------------|------|
| `GET` | `/api/0/notification_channel_types` | Channel type schemas | Authenticated |
| `GET` | `/api/0/notification_channels` | List visible channels | Authenticated |
| `POST` | `/api/0/notification_channels` | Create a channel | Authenticated |
| `PUT` | `/api/0/notification_channels/{name}` | Update a channel | Owner or Admin |
| `DELETE` | `/api/0/notification_channels/{name}` | Delete a channel | Owner or Admin |
### Host Management ### Host Management
#### GET /api/0/hosts #### GET /api/0/hosts
Get list of all monitored hosts with their state information. Get list of all monitored hosts with their state information. When auth is enabled, only hosts the caller has at least **monitor** access to are returned.
**Response:** **Response:**
```json ```json
@@ -28,6 +76,9 @@ Get list of all monitored hosts with their state information.
{ {
"name": "webserver01", "name": "webserver01",
"dyn": false, "dyn": false,
"owner": "alice",
"managers": ["bob"],
"monitors": ["carol"],
"connections": [...] "connections": [...]
} }
] ]
@@ -137,6 +188,127 @@ curl http://localhost:50004/api/0/hosts/database01/plugins/disk_monitor
--- ---
### Host Access
#### GET /api/0/hosts/{hostname}/access
Get owner/managers/monitors for a host. Requires **monitor** role or higher.
**Response:**
```json
{
"owner": "alice",
"managers": ["bob"],
"monitors": ["carol"]
}
```
#### PUT /api/0/hosts/{hostname}/access
Update owner/managers/monitors. Requires **owner** role or admin.
**Request body** (all fields optional):
```json
{ "owner": "bob", "managers": ["carol"], "monitors": [] }
```
Changes take effect immediately but are not written back to the config file. Update the config file and send `SIGHUP` to make them permanent.
---
---
### Notification Channel Endpoints
Channels are visible to all users by default. Channels marked `private: true` are only visible to their owner. Admins see all channels.
#### GET /api/0/notification_channel_types
Return the schema for every supported notifier type. Used by the web UI to dynamically render the channel creation form.
**Response:**
```json
{
"pushover": {
"label": "Pushover",
"fields": [
{"key": "token", "label": "App token", "type": "secret", "required": true},
{"key": "user", "label": "User key", "type": "secret", "required": true},
{"key": "sound", "label": "Sound", "type": "text", "required": false}
]
},
"email": { "label": "E-mail", "fields": [ ... ] },
...
}
```
---
#### GET /api/0/notification_channels
List channels visible to the current user (public channels + own private channels). Admins receive all channels.
**Response:**
```json
[
{
"name": "pushover_ops",
"type": "pushover",
"type_label": "Pushover",
"owner": null,
"private": false,
"min_level": "WARNING",
"fields": [
{"key": "token", "label": "App token", "value": "•••", "sensitive": true},
{"key": "user", "label": "User key", "value": "•••", "sensitive": true}
]
}
]
```
Sensitive fields (`type: "secret"`) are always returned as `"•••"`.
---
#### POST /api/0/notification_channels
Create a new channel. The creating user becomes the channel's `owner`.
**Request body:**
```json
{
"name": "my_pushover",
"type": "pushover",
"token": "app-token",
"user": "user-key",
"min_level": "WARNING",
"private": true
}
```
**Response:** `{"ok": true, "name": "my_pushover"}`
**Status codes:** `200 OK`, `400` (missing required field or unknown type), `409` (name already exists)
---
#### PUT /api/0/notification_channels/{name}
Update an existing channel. Only the channel owner or an admin may update it.
Secret fields sent as `"•••"` are preserved from the existing config (same pattern as OAuth secrets in the admin config editor).
**Request body:** same shape as POST, `name` ignored (taken from URL).
**Response:** `{"ok": true}`
**Status codes:** `200 OK`, `403 Forbidden`, `404 Not Found`
---
#### DELETE /api/0/notification_channels/{name}
Delete a channel. Only the channel owner or an admin may delete it.
**Response:** `{"ok": true}`
**Status codes:** `200 OK`, `403 Forbidden`, `404 Not Found`
---
### Alert Endpoints ### Alert Endpoints
#### GET /api/0/hosts/{hostname}/alerts #### GET /api/0/hosts/{hostname}/alerts
@@ -226,6 +398,16 @@ curl http://localhost:50004/api/0/alerts | jq .
## Web UI Pages ## Web UI Pages
### Login
**URL:** `/login`
Shown automatically when a browser request is made without a valid session (when users are configured). After successful login the browser is redirected to the originally requested page.
### Logout
**URL:** `/logout`
Clears the session cookie and redirects to `/login`.
### Live Dashboard ### Live Dashboard
**URL:** `/live` **URL:** `/live`
@@ -288,7 +470,13 @@ Comprehensive alert monitoring:
#!/bin/bash #!/bin/bash
# Check for critical alerts and send notification # Check for critical alerts and send notification
RESPONSE=$(curl -s http://localhost:50004/api/0/alerts) # Log in first (when auth is configured)
TOKEN=$(curl -s -X POST http://localhost:50004/api/0/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"monitor","password":"secret"}' | jq -r .token)
AUTH="-H \"Authorization: Bearer $TOKEN\""
RESPONSE=$(curl -s $AUTH http://localhost:50004/api/0/alerts)
CRITICAL_COUNT=$(echo "$RESPONSE" | jq '.summary.critical') CRITICAL_COUNT=$(echo "$RESPONSE" | jq '.summary.critical')
if [ "$CRITICAL_COUNT" -gt 0 ]; then if [ "$CRITICAL_COUNT" -gt 0 ]; then
@@ -305,8 +493,16 @@ fi
import requests import requests
import json import json
BASE = 'http://localhost:50004'
# Log in (skip if auth not configured)
resp = requests.post(f'{BASE}/api/0/auth/login',
json={"username": "alice", "password": "secret"})
token = resp.json().get("token")
headers = {"Authorization": f"Bearer {token}"} if token else {}
# Get all plugin data for a host # Get all plugin data for a host
response = requests.get('http://localhost:50004/api/0/hosts/webserver01/plugins') response = requests.get(f'{BASE}/api/0/hosts/webserver01/plugins', headers=headers)
data = response.json() data = response.json()
print(f"Host: {data['hostname']}") print(f"Host: {data['hostname']}")
@@ -318,7 +514,7 @@ for plugin, info in data['plugins'].items():
print(f" {metric}: {value}") print(f" {metric}: {value}")
# Check for alerts # Check for alerts
response = requests.get('http://localhost:50004/api/0/alerts') response = requests.get(f'{BASE}/api/0/alerts', headers=headers)
alerts = response.json() alerts = response.json()
if alerts['summary']['critical'] > 0: if alerts['summary']['critical'] > 0:
@@ -389,6 +585,8 @@ API errors return appropriate HTTP status codes with JSON:
**Common Status Codes:** **Common Status Codes:**
- `200 OK` - Success - `200 OK` - Success
- `400 Bad Request` - Invalid parameters - `400 Bad Request` - Invalid parameters
- `401 Unauthorized` - Missing or invalid session token
- `403 Forbidden` - Authenticated but insufficient role
- `404 Not Found` - Resource not found - `404 Not Found` - Resource not found
- `500 Internal Server Error` - Server error - `500 Internal Server Error` - Server error
@@ -506,6 +704,14 @@ for route in list(app.router.routes()):
## Troubleshooting ## Troubleshooting
### API Returns 401
- Auth is configured — include `Authorization: Bearer <token>` header
- Token may have expired (24 h TTL) — log in again
### API Returns 403
- Authenticated user lacks the required role for this host/action
- Check host's `owner`, `managers`, `monitors` config
### API Returns 404 ### API Returns 404
- Verify hostname in URL matches actual host name - Verify hostname in URL matches actual host name
- Check host is sending heartbeats: `curl http://localhost:50004/api/0/hosts` - Check host is sending heartbeats: `curl http://localhost:50004/api/0/hosts`
@@ -525,6 +731,7 @@ for route in list(app.router.routes()):
## See Also ## See Also
- [User Management](USERS.md)
- [Plugin Development Guide](PLUGIN_DEVELOPMENT.md) - [Plugin Development Guide](PLUGIN_DEVELOPMENT.md)
- [Threshold Alerting Documentation](THRESHOLD_ALERTING.md) - [Threshold Alerting Documentation](THRESHOLD_ALERTING.md)
- [Message Journal Documentation](MESSAGE_JOURNAL.md) - [Message Journal Documentation](MESSAGE_JOURNAL.md)
-5
View File
@@ -104,11 +104,6 @@ The `nagios_runner` plugin collects:
- `{name}_{metric}_min` - Minimum value (if present) - `{name}_{metric}_min` - Minimum value (if present)
- `{name}_{metric}_max` - Maximum 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 ## Configuration Options
```yaml ```yaml
+262 -470
View File
@@ -2,532 +2,324 @@
## Overview ## Overview
The Heartbeat Monitoring System includes a flexible notification system that can send alerts through multiple channels including Email, Pushover, Signal, and Mattermost. The system supports centralized channel definitions with per-host routing, allowing fine-grained control over notification delivery. Notifications are dispatched to the **owner and managers** of a host, each via their own configured notification channels. Channel definitions are global; users reference them by name. No users configured → no notifications sent.
## Architecture ## Architecture
### Components ```
Alert event (udp.py / threshold.py)
└─ notify.send_notification(host_name, Notification)
├─ look up host.owner + host.managers
├─ for each user → user.notification_channels
└─ for each channel → _dispatch_to_channel (filtered by min_level)
```
1. **Notification Channels** (`notification_channels` in config) Every notification carries:
- Centralized definitions of notification providers - **title** — `[LEVEL] hostname` (e.g. `[CRITICAL] webserver01`)
- Each channel has a type and type-specific credentials - **body** — detail message (metric value, threshold, duration)
- Reusable across multiple hosts - **url** — link to the plugin metrics page (`{base_url}/plugins#{hostname}`)
- **level** — `RECOVER | WARNING | CRITICAL | INFO`
2. **Channel Dispatcher** (`hbd/server/notify.py`)
- `pushmsg_for_host(hostname, message)`: Main entry point for host-specific notifications
- `_dispatch_to_channel(channel_name, channel_config, message)`: Routes to specific provider
- Provider functions: `pushover()`, `pushsignal()`, `pushmattermost()`, `send_email()`
3. **Configuration Utilities** (`hbd/server/config.py`)
- `get_notification_channels_for_host(config, hostname)`: Retrieves channel names for a host
- `get_notification_channels_config(config, hostname)`: Retrieves full channel configurations
- `get_channel_config(config, channel_name)`: Gets configuration for a specific channel
4. **Integration Points**
- **Threshold alerts**: `threshold.py` calls `notify_mod.pushmsg_for_host()`
- **Heartbeat events**: `udp.py` calls `notify_mod.pushmsg_for_host()` for boot/shutdown/overdue
- **Custom alerts**: Any code can call `notify_mod.pushmsg_for_host(hostname, message)`
## Configuration ## Configuration
### Centralized Channel Definitions ### Base URL
Define notification channels once in your configuration file: Set `base_url` so notification links point to your hbd instance:
```yaml
base_url: https://hbd.example.com
```
### Channel definitions
Channels are defined under `notification_channels`. Each entry specifies a delivery type and its credentials. Two optional metadata fields control visibility:
| Field | Default | Description |
|---|---|---|
| `owner` | *(absent)* | Username who created/owns this channel. Absent = admin-created. |
| `private` | `false` | When `true`, only the owner can see and select this channel. |
| `min_level` | `WARNING` | Minimum alert level this channel receives. |
**Admin-created channels** (set in the config file or via the admin settings UI) are public by default — all users can select them:
```yaml ```yaml
notification_channels: notification_channels:
# Signal notifications
signal_ops: pushover_ops:
type: signal type: pushover
cli_path: /usr/local/bin/signal-cli token: your-app-token
user: +1234567890 # Your Signal number user: your-user-key
recipient: +1234567890 # Recipient number min_level: WARNING
signal_oncall:
type: signal
cli_path: /usr/local/bin/signal-cli
user: +1234567890
recipient: +0987654321 # Different recipient
# Email notifications
email_ops: email_ops:
type: email type: email
recipients: recipients: [ops@example.com]
- ops@example.com sender: hbd@example.com
- alerts@example.com
sender: heartbeat@example.com
smtp_server: smtp.example.com smtp_server: smtp.example.com
smtp_port: 587 smtp_port: 587
smtp_user: heartbeat@example.com smtp_user: hbd@example.com
smtp_password: your-smtp-password smtp_password: secret
min_level: WARNING
email_devteam:
type: email
recipients: [dev-alerts@example.com]
sender: heartbeat-dev@example.com
smtp_server: smtp.example.com
smtp_port: 587
smtp_user: heartbeat-dev@example.com
smtp_password: your-smtp-password
# Pushover notifications
pushover_urgent:
type: pushover
token: your-pushover-app-token
user: your-pushover-user-key
pushover_normal:
type: pushover
token: your-pushover-app-token
user: another-user-key
# Mattermost notifications
mattermost_devops:
type: mattermost
host: mattermost.example.com
token: your-webhook-token
channel: devops-alerts
username: heartbeat-bot
icon: https://example.com/heartbeat-icon.png
```
### Default Notification Channels matrix_oncall:
type: matrix
homeserver: https://matrix.example.org
access_token: syt_xxx
room_id: "!abc:matrix.example.org"
min_level: CRITICAL
Specify default channels for hosts that don't have specific channel assignments: sms_oncall:
type: sms_voipms
api_user: me@example.com
api_password: secret
did: "5551234567"
dst: "5559876543"
min_level: CRITICAL
```yaml signal_ops:
default_notification_channels:
- email_ops
- mattermost_devops
```
Hosts without `notification_channels` defined will use these defaults.
### Per-Host Channel Assignment
Assign specific channels to each host in the `hosts` section:
```yaml
hosts:
# Critical production web server - multiple channels for redundancy
prod-web-01:
threshold_config: high_sensitivity
watch: true
notification_channels:
- signal_oncall # Immediate mobile notification
- pushover_urgent # Secondary mobile notification
- email_ops # Email for record keeping
dyndns: false
# Database server - ops team notifications only
prod-db-01:
threshold_config: database
watch: true
notification_channels:
- signal_ops
- email_ops
dyndns: false
# Development server - email only, no urgent notifications
dev-server-01:
threshold_config: low_sensitivity
watch: false
notification_channels:
- email_devteam
dyndns: false
# Test server - uses default_notification_channels
test-server-01:
threshold_config: default
watch: false
dyndns: false
# No notification_channels specified = uses default_notification_channels
```
## Channel Types
### Email
Sends notifications via SMTP.
**Configuration fields:**
```yaml
type: email
recipients: [email1@example.com, email2@example.com] # Required: List of recipients
sender: heartbeat@example.com # Required: From address
smtp_server: smtp.example.com # Required: SMTP server hostname
smtp_port: 587 # Optional: Default 587
smtp_user: heartbeat@example.com # Optional: For authenticated SMTP
smtp_password: your-password # Optional: For authenticated SMTP
```
**Features:**
- Supports multiple recipients
- TLS/STARTTLS support on port 587
- Authenticated and unauthenticated SMTP
**Example:**
```yaml
notification_channels:
email_critical:
type: email
recipients: [admin@example.com, oncall@example.com]
sender: alerts@example.com
smtp_server: smtp.fastmail.com
smtp_port: 587
smtp_user: alerts@example.com
smtp_password: app-specific-password
```
### Pushover
Sends push notifications to mobile devices via Pushover API.
**Configuration fields:**
```yaml
type: pushover
token: your-application-token # Required: Your Pushover app token
user: your-user-key # Required: Recipient's user key
```
**Features:**
- Instant mobile push notifications
- Works on iOS and Android
- Supports delivery confirmations
**Setup:**
1. Create a Pushover account at https://pushover.net
2. Create an application to get your app token
3. Note your user key from your account dashboard
**Example:**
```yaml
notification_channels:
pushover_admin:
type: pushover
token: azGDORePK8gMaC0QOYAMyEEuzJnyUi
user: uQiRzpo4DXghDmr9QzzfQu27cmVRsG
```
### Signal
Sends notifications via Signal messenger using signal-cli.
**Configuration fields:**
```yaml
type: signal
cli_path: /usr/local/bin/signal-cli # Optional: Path to signal-cli binary
user: +1234567890 # Required: Your Signal phone number
recipient: +0987654321 # Required: Recipient phone number
```
**Prerequisites:**
1. Install signal-cli: https://github.com/AsamK/signal-cli
2. Register signal-cli with your phone number:
```bash
signal-cli -u +1234567890 register
signal-cli -u +1234567890 verify CODE
```
3. Ensure signal-cli is in PATH or specify full path in config
**Features:**
- End-to-end encrypted messaging
- Works without phone being online
- No API fees or rate limits
**Example:**
```yaml
notification_channels:
signal_admin:
type: signal type: signal
cli_path: /usr/local/bin/signal-cli cli_path: /usr/local/bin/signal-cli
user: +12025551234 user: +12025551234
recipient: +12025559999 recipient: +12025559999
mattermost_devops:
type: mattermost
host: mattermost.example.com
token: webhook-token
channel: devops-alerts
username: heartbeat-bot
``` ```
### Mattermost **User-created channels** are written by authenticated users through the API or their profile page. They carry an `owner` field and optionally `private: true`:
Sends notifications to Mattermost team chat via incoming webhooks.
**Configuration fields:**
```yaml
type: mattermost
host: mattermost.example.com # Required: Mattermost server hostname
token: your-webhook-token # Required: Incoming webhook token
channel: channel-name # Required: Target channel name
username: heartbeat-bot # Optional: Bot display name
icon: https://example.com/icon.png # Optional: Bot icon URL
```
**Prerequisites:**
1. Enable incoming webhooks in Mattermost
2. Create an incoming webhook for your team
3. Note the webhook token from the webhook URL
**Features:**
- Team-wide visibility
- Rich formatting support
- Message threading
**Example:**
```yaml ```yaml
notification_channels: notification_channels:
mattermost_ops:
type: mattermost alice_personal:
host: chat.example.com type: pushover
token: abc123def456ghi789 token: personal-token
channel: infrastructure-alerts user: personal-key
username: heartbeat-monitor owner: alice # created by alice
icon: https://example.com/heartbeat-icon.png private: true # only alice can see this channel
``` ```
## Notification Events ### Channel visibility
The system sends notifications for various events: | Channel | Who can see / select it |
|---|---|
| No `private` field (or `private: false`) | All users |
| `private: true` | Only the `owner` |
| Any channel | Admins always see everything |
### Threshold Alerts ### Users with notification channels
When monitored metrics exceed configured thresholds: Each user lists which channels they receive notifications on. Users can manage their own selection from the profile page:
- **State changes**: OK → WARNING, WARNING → CRITICAL, CRITICAL → OK ```yaml
- **Format**: `{LEVEL}: {hostname} - {metric_path} = {value} {threshold_info}` users:
- **Example**: `CRITICAL: prod-web-01 - cpu_monitor.cpu_percent = 95.2 (threshold: > 90.0)` alice:
- **Re-notifications**: Periodic reminders for ongoing alerts (default: hourly) full_name: Alice Smith
password: pbkdf2:sha256:...
admin: true
notification_channels: [pushover_ops, email_ops]
### Heartbeat Events bob:
full_name: Bob Jones
Host lifecycle events: password: pbkdf2:sha256:...
notification_channels: [sms_oncall, matrix_oncall]
- **Host boot**: `{hostname} booted`
- **Host shutdown**: `{hostname} {connection_type} shutdown`
- **Host recovery**: `{hostname} {connection_type} is back`
- **Connection issues**: `{hostname} {message}`
- **Host overdue**: `{hostname} {connection_type} overdue`
Only hosts with `watch: true` send heartbeat event notifications.
### Custom Alerts
Application code can send custom notifications:
```python
from hbd.server import notify as notify_mod
# Send to host-specific channels
notify_mod.pushmsg_for_host("prod-web-01", "Custom alert message")
# Send using global config
notify_mod.pushmsg_from_config("Global notification")
# Send to specific config
notify_mod.pushmsg(custom_config_dict, "Targeted notification")
``` ```
## Design Principles ### Host access — owner and managers
The notification system follows these core principles: Notifications for a host go to its owner and all managers:
- **Centralization**: Define notification providers once, reference them by name
- **Flexibility**: Each host can use different channels for different notification needs
- **Redundancy**: Critical hosts can specify multiple channels for failover
- **Clarity**: Clean separation between channel definition and channel assignment
- **Type Safety**: Provider-specific validation at configuration time
## Best Practices
### Channel Organization
- **Create purpose-specific channels**: `email_ops`, `signal_oncall`, `pushover_urgent`
- **Separate by team/role**: `email_devteam`, `signal_dbateam`, `mattermost_security`
- **Use descriptive names**: Channel names appear in logs and debugging
### Redundancy
For critical hosts, use multiple notification channels:
```yaml ```yaml
hosts: hosts:
critical-db: webserver01:
notification_channels: owner: alice # receives all notifications for this host
- signal_oncall # Primary: Mobile alert managers: [bob] # also receives notifications
- pushover_urgent # Backup: Different mobile platform threshold_config: default
- email_ops # Tertiary: Email for record-keeping watch: true # bold in dashboard (cosmetic only)
dyndns: false
dbserver01:
owner: alice
managers: [bob]
threshold_config: database
dyndns: false
``` ```
### Notification Fatigue Prevention `watch: true` only affects display (bold name in the live dashboard). Notifications are now controlled entirely by owner/managers.
- **Use `watch: false`** for non-critical hosts ## Channel Types
- **Configure appropriate thresholds** to avoid false positives
- **Set different channels for different severities**
- **Use `default_notification_channels`** for baseline, add more for critical systems
### Security ### `min_level` filtering
- **Protect credentials**: Use file permissions to protect config files with passwords/tokens Every channel accepts an optional `min_level` field:
- **Rotate tokens**: Periodically rotate API tokens and passwords
- **Use app-specific passwords**: For email, use app-specific passwords instead of main account password
- **Separate accounts**: Consider separate notification accounts for different environments (prod vs dev)
### Testing | Value | Channels receive |
|---|---|
| `WARNING` (default) | WARNING, CRITICAL, RECOVER |
| `CRITICAL` | CRITICAL only (and RECOVER) |
Test notification channels before relying on them: `RECOVER` is always passed through — you don't want to miss a recovery.
### pushover
Sends push notifications via [Pushover](https://pushover.net). Includes title, body, and a clickable URL.
```yaml
type: pushover
token: your-app-token # Required: Pushover application token
user: your-user-key # Required: Recipient's user key
min_level: WARNING
```
### email
Sends via SMTP. Subject = title, body = message + URL on final line.
```yaml
type: email
recipients: [ops@example.com, oncall@example.com]
sender: hbd@example.com
smtp_server: smtp.example.com
smtp_port: 587 # 587 = STARTTLS (default), 465 = SSL
smtp_user: hbd@example.com
smtp_password: secret
min_level: WARNING
```
### matrix
Sends a formatted HTML message to a Matrix room via [matrix-nio](https://github.com/poljar/matrix-nio).
```yaml
type: matrix
homeserver: https://matrix.example.org
access_token: syt_xxx # Bot account access token
room_id: "!abc:matrix.example.org"
min_level: WARNING
```
**Setup:**
1. Create a bot Matrix account
2. Obtain its access token (Element → Settings → Help & About → Access Token)
3. Invite the bot to the target room and note the room ID
### sms_voipms
Sends SMS via the [voip.ms REST API](https://voip.ms/api/v1/rest.php). Message is truncated to 160 characters.
```yaml
type: sms_voipms
api_user: me@example.com # voip.ms account email
api_password: secret # voip.ms API password
did: "5551234567" # Your voip.ms DID (sending number)
dst: "5559876543" # Destination number
min_level: CRITICAL
```
### signal
Sends via [signal-cli](https://github.com/AsamK/signal-cli).
```yaml
type: signal
cli_path: /usr/local/bin/signal-cli
user: +12025551234 # Your registered Signal number
recipient: +12025559999 # Recipient number
min_level: WARNING
```
**Setup:**
```bash ```bash
# Test signal-cli directly signal-cli -u +12025551234 register
signal-cli -u +1234567890 send -m "Test message" +0987654321 signal-cli -u +12025551234 verify CODE
# Test SMTP
echo "Test" | mail -s "Test Subject" admin@example.com
# Test through heartbeat system (Python REPL)
from hbd.server import notify as notify_mod, config as config_mod
cfg = config_mod.load_config(".hb.yaml")
notify_mod.setup(cfg)
notify_mod.pushmsg_for_host("test-host", "Test notification")
``` ```
### mattermost
Sends via Mattermost incoming webhook. Message is formatted as Markdown.
```yaml
type: mattermost
host: mattermost.example.com
token: your-webhook-token
channel: devops-alerts
username: heartbeat-bot # Optional: display name
icon: https://…/icon.png # Optional: bot icon URL
min_level: WARNING
```
## Notification events
| Source | Level | Title example | Body example |
|---|---|---|---|
| Host overdue | CRITICAL | `[CRITICAL] webserver01` | `IPv4 overdue` |
| Host recover | RECOVER | `[RECOVER] webserver01` | `IPv4 back after being overdue for 5:23` |
| Host boot | INFO | `[INFO] webserver01` | `webserver01 booted` |
| Host shutdown | INFO | `[INFO] webserver01` | `IPv4 shutdown` |
| Threshold breach | WARNING/CRITICAL | `[CRITICAL] webserver01` | `cpu_percent = 95.2 (threshold: > 90.0)` |
| Threshold reminder | CRITICAL | `[REMINDER/CRITICAL] webserver01` | `REMINDER (CRITICAL): … ongoing for 3600s` |
| Connection issue | WARNING | `[WARNING] webserver01` | `new address detected …` |
Reminder notifications (re-notify) are sent only for CRITICAL level alerts.
## API reference
### `send_notification(host_name, notif) -> dict`
Main entry point. Dispatches to owner + managers.
```python
from hbd.server.notify import send_notification, Notification
send_notification(
"webserver01",
Notification(
title="[CRITICAL] webserver01",
body="cpu_percent = 95.2 (threshold: > 90.0)",
level="CRITICAL",
url="https://hbd.example.com/plugins#webserver01",
),
)
```
Returns `{channel_name: bool}` for each channel dispatched.
### `setup(cfg, loop=None)`
Called once at startup from `main.py`. Pass the running asyncio event loop so Matrix sends work correctly.
## Troubleshooting ## Troubleshooting
### Notifications Not Sending **No notifications sent:**
- Check that users are configured (`users:` section in yaml)
- Check that the host has an `owner` or `managers` set
- Check that users have `notification_channels` listed
- Check that the channel names in user config match keys under `notification_channels:`
- If a user can't select a channel, check whether it is `private: true` and owned by someone else
1. **Check logs**: Look for "Failed to send notification" errors **min_level filtering too aggressive:**
2. **Verify host is watched**: Ensure `watch: true` in host definition - Default is `WARNING` — both WARNING and CRITICAL are sent
3. **Check channel configuration**: Verify credentials and settings - Set `min_level: WARNING` explicitly if you were expecting warnings but set CRITICAL
4. **Test channel directly**: Use command-line tools to test provider
5. **Check network**: Ensure server can reach notification endpoints
### Signal Issues **Matrix sends time out:**
- Verify the access token is valid and the bot is in the room
- `matrix-nio` must be installed: `pip install matrix-nio`
- **signal-cli not found**: Specify full path in `cli_path` **voip.ms SMS fails:**
- **Not registered**: Run `signal-cli -u +NUMBER register` and verify - Enable the API in your voip.ms account (Account → API)
- **Trust issues**: Run `signal-cli -u +NUMBER receive` to sync trust store - Verify the DID is SMS-capable in your voip.ms account
- **Recipient not found**: Ensure recipient is in your Signal contacts
### Email Issues **Signal not found:**
- Specify full `cli_path`
- Run `signal-cli -u +NUMBER receive` to sync trust store
- **Authentication failed**: Check SMTP username/password **Email authentication failed:**
- **TLS errors**: Verify SMTP port (587 for STARTTLS, 465 for SSL) - Use app-specific passwords for Gmail/Fastmail
- **Relay denied**: Ensure SMTP server allows relay from your IP - Verify port: 587 for STARTTLS, 465 for SSL
- **Timeout**: Check firewall rules for SMTP ports
### Pushover Issues **Pushover `400` errors:**
- Double-check `token` (app) and `user` (user key) — they are different values
- **Invalid token/user**: Verify token and user key from Pushover dashboard
- **API rate limits**: Pushover has monthly message limits on free tier
- **HTTP errors**: Check Pushover API status page
### Mattermost Issues
- **Webhook not found**: Verify webhook token and ensure webhook is enabled
- **Channel not found**: Check channel name spelling and permissions
- **Driver import error**: Install mattermostdriver: `pip install mattermostdriver`
## API Reference
### Main Functions
#### `pushmsg_for_host(hostname: str, msg: str, debug: int = 0) -> dict`
Send notification to host-specific channels.
**Parameters:**
- `hostname`: Name of the host (used to look up notification channels)
- `msg`: Message to send
- `debug`: Debug level (0=no debug, 1+=debug output)
**Returns:** Dictionary of results per channel: `{"signal_ops": True, "email_ops": False}`
**Example:**
```python
from hbd.server import notify as notify_mod
notify_mod.pushmsg_for_host("prod-web-01", "Server CPU at 95%")
```
**Behavior:**
1. Looks up notification channels configured for the host
2. If no host-specific channels, uses `default_notification_channels`
3. Dispatches to each channel in parallel
4. Returns dict of results keyed by channel name
5. Logs success/failure for each channel
## Examples
### Complete Configuration Example
```yaml
# Notification channel definitions
notification_channels:
signal_oncall:
type: signal
cli_path: /usr/local/bin/signal-cli
user: +12025551234
recipient: +12025555678
email_ops:
type: email
recipients: [ops@example.com, alerts@example.com]
sender: heartbeat@example.com
smtp_server: smtp.fastmail.com
smtp_port: 587
smtp_user: heartbeat@example.com
smtp_password: app-password-here
# Default channels
default_notification_channels: [email_ops]
# Host definitions with channel assignments
hosts:
prod-web-01:
threshold_config: high_sensitivity
watch: true
notification_channels: [signal_oncall, email_ops]
dyndns: false
dev-server-01:
threshold_config: low_sensitivity
watch: false
notification_channels: [email_ops]
dyndns: false
```
### Multiple Environments Example
```yaml
notification_channels:
# Production channels
signal_prod_oncall:
type: signal
user: +12025551234
recipient: +12025551111 # On-call phone
email_prod_ops:
type: email
recipients: [prod-ops@example.com]
sender: prod-heartbeat@example.com
smtp_server: smtp.example.com
# Staging channels
email_staging:
type: email
recipients: [staging-alerts@example.com]
sender: staging-heartbeat@example.com
smtp_server: smtp.example.com
# Development channels
mattermost_dev:
type: mattermost
host: chat.example.com
token: dev-webhook-token
channel: dev-alerts
hosts:
prod-api-01:
notification_channels: [signal_prod_oncall, email_prod_ops]
staging-api-01:
notification_channels: [email_staging]
dev-api-01:
notification_channels: [mattermost_dev]
```
+23
View File
@@ -8,6 +8,7 @@ This guide explains how to create custom plugins for the Heartbeat monitoring sy
- [Plugin Types](#plugin-types) - [Plugin Types](#plugin-types)
- [Creating a Plugin](#creating-a-plugin) - [Creating a Plugin](#creating-a-plugin)
- [Plugin Lifecycle](#plugin-lifecycle) - [Plugin Lifecycle](#plugin-lifecycle)
- [Server-initiated InfoPlugin refresh](#server-initiated-infoplugin-refresh)
- [Configuration](#configuration) - [Configuration](#configuration)
- [Best Practices](#best-practices) - [Best Practices](#best-practices)
- [Examples](#examples) - [Examples](#examples)
@@ -250,6 +251,28 @@ Understanding the plugin lifecycle helps you implement plugins correctly:
└─> Plugin releases resources, closes connections └─> 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 ## Configuration
### Plugin-Specific Configuration ### Plugin-Specific Configuration
+231 -72
View File
@@ -256,6 +256,56 @@ disk_monitor:
operator: "<" 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 ### Network Monitor
```yaml ```yaml
@@ -814,42 +864,39 @@ Planned features:
## Multi-Threshold Configuration ## Multi-Threshold Configuration
**New in version 2.0**: Support for multiple named threshold configurations with per-host mapping. Support for multiple named threshold configurations with per-host mapping and composable layering.
### Overview ### Overview
The multi-threshold feature allows you to: The multi-threshold feature allows you to:
- Define multiple sets of threshold configurations - Define multiple named threshold configurations
- Map different hosts to different threshold sets - Assign one or more configurations to each host
- Compose configurations by layering — each named config's overrides are applied in order on top of the defaults
- Use different sensitivity levels for different environments - Use different sensitivity levels for different environments
- Maintain a default configuration for unmapped hosts
### Configuration Structure ### Configuration Structure
Named configurations are defined under `threshold_configs`. Each host selects which ones to use via `threshold_config` in the `hosts` section (a string for a single config, or a list to layer multiple):
```yaml ```yaml
# Optional: Set the default configuration name (defaults to "default") # Optional: set the default configuration name (defaults to "default")
default_threshold_config: "default" default_threshold_config: "default"
# Define multiple named threshold configurations
threshold_configs: threshold_configs:
# Configuration name 1
default: default:
thresholds: thresholds:
# Standard threshold definitions
cpu_monitor: cpu_monitor:
cpu_percent: cpu_percent:
warning: 80.0 warning: 80.0
critical: 90.0 critical: 90.0
# Configuration name 2
high_sensitivity: high_sensitivity:
thresholds: thresholds:
cpu_monitor: cpu_monitor:
cpu_percent: cpu_percent:
warning: 60.0 warning: 60.0
critical: 75.0 critical: 75.0
# Configuration name 3
low_sensitivity: low_sensitivity:
thresholds: thresholds:
cpu_monitor: cpu_monitor:
@@ -857,14 +904,77 @@ threshold_configs:
warning: 90.0 warning: 90.0
critical: 95.0 critical: 95.0
# Map specific hosts to specific configurations hosts:
host_threshold_mapping: prod-web-01:
prod-web-01: high_sensitivity threshold_config: high_sensitivity # single config
prod-web-02: high_sensitivity
dev-server-01: low_sensitivity dev-server-01:
# Unmapped hosts use default_threshold_config threshold_config: low_sensitivity
# Hosts with no threshold_config use default_threshold_config
``` ```
### Composable Configurations (list form)
`threshold_config` can be a list. Configs are applied **left to right**: the defaults are the base, then each named config's overrides are layered on top. Later entries in the list win on any metric they define.
```yaml
threshold_configs:
default:
thresholds:
cpu_monitor:
cpu_percent: {warning: 80, critical: 90}
memory_monitor:
memory_percent: {warning: 85, critical: 95}
disk_monitor:
partitions:
/:
percent: {warning: 80, critical: 90}
# Tighter CPU limits for busy servers
high_cpu_load:
thresholds:
cpu_monitor:
cpu_percent: {warning: 60, critical: 75}
# Tighter disk limits for data-heavy servers
busy_disk:
thresholds:
disk_monitor:
partitions:
/:
percent: {warning: 70, critical: 85}
hosts:
# Gets default thresholds only
web-01:
threshold_config: default
# Gets tighter CPU limits, default memory and disk
build-server:
threshold_config: high_cpu_load
# Layers both: tighter CPU AND tighter disk, default memory
db-01:
threshold_config: [high_cpu_load, busy_disk]
# Three layers: busy_disk overrides high_cpu_load if they conflict
storage-01:
threshold_config: [default, high_cpu_load, busy_disk]
```
**How layering works:**
Starting from the `default` thresholds:
| Layer | Applied config | Effect |
|-------|---------------|--------|
| Base | `default` | all default thresholds |
| +1 | `high_cpu_load` | cpu_percent overridden to 60/75 |
| +2 | `busy_disk` | disk percent overridden to 70/85; cpu_percent stays at 60/75 |
Each named config only overrides the metrics it explicitly defines. Metrics not mentioned in a config inherit from the layers beneath.
### Use Cases ### Use Cases
#### 1. Environment-Based Thresholds #### 1. Environment-Based Thresholds
@@ -879,7 +989,7 @@ threshold_configs:
cpu_percent: cpu_percent:
warning: 70.0 # Alert earlier in production warning: 70.0 # Alert earlier in production
critical: 85.0 critical: 85.0
development: development:
thresholds: thresholds:
cpu_monitor: cpu_monitor:
@@ -887,11 +997,15 @@ threshold_configs:
warning: 90.0 # More relaxed for dev warning: 90.0 # More relaxed for dev
critical: 98.0 critical: 98.0
host_threshold_mapping: hosts:
prod-web-01: production prod-web-01:
prod-web-02: production threshold_config: production
dev-web-01: development prod-web-02:
dev-web-02: development threshold_config: production
dev-web-01:
threshold_config: development
dev-web-02:
threshold_config: development
``` ```
#### 2. Server Role-Based Thresholds #### 2. Server Role-Based Thresholds
@@ -906,7 +1020,7 @@ threshold_configs:
cpu_percent: cpu_percent:
warning: 80.0 warning: 80.0
critical: 90.0 critical: 90.0
database: database:
thresholds: thresholds:
cpu_monitor: cpu_monitor:
@@ -914,7 +1028,7 @@ threshold_configs:
warning: 70.0 warning: 70.0
critical: 85.0 critical: 85.0
memory_monitor: memory_monitor:
percent: memory_percent:
warning: 90.0 # Databases can use high memory warning: 90.0 # Databases can use high memory
critical: 97.0 critical: 97.0
disk_monitor: disk_monitor:
@@ -923,21 +1037,27 @@ threshold_configs:
percent: percent:
warning: 75.0 warning: 75.0
critical: 85.0 critical: 85.0
cache: cache:
thresholds: thresholds:
memory_monitor: memory_monitor:
percent: memory_percent:
warning: 95.0 # Redis/Memcached can use very high memory warning: 95.0 # Redis/Memcached can use very high memory
critical: 99.0 critical: 99.0
host_threshold_mapping: hosts:
web-01: webserver web-01:
web-02: webserver threshold_config: webserver
db-01: database web-02:
db-02: database threshold_config: webserver
redis-01: cache db-01:
memcached-01: cache threshold_config: database
db-02:
threshold_config: database
redis-01:
threshold_config: cache
memcached-01:
threshold_config: cache
``` ```
#### 3. Sensitivity Levels #### 3. Sensitivity Levels
@@ -952,10 +1072,10 @@ threshold_configs:
partitions: partitions:
/: /:
percent: percent:
warning: 70.0 # Very sensitive warning: 70.0
critical: 80.0 critical: 80.0
hysteresis: 0.15 hysteresis: 0.15
standard: standard:
thresholds: thresholds:
disk_monitor: disk_monitor:
@@ -965,7 +1085,7 @@ threshold_configs:
warning: 85.0 warning: 85.0
critical: 95.0 critical: 95.0
hysteresis: 0.1 hysteresis: 0.1
relaxed: relaxed:
thresholds: thresholds:
disk_monitor: disk_monitor:
@@ -976,52 +1096,91 @@ threshold_configs:
critical: 98.0 critical: 98.0
hysteresis: 0.05 hysteresis: 0.05
host_threshold_mapping: hosts:
payment-gateway: critical payment-gateway:
auth-server: critical threshold_config: critical
web-01: standard auth-server:
web-02: standard threshold_config: critical
test-server: relaxed web-01:
threshold_config: standard
web-02:
threshold_config: standard
test-server:
threshold_config: relaxed
``` ```
### Backward Compatibility #### 4. Composable Profiles
The legacy single threshold configuration is fully supported: Build host-specific thresholds by combining small, focused configs:
```yaml ```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: threshold_configs:
# Baseline — everything at default levels
default: default:
thresholds: thresholds:
cpu_monitor: cpu_monitor:
cpu_percent: cpu_percent: {warning: 80, critical: 90}
warning: 80.0 memory_monitor:
critical: 90.0 memory_percent: {warning: 85, critical: 95}
```
# Overlay: tighter CPU only
tight_cpu:
thresholds:
cpu_monitor:
cpu_percent: {warning: 60, critical: 75}
# Overlay: tighter memory only
tight_memory:
thresholds:
memory_monitor:
memory_percent: {warning: 70, critical: 85}
# Overlay: extra disk partition for database servers
db_disk:
thresholds:
disk_monitor:
partitions:
/var/lib/postgresql:
percent: {warning: 75, critical: 88}
hosts:
# Plain web server
web-01:
threshold_config: default
# Build server: tight CPU, default memory and disk
build-01:
threshold_config: tight_cpu
# Database: tight CPU + tight memory + extra disk partition
db-01:
threshold_config: [tight_cpu, tight_memory, db_disk]
# Replica database: tight memory + extra disk, normal CPU
db-02:
threshold_config: [tight_memory, db_disk]
```
### Configuration Priority ### Configuration Priority
1. **Host-specific mapping**: If host is in `host_threshold_mapping`, use that config 1. **Host `threshold_config` (list)**: Layer each named config's overrides left-to-right on top of the defaults
2. **Default config**: Use `default_threshold_config` 2. **Host `threshold_config` (string)**: Use that single named config directly
3. **First alphabetically**: If default not found, use first config alphabetically 3. **`host_threshold_mapping`** (legacy): Same as above, string only
4. **Legacy fallback**: If `threshold_configs` not present, use `thresholds` 4. **`default_threshold_config`**: Used for hosts with no mapping
5. **First alphabetically**: If the default config is not found, use the first config alphabetically
6. **Legacy `thresholds` section**: Used when `threshold_configs` is absent entirely
### Example: Complete Multi-Threshold Setup ### Backward Compatibility
See `hbd/config_multi_threshold_example.yaml` for a complete example with: The legacy `host_threshold_mapping` top-level key and the flat `thresholds` section are still fully supported:
- 4 named configurations (default, high_sensitivity, low_sensitivity, database)
- Host-to-config mappings for production, development, and test systems ```yaml
- Specialized database server thresholds # Still works — equivalent to hosts: {prod-web-01: {threshold_config: high_sensitivity}}
- Custom display messages with plugin data host_threshold_mapping:
prod-web-01: high_sensitivity
# Still works — equivalent to threshold_configs: {default: {thresholds: ...}}
thresholds:
cpu_monitor:
cpu_percent: {warning: 80, critical: 90}
```
+286
View File
@@ -0,0 +1,286 @@
# User Management
Heartbeat supports optional user accounts with role-based access control per host. When no users are configured the server runs in **unauthenticated mode** — all existing behaviour is unchanged.
---
## Overview
Users are defined in the server config file. Each host can have an **owner**, zero or more **managers**, and zero or more **monitors**. A **default owner** catches any host that does not name an explicit owner.
### Roles
| Role | Inherits | Permissions |
|------|----------|-------------|
| **monitor** | — | View host status, plugin data, alerts; acknowledge alerts they were notified for |
| **manager** | monitor | + Queue commands (`/c`), trigger DNS re-registration (`/n`), queue upgrades (`/u`); add/remove monitors |
| **owner** | manager | + Drop host (`/d`); add/remove managers; transfer ownership; update host access |
| **admin** *(flag)* | owner on all hosts | Full access to every host and the user list |
`admin` is a flag on the user, not a per-host role. An admin user has owner-level access on every host without being listed as owner/manager/monitor.
---
## Configuration
### Defining users
```yaml
users:
andreas:
full_name: Andreas Wrede
avatar: /path/to/avatar.png # file path, URL, or base64 data URI (optional)
password: pbkdf2:sha256:... # generated with: hbd passwd andreas
admin: true # optional — grants server-wide owner access
bob:
full_name: Bob Smith
password: pbkdf2:sha256:...
notification_channels: [pushover_standard] # channels bob has selected
carol:
full_name: Carol Jones
password: pbkdf2:sha256:...
default_owner: andreas # owns hosts with no explicit owner
# falls back to the first admin user if omitted
```
### 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
hosts:
webserver01:
owner: andreas
managers: [bob]
monitors: [carol]
threshold_config: default
watch: true
notification_channels: [pushover_standard]
unattended-host: # no owner → owned by default_owner
threshold_config: default
watch: true
```
### Generating a password hash
```bash
hbd passwd andreas
```
Enter and confirm the password when prompted. Paste the printed hash into the config file under the user's `password` key.
You can also generate a hash non-interactively from Python:
```python
from hbd.server.users import hash_password
print(hash_password("mysecret"))
```
Passwords are stored as PBKDF2-HMAC-SHA256 hashes (260 000 iterations). No third-party libraries are required — only Python's standard `hashlib`.
---
## Authentication
When at least one user is defined, every request must be authenticated. Unauthenticated requests to HTML pages are redirected to `/login`; unauthenticated API requests receive `401 Unauthorized`.
### Browser login
Navigate to any page — you will be redirected to `/login` automatically. After submitting valid credentials the server sets an `hbd_session` cookie (HttpOnly, SameSite=Lax, 24 h lifetime). All subsequent requests, including JavaScript `fetch()` calls on the dashboards, carry the cookie automatically.
To log out, visit `/logout`.
### API / programmatic login
```bash
# Log in and capture the token
TOKEN=$(curl -s -X POST http://localhost:50004/api/0/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"andreas","password":"mysecret"}' | jq -r .token)
# Use the token in subsequent requests
curl -H "Authorization: Bearer $TOKEN" http://localhost:50004/api/0/hosts
```
The token is identical to the session cookie value — both mechanisms work simultaneously.
```bash
# Log out
curl -s -X POST http://localhost:50004/api/0/auth/logout \
-H "Authorization: Bearer $TOKEN"
```
---
## API Endpoints
### Authentication
#### POST /api/0/auth/login
Obtain a session token.
**Request body:**
```json
{ "username": "andreas", "password": "mysecret" }
```
**Response:**
```json
{ "token": "<opaque-hex-token>", "username": "andreas" }
```
Also sets the `hbd_session` cookie for browser clients.
**Status codes:** `200 OK`, `401 Unauthorized`, `404` (auth not configured)
---
#### POST /api/0/auth/logout
Invalidate the current session.
**Headers:** `Authorization: Bearer <token>` or cookie
**Response:** `{ "success": true }`
---
### Users
#### GET /api/0/users
List all users. **Admin only.**
**Response:**
```json
[
{ "username": "andreas", "full_name": "Andreas Wrede", "avatar": "", "admin": true, "notification_channels": [] },
{ "username": "bob", "full_name": "Bob Smith", "avatar": "", "admin": false, "notification_channels": ["pushover_standard"] }
]
```
---
#### GET /api/0/users/me
Return the currently authenticated user's profile.
**Response:**
```json
{ "username": "carol", "full_name": "Carol Jones", "avatar": "", "admin": false, "notification_channels": [] }
```
---
#### PUT /api/0/users/me
Update the current user's profile. All fields are optional — send only what you want to change.
**Update display name and avatar:**
```json
{ "full_name": "Carol Jones", "avatar": "/avatars/carol.png" }
```
**Change notification channel selection:**
```json
{ "notification_channels": ["pushover_ops", "email_ops"] }
```
Only channels visible to the user (public + own private) are accepted; others are silently dropped.
**Change password:**
```json
{ "password": { "current": "oldpass", "new": "newpass" } }
```
Requires the correct current password. New password is hashed before storage.
**Response:** `{"ok": true}`
**Status codes:** `200 OK`, `400` (missing/invalid field), `401` (unauthenticated), `403` (wrong current password)
---
### Host Access
#### GET /api/0/hosts/{hostname}/access
Return owner/managers/monitors for a host. Requires at least **monitor** role.
**Response:**
```json
{
"owner": "andreas",
"managers": ["bob"],
"monitors": ["carol"]
}
```
---
#### PUT /api/0/hosts/{hostname}/access
Update owner/managers/monitors. Requires **owner** role or admin.
**Request body** (all fields optional):
```json
{
"owner": "bob",
"managers": ["carol"],
"monitors": []
}
```
Changes take effect immediately in memory. They are not written back to the config file — reload (`SIGHUP`) will re-apply config values. To make changes permanent, update the config file.
---
## Host visibility
When users are configured, `GET /api/0/hosts` only returns hosts the authenticated user has at least monitor access to. Admins see all hosts.
---
## Config reload
On `SIGHUP`, the server reloads the config file, re-loads the user registry, and re-applies `owner`/`managers`/`monitors` from config to all known hosts. Existing sessions remain valid after a reload.
---
## No-auth mode
If `users:` is absent or empty, the server starts in **unauthenticated mode**:
- No login required — all pages and API endpoints are accessible without credentials.
- All permission checks pass unconditionally.
- `/login`, `/logout`, and the auth/user API endpoints return `404`.
This preserves full backwards compatibility with existing deployments.
---
## Security notes
- Session tokens are 64-character cryptographically random hex strings (`secrets.token_hex(32)`).
- Sessions expire after 24 hours (configurable via `users_mod.SESSION_TTL`).
- Cookies are `HttpOnly` and `SameSite=Lax` — they are not accessible to JavaScript and are not sent on cross-site requests.
- The HTTP API does not yet enforce TLS. For production use, place hbd behind a TLS-terminating reverse proxy (nginx, Caddy, etc.) or enable WSS.
---
## See Also
- [HTTP API Documentation](HTTP_API.md)
- [Notifications](NOTIFICATIONS.md)
- Configuration example: `hbd/config_example.yaml`
-9
View File
@@ -1,9 +0,0 @@
Plan
Heartbeat is a client/server based network monitor and host observer. hbd, the server portion receives heartbeat and state messages from clients and maintaines state and hisgtory of the informations it receives.
hbc, the client portion gathers information on various aspects of the
system it is running on, and sends it to hbd. Initially this info is basic, like OS make and version, hardware info (CPU type, memory and disks), fileystem info and some resource info. hbc/hbd support a plugin system to extend the info gathered and stored.
hbd also can send notification based on missed hbc updates, and on violation of pre-set limits for various state paramaters.
+1 -1
View File
@@ -14,4 +14,4 @@ Install options:
""" """
__all__ = ["__version__"] __all__ = ["__version__"]
__version__ = "5.0.8" __version__ = "5.3.8"
+1 -1
View File
@@ -1,3 +1,3 @@
"""HeartBeat Client (hbc) - System monitoring client.""" """HeartBeat Client (hbc) - System monitoring client."""
__version__ = "5.0.5" from hbd import __version__
+13 -6
View File
@@ -2,6 +2,9 @@
import logging import logging
import os import os
import logging
logger = logging.getLogger(__name__)
try: try:
import yaml import yaml
@@ -12,12 +15,15 @@ CLIENT_DEFAULTS = {
# Network settings # Network settings
"hb_port": 50003, # Port where hbd servers listen "hb_port": 50003, # Port where hbd servers listen
"interval": 10, # Heartbeat interval in seconds "interval": 10, # Heartbeat interval in seconds
# Host identity
"owner": None, # Optional username to set as this host's owner on the server
# Runtime flags # Runtime flags
"foreground": False, "foreground": False,
"verbose": False, "verbose": False,
"debug": 0, "debug": 0,
# Plugin configuration # Plugin configuration
"plugins": {}, # Per-plugin configuration "plugins": {}, # Per-plugin configuration
"thresholds": {}, # Threshold configuration for monitoring "thresholds": {}, # Threshold configuration for monitoring
@@ -30,18 +36,19 @@ def load_config(path=None):
If YAML is not available or the file does not exist, defaults are returned. If YAML is not available or the file does not exist, defaults are returned.
Args: Args:
path: Path to YAML config file (default: ~/.hb.yaml) path: Path to YAML config file (default: ~/.hbc.yaml)
Returns: Returns:
Dictionary with configuration Dictionary with configuration
""" """
cfg = CLIENT_DEFAULTS.copy() cfg = CLIENT_DEFAULTS.copy()
if not path: if not path:
# default path (~/.hb.yaml) # default path (~/.hbc.yaml)
path = os.path.join(os.path.expanduser("~"), ".hb.yaml") path = os.path.join(os.path.expanduser("~"), ".hbc.yaml")
if os.path.exists(path): if os.path.exists(path):
if yaml: if yaml:
logger.info("Loading configuration from %s", path)
with open(path) as fh: with open(path) as fh:
data = yaml.safe_load(fh) data = yaml.safe_load(fh)
# Merge YAML data with defaults # Merge YAML data with defaults
@@ -50,5 +57,5 @@ def load_config(path=None):
cfg[k] = v cfg[k] = v
else: else:
# yaml not installed: do not attempt to parse; user must ensure defaults # yaml not installed: do not attempt to parse; user must ensure defaults
pass logger.warning("PyYAML not available - cannot load config from %s, using defaults", path)
return cfg return cfg
+227 -122
View File
@@ -14,13 +14,14 @@ import signal
import socket import socket
import sys import sys
import time import time
from hashlib import md5 from logging.handlers import SysLogHandler
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional from typing import Dict, List, Optional
# Import protocol and config # Import protocol and config
from .config import load_config from .config import load_config
from ..common.proto import dicttos, stodict from ..common.proto import dicttos, stodict
from .. import __version__
# Import plugin system # Import plugin system
from .plugin import PluginRegistry, PluginLoader, InfoPlugin, MonitorPlugin from .plugin import PluginRegistry, PluginLoader, InfoPlugin, MonitorPlugin
@@ -55,23 +56,28 @@ class AsyncConnection:
self.transport: Optional[asyncio.DatagramTransport] = None self.transport: Optional[asyncio.DatagramTransport] = None
self.protocol: Optional[asyncio.DatagramProtocol] = None self.protocol: Optional[asyncio.DatagramProtocol] = None
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}") self.logger = logging.getLogger(f"hbc.conn.{addr}")
async def open(self) -> bool: async def open(self) -> bool:
"""Open the UDP connection. """Open the UDP connection.
Returns: Returns:
True if successful, False otherwise True if successful, False otherwise
""" """
try: try:
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
# Create datagram endpoint # Create datagram endpoint
self.transport, self.protocol = await loop.create_datagram_endpoint( self.transport, self.protocol = await loop.create_datagram_endpoint(
lambda: HeartbeatProtocol(self), lambda: HeartbeatProtocol(self),
family=self.af family=self.af
) )
self._ever_opened = True
self.logger.debug(f"Opened connection to {self.addr}:{self.port}") self.logger.debug(f"Opened connection to {self.addr}:{self.port}")
return True return True
except Exception as e: except Exception as e:
@@ -92,9 +98,12 @@ class AsyncConnection:
msg: Message dictionary msg: Message dictionary
msg_id: Message ID (HTB, PLG, etc.) msg_id: Message ID (HTB, PLG, etc.)
""" """
if self._dead:
return
if not self.transport: if not self.transport:
await self.open() await self.open()
if not self.transport: if not self.transport:
self.logger.error("Cannot send - no transport") self.logger.error("Cannot send - no transport")
return return
@@ -130,6 +139,9 @@ class AsyncConnection:
self.ackcount += 1 self.ackcount += 1
self.logger.debug(f"ACK received, RTT: {rtt:.1f}ms") 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): class HeartbeatProtocol(asyncio.DatagramProtocol):
@@ -165,8 +177,9 @@ class HeartbeatProtocol(asyncio.DatagramProtocol):
self.logger.error(f"Error processing datagram: {e}", exc_info=True) self.logger.error(f"Error processing datagram: {e}", exc_info=True)
def error_received(self, exc): def error_received(self, exc):
"""Handle protocol errors.""" """Handle protocol errors — close transport so the heartbeat sender retries."""
self.logger.error(f"Protocol error: {exc}") self.logger.warning(f"Protocol error on {self.connection.addr}: {exc} — will retry")
self.connection.close()
async def handle_command(conn: AsyncConnection, msg: dict): async def handle_command(conn: AsyncConnection, msg: dict):
@@ -203,55 +216,52 @@ async def handle_command(conn: AsyncConnection, msg: dict):
await conn.sendto(response) await conn.sendto(response)
async def handle_update(conn: AsyncConnection, msg: dict): async def handle_update(conn: AsyncConnection, _msg: dict): # pyright: ignore[reportUnusedParameter]
"""Handle self-update from server.""" """Handle self-update by running hb_install.sh."""
import codecs
import shutil import shutil
logger = logging.getLogger("hbc.update") logger = logging.getLogger("hbc.update")
installer = shutil.which("hb_install.sh")
if installer is None:
candidate = Path(sys.argv[0]).parent / "hb_install.sh"
if candidate.exists():
installer = str(candidate)
if installer is None:
error = "hb_install.sh not found in PATH or alongside hbc"
logger.error(error)
await conn.sendto({"service": "update", "msg": error})
return
logger.info(f"Running installer: {installer}")
try: try:
code = codecs.decode(msg["code"], "base64").decode() proc = await asyncio.create_subprocess_exec(
csum = msg["csum"] installer, "client",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
out, _ = await asyncio.wait_for(proc.communicate(), timeout=120)
except asyncio.TimeoutError:
error = "Installer timed out"
logger.error(error)
await conn.sendto({"service": "update", "msg": error})
return
except Exception as e: except Exception as e:
error = f"Missing code/csum: {e}" error = f"Installer failed: {e}"
logger.error(error) logger.error(error)
await conn.sendto({"service": "update", "msg": error}) await conn.sendto({"service": "update", "msg": error})
return return
# Verify checksum if proc.returncode != 0:
m = md5() error = f"Installer exited {proc.returncode}: {out.decode().strip()}"
m.update(code.encode())
if m.hexdigest() != csum:
error = "Checksum mismatch"
logger.error(error) logger.error(error)
await conn.sendto({"service": "update", "msg": error}) await conn.sendto({"service": "update", "msg": error})
return return
# Backup current file
fn = sys.argv[0]
ofn = f"{fn}.sav"
try:
shutil.copy2(fn, ofn)
except Exception as e:
error = f"Backup failed: {e}"
logger.error(error)
await conn.sendto({"service": "update", "msg": error})
return
# Write new code
try:
with open(fn, "w") as fh:
fh.write(code)
except Exception as e:
error = f"Write failed: {e}"
logger.error(error)
await conn.sendto({"service": "update", "msg": error})
return
logger.info("Update successful, restart required") logger.info("Update successful, restart required")
await conn.sendto({"service": "update", "msg": "OK"}) await conn.sendto({"service": "update", "msg": "OK"})
# Trigger restart # Trigger restart
global dorestart global dorestart
dorestart = True dorestart = True
@@ -259,15 +269,51 @@ async def handle_update(conn: AsyncConnection, msg: dict):
async def heartbeat_sender(conn: AsyncConnection, interval: int): async def heartbeat_sender(conn: AsyncConnection, interval: int):
"""Send periodic heartbeats. """Send periodic heartbeats, retrying the connection if it is not open.
IPv6 connections that fail to open before their first successful send are
dropped after IPV6_EARLY_FAIL_LIMIT attempts so that a network without IPv6
does not keep a dead sender alive. IPv4 connections are retried indefinitely.
Args: Args:
conn: Connection to send on conn: Connection to send on
interval: Heartbeat interval in seconds interval: Heartbeat interval in seconds
""" """
logger = logging.getLogger("hbc.heartbeat") logger = logging.getLogger("hbc.heartbeat")
IPV6_EARLY_FAIL_LIMIT = 3
while running:
while running and not conn._dead:
# Ensure transport is open before attempting to send.
if not conn.transport:
opened = await conn.open()
if opened:
conn._open_fail_count = 0
else:
conn._open_fail_count += 1
# Drop an IPv6 connection that has never come up within the
# first few attempts — it is likely unavailable on this network.
if (not conn._ever_opened
and conn.af == socket.AF_INET6
and conn._open_fail_count >= IPV6_EARLY_FAIL_LIMIT):
logger.warning(
f"IPv6 connection to {conn.addr} unreachable after "
f"{conn._open_fail_count} attempts, disabling"
)
conn._dead = True
break
# Retry after the normal interval; IPv4 retries forever.
try:
if shutdown_event:
await asyncio.wait_for(shutdown_event.wait(), timeout=interval)
break
else:
await asyncio.sleep(interval)
except asyncio.TimeoutError:
pass
except asyncio.CancelledError:
raise
continue
try: try:
msg = { msg = {
"acks": conn.ackcount, "acks": conn.ackcount,
@@ -275,20 +321,17 @@ async def heartbeat_sender(conn: AsyncConnection, interval: int):
"interval": interval "interval": interval
} }
await conn.sendto(msg, "HTB") await conn.sendto(msg, "HTB")
except Exception as e:
logger.error(f"Error sending heartbeat: {e}", exc_info=True)
except asyncio.CancelledError: except asyncio.CancelledError:
logger.debug("Heartbeat sender cancelled") logger.debug("Heartbeat sender cancelled")
raise raise
except Exception as e:
logger.error(f"Error sending heartbeat: {e}", exc_info=True)
# Wait for next interval or shutdown event # Wait for next interval or shutdown event
try: try:
if shutdown_event: if shutdown_event:
await asyncio.wait_for( await asyncio.wait_for(shutdown_event.wait(), timeout=interval)
shutdown_event.wait(),
timeout=interval
)
break break
else: else:
await asyncio.sleep(interval) await asyncio.sleep(interval)
@@ -299,15 +342,35 @@ async def heartbeat_sender(conn: AsyncConnection, interval: int):
raise 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): async def plugin_collector(conn: AsyncConnection, registry: PluginRegistry):
"""Collect and send plugin data. """Collect and send plugin data.
Args: Args:
conn: Connection to send on conn: Connection to send on
registry: Plugin registry registry: Plugin registry
""" """
logger = logging.getLogger("hbc.plugins") logger = logging.getLogger("hbc.plugins")
# Collect InfoPlugins once at startup # Collect InfoPlugins once at startup
info_plugins = registry.get_by_type(InfoPlugin) info_plugins = registry.get_by_type(InfoPlugin)
for plugin in info_plugins: for plugin in info_plugins:
@@ -320,34 +383,31 @@ async def plugin_collector(conn: AsyncConnection, registry: PluginRegistry):
logger.info(f"Sent {plugin.name} data") logger.info(f"Sent {plugin.name} data")
except Exception as e: except Exception as e:
logger.error(f"Error collecting {plugin.name}: {e}", exc_info=True) logger.error(f"Error collecting {plugin.name}: {e}", exc_info=True)
# Schedule MonitorPlugins # Schedule MonitorPlugins
# Group plugins by interval # Group plugins by interval
from collections import defaultdict from collections import defaultdict
by_interval = defaultdict(list) by_interval = defaultdict(list)
monitor_plugins = registry.get_by_type(MonitorPlugin) monitor_plugins = registry.get_by_type(MonitorPlugin)
for plugin in monitor_plugins: for plugin in monitor_plugins:
by_interval[plugin.interval].append(plugin) by_interval[plugin.interval].append(plugin)
# Create tasks for each interval # Create tasks for each interval; always include the info-refresh watcher
tasks = [] tasks = [asyncio.create_task(_info_plugin_refresh_loop(conn, info_plugins))]
for interval, plugins in by_interval.items(): for interval, plugins in by_interval.items():
task = asyncio.create_task( tasks.append(asyncio.create_task(
plugin_collector_interval(conn, plugins, interval) plugin_collector_interval(conn, plugins, interval)
) ))
tasks.append(task)
try:
# Wait for all tasks await asyncio.gather(*tasks, return_exceptions=True)
if tasks: except asyncio.CancelledError:
try: logger.debug("Plugin collector cancelled, cancelling sub-tasks")
await asyncio.gather(*tasks, return_exceptions=True) for task in tasks:
except asyncio.CancelledError: if not task.done():
logger.debug("Plugin collector cancelled, cancelling sub-tasks") task.cancel()
for task in tasks: raise
if not task.done():
task.cancel()
raise
async def plugin_collector_interval( async def plugin_collector_interval(
@@ -424,16 +484,13 @@ async def cleanup(connections: List[AsyncConnection]):
logger = logging.getLogger("hbc.cleanup") logger = logging.getLogger("hbc.cleanup")
logger.info("Cleaning up connections") 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: try:
msg = { await target.sendto({"shutdown": 1, "acks": target.ackcount})
"shutdown": 1,
"acks": conn.ackcount
}
await conn.sendto(msg)
except Exception as e: except Exception as e:
logger.error(f"Error sending shutdown: {e}") logger.error(f"Error sending shutdown: {e}")
for conn in connections:
conn.close() conn.close()
# Give messages time to send # Give messages time to send
@@ -442,7 +499,7 @@ async def cleanup(connections: List[AsyncConnection]):
async def async_main(args, config): async def async_main(args, config):
"""Async main function.""" """Async main function."""
global running, shutdown_event, active_tasks global running, shutdown_event, active_tasks, send_shutdown
# Create shutdown event # Create shutdown event
shutdown_event = asyncio.Event() shutdown_event = asyncio.Event()
@@ -459,47 +516,62 @@ async def async_main(args, config):
hb_port = config.get("hb_port", PORT) hb_port = config.get("hb_port", PORT)
interval = config.get("interval", INTERVAL) interval = config.get("interval", INTERVAL)
logger.info(f"Starting hbc for {iam} -> {hb_hosts}") logger.info(f"hbc {__version__} on {iam} -> {hb_hosts} port={hb_port}, interval={interval}s")
logger.info(f"Port: {hb_port}, Interval: {interval}s")
af_filter = (socket.AF_INET if getattr(args, "ipv4_only", False)
else socket.AF_INET6 if getattr(args, "ipv6_only", False)
else 0)
# Create connections # Create connections
connections = [] connections = []
conn_id = 1 conn_id = 1
_retry_delay = 5
for host in hb_hosts:
try: while running and not connections:
addrs = socket.getaddrinfo(host, hb_port, 0, 0, socket.SOL_UDP) for host in hb_hosts:
except socket.gaierror as e: try:
logger.error(f"Cannot resolve {host}: {e}") addrs = socket.getaddrinfo(host, hb_port, af_filter, 0, socket.SOL_UDP)
continue except socket.gaierror as e:
logger.warning(f"Cannot resolve {host}: {e} — retrying in {_retry_delay}s")
for addr_info in addrs: continue
af = addr_info[0] for addr_info in addrs:
addr = addr_info[4][0] af = addr_info[0]
addr = addr_info[4][0]
conn = AsyncConnection(conn_id, addr, hb_port, af, iam) conn = AsyncConnection(conn_id, addr, hb_port, af, iam)
if await conn.open(): if not await conn.open():
logger.warning(f"Initial open to {addr} failed, heartbeat sender will retry")
connections.append(conn) connections.append(conn)
conn_id += 1 conn_id += 1
if not connections:
try:
if shutdown_event:
await asyncio.wait_for(shutdown_event.wait(), timeout=_retry_delay)
else:
await asyncio.sleep(_retry_delay)
except asyncio.TimeoutError:
pass
_retry_delay = min(_retry_delay * 2, 60)
if not connections: if not connections:
logger.error("No connections established")
return 1 return 1
logger.info(f"Created {len(connections)} connections") logger.info(f"Created {len(connections)} connections")
# Send boot/message if requested # Send boot/message if requested
send_shutdown = False
if args.boot or args.message: if args.boot or args.message:
boot_msg = {} boot_msg = {}
if args.boot: if args.boot:
boot_msg["boot"] = 1 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: if args.message:
boot_msg["service"] = "service" boot_msg["service"] = "service"
boot_msg["msg"] = args.message boot_msg["msg"] = args.message
boot_msg["acks"] = 0 boot_msg["acks"] = 0
for conn in connections: target = next((c for c in connections if c.transport), connections[0])
await conn.sendto(boot_msg) await target.sendto(boot_msg)
if args.message and not args.daemon: if args.message and not args.daemon:
# Message-only mode # Message-only mode
@@ -521,6 +593,13 @@ async def async_main(args, config):
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
for sig in (signal.SIGTERM, signal.SIGINT): for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, stop) loop.add_signal_handler(sig, stop)
def _sighup():
global dorestart
dorestart = True
stop()
loop.add_signal_handler(signal.SIGHUP, _sighup)
# Start async tasks # Start async tasks
# Heartbeat senders (one per connection) # Heartbeat senders (one per connection)
@@ -586,6 +665,36 @@ def daemonize(
os.dup2(se.fileno(), sys.stderr.fileno()) os.dup2(se.fileno(), sys.stderr.fileno())
def _reconfigure_logging_for_daemon(log_level: int) -> None:
"""Replace StreamHandlers (now writing to /dev/null) with a SysLogHandler."""
root = logging.getLogger()
for handler in root.handlers[:]:
root.removeHandler(handler)
handler.close()
use_udp_fallback = not os.path.exists("/dev/log")
if use_udp_fallback:
syslog_handler = SysLogHandler(
address=("localhost", 514),
facility=SysLogHandler.LOG_DAEMON,
)
else:
syslog_handler = SysLogHandler(
address="/dev/log",
facility=SysLogHandler.LOG_DAEMON,
)
syslog_handler.setFormatter(
logging.Formatter("hbc[%(process)d]: %(name)s %(levelname)s: %(message)s")
)
root.addHandler(syslog_handler)
root.setLevel(log_level)
if use_udp_fallback:
logging.warning("/dev/log not found, using syslog UDP localhost:514")
def build_parser(): def build_parser():
"""Build argument parser.""" """Build argument parser."""
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
@@ -629,6 +738,9 @@ def build_parser():
default=0, default=0,
help="Increase debug level" help="Increase debug level"
) )
af_group = parser.add_mutually_exclusive_group()
af_group.add_argument("-4", dest="ipv4_only", action="store_true", help="Use IPv4 only")
af_group.add_argument("-6", dest="ipv6_only", action="store_true", help="Use IPv6 only")
parser.add_argument( parser.add_argument(
"hosts", "hosts",
nargs="+", nargs="+",
@@ -644,13 +756,10 @@ def main(argv=None):
parser = build_parser() parser = build_parser()
args = parser.parse_args(argv) args = parser.parse_args(argv)
# Load config
config = load_config(args.configfile)
# Setup logging # Setup logging
log_level = logging.INFO log_level = logging.WARNING
if args.verbose: if args.verbose:
log_level = logging.DEBUG log_level = logging.INFO
if args.debug: if args.debug:
log_level = logging.DEBUG log_level = logging.DEBUG
@@ -659,20 +768,16 @@ def main(argv=None):
format="%(asctime)s %(name)s %(levelname)s: %(message)s", format="%(asctime)s %(name)s %(levelname)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S" datefmt="%Y-%m-%d %H:%M:%S"
) )
# Load config
config = load_config(args.configfile)
# Daemonize if requested # Daemonize if requested
if args.daemon: if args.daemon:
print("Daemonizing...") logging.info("Daemonizing...")
import syslog
syslog.openlog("hbc", syslog.LOG_PID, syslog.LOG_DAEMON)
syslog.syslog(syslog.LOG_INFO, f"Starting heartbeat to {', '.join(args.hosts)}")
daemonize() daemonize()
_reconfigure_logging_for_daemon(log_level)
# Reconfigure logging for syslog logging.info(f"hbc starting, sending heartbeat to {', '.join(args.hosts)}")
logging.basicConfig(
level=log_level,
format="hbc[%(process)d]: %(name)s %(levelname)s: %(message)s"
)
# Run async main # Run async main
try: try:
+23 -8
View File
@@ -22,13 +22,14 @@ from typing import Any, Dict, List, Optional, Type
class Plugin(ABC): class Plugin(ABC):
"""Base class for all plugins. """Base class for all plugins.
Attributes: Attributes:
name: Unique plugin identifier (e.g., "os_info", "cpu_monitor") name: Unique plugin identifier (e.g., "os_info", "cpu_monitor")
version: Plugin version string version: Plugin version string
description: Human-readable description description: Human-readable description
interval: Collection interval in seconds (0 for InfoPlugin = collect once) interval: Collection interval in seconds (0 for InfoPlugin = collect once)
enabled: Whether plugin is active (can be disabled via config) enabled: Whether plugin is active (can be disabled via config)
skip_reason: Set by plugin before returning False from initialize(); causes loader to log INFO instead of WARNING.
""" """
name: str = "" name: str = ""
@@ -39,13 +40,14 @@ class Plugin(ABC):
def __init__(self, config: Optional[Dict[str, Any]] = None): def __init__(self, config: Optional[Dict[str, Any]] = None):
"""Initialize plugin with optional configuration. """Initialize plugin with optional configuration.
Args: Args:
config: Plugin-specific configuration from YAML (e.g., thresholds, paths) config: Plugin-specific configuration from YAML (e.g., thresholds, paths)
""" """
self.config = config or {} self.config = config or {}
self.logger = logging.getLogger(f"plugin.{self.name}") self.logger = logging.getLogger(f"plugin.{self.name}")
self._initialized = False self._initialized = False
self.skip_reason: Optional[str] = None
@abstractmethod @abstractmethod
async def initialize(self) -> bool: async def initialize(self) -> bool:
@@ -311,7 +313,11 @@ class PluginLoader:
return 0 return 0
loaded_count = 0 loaded_count = 0
plugin_config = config or {} raw_config = config or {}
# Per-plugin config lives under the 'plugins' key or at top-level.
# CLIENT_DEFAULTS seeds "plugins": {} so the key always exists; check
# both the subdict and top-level so that either layout in .hbc.yaml works.
plugins_subconfig = raw_config.get("plugins", {})
# Scan for Python files # Scan for Python files
for plugin_file in directory.glob("*.py"): for plugin_file in directory.glob("*.py"):
@@ -356,17 +362,26 @@ class PluginLoader:
self.logger.debug(f"Found plugin class: {name}") self.logger.debug(f"Found plugin class: {name}")
# Instantiate plugin with config # Instantiate plugin with config — check plugins subdict first,
plugin_instance_config = plugin_config.get(obj.name, {}) # then top-level keys (e.g. nagios_runner: ... at root of config).
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) plugin = obj(config=plugin_instance_config)
# Initialize plugin # Initialize plugin
try: try:
initialized = await plugin.initialize() initialized = await plugin.initialize()
if not initialized: if not initialized:
self.logger.warning( if plugin.skip_reason:
f"Plugin {plugin.name} failed initialization, skipping" self.logger.info(
) f"Plugin {plugin.name} skipped: {plugin.skip_reason}"
)
else:
self.logger.warning(
f"Plugin {plugin.name} failed initialization, skipping"
)
continue continue
except Exception as e: except Exception as e:
self.logger.error( self.logger.error(
+7
View File
@@ -118,6 +118,13 @@ class CPUMonitorPlugin(MonitorPlugin):
data["cpu_iowait"] = round(cpu_times.iowait, 1) data["cpu_iowait"] = round(cpu_times.iowait, 1)
except Exception as e: except Exception as e:
self.logger.debug(f"Could not get CPU times: {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( self.logger.debug(
f"Collected CPU metrics: {data.get('cpu_percent', 'N/A')}% usage" 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 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__) logger = logging.getLogger(__name__)
@@ -101,11 +119,21 @@ class MemoryMonitorPlugin(MonitorPlugin):
# Virtual (physical) memory statistics # Virtual (physical) memory statistics
vmem = psutil.virtual_memory() 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_total'] = vmem.total
metrics['memory_available'] = vmem.available metrics['memory_available'] = available
metrics['memory_used'] = vmem.used metrics['memory_used'] = used
metrics['memory_free'] = vmem.free metrics['memory_free'] = vmem.free
metrics['memory_percent'] = vmem.percent metrics['memory_percent'] = percent
# Platform-specific memory details # Platform-specific memory details
if hasattr(vmem, 'active'): if hasattr(vmem, 'active'):
+81 -77
View File
@@ -21,24 +21,23 @@ nagios_runner:
``` ```
""" """
import asyncio
import os
import re import re
import subprocess import shlex
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from hbd.client.plugin import MonitorPlugin from hbd.client.plugin import MonitorPlugin
# Nagios exit codes # Nagios exit codes
NAGIOS_OK = 0
NAGIOS_WARNING = 1
NAGIOS_CRITICAL = 2
NAGIOS_UNKNOWN = 3 NAGIOS_UNKNOWN = 3
STATUS_NAMES = { STATUS_NAMES = {
NAGIOS_OK: "OK", 0: "OK",
NAGIOS_WARNING: "WARNING", 1: "WARNING",
NAGIOS_CRITICAL: "CRITICAL", 2: "CRITICAL",
NAGIOS_UNKNOWN: "UNKNOWN" 3: "UNKNOWN",
} }
@@ -52,8 +51,7 @@ class NagiosRunnerPlugin(MonitorPlugin):
interval: Collection interval in seconds (default: 300) interval: Collection interval in seconds (default: 300)
commands: List of command definitions with 'name' and 'command' keys commands: List of command definitions with 'name' and 'command' keys
timeout: Command execution timeout in seconds (default: 30) timeout: Command execution timeout in seconds (default: 30)
shell: Whether to execute commands via shell (default: True)
Example: Example:
nagios_runner: nagios_runner:
interval: 300 # Check every 5 minutes interval: 300 # Check every 5 minutes
@@ -76,32 +74,48 @@ class NagiosRunnerPlugin(MonitorPlugin):
# Extract configuration # Extract configuration
self.commands: List[Dict[str, str]] = config.get("commands", []) if config else [] self.commands: List[Dict[str, str]] = config.get("commands", []) if config else []
self.timeout: int = config.get("timeout", 30) if config else 30 self.timeout: int = config.get("timeout", 30) if config else 30
self.shell: bool = config.get("shell", True) if config else True
self.interval = config.get("interval", 300) if config else 300 self.interval = config.get("interval", 300) if config else 300
# Validate commands
if not self.commands:
self.logger.warning(
"No Nagios commands configured. Add 'nagios_runner.commands' to config."
)
async def initialize(self) -> bool: async def initialize(self) -> bool:
"""Initialize the Nagios runner plugin. """Initialize the Nagios runner plugin.
Returns: Returns:
True if at least one command is configured, False otherwise True if at least one command is configured, False otherwise
""" """
self.logger.info(f"Initializing {self.name} plugin") self.logger.info(f"Initializing {self.name} plugin")
if not self.commands: if not self.commands:
self.logger.error("No Nagios commands configured") self.skip_reason = "no commands configured (add nagios_runner.commands to config)"
return False return False
self.logger.info(f"Configured to run {len(self.commands)} Nagios plugin(s)") self.logger.info(f"Configured to run {len(self.commands)} Nagios plugin(s)")
for cmd_config in self.commands: for cmd_config in self.commands:
name = cmd_config.get("name", "unnamed") name = cmd_config.get("name", "unnamed")
self.logger.info(f" - {name}: {cmd_config.get('command', 'N/A')}") self.logger.info(f" - {name}: {cmd_config.get('command', 'N/A')}")
# Validate absolute command paths early
for cmd_config in self.commands:
name = cmd_config.get("name", "unnamed")
command = cmd_config.get("command", "")
if not command:
continue
try:
tokens = shlex.split(command)
except ValueError:
continue # malformed command string; skip validation
if not tokens:
continue
exe = tokens[0]
if os.path.isabs(exe):
if not os.path.isfile(exe):
self.logger.warning(
f"Command '{name}': executable not found: {exe}"
)
elif not os.access(exe, os.X_OK):
self.logger.warning(
f"Command '{name}': executable not executable: {exe}"
)
return True return True
async def _collect_metrics(self) -> Dict[str, Any]: async def _collect_metrics(self) -> Dict[str, Any]:
@@ -111,98 +125,88 @@ class NagiosRunnerPlugin(MonitorPlugin):
Dictionary with results from all plugins Dictionary with results from all plugins
""" """
results = {} results = {}
# Track overall status (worst status wins)
worst_status = NAGIOS_OK
for cmd_config in self.commands: for cmd_config in self.commands:
name = cmd_config.get("name") name = cmd_config.get("name")
command = cmd_config.get("command") command = cmd_config.get("command")
if not name or not command: if not name or not command:
self.logger.warning("Skipping command with missing name or command") self.logger.warning("Skipping command with missing name or command")
continue continue
# Execute plugin # Execute plugin
try: try:
status_code, output, perfdata = await self._run_nagios_plugin(command) status_code, output, perfdata = await self._run_nagios_plugin(command)
# Store results # Store results
results[f"{name}_status"] = STATUS_NAMES.get(status_code, "UNKNOWN") results[f"{name}_status"] = STATUS_NAMES.get(status_code, "UNKNOWN")
results[f"{name}_status_code"] = status_code results[f"{name}_status_code"] = status_code
results[f"{name}_output"] = output results[f"{name}_output"] = output
# Track worst status
if status_code > worst_status:
worst_status = status_code
# Parse and add performance data # Parse and add performance data
if perfdata: if perfdata:
for metric_name, metric_value in perfdata.items(): for metric_name, metric_value in perfdata.items():
results[f"{name}_{metric_name}"] = metric_value results[f"{name}_{metric_name}"] = metric_value
self.logger.debug( self.logger.info(
f"Executed {name}: {STATUS_NAMES.get(status_code, 'UNKNOWN')} - {output[:50]}" f"Executed {name}: {STATUS_NAMES.get(status_code, 'UNKNOWN')} - {output[:50]}"
) )
except Exception as e: except Exception as e:
self.logger.error(f"Error running {name}: {e}", exc_info=True) self.logger.error(f"Error running {name}: {e}", exc_info=True)
results[f"{name}_status"] = "ERROR" results[f"{name}_status"] = "ERROR"
results[f"{name}_status_code"] = NAGIOS_UNKNOWN results[f"{name}_status_code"] = NAGIOS_UNKNOWN
results[f"{name}_output"] = str(e) 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 return results
async def _run_nagios_plugin( async def _run_nagios_plugin(
self, self,
command: str command: str
) -> Tuple[int, str, Dict[str, Any]]: ) -> Tuple[int, str, Dict[str, Any]]:
"""Execute a Nagios plugin and parse its output. """Execute a Nagios plugin and parse its output."""
Args:
command: Command string to execute
Returns:
Tuple of (status_code, output_message, performance_data_dict)
"""
try: try:
# Run command proc = await asyncio.create_subprocess_shell(
result = subprocess.run(
command, command,
shell=self.shell, stdout=asyncio.subprocess.PIPE,
capture_output=True, stderr=asyncio.subprocess.PIPE,
timeout=self.timeout,
text=True
) )
try:
status_code = result.returncode stdout_bytes, stderr_bytes = await asyncio.wait_for(
output = result.stdout.strip() proc.communicate(), timeout=self.timeout
)
# Nagios plugins can return codes > 3, treat as UNKNOWN except asyncio.TimeoutError:
proc.kill()
await proc.communicate()
self.logger.error(f"Command timed out: {command}")
return NAGIOS_UNKNOWN, f"Command timed out after {self.timeout}s", {}
status_code = proc.returncode
if status_code < 0:
return NAGIOS_UNKNOWN, f"Process killed by signal {-status_code}", {}
if status_code > 3: if status_code > 3:
status_code = NAGIOS_UNKNOWN status_code = NAGIOS_UNKNOWN
# Parse performance data stdout = stdout_bytes.decode(errors="replace").strip()
perfdata = self._parse_perfdata(output) stderr = stderr_bytes.decode(errors="replace").strip()
# Extract just the status message (before the pipe if present) # Parse perfdata from stdout before mixing in stderr
if '|' in output: perfdata = self._parse_perfdata(stdout)
output_msg = output.split('|')[0].strip()
# Build status message
status_part = stdout.split('|')[0].strip() if '|' in stdout else stdout
if not stdout and stderr:
output_msg = stderr
elif stdout and stderr:
output_msg = f"{status_part} [stderr: {stderr}]"
else: else:
output_msg = output output_msg = status_part
return status_code, output_msg, perfdata return status_code, output_msg, perfdata
except subprocess.TimeoutExpired:
self.logger.error(f"Command timed out: {command}")
return NAGIOS_UNKNOWN, f"Command timed out after {self.timeout}s", {}
except Exception as e: except Exception as e:
self.logger.error(f"Error executing command: {e}") self.logger.error(f"Error executing command: {e}")
return NAGIOS_UNKNOWN, f"Execution error: {str(e)}", {} return NAGIOS_UNKNOWN, f"Execution error: {str(e)}", {}
+6
View File
@@ -48,6 +48,7 @@ class OSInfoPlugin(InfoPlugin):
Dictionary with OS details Dictionary with OS details
""" """
try: try:
from hbd import __version__ as hbc_version
data = { data = {
"system": platform.system(), # e.g., "Linux", "Darwin", "Windows" "system": platform.system(), # e.g., "Linux", "Darwin", "Windows"
"node": platform.node(), # hostname "node": platform.node(), # hostname
@@ -58,7 +59,12 @@ class OSInfoPlugin(InfoPlugin):
"architecture": platform.architecture()[0], # e.g., "64bit" "architecture": platform.architecture()[0], # e.g., "64bit"
"python_version": platform.python_version(), "python_version": platform.python_version(),
"python_implementation": platform.python_implementation(), "python_implementation": platform.python_implementation(),
"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 # Add Linux-specific distribution info
if platform.system() == "Linux": if platform.system() == "Linux":
+147
View File
@@ -0,0 +1,147 @@
"""Ping Monitor Plugin for Heartbeat.
Pings one or more hosts and reports round-trip time. Results are sent as
plugin metrics so the server-side threshold system can raise WARNING/CRITICAL
alerts using the same RTT threshold configuration format used for heartbeat RTT.
Example configuration in ~/.hbc.yaml (or the plugins section of ~/.hb.yaml):
```yaml
plugins:
ping_monitor:
interval: 60 # ping every 60 seconds (default)
count: 3 # ICMP packets per ping run (default 3)
timeout: 5 # seconds before a host is considered unreachable (default 5)
hosts:
- 8.8.8.8
- 192.168.1.1
```
Reported metrics per host (metric key uses the hostname with dots/colons replaced
by underscores so it is a valid identifier):
ping.<hostname>.rtt_avg average RTT in ms (float, or inf if unreachable)
ping.<hostname>.rtt_min minimum RTT in ms
ping.<hostname>.rtt_max maximum RTT in ms
ping.<hostname>.loss packet loss percentage (0100)
Server-side threshold config example:
```yaml
threshold_configs:
default:
thresholds:
ping_monitor:
8_8_8_8_rtt_avg:
warning: 20.0
critical: 100.0
```
"""
import asyncio
import re
import sys
from typing import Any, Dict, Optional
from hbd.client.plugin import MonitorPlugin
def _host_key(host: str) -> str:
"""Convert a hostname/IP to a safe metric key (replace . and : with _)."""
return re.sub(r"[^a-zA-Z0-9_]", "_", host)
class PingMonitorPlugin(MonitorPlugin):
"""Ping one or more configured hosts and report RTT metrics."""
name = "ping_monitor"
version = "1.0.0"
description = "ICMP ping latency monitoring"
interval = 60
def __init__(self, config: Optional[Dict[str, Any]] = None):
super().__init__(config)
cfg = config or {}
self.interval = cfg.get("interval", 60)
self.count = int(cfg.get("count", 3))
self.timeout = int(cfg.get("timeout", 5))
# hosts: dict of {hostname: {warning: x, critical: y}} or list of hostnames
raw_hosts = cfg.get("hosts", {})
if isinstance(raw_hosts, list):
self.hosts = {h: {} for h in raw_hosts}
else:
self.hosts = dict(raw_hosts)
async def initialize(self) -> bool:
if not self.hosts:
self.logger.warning("ping_monitor: no hosts configured, plugin disabled")
return False
self.logger.info(
"ping_monitor initialized: %d host(s), interval=%ds, count=%d, timeout=%ds",
len(self.hosts), self.interval, self.count, self.timeout,
)
return True
async def _ping(self, host: str) -> Dict[str, float]:
"""Run a system ping command and return rtt_min/avg/max/loss."""
if sys.platform == "win32":
cmd = ["ping", "-n", str(self.count), "-w", str(self.timeout * 1000), host]
else:
cmd = ["ping", "-c", str(self.count), "-W", str(self.timeout), host]
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await asyncio.wait_for(
proc.communicate(),
timeout=self.timeout * self.count + 2,
)
output = stdout.decode(errors="replace")
except (asyncio.TimeoutError, FileNotFoundError, OSError) as e:
self.logger.warning("ping_monitor: ping failed for %s: %s", host, e)
return {"rtt_min": float("inf"), "rtt_avg": float("inf"),
"rtt_max": float("inf"), "loss": 100.0}
# Parse packet loss
loss = 100.0
loss_match = re.search(r"(\d+(?:\.\d+)?)\s*%\s*packet\s*loss", output)
if loss_match:
loss = float(loss_match.group(1))
# Parse rtt min/avg/max — Linux: "rtt min/avg/max/mdev = x/x/x/x ms"
# macOS: "round-trip min/avg/max/stddev = x/x/x/x ms"
rtt_match = re.search(
r"(?:rtt|round-trip)\s+min/avg/max/\S+\s*=\s*([\d.]+)/([\d.]+)/([\d.]+)",
output,
)
if rtt_match:
return {
"rtt_min": float(rtt_match.group(1)),
"rtt_avg": float(rtt_match.group(2)),
"rtt_max": float(rtt_match.group(3)),
"loss": loss,
}
# Host unreachable or all packets lost
return {"rtt_min": float("inf"), "rtt_avg": float("inf"),
"rtt_max": float("inf"), "loss": loss}
async def _collect_metrics(self) -> Dict[str, Any]:
data: Dict[str, Any] = {}
tasks = {host: asyncio.create_task(self._ping(host)) for host in self.hosts}
for host, task in tasks.items():
try:
result = await task
except Exception as e:
self.logger.error("ping_monitor: error pinging %s: %s", host, e)
result = {"rtt_min": float("inf"), "rtt_avg": float("inf"),
"rtt_max": float("inf"), "loss": 100.0}
key = _host_key(host)
for metric, value in result.items():
data[f"{key}_{metric}"] = value
status = "unreachable" if result["loss"] == 100.0 else f"{result['rtt_avg']:.1f}ms"
self.logger.debug("ping_monitor: %s -> %s", host, status)
return data
+140
View File
@@ -0,0 +1,140 @@
"""
ZFS pool monitoring plugin for Heartbeat.
Collects per-pool health, capacity, and cumulative I/O statistics via zpool(8).
"""
import asyncio
import logging
import shutil
from typing import Any, Dict, List, Optional
from hbd.client.plugin import MonitorPlugin
logger = logging.getLogger(__name__)
def _int(s: str) -> Optional[int]:
try:
return int(s.strip().rstrip("KMGTkBkmgt%x"))
except (ValueError, AttributeError):
return None
def _float(s: str) -> Optional[float]:
try:
return float(s.strip().rstrip("%x"))
except (ValueError, AttributeError):
return None
class ZFSMonitorPlugin(MonitorPlugin):
"""Monitor ZFS pool health, capacity, and I/O statistics.
Collects per pool:
- health: ONLINE, DEGRADED, FAULTED, etc.
- size / alloc / free: total, allocated and free bytes
- capacity: percentage used (0-100)
- frag: fragmentation percentage
- dedup: deduplication ratio
- read_ops / write_ops: cumulative I/O operations since last boot/clear
- read_bw / write_bw: cumulative bytes transferred since last boot/clear
Configuration:
interval: collection interval in seconds (default: 300)
pools: list of pool names to monitor (default: all)
"""
name = "zfs_monitor"
description = "ZFS pool health, capacity, and I/O statistics"
interval = 300
def __init__(self, config: Optional[Dict[str, Any]] = None):
super().__init__(config)
self.interval = self.config.get("interval", 300)
self._pools_filter: Optional[List[str]] = self.config.get("pools", None)
async def initialize(self) -> bool:
if not shutil.which("zpool"):
self.skip_reason = "zpool not found"
return False
logger.info("ZFS monitor initialized (interval: %ds)", self.interval)
return True
async def _run(self, *args: str) -> List[str]:
"""Run a command and return its stdout lines, or [] on error."""
try:
proc = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=15)
return stdout.decode(errors="replace").splitlines()
except (FileNotFoundError, asyncio.TimeoutError) as exc:
logger.warning("zfs_monitor: %s: %s", args[0], exc)
return []
async def _zpool_list(self) -> Dict[str, Dict]:
"""Return per-pool health and capacity from `zpool list`."""
lines = await self._run(
"zpool", "list", "-H", "-p",
"-o", "name,health,size,alloc,free,cap,frag,dedup",
)
pools: Dict[str, Dict] = {}
for line in lines:
parts = line.split("\t")
if len(parts) < 8:
continue
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": health,
"status": status,
"size": _int(parts[2]),
"alloc": _int(parts[3]),
"free": _int(parts[4]),
"capacity": _float(parts[5]),
"frag": _float(parts[6]),
"dedup": _float(parts[7]),
}
return pools
async def _zpool_iostat(self) -> Dict[str, Dict]:
"""Return per-pool cumulative I/O counters from `zpool iostat`."""
lines = await self._run("zpool", "iostat", "-H", "-p")
io: Dict[str, Dict] = {}
for line in lines:
parts = line.split("\t")
if len(parts) < 7:
continue
name = parts[0].strip()
if not name or name.startswith(" "):
continue
io[name] = {
"read_ops": _int(parts[3]),
"write_ops": _int(parts[4]),
"read_bw": _int(parts[5]),
"write_bw": _int(parts[6]),
}
return io
async def _collect_metrics(self) -> Dict[str, Any]:
pools, io = await asyncio.gather(self._zpool_list(), self._zpool_iostat())
for name, stats in io.items():
if name in pools:
pools[name].update(stats)
return {"pools": pools}
plugin = ZFSMonitorPlugin
+1 -1
View File
@@ -1,3 +1,3 @@
"""Common utilities shared between hbc and hbd.""" """Common utilities shared between hbc and hbd."""
__version__ = "5.0.5" from hbd import __version__
+9 -4
View File
@@ -52,12 +52,17 @@ def decode_value(val: str) -> Any:
except Exception: except Exception:
return val[1:] # Return as string without @ return val[1:] # Return as string without @
# Try numeric evaluation (original behavior) # Try numeric conversion (avoid eval to prevent SyntaxWarnings on version strings)
if val[0].isdigit() or (val[0] == '-' and len(val) > 1 and val[1].isdigit()): if val[0].isdigit() or (val[0] == '-' and len(val) > 1 and val[1].isdigit()):
try: try:
return eval(val) return int(val)
except Exception: except ValueError:
return val pass
try:
return float(val)
except ValueError:
pass
return val
return val return val
+25
View File
@@ -134,6 +134,31 @@ thresholds:
hysteresis: 0.1 hysteresis: 0.1
enabled: true 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 alerting
grace: 0 # Fire immediately — don't wait for a second collection
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 # Network Monitor Thresholds
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
+1 -1
View File
@@ -1,3 +1,3 @@
"""HeartBeat Daemon (hbd) - Server/daemon component.""" """HeartBeat Daemon (hbd) - Server/daemon component."""
__version__ = "5.0.5" from hbd import __version__
+258 -10
View File
@@ -1,6 +1,8 @@
"""Command line interface for hbd package.""" """Command line interface for hbd package."""
import argparse import argparse
import getpass
import sys
from .config import load_config from .config import load_config
from .main import run as run_server from .main import run as run_server
@@ -14,26 +16,272 @@ def build_parser():
description="HeartBeatDaemon - Wait for heartbeat messages and act on them (or their absence)", description="HeartBeatDaemon - Wait for heartbeat messages and act on them (or their absence)",
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
) )
parser.add_argument(
"-c", "--config", dest="configfile", help="Config file path (YAML)" subparsers = parser.add_subparsers(dest="command")
)
parser.add_argument( # --- serve (default) ---
"-f", "--foreground", action="store_true", help="Run in foreground" serve_p = subparsers.add_parser("serve", help="Start the hbd server (default)")
) serve_p.add_argument("-c", "--config", dest="configfile", help="Config file path (YAML)")
serve_p.add_argument("-f", "--foreground", action="store_true", help="Run in foreground")
serve_p.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
serve_p.add_argument("-p", "--pushsrv", dest="pushsrv", choices=PUSHSRVS,
help="Push service to use")
serve_p.add_argument("-x", "--debug", action="count", default=0, help="Increase debug level")
# Legacy top-level flags (no subcommand) — kept for backward compatibility
parser.add_argument("-c", "--config", dest="configfile", help="Config file path (YAML)")
parser.add_argument("-f", "--foreground", action="store_true", help="Run in foreground")
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output") parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
parser.add_argument( parser.add_argument("-p", "--pushsrv", dest="pushsrv", choices=PUSHSRVS,
"-p", "--pushsrv", dest="pushsrv", choices=PUSHSRVS, help="Push service to use" help="Push service to use")
parser.add_argument("-x", "--debug", action="count", default=0, help="Increase debug level")
# --- passwd ---
passwd_p = subparsers.add_parser(
"passwd",
help="Generate a password hash for use in the config file",
) )
parser.add_argument( passwd_p.add_argument(
"-x", "--debug", action="count", default=0, help="Increase debug level" "username",
nargs="?",
help="Username (informational only, for display)",
) )
# --- notify ---
notify_p = subparsers.add_parser(
"notify",
help="Send a test message via a configured notification channel",
)
notify_p.add_argument("-c", "--config", dest="configfile", help="Config file path (YAML)")
notify_p.add_argument(
"channel",
help="Channel name as defined in notification_channels",
)
notify_p.add_argument(
"message",
nargs="?",
default="Test notification from hbd",
help="Message body (default: 'Test notification from hbd')",
)
notify_p.add_argument(
"--level",
default="WARNING",
choices=["INFO", "WARNING", "CRITICAL", "RECOVER"],
help="Notification level (default: WARNING)",
)
notify_p.add_argument(
"--title",
default=None,
help="Notification title (default: '[LEVEL] test')",
)
# --- stop ---
stop_p = subparsers.add_parser("stop", help="Stop the running hbd instance")
stop_p.add_argument("-c", "--config", dest="configfile", help="Config file path (YAML)")
# --- reload ---
reload_p = subparsers.add_parser("reload", help="Reload configuration (SIGHUP)")
reload_p.add_argument("-c", "--config", dest="configfile", help="Config file path (YAML)")
# --- restart ---
restart_p = subparsers.add_parser("restart", help="Restart the running hbd instance")
restart_p.add_argument("-c", "--config", dest="configfile", help="Config file path (YAML)")
restart_p.add_argument("-f", "--foreground", action="store_true", help="Run in foreground after restart")
restart_p.add_argument("-v", "--verbose", action="store_true", help="Verbose output after restart")
return parser return parser
def cmd_passwd(args):
"""Interactive password hash generator."""
from .users import hash_password
username = args.username or ""
prompt = f"New password for {username}: " if username else "New password: "
while True:
pw = getpass.getpass(prompt)
if not pw:
print("Password must not be empty.", file=sys.stderr)
continue
pw2 = getpass.getpass("Confirm password: ")
if pw != pw2:
print("Passwords do not match, try again.", file=sys.stderr)
continue
break
hashed = hash_password(pw)
if username:
print(f"\nAdd the following to your config under users: -> {username}:")
else:
print("\nPassword hash (paste into config file under the user's 'password' key):")
print(f" password: {hashed}")
def cmd_notify(args):
"""Send a test message via a single notification channel."""
from .config import load_config
from .notify import Notification, _dispatch_to_channel, setup
config = load_config(args.configfile)
setup(config)
channels = config.get("notification_channels", {})
if args.channel not in channels:
available = ", ".join(channels.keys()) if channels else "(none)"
print(f"Error: channel '{args.channel}' not found in notification_channels.", file=sys.stderr)
print(f"Available channels: {available}", file=sys.stderr)
sys.exit(1)
channel_cfg = channels[args.channel]
level = args.level.upper()
title = args.title or f"[{level}] test"
base_url = config.get("base_url", "").rstrip("/")
notif = Notification(
title=title,
body=args.message,
level=level,
url=f"{base_url}/plugins" if base_url else "",
)
import asyncio
from .notify import _send_matrix_async, _send_sms_voipms_async, _DRIVERS
ch_type = channel_cfg.get("type", "")
print(f"Sending via {args.channel} ({ch_type}): {title}{args.message}")
if ch_type == "matrix":
ok = asyncio.run(_send_matrix_async(channel_cfg, notif))
elif ch_type == "sms_voipms":
ok = asyncio.run(_send_sms_voipms_async(channel_cfg, notif))
else:
driver = _DRIVERS.get(ch_type)
if driver is None:
print(f"Error: unknown channel type '{ch_type}'", file=sys.stderr)
sys.exit(1)
ok = driver(channel_cfg, notif)
if ok:
print("OK")
else:
print("FAILED — check logs for details", file=sys.stderr)
sys.exit(1)
def _read_pid(configfile) -> int | None:
"""Return the PID from the pidfile, or None if not found / not running."""
import os
config = load_config(configfile)
pidfile = config.get("pidfile", "")
if not pidfile:
print("Error: no pidfile configured.", file=sys.stderr)
return None
try:
with open(pidfile) as f:
pid = int(f.read().strip())
# Verify process is actually running
os.kill(pid, 0)
return pid
except FileNotFoundError:
print(f"PID file not found ({pidfile}). Is hbd running?", file=sys.stderr)
return None
except ProcessLookupError:
print(f"PID file exists but process {pid} is not running.", file=sys.stderr)
return None
except Exception as e:
print(f"Error reading pidfile: {e}", file=sys.stderr)
return None
def cmd_stop(args):
import os, signal as _signal, time
pid = _read_pid(args.configfile)
if pid is None:
sys.exit(1)
print(f"Stopping hbd (pid {pid})...")
os.kill(pid, _signal.SIGTERM)
# Wait up to 10 s for the process to exit
for _ in range(20):
time.sleep(0.5)
try:
os.kill(pid, 0)
except ProcessLookupError:
print("hbd stopped.")
return
print("Warning: hbd did not stop within 10 seconds.", file=sys.stderr)
sys.exit(1)
def cmd_reload(args):
import os, signal as _signal
pid = _read_pid(args.configfile)
if pid is None:
sys.exit(1)
print(f"Sending SIGHUP to hbd (pid {pid})...")
os.kill(pid, _signal.SIGHUP)
print("Reload signal sent.")
def cmd_restart(args):
import os, signal as _signal, time, subprocess
pid = _read_pid(args.configfile)
if pid is not None:
print(f"Stopping hbd (pid {pid})...")
os.kill(pid, _signal.SIGTERM)
for _ in range(20):
time.sleep(0.5)
try:
os.kill(pid, 0)
except ProcessLookupError:
print("hbd stopped.")
break
else:
print("Warning: hbd did not stop within 10 seconds.", file=sys.stderr)
sys.exit(1)
else:
print("hbd does not appear to be running — starting fresh.")
# Re-launch hbd with the same config
cmd = [sys.executable, "-m", "hbd.server.cli", "serve"]
if args.configfile:
cmd += ["-c", args.configfile]
if getattr(args, "foreground", False):
cmd += ["-f"]
if getattr(args, "verbose", False):
cmd += ["-v"]
if getattr(args, "foreground", False):
# Run in foreground — replace current process
os.execv(sys.executable, cmd)
else:
subprocess.Popen(cmd, start_new_session=True)
print("hbd restarted.")
def main(argv=None): def main(argv=None):
parser = build_parser() parser = build_parser()
args = parser.parse_args(argv) args = parser.parse_args(argv)
if args.command == "passwd":
cmd_passwd(args)
return
if args.command == "notify":
cmd_notify(args)
return
if args.command == "stop":
cmd_stop(args)
return
if args.command == "reload":
cmd_reload(args)
return
if args.command == "restart":
cmd_restart(args)
return
# Default: run the server (supports both `hbd serve ...` and `hbd ...`)
config = load_config(args.configfile) config = load_config(args.configfile)
# Apply CLI overrides # Apply CLI overrides
+145 -153
View File
@@ -14,35 +14,38 @@ SERVER_DEFAULTS = {
"hb_port": 50003, # Port to listen for heartbeats "hb_port": 50003, # Port to listen for heartbeats
"hbd_port": 50004, # HTTP API port "hbd_port": 50004, # HTTP API port
"hbd_host": "", # Bind address (empty = all interfaces) "hbd_host": "", # Bind address (empty = all interfaces)
# Persistence # Persistence
"pickfile": "/tmp/hb.pick", "pickfile": os.path.join(os.path.expanduser("~"), ".hb.pick"), # File to store host state between restarts
"pidfile": os.path.join(os.path.expanduser("~"), ".hb.pid"), # PID file for stop/restart/reload
# Logging # Logging
"logfile": "/var/log/heartbeat.log", "logfile": os.path.join(os.path.expanduser("~"), ".hb.log"),
"logfmt": "text", # text or msg or json
# Notification channels # Notification channels
"notification_channels": {}, # Named channels with type and credentials "notification_channels": {}, # Named channels with type and credentials
"default_notification_channels": [], # Default channels if host doesn't specify "base_url": "", # Base URL for notification links (e.g. https://hbd.example.com)
# Monitoring settings # Monitoring settings
"interval": 20, # Expected heartbeat interval (for server checks) "interval": 20, # Expected heartbeat interval (for server checks)
"grace": 2, # Grace multiplier (interval * grace = timeout) "grace": 2, # Grace period (extra seconds before notifying after a missed heartbeat)
"threshold_renotify_interval": 3600, # Seconds between threshold re-notifications "threshold_renotify_interval": 3600, # Seconds between threshold re-notifications
# User management
"users": {}, # username -> {full_name, avatar, password, admin, notification_channels}
"default_owner": None, # Username that owns hosts with no explicit owner
# OAuth2 providers
"oauth": {}, # oauth.gitea.{url,client_id,client_secret}
# Host management # Host management
"hosts": {}, # New unified host definitions (optional) "hosts": {}, # Unified host definitions
"watchhosts": [], # Hosts to monitor and notify about (legacy) "dyndomains": ["example.org"], # Domains to update via nsupdate when a host with dyndns: true is updated
"dyndnshosts": [], # Hosts with dynamic DNS (legacy)
"drophosts": [], # Hosts to ignore
"dyndomains": ["wrede.org"],
# DNS updates # DNS updates
"nsupdate_bin": "/usr/bin/nsupdate", "nsupdate_bin": "/usr/bin/nsupdate", # Path to nsupdate binary
# WebSocket settings # WebSocket settings
"ws_port": 50005, "ws_port": 50005,
"wss_port": None, "wss_port": None,
"cert_path": "/usr/local/etc/ssl/", "cert_path": "/usr/local/etc/ssl/",
"wss_pem": "fullchain.pem", "wss_pem": "fullchain.pem",
@@ -65,6 +68,66 @@ SERVER_DEFAULTS = {
"thresholds": {}, "thresholds": {},
} }
THRESHOLD_DEFAULTS = {
'thresholds': {
'cpu_monitor': {
'cpu_percent': {
'warning': 80.0,
'critical': 90.0
}
},
'memory_monitor': {
'memory_percent': {
'warning': 85.0,
'critical': 95.0
},
'swap_percent': {
'warning': 40.0,
'critical': 75.0
}
},
'disk_monitor': {
'partitions': {
'/': {
'percent': {
'warning': 85.0,
'critical': 90.0
}
}
}
},
'rtt': {
'warning': 200,
'critical': 250.0,
'count': 3 # Optional: number of consecutive breaches before alerting
},
'nagios_runner': {
'status_code': {
'display': '{check_name} {output}',
'operator': "nagios"
}
},
'zfs_monitor': {
'pools': {
'*': {
'status': {
'warning': 1,
'critical': 2,
'operator': '>=',
'hysteresis': 0.0,
'grace': 0,
'display': 'ZFS pool {pool_name} is {health}'
},
'capacity': {
'warning': 80.0,
'critical': 90.0,
}
}
}
},
}
}
def load_config(path=None): def load_config(path=None):
"""Load configuration from a YAML file and merge with server defaults. """Load configuration from a YAML file and merge with server defaults.
@@ -182,159 +245,88 @@ class ReloadableConfig:
def get_watchhosts(config): def get_watchhosts(config):
"""Extract watchhosts from config, supporting both new and legacy formats. """Extract watched hostnames from config (hosts with watch: true).
Args:
config: Configuration dictionary
Returns: Returns:
List of hostnames to watch # List of hostnames to watch
""" """
watchhosts = [] watchhosts = []
hosts_config = config.get("hosts", {})
# New format: hosts section with watch attribute if isinstance(hosts_config, dict):
if "hosts" in config: for host_name, host_attrs in hosts_config.items():
hosts_config = config["hosts"] if isinstance(host_attrs, dict) and host_attrs.get("watch", True):
if isinstance(hosts_config, dict): watchhosts.append(host_name)
for host_name, host_attrs in hosts_config.items(): return watchhosts
if isinstance(host_attrs, dict) and host_attrs.get("watch", False):
watchhosts.append(host_name)
# Legacy format: watchhosts list
if "watchhosts" in config:
legacy_watchhosts = config.get("watchhosts", [])
if isinstance(legacy_watchhosts, (list, set)):
watchhosts.extend(legacy_watchhosts)
elif isinstance(legacy_watchhosts, dict):
# Old dict format: {"host1": {attrs}, "host2": {attrs}}
watchhosts.extend(legacy_watchhosts.keys())
return list(set(watchhosts)) # Remove duplicates
def get_dyndnshosts(config): def get_dyndnshosts(config):
"""Extract dyndnshosts from config, supporting both new and legacy formats. """Return hostnames that have a dyndns setting in the hosts section."""
hosts_config = config.get("hosts", {})
Args: if not isinstance(hosts_config, dict):
config: Configuration dictionary return []
return [
Returns: name for name, attrs in hosts_config.items()
List of hostnames with dynamic DNS if isinstance(attrs, dict) and attrs.get("dyndns")
""" ]
dyndnshosts = []
# New format: hosts section with dyndns attribute
if "hosts" in config:
hosts_config = config["hosts"]
if isinstance(hosts_config, dict):
for host_name, host_attrs in hosts_config.items():
if isinstance(host_attrs, dict) and host_attrs.get("dyndns", False):
dyndnshosts.append(host_name)
# Legacy format: dyndnshosts list/set
if "dyndnshosts" in config:
legacy_dyndnshosts = config.get("dyndnshosts", [])
if isinstance(legacy_dyndnshosts, (list, set)):
dyndnshosts.extend(legacy_dyndnshosts)
return list(set(dyndnshosts)) # Remove duplicates
def get_host_config(config, hostname): def get_host_config(config, hostname):
"""Get configuration for a specific host. """Get configuration for a specific host from the hosts section.
Args:
config: Configuration dictionary
hostname: Host name
Returns: Returns:
Dictionary with host attributes or empty dict Dictionary with host attributes or empty dict
""" """
if "hosts" in config: hosts_config = config.get("hosts", {})
hosts_config = config.get("hosts", {}) if isinstance(hosts_config, dict) and hostname in hosts_config:
if isinstance(hosts_config, dict) and hostname in hosts_config: val = hosts_config[hostname]
return hosts_config[hostname] if isinstance(hosts_config[hostname], dict) else {} return val if isinstance(val, dict) else {}
# Check legacy watchhosts for notification settings
if "watchhosts" in config:
watchhosts = config.get("watchhosts", {})
if isinstance(watchhosts, dict) and hostname in watchhosts:
legacy_attrs = watchhosts[hostname]
if isinstance(legacy_attrs, dict):
# Convert legacy format to new format
return {
"watch": True,
"notify": legacy_attrs.get("notify"),
"notify_src": legacy_attrs.get("src"),
}
return {} return {}
def get_notification_channels_for_host(config, hostname): # ---------------------------------------------------------------------------
"""Get notification channels configured for a specific host. # User / host-access helpers
# ---------------------------------------------------------------------------
Args:
config: Configuration dictionary
hostname: Host name
Returns:
List of channel names to use for this host
"""
host_config = get_host_config(config, hostname)
# Check if host specifies notification channels
channels = host_config.get("notification_channels", [])
if channels:
if isinstance(channels, str):
return [channels]
elif isinstance(channels, list):
return channels
# Fall back to default channels
default_channels = config.get("default_notification_channels", [])
if default_channels:
if isinstance(default_channels, str):
return [default_channels]
elif isinstance(default_channels, list):
return default_channels
# No channels configured, return empty list (will use legacy global config)
return []
def get_default_owner(config) -> str | None:
def get_channel_config(config, channel_name): """Return the configured default_owner username, or the first admin user, or None."""
"""Get configuration for a specific notification channel. explicit = config.get("default_owner")
if explicit:
Args: return explicit
config: Configuration dictionary # Fall back to first admin user found in config
channel_name: Name of the notification channel users_cfg = config.get("users", {})
if isinstance(users_cfg, dict):
Returns: for username, attrs in users_cfg.items():
Dictionary with channel configuration or None if not found if isinstance(attrs, dict) and attrs.get("admin", False):
""" return username
channels = config.get("notification_channels", {})
if isinstance(channels, dict) and channel_name in channels:
return channels[channel_name]
return None return None
def get_notification_channels_config(config, hostname): def get_host_access(config, hostname) -> dict:
"""Get list of notification channel configurations for a host. """Return the access dict for *hostname*: owner, managers, monitors.
Args: Falls back to default_owner for hosts without an explicit owner.
config: Configuration dictionary
hostname: Host name
Returns: Returns:
List of (channel_name, channel_config) tuples {
"owner": str | None,
"managers": list[str],
"monitors": list[str],
}
""" """
channel_names = get_notification_channels_for_host(config, hostname) host_cfg = get_host_config(config, hostname)
channels = [] owner = host_cfg.get("owner") # or get_default_owner(config)
for channel_name in channel_names:
channel_config = get_channel_config(config, channel_name) managers = host_cfg.get("managers", [])
if channel_config and channel_config.get("type"): if isinstance(managers, str):
channels.append((channel_name, channel_config)) managers = [managers]
return channels monitors = host_cfg.get("monitors", [])
if isinstance(monitors, str):
monitors = [monitors]
return {
"owner": owner,
"managers": list(managers),
"monitors": list(monitors),
}
+136
View File
@@ -0,0 +1,136 @@
"""YAML round-trip read/write for .hb.yaml, with backup and atomic writes."""
import glob
import os
import threading
from datetime import datetime
from ruamel.yaml import YAML
_write_lock = threading.Lock()
def _make_yaml() -> YAML:
y = YAML()
y.preserve_quotes = True
return y
# Top-level keys managed by the 'server' logical section
_SERVER_KEYS = [
"hbd_port", "hbd_host", "ws_port", "wss_port", "hb_port",
"interval", "grace", "base_url", "threshold_renotify_interval",
"logfile", "pidfile", "pickfile", "journal_enabled", "journal_dir",
"journal_max_size", "journal_max_backups", "default_owner",
"default_threshold_config",
]
# Top-level keys managed by the 'dns' logical section
_DNS_KEYS = ["nsupdate_bin", "rndc_key", "dyndomains"]
def read_roundtrip(path: str):
"""Load .hb.yaml with ruamel.yaml, preserving comments and ordering."""
with open(path, "r", encoding="utf-8") as f:
return _make_yaml().load(f)
def write_config(path: str, data) -> None:
"""Backup current file then atomically write data.
Backup naming: {path}.bak.YYYYMMDD-HHMMSS
Rotation: keep the 10 most recent backups, delete older ones.
Atomic write: write to {path}.tmp then os.replace({path}.tmp, path).
Acquires _write_lock for the full backup+write sequence.
"""
with _write_lock:
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
backup_path = f"{path}.bak.{ts}"
n = 0
while os.path.exists(backup_path):
n += 1
backup_path = f"{path}.bak.{ts}-{n}"
orig_mode = None
if os.path.exists(path):
orig_mode = os.stat(path).st_mode
with open(path, "rb") as src, open(backup_path, "wb") as dst:
dst.write(src.read())
os.chmod(backup_path, orig_mode)
backups = sorted(glob.glob(f"{path}.bak.*"), reverse=True)
for old in backups[10:]:
os.unlink(old)
tmp = f"{path}.tmp"
try:
with open(tmp, "w", encoding="utf-8") as f:
_make_yaml().dump(data, f)
if orig_mode is not None:
os.chmod(tmp, orig_mode)
os.replace(tmp, path)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
def list_backups(path: str) -> list:
"""Return backup paths sorted newest-first."""
return sorted(glob.glob(f"{path}.bak.*"), reverse=True)
def apply_structured_section(data, section: str, values: dict) -> None:
"""Merge a dict of scalar/list values into data for the named logical section.
For 'server': updates each known key individually, preserving comments on
unchanged keys. For 'users': replaces the entire users dict.
"""
if section == "server":
for key in _SERVER_KEYS:
if key in values:
data[key] = values[key]
elif section == "dns":
for key in _DNS_KEYS:
if key in values:
data[key] = values[key]
else:
data.pop(key, None)
elif section == "users":
data["users"] = values
elif section == "hosts":
data["hosts"] = values
else:
raise ValueError(f"Unknown structured section: {section!r}")
def apply_channel(data, name: str, channel_cfg: dict) -> None:
"""Insert or replace a single notification channel entry, preserving others."""
if not data.get("notification_channels"):
data["notification_channels"] = {}
data["notification_channels"][name] = channel_cfg
def delete_channel(data, name: str) -> None:
"""Remove a notification channel by name. No-op if not found."""
nc = data.get("notification_channels") or {}
nc.pop(name, None)
def apply_yaml_section(data, section: str, yaml_text: str) -> None:
"""Replace the named logical section by parsing yaml_text."""
parsed = _make_yaml().load(yaml_text)
if section == "notification_channels":
data["notification_channels"] = parsed
elif section == "thresholds":
data["threshold_configs"] = parsed
elif section == "hosts":
data["hosts"] = parsed
elif section == "dns":
if parsed:
for key in _DNS_KEYS:
if key in parsed:
data[key] = parsed[key]
else:
for key in _DNS_KEYS:
data.pop(key, None)
else:
raise ValueError(f"Unknown YAML section: {section!r}")
+18 -15
View File
@@ -4,6 +4,9 @@ from __future__ import annotations
from subprocess import Popen, PIPE, STDOUT from subprocess import Popen, PIPE, STDOUT
from typing import Optional from typing import Optional
import asyncio import asyncio
import logging
logger = logging.getLogger(__name__)
def create_nsupdate_payload( def create_nsupdate_payload(
@@ -123,7 +126,6 @@ async def dns_update_worker(
pass pass
continue continue
m = f"changed address to {addr}"
for dyndomain in cfg.get("dyndomains", []): for dyndomain in cfg.get("dyndomains", []):
err = await loop.run_in_executor( err = await loop.run_in_executor(
None, None,
@@ -135,28 +137,29 @@ async def dns_update_worker(
cfg.get("rndc_key", "/etc/dhcpc/rndc-key"), cfg.get("rndc_key", "/etc/dhcpc/rndc-key"),
) )
if err: if err:
m += f", DNS update failed: {err}" m = f"DNS update failed for {addr} ({dyndomain}): {err}"
logger.error("DNS update failed for %s: %s", name, err) logger.error("DNS update failed for %s: %s", name, err)
if log:
try:
await loop.run_in_executor(None, log, name, "ERROR", m)
except Exception:
pass
else: else:
m += ", DNS updated." m = f"DNS updated {name}.dy.{dyndomain}{addr}"
if log:
try:
await loop.run_in_executor(None, log, name, "INFO", m)
except Exception:
pass
if not cfg.get("dyndomains"):
logger.warning("DNS update triggered for %s but no dyndomains configured", name)
try: try:
dnsq.task_done() dnsq.task_done()
except Exception: except Exception:
pass pass
if log:
try:
await loop.run_in_executor(None, log, name, m)
except Exception:
pass
if log:
try:
await loop.run_in_executor(None, log, None, "dns_update_worker exiting")
except Exception:
pass
def start_dns_worker( def start_dns_worker(
hbdclass, hbdclass,
+55 -4
View File
@@ -95,7 +95,7 @@ class Connection:
if not Null: if not Null:
d["addr"] = self.addr d["addr"] = self.addr
if self.rtts[-1]: if self.rtts[-1]:
d["rtt"] = "%0.1f" % self.rtts[-1] d["rtt"] = "%d" % round(self.rtts[-1])
elif self.state == Connection.UNKNOWN: elif self.state == Connection.UNKNOWN:
d["rtt"] = "" d["rtt"] = ""
else: else:
@@ -189,7 +189,7 @@ class Connection:
except Exception: except Exception:
pass pass
self.addr = addr self.addr = addr
Connection.htab[addr] = self.host.nameconnection_count Connection.htab[addr] = self.host.name
if self.host.isDynDns(): if self.host.isDynDns():
Host.dnsQ.put((self.host.name, self.addr)) Host.dnsQ.put((self.host.name, self.addr))
return r return r
@@ -297,9 +297,14 @@ class Host:
self.plugin_retention = 100 # Keep last N samples per plugin self.plugin_retention = 100 # Keep last N samples per plugin
# Alert state tracking: {metric_path: AlertState} # Alert state tracking: {metric_path: AlertState}
self.alert_states = {} self.alert_states = {}
# User access control
self.owner: str | None = None # username of owner
self.managers: list = [] # usernames with manager role
self.monitors: list = [] # usernames with monitor role
def statedict(self): def statedict(self):
d = {} d = {}
d["raw_name"] = self.name
d["name"] = self.name d["name"] = self.name
if self.dyn: if self.dyn:
d["name"] += "*" d["name"] += "*"
@@ -412,7 +417,20 @@ class Host:
ddict["alert_warning_acked"] = warning_acked ddict["alert_warning_acked"] = warning_acked
ddict["alert_critical_unacked"] = critical_unacked ddict["alert_critical_unacked"] = critical_unacked
ddict["alert_critical_acked"] = critical_acked ddict["alert_critical_acked"] = critical_acked
# User access
ddict["owner"] = getattr(self, "owner", None)
ddict["managers"] = list(getattr(self, "managers", []))
ddict["monitors"] = list(getattr(self, "monitors", []))
# hbc version from latest os_info plugin data
hbc_version = None
latest_os = self.get_latest_plugin_data("os_info")
if latest_os:
_, os_data = latest_os
hbc_version = os_data.get("hbc_version")
ddict["hbc_version"] = hbc_version
return ddict return ddict
def jsons(self): def jsons(self):
@@ -458,6 +476,13 @@ class Host:
self.plugin_retention = 100 self.plugin_retention = 100
if not hasattr(self, "alert_states"): if not hasattr(self, "alert_states"):
self.alert_states = {} self.alert_states = {}
# User access fields (added in user-management feature)
if not hasattr(self, "owner"):
self.owner = None
if not hasattr(self, "managers"):
self.managers = []
if not hasattr(self, "monitors"):
self.monitors = []
pass pass
@@ -511,12 +536,38 @@ class Host:
def get_all_plugin_data(self): def get_all_plugin_data(self):
"""Get all plugin data for this host. """Get all plugin data for this host.
Returns: Returns:
Dict of {plugin_name: [(timestamp, data), ...]} Dict of {plugin_name: [(timestamp, data), ...]}
""" """
return self.plugin_data return self.plugin_data
# ------------------------------------------------------------------
# User-role helpers
# ------------------------------------------------------------------
def apply_access(self, owner, managers, monitors):
"""Set owner/managers/monitors on this host (called from config load)."""
self.owner = owner
self.managers = list(managers)
self.monitors = list(monitors)
def is_owner(self, username: str) -> bool:
return self.owner == username
def is_manager(self, username: str) -> bool:
return username in self.managers or self.is_owner(username)
def is_monitor(self, username: str) -> bool:
return username in self.monitors or self.is_manager(username)
def access_dict(self) -> dict:
return {
"owner": self.owner,
"managers": list(self.managers),
"monitors": list(self.monitors),
}
hostfields_long = [ hostfields_long = [
"name", "name",
"IPv4.addr", "IPv4.addr",
+1346 -53
View File
File diff suppressed because it is too large Load Diff
+153 -72
View File
@@ -14,7 +14,8 @@ from . import hbdclass
from . import ws as ws_mod from . import ws as ws_mod
from . import notify as notify_mod from . import notify as notify_mod
from . import data from . import data
from . import users as users_mod
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
msg_to_websockets = ws_mod.broadcast msg_to_websockets = ws_mod.broadcast
@@ -22,12 +23,13 @@ eventlog = notify_mod.eventlog
# shared runtime collections and helpers # shared runtime collections and helpers
def cleanup_function(config, hbdclass): def save_state(config, hbdclass):
"""This function will be executed upon program exit.""" """Save current state to pickle file. Safe to call at any time."""
logger.info("Running cleanup function...")
import pickle import pickle
import os
from . import users as users_mod
# Ensure all timer references are cleared before pickling # Clear timer references before pickling (they can't be serialized)
for hostname, host in list(hbdclass.Host.hosts.items()): for hostname, host in list(hbdclass.Host.hosts.items()):
for conn_type, conn in host.connections.items(): for conn_type, conn in host.connections.items():
if hasattr(conn, 'cancel_overdue_timer'): if hasattr(conn, 'cancel_overdue_timer'):
@@ -40,13 +42,27 @@ def cleanup_function(config, hbdclass):
conn.timeout_duration = None conn.timeout_duration = None
pickfile = config.get("pickfile", "hbd.pickle") pickfile = config.get("pickfile", "hbd.pickle")
tmpfile = pickfile + ".tmp"
pickf = open(pickfile, "wb") try:
pick = pickle.Pickler(pickf) with open(tmpfile, "wb") as pickf:
pick.dump(hbdclass.Host.hosts) pick = pickle.Pickler(pickf)
pick.dump(data.msgs) pick.dump(hbdclass.Host.hosts)
pickf.close() pick.dump(data.msgs)
pick.dump(users_mod.save_sessions())
os.replace(tmpfile, pickfile)
except Exception as e:
logger.error("Failed to save state: %s", e)
try:
os.unlink(tmpfile)
except Exception:
pass
def cleanup_function(config, hbdclass):
"""This function will be executed upon program exit."""
logger.info("Running cleanup function...")
save_state(config, hbdclass)
logger.info("Cleanup complete.") logger.info("Cleanup complete.")
@@ -62,19 +78,31 @@ async def reload_configuration(config_obj, config_path, components):
True if reload succeeded, False otherwise True if reload succeeded, False otherwise
""" """
try: try:
logger.info("=" * 60)
logger.info("Starting configuration reload...") logger.info("Starting configuration reload...")
logger.info("=" * 60)
# Reload config file # Reload config file
new_config = await config_obj.reload(config_path) new_config = await config_obj.reload(config_path)
# Update notify module # Update notify module
notify_mod.reload_config(new_config) notify_mod.reload_config(new_config)
# Reload threshold checker # Reload users
users_mod.load_users(new_config)
# Re-apply host attributes from updated config to all known hosts
from . import config as config_mod
dyndnshosts = config_mod.get_dyndnshosts(new_config)
watchhosts = config_mod.get_watchhosts(new_config)
for hostname, host in hbdclass.Host.hosts.items():
host.dyn = hostname in dyndnshosts
host.watched = hostname in watchhosts
access = config_mod.get_host_access(new_config, hostname)
host.apply_access(access["owner"], access["managers"], access["monitors"])
# Reload threshold checker and prune alerts orphaned by the new config
if 'threshold_checker' in components: if 'threshold_checker' in components:
components['threshold_checker'].reload(new_config) components['threshold_checker'].reload(new_config)
components['threshold_checker'].purge_stale_alerts(hbdclass)
# Note: Changes to the following require restart: # Note: Changes to the following require restart:
# - hb_port, hbd_port, ws_port (already bound) # - hb_port, hbd_port, ws_port (already bound)
@@ -85,13 +113,11 @@ async def reload_configuration(config_obj, config_path, components):
# These are reloadable and effective immediately: # These are reloadable and effective immediately:
# - notification_channels # - notification_channels
# - threshold_configs # - threshold_configs
# - hosts (watchhosts, dyndnshosts, notification_channels) # - hosts (watchhosts, dyndns, notification_channels)
# - grace period (used on next heartbeat) # - grace period (used on next heartbeat)
# - debug/verbose flags (used on next message) # - debug/verbose flags (used on next message)
logger.info("=" * 60)
logger.info("Configuration reload completed successfully") logger.info("Configuration reload completed successfully")
logger.info("=" * 60)
return True return True
except Exception as e: except Exception as e:
@@ -103,6 +129,10 @@ async def reload_configuration(config_obj, config_path, components):
async def _run_async(config, config_path=None): async def _run_async(config, config_path=None):
from .config import ReloadableConfig
if not isinstance(config, ReloadableConfig):
config = ReloadableConfig(config, config_path)
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
shutdown_event = asyncio.Event() shutdown_event = asyncio.Event()
reload_event = asyncio.Event() reload_event = asyncio.Event()
@@ -129,7 +159,7 @@ async def _run_async(config, config_path=None):
from . import journal as journal_mod from . import journal as journal_mod
from . import threshold as threshold_mod from . import threshold as threshold_mod
notify_mod.setup(config) notify_mod.setup(config, loop=loop)
# Initialize message journal # Initialize message journal
msg_journal = journal_mod.get_journal(config) msg_journal = journal_mod.get_journal(config)
@@ -160,30 +190,60 @@ async def _run_async(config, config_path=None):
f"Warning: Could not reset IPV6_V6ONLY not supported or dual-stack is unavailable. Error: {e}" f"Warning: Could not reset IPV6_V6ONLY not supported or dual-stack is unavailable. Error: {e}"
) )
# 3. Bind to all interfaces (::) on a specific port
# UDP server endpoint (handler wired to handle_datagram with context)
bind_addr = ("::", config.get("hb_port", 50003)) bind_addr = ("::", config.get("hb_port", 50003))
sock.bind(bind_addr) sock.bind(bind_addr)
logger.info("Starting UDP server on %s:%s", *bind_addr) logger.info("Starting UDP server on %s:%s", *bind_addr)
def udp_handler(msg, addr, transport): # Try to enable kernel receive timestamps (Linux SO_TIMESTAMP).
# If supported, read datagrams via recvmsg() so RTT uses the kernel
# timestamp rather than the time.time() call after asyncio scheduling.
use_kernel_ts = udp.enable_kernel_timestamps(sock)
if use_kernel_ts:
logger.info("SO_TIMESTAMP enabled: using kernel receive timestamps for RTT")
else:
logger.info("SO_TIMESTAMP not available: using time.time() for RTT")
def udp_handler(msg, addr, transport, recv_ts=None):
ctx = dict( ctx = dict(
config=config, config=config,
hbdclass=hbdclass, hbdclass=hbdclass,
log=eventlog,
msg_to_websockets=msg_to_websockets, msg_to_websockets=msg_to_websockets,
msg_journal=msg_journal, msg_journal=msg_journal,
threshold_checker=threshold_checker, threshold_checker=threshold_checker,
DEBUG=config.get("debug", 0), DEBUG=config.get("debug", 0),
verbose=config.get("verbose", False), verbose=config.get("verbose", False),
recv_ts=recv_ts,
) )
udp.handle_datagram(msg, addr, transport, ctx) udp.handle_datagram(msg, addr, transport, ctx)
transport, protocol = await loop.create_datagram_endpoint( if use_kernel_ts:
lambda: udp.EchoServerProtocol(config=config, handler=udp_handler), # recvmsg path: manage the socket ourselves with loop.add_reader()
sock=sock, sock.setblocking(False)
transport = udp.RecvmsgTransport(loop, sock)
reader = udp.make_recvmsg_reader(sock, udp_handler, transport)
loop.add_reader(sock.fileno(), reader)
protocol = None
else:
transport, protocol = await loop.create_datagram_endpoint(
lambda: udp.EchoServerProtocol(config=config, handler=udp_handler),
sock=sock,
)
# Restore connection timers for hosts loaded from pickle
restore_ctx = dict(
config=config,
hbdclass=hbdclass,
msg_to_websockets=msg_to_websockets,
threshold_checker=threshold_checker,
) )
udp.restore_connection_timers(hbdclass, restore_ctx)
# Drop alert states that no longer have a matching threshold (stale after
# upgrade or config change between runs).
threshold_checker.purge_stale_alerts(hbdclass)
async def _http_reload_callback():
await reload_configuration(config, config_path, components)
# HTTP server (asyncio-based via aiohttp) # HTTP server (asyncio-based via aiohttp)
try: try:
@@ -194,9 +254,11 @@ async def _run_async(config, config_path=None):
config=config, config=config,
hbdclass=hbdclass, hbdclass=hbdclass,
tcss=None, tcss=None,
threshold_checker=threshold_checker,
verbose=config.get("verbose", False), verbose=config.get("verbose", False),
get_now=lambda: time.time(), get_now=lambda: time.time(),
VER="", VER="",
reload_callback=_http_reload_callback,
) )
) )
logger.info( logger.info(
@@ -217,45 +279,30 @@ async def _run_async(config, config_path=None):
except Exception as e: except Exception as e:
logger.exception("dns worker failed to start: %s", e) logger.exception("dns worker failed to start: %s", e)
# Start the websocket servers as a background task # Register WebSocket state — connections are now served through /ws on the HTTP port
if config.get("wss_port", None): ws_task = None
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ws_mod.setup(
ssl_path = config.get("cert_path", "") loop=loop,
wss_pem = ssl_path + config.get("wss_pem", "") get_hosts=lambda: [
wss_key = ssl_path + config.get("wss_key", "") hbdclass.Host.hosts[h].stateinfo()
try: for h in sorted(hbdclass.Host.hosts)
ssl_context.load_cert_chain(wss_pem, keyfile=wss_key) ],
except FileNotFoundError: verbose=config.get("verbose", False),
logger.error("error: missing SSL keys %s or %s", wss_pem, wss_key) )
sys.exit(1) logger.info("WebSocket handler registered on /ws (HTTP port %s)", config.get("hbd_port", 50004))
logger.info(
"Starting secure WebSocket server on port %s with cert %s",
config.get("wss_port", None),
wss_pem,
)
else:
ssl_context = None
try: # Periodic autosave task
ws_port = config.get("ws_port", 50005) autosave_interval = config.get("autosave_interval", 300) # default: 5 minutes
logger.info("Starting WebSocket server on port %s", ws_port)
ws_task = asyncio.create_task( async def autosave_task():
ws_mod.start( while True:
host=config.get("hbd_host", ""), await asyncio.sleep(autosave_interval)
ws_port=ws_port, logger.debug("Autosaving state...")
wss_port=config.get("wss_port", None), save_state(config, hbdclass)
ssl_context=ssl_context, logger.debug("Autosave complete (%d hosts)", len(hbdclass.Host.hosts))
get_hosts=lambda: [
hbdclass.Host.hosts[h].stateinfo() autosave = asyncio.create_task(autosave_task())
for h in sorted(hbdclass.Host.hosts) logger.info("Autosave task started (interval: %ds)", autosave_interval)
],
# get_msgs=lambda: msgs,
config=config,
)
)
logger.info("WebSocket task started")
except Exception as e:
logger.exception("websocket server failed to start: %s", e)
# Main event loop - monitor shutdown and reload events # Main event loop - monitor shutdown and reload events
try: try:
@@ -304,7 +351,7 @@ async def _run_async(config, config_path=None):
except Exception as e: except Exception as e:
logger.warning("Error closing UDP transport: %s", e) logger.warning("Error closing UDP transport: %s", e)
tasks_to_cancel = [http_task, ws_task] tasks_to_cancel = [http_task, autosave]
for task in tasks_to_cancel: for task in tasks_to_cancel:
if task: if task:
try: try:
@@ -355,6 +402,13 @@ async def _run_async(config, config_path=None):
except Exception as e: except Exception as e:
logger.warning("Error stopping DNS worker: %s", e) logger.warning("Error stopping DNS worker: %s", e)
# Save state (hosts + sessions) on clean shutdown
try:
save_state(config, hbdclass)
logger.info("State saved on shutdown")
except Exception as e:
logger.warning("Error saving state on shutdown: %s", e)
logger.info("All tasks cancelled") logger.info("All tasks cancelled")
@@ -363,11 +417,11 @@ def load_pickled_hosts(config, hbdclass):
import os import os
import pickle import pickle
from . import config as config_mod from . import config as config_mod
from . import users as users_mod
pickfile = config.get("pickfile", "hbd.pickle") pickfile = config.get("pickfile", "hbd.pickle")
dyndnshosts = config_mod.get_dyndnshosts(config) dyndnshosts = config_mod.get_dyndnshosts(config)
watchhosts = config_mod.get_watchhosts(config) watchhosts = config_mod.get_watchhosts(config)
drophosts = config.get("drophosts", [])
if 1 and os.path.exists(pickfile): if 1 and os.path.exists(pickfile):
if config.get("verbose", False): if config.get("verbose", False):
logger.info("opening pickls %s", pickfile) logger.info("opening pickls %s", pickfile)
@@ -376,6 +430,10 @@ def load_pickled_hosts(config, hbdclass):
try: try:
hbdclass.Host.hosts = pick.load() hbdclass.Host.hosts = pick.load()
data.msgs = pick.load() data.msgs = pick.load()
try:
users_mod.load_sessions(pick.load())
except Exception:
pass # older pickle without sessions — fine
pickf.close() pickf.close()
except Exception as e: except Exception as e:
logger.exception("load pickled failed: %s", e) logger.exception("load pickled failed: %s", e)
@@ -385,9 +443,10 @@ def load_pickled_hosts(config, hbdclass):
hbdclass.Host.hosts[h].dyn = h in dyndnshosts hbdclass.Host.hosts[h].dyn = h in dyndnshosts
hbdclass.Host.hosts[h].watched = h in watchhosts hbdclass.Host.hosts[h].watched = h in watchhosts
hbdclass.Host.hosts[h].fixup() hbdclass.Host.hosts[h].fixup()
for h in drophosts: access = config_mod.get_host_access(config, h)
if h in hbdclass.Host.hosts: hbdclass.Host.hosts[h].apply_access(
del hbdclass.Host.hosts[h] access["owner"], access["managers"], access["monitors"]
)
if config.get("verbose", False): if config.get("verbose", False):
logger.info("%s pickled hosts loaded", len(hbdclass.Host.hosts)) logger.info("%s pickled hosts loaded", len(hbdclass.Host.hosts))
else: else:
@@ -406,12 +465,28 @@ def run(config, config_path=None):
""" """
import os import os
logging.basicConfig( log_level = logging.WARNING
level=logging.DEBUG if config.get("debug", 0) > 0 else logging.INFO if config.get("verbose", False):
) log_level = logging.INFO
if config.get("debug", 0) > 0:
log_level = logging.DEBUG
logging.basicConfig(level=log_level)
if not config.get("debug", 0):
logging.getLogger("aiohttp.access").propagate = False
load_pickled_hosts(config, hbdclass) load_pickled_hosts(config, hbdclass)
notify_mod.initlog(logfile=config.get("logfile", "messages.log")) notify_mod.initlog(logfile=config.get("logfile", "messages.log"))
users_mod.load_users(config)
# Write pidfile
pidfile = config.get("pidfile", "")
if pidfile:
try:
with open(pidfile, "w") as f:
f.write(str(os.getpid()))
except Exception as e:
logger.warning("Failed to write pidfile %s: %s", pidfile, e)
eventlog(None, "INFO", f"hbd version {__version__} starting up") eventlog(None, "INFO", f"hbd version {__version__} starting up")
if config_path: if config_path:
@@ -434,6 +509,12 @@ def run(config, config_path=None):
logger.info("hbd shutdown complete") logger.info("hbd shutdown complete")
eventlog(None, "INFO", f"hbd version {__version__} shutdown") eventlog(None, "INFO", f"hbd version {__version__} shutdown")
notify_mod.closelog() notify_mod.closelog()
# Remove pidfile
if pidfile:
try:
os.unlink(pidfile)
except Exception:
pass
# Explicitly close the loop # Explicitly close the loop
try: try:
# Cancel all remaining tasks # Cancel all remaining tasks
+403 -234
View File
@@ -1,37 +1,100 @@
"""Notification helpers: email, pushover, mattermost, signal and dispatcher.""" """Notification helpers: email, pushover, matrix, mattermost, signal, sms and dispatcher.
Channel types supported:
pushover - Pushover app notifications
email - SMTP email
matrix - Matrix (via matrix-nio)
mattermost - Mattermost webhook
signal - Signal via signal-cli subprocess
sms_voipms - SMS via voip.ms REST API
Each channel can specify ``min_level: WARNING|CRITICAL`` (default: WARNING).
Notifications are dispatched to the owner + managers of the host, each via
their own ``notification_channels`` list. When no users are configured the
server runs silently (no notifications sent).
"""
import asyncio
import logging import logging
from typing import Optional
import http.client
import urllib.parse
import subprocess
import smtplib import smtplib
import subprocess
import time import time
import sys import sys
from dataclasses import dataclass, field
from typing import Optional
from . import data from . import data
from . import ws as ws_mod from . import ws as ws_mod
from . import main as main_mod
DEFAULT_PUSHPROVIDERS = ["all", "pushover", "mattermost", "signal"]
msg_to_websockets = ws_mod.broadcast
# module-level configuration set via setup()
_config = {}
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
msg_to_websockets = ws_mod.broadcast
# Module-level state set via setup()
_config: dict = {}
# Tracks which channels fired a WARNING/CRITICAL per host.
# {host_name: set of channel_names} — used to route RECOVER to the same channels.
_alerted_channels: dict = {}
logf = None logf = None
# ---------------------------------------------------------------------------
# Level ordering
# ---------------------------------------------------------------------------
_LEVEL_ORDER = {"RECOVER": 0, "INFO": 0, "WARNING": 1, "CRITICAL": 2}
def _level_value(level: str) -> int:
return _LEVEL_ORDER.get(level.upper(), 0)
# ---------------------------------------------------------------------------
# Notification dataclass
# ---------------------------------------------------------------------------
@dataclass
class Notification:
"""Structured notification payload."""
title: str # e.g. "[CRITICAL] webserver01"
body: str # detail message
level: str # RECOVER | WARNING | CRITICAL | INFO
url: str = "" # link to plugin metrics page
# ---------------------------------------------------------------------------
# Module setup
# ---------------------------------------------------------------------------
def setup(cfg: dict, loop: Optional[asyncio.AbstractEventLoop] = None):
"""Initialize notifier from configuration dict."""
global _config
_config = dict(cfg)
def reload_config(cfg: dict):
"""Reload notification configuration on SIGHUP."""
global _config
_config = dict(cfg)
logger.info("Notification configuration reloaded")
# ---------------------------------------------------------------------------
# Event log (websocket + file + in-memory)
# ---------------------------------------------------------------------------
def initlog(logfile): def initlog(logfile):
global logf global logf
try: try:
logf = open(logfile, "a+") logf = open(logfile, "a+")
except Exception as e: except Exception as e:
import sys
print("cannot open logfile %s, using STDERR: %s" % (logfile, e)) print("cannot open logfile %s, using STDERR: %s" % (logfile, e))
logf = sys.stderr logf = sys.stderr
return logf return logf
def closelog(): def closelog():
global logf global logf
if logf and logf != sys.stderr: if logf and logf != sys.stderr:
@@ -40,13 +103,21 @@ def closelog():
except Exception: except Exception:
pass pass
def eventlog(host, lvl, m, service=None): def eventlog(host, lvl, m, service=None):
ts = time.time() 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} " s = f"{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(ts))} {lvl} "
if host: if host:
s += f"{host} " s += f"{host} "
s += m s += m
data.msgs.append(s)
logger.info(s) logger.info(s)
if logf: if logf:
try: try:
@@ -54,93 +125,33 @@ def eventlog(host, lvl, m, service=None):
logf.flush() logf.flush()
except Exception as e: except Exception as e:
logger.warning("failed to write to logfile: %s", e) logger.warning("failed to write to logfile: %s", e)
msg_to_websockets("message", s) msg_to_websockets("message", msg)
def setup(cfg: dict):
"""Initialize notifier defaults from a configuration dict."""
global _config
_config = dict(cfg)
def reload_config(cfg: dict): # ---------------------------------------------------------------------------
"""Reload notification configuration. # Low-level channel drivers
# ---------------------------------------------------------------------------
This function updates the module-level notification configuration
during runtime config reloads.
Args:
cfg: New configuration dictionary
"""
global _config
_config = dict(cfg)
logger.info("Notification configuration reloaded")
def _send_pushover(channel_cfg: dict, notif: Notification) -> bool:
def send_email(toaddrs, smtpserver, sender, subject, body, debug=0): import http.client
"""Send a plain email via SMTP. Returns True on success.""" import urllib.parse
try: token = channel_cfg.get("token", "")
smtpport = _config.get("smtpport", 587) user = channel_cfg.get("user", "")
server = smtplib.SMTP(smtpserver, smtpport) if not token or not user:
if debug > 0: logger.warning("pushover: missing token or user")
server.set_debuglevel(1)
if smtpport == 587:
server.starttls()
server.ehlo()
smtpuser = _config.get("smtpuser", None)
smtppassword = _config.get("smtppassword", None)
if smtpuser and smtppassword:
server.login(smtpuser, smtppassword)
server.sendmail(sender, toaddrs, body)
except Exception as e:
logger.warning("email send failed: %s", e)
try:
server.quit()
except Exception:
pass
return False return False
try: params: dict = {"token": token, "user": user, "title": notif.title, "message": notif.body}
server.quit() if channel_cfg.get("sound"):
except Exception: params["sound"] = channel_cfg["sound"]
pass if notif.url:
return True params["url"] = notif.url
params["url_title"] = "Heartbeat"
def email(subject: str, msg: str, debug: int = 0) -> bool:
"""Convenience wrapper exposed to the rest of the application.
Uses module-level configuration to supply recipient list, smtp server
and sender address.
"""
toaddrs = _config.get("toemail")
fromemail = _config.get("fromemail")
smtpserver = _config.get("smtpserver")
if not toaddrs or not fromemail or not smtpserver:
logger.warning(
"email config incomplete: toemail=%s, fromemail=%s, smtpserver=%s",
toaddrs,
fromemail,
smtpserver,
)
return False
date = time.strftime("%a, %d %b %Y %H:%M:%S %z", time.localtime())
body = "To: %s\nFrom: %s\nSubject: %s\nDate: %s\n\n%s" % (
toaddrs[0] if toaddrs else "",
fromemail,
subject,
date,
msg,
)
return send_email(toaddrs, smtpserver, fromemail, subject, body, debug=debug)
def pushover(token: str, user: str, msg: str, debug: int = 0) -> bool:
"""Send message via Pushover API."""
conn = http.client.HTTPSConnection("api.pushover.net:443") conn = http.client.HTTPSConnection("api.pushover.net:443")
try: try:
conn.request( conn.request(
"POST", "POST",
"/1/messages.json", "/1/messages.json",
urllib.parse.urlencode({"token": token, "user": user, "message": msg}), urllib.parse.urlencode(params),
{"Content-type": "application/x-www-form-urlencoded"}, {"Content-type": "application/x-www-form-urlencoded"},
) )
r = conn.getresponse() r = conn.getresponse()
@@ -151,176 +162,334 @@ def pushover(token: str, user: str, msg: str, debug: int = 0) -> bool:
return False return False
def pushmattermost( def _send_email(channel_cfg: dict, notif: Notification) -> bool:
host: str, recipients = channel_cfg.get("recipients", [])
token: str, sender = channel_cfg.get("sender", "")
channel: str, smtp_server = channel_cfg.get("smtp_server", "")
msg: str, smtp_port = channel_cfg.get("smtp_port", 587)
username: str = "hbd", smtp_user = channel_cfg.get("smtp_user")
icon: Optional[str] = None, smtp_password = channel_cfg.get("smtp_password")
debug: int = 0,
) -> bool:
"""Send a message to Mattermost via simple webhook driver if available.
This helper tries to import mattermostdriver.Driver and uses webhooks if present. if not recipients or not sender or not smtp_server:
If the import fails it returns False. logger.warning("email: missing recipients, sender, or smtp_server")
""" return False
date = time.strftime("%a, %d %b %Y %H:%M:%S %z", time.localtime())
body_text = notif.body
if notif.url:
body_text += f"\n\n{notif.url}"
raw = "To: %s\nFrom: %s\nSubject: %s\nDate: %s\n\n%s" % (
recipients[0] if isinstance(recipients, list) else recipients,
sender,
notif.title,
date,
body_text,
)
try:
server = smtplib.SMTP(smtp_server, smtp_port)
if smtp_port == 587:
server.starttls()
server.ehlo()
if smtp_user and smtp_password:
server.login(smtp_user, smtp_password)
server.sendmail(sender, recipients, raw)
server.quit()
return True
except Exception as e:
logger.warning("email send failed: %s", e)
try:
server.quit()
except Exception:
pass
return False
def _send_mattermost(channel_cfg: dict, notif: Notification) -> bool:
try: try:
from mattermostdriver import Driver from mattermostdriver import Driver
except Exception: except ImportError:
logger.error("mattermostdriver not installed")
return False return False
host = channel_cfg.get("host", "")
token = channel_cfg.get("token", "")
channel = channel_cfg.get("channel", "")
if not host or not token or not channel:
logger.warning("mattermost: missing host, token, or channel")
return False
text = f"**{notif.title}**\n{notif.body}"
if notif.url:
text += f"\n[Plugin metrics] {notif.url}"
ses = {"url": host, "scheme": "http", "basepath": "/api/v4", "port": 8065} ses = {"url": host, "scheme": "http", "basepath": "/api/v4", "port": 8065}
mm = Driver(ses) mm = Driver(ses)
payload = {"text": msg, "channel": channel, "username": username} payload: dict = {"text": text, "channel": channel, "username": channel_cfg.get("username", "hbd")}
icon = channel_cfg.get("icon")
if icon: if icon:
payload["icon_url"] = icon payload["icon_url"] = icon
try: try:
rc = mm.webhooks.call_webhook(token, payload) rc = mm.webhooks.call_webhook(token, payload)
logger.debug("mattermost rc: %s", rc)
return bool(rc is None or rc == "") return bool(rc is None or rc == "")
except Exception as e: except Exception as e:
logger.error("mattermost error: %s", e) logger.error("mattermost error: %s", e)
return False return False
def pushsignal( def _send_signal(channel_cfg: dict, notif: Notification) -> bool:
signal_cli_bin: str, user: str, recipient: str, msg: str, debug: int = 0 cli = channel_cfg.get("cli_path", "/usr/local/bin/signal-cli")
) -> bool: user = channel_cfg.get("user", "")
"""Send a message via signal-cli (requires local installation). recipient = channel_cfg.get("recipient", "")
if not user or not recipient:
Uses subprocess to call signal-cli. Returns True if the command succeeded. logger.warning("signal: missing user or recipient")
""" return False
CLI = [signal_cli_bin, "-u", user, "send", "-m", msg, recipient] msg = f"{notif.title}\n{notif.body}"
logger.debug("signal cli: %s", CLI) if notif.url:
msg += f"\n{notif.url}"
try: try:
res = subprocess.run(CLI, capture_output=True) res = subprocess.run([cli, "-u", user, "send", "-m", msg, recipient], capture_output=True)
if res.returncode != 0: if res.returncode != 0:
logger.error("signal failed: %s".res.stderr.decode()) logger.error("signal failed: %s", res.stderr.decode())
return False return False
logger.debug("signal sent: %s", res.stdout.decode())
return True return True
except Exception as e: except Exception as e:
logger.exception("signal exception: %s", e) logger.exception("signal exception: %s", e)
return False return False
def _dispatch_to_channel(channel_name: str, channel_config: dict, msg: str, debug: int = 0) -> bool: async def _send_sms_voipms_async(channel_cfg: dict, notif: Notification) -> bool:
"""Dispatch a message to a specific notification channel. """Send SMS via voip.ms REST API using multipart form-data POST."""
import json
Args: import aiohttp
channel_name: Name of the channel (for logging)
channel_config: Channel configuration dictionary with 'type' and type-specific fields api_user = channel_cfg.get("api_user", "")
msg: Message to send api_password = channel_cfg.get("api_password", "")
debug: Debug level did = channel_cfg.get("did", "")
dst = channel_cfg.get("dst", "")
Returns: if not api_user or not api_password or not did or not dst:
True if notification sent successfully, False otherwise logger.warning("sms_voipms: missing api_user, api_password, did, or dst")
""" return False
channel_type = channel_config.get("type")
# SMS body: title + body, truncated to 160 chars
if channel_type == "pushover": text = f"{notif.title}: {notif.body}"
return pushover( if len(text) > 160:
channel_config.get("token", ""), text = text[:157] + "..."
channel_config.get("user", ""),
msg, form_data = {
debug=debug "api_username": api_user,
) "api_password": api_password,
"method": "sendSMS",
elif channel_type == "email": "did": did,
# Build email from channel config "dst": dst,
recipients = channel_config.get("recipients", []) "message": text,
sender = channel_config.get("sender", "") }
smtp_server = channel_config.get("smtp_server", "")
smtp_port = channel_config.get("smtp_port", 587) try:
smtp_user = channel_config.get("smtp_user") async with aiohttp.ClientSession() as session:
smtp_password = channel_config.get("smtp_password") with aiohttp.MultipartWriter("form-data") as mp:
for key, value in form_data.items():
if not recipients or not sender or not smtp_server: part = mp.append(value)
logger.warning( part.set_content_disposition("form-data", name=key)
"Email channel '%s' missing required fields: recipients=%s, sender=%s, smtp_server=%s", async with session.post("https://voip.ms/api/v1/rest.php", data=mp) as resp:
channel_name, recipients, sender, smtp_server body = await resp.text()
) if resp.status != 200:
return False logger.error("sms_voipms HTTP %s: %s", resp.status, body)
return False
# Temporarily update _config for email() function result = json.loads(body)
old_config = dict(_config) if result.get("status") == "success":
_config["toemail"] = recipients return True
_config["fromemail"] = sender logger.error("sms_voipms error: %s", result.get("status"))
_config["smtpserver"] = smtp_server return False
_config["smtpport"] = smtp_port except Exception as e:
if smtp_user: logger.error("sms_voipms exception: %s", e)
_config["smtpuser"] = smtp_user
if smtp_password:
_config["smtppassword"] = smtp_password
result = email("Heartbeat notification", msg, debug=debug)
# Restore config
_config.clear()
_config.update(old_config)
return result
elif channel_type == "signal":
return pushsignal(
channel_config.get("cli_path", "/usr/local/bin/signal-cli"),
channel_config.get("user", ""),
channel_config.get("recipient", ""),
msg,
debug=debug
)
elif channel_type == "mattermost":
return pushmattermost(
channel_config.get("host", ""),
channel_config.get("token", ""),
channel_config.get("channel", ""),
msg,
username=channel_config.get("username", "hbd"),
icon=channel_config.get("icon"),
debug=debug
)
else:
logger.warning("Unknown channel type '%s' for channel '%s'", channel_type, channel_name)
return False return False
def pushmsg_for_host(hostname: str, msg: str, debug: int = 0) -> dict:
"""Send notification for a specific host using its configured channels.
async def _send_matrix_async(channel_cfg: dict, notif: Notification) -> bool:
This function looks up the host's notification channels from the config """Send a Matrix message using matrix-nio."""
and sends the message to those channels. try:
from nio import AsyncClient, RoomMessageText # noqa: F401
Args: except ImportError:
hostname: Name of the host to send notification for logger.error("matrix-nio not installed; pip install matrix-nio")
msg: Message to send return False
debug: Debug level
from nio import AsyncClient
Returns: homeserver = channel_cfg.get("homeserver", "")
Dictionary of results per channel: {"channel_name": True/False} access_token = channel_cfg.get("access_token", "")
room_id = channel_cfg.get("room_id", "")
if not homeserver or not access_token or not room_id:
logger.warning("matrix: missing homeserver, access_token, or room_id")
return False
text = f"{notif.title}\n{notif.body}"
if notif.url:
text += f"\n{notif.url}"
html = f"<strong>{notif.title}</strong><br>{notif.body}"
if notif.url:
html += f'<br><a href="{notif.url}">Plugin metrics</a>'
client = AsyncClient(homeserver)
client.access_token = access_token
try:
from nio import RoomSendResponse
content = {
"msgtype": "m.text",
"body": text,
"format": "org.matrix.custom.html",
"formatted_body": html,
}
resp = await client.room_send(room_id, "m.room.message", content)
if hasattr(resp, "event_id"):
return True
logger.error("matrix send failed: %s", resp)
return False
except Exception as e:
logger.error("matrix exception: %s", e)
return False
finally:
await client.close()
# ---------------------------------------------------------------------------
# Channel dispatcher (all async — sync drivers run in a thread executor)
# ---------------------------------------------------------------------------
# Sync drivers kept for `hbd notify` CLI usage (asyncio.run wraps them there).
_DRIVERS = {
"pushover": _send_pushover,
"email": _send_email,
"mattermost": _send_mattermost,
"signal": _send_signal,
}
_TIMEOUT = 15 # seconds per channel send
async def _dispatch_to_channel(channel_name: str, channel_cfg: dict, notif: Notification) -> bool:
"""Send *notif* to a single named channel, honouring min_level."""
# Strip ownership metadata — notifier drivers only need delivery credentials.
channel_cfg = {k: v for k, v in channel_cfg.items() if k not in ("owner", "private")}
level = notif.level.upper()
if level != "RECOVER":
min_level = channel_cfg.get("min_level", "WARNING").upper()
if _level_value(level) < _level_value(min_level):
logger.debug(
"channel '%s': skipping level %s (min_level=%s)", channel_name, level, min_level
)
return True # filtered intentionally
ch_type = channel_cfg.get("type", "")
try:
if ch_type == "matrix":
return await asyncio.wait_for(_send_matrix_async(channel_cfg, notif), timeout=_TIMEOUT)
if ch_type == "sms_voipms":
return await asyncio.wait_for(_send_sms_voipms_async(channel_cfg, notif), timeout=_TIMEOUT)
sync_driver = _DRIVERS.get(ch_type)
if sync_driver is None:
logger.warning("unknown channel type '%s' for channel '%s'", ch_type, channel_name)
return False
return await asyncio.wait_for(
asyncio.to_thread(sync_driver, channel_cfg, notif), timeout=_TIMEOUT
)
except asyncio.TimeoutError:
logger.error("channel '%s' timed out after %ds", channel_name, _TIMEOUT)
return False
# ---------------------------------------------------------------------------
# Central dispatch function
# ---------------------------------------------------------------------------
def _build_url(host_name: str) -> str:
base_url = _config.get("base_url", "").rstrip("/")
if not base_url:
return ""
return f"{base_url}/alerts?filter={host_name}"
async def send_notification(host_name: str, notif: Notification) -> dict:
"""Dispatch *notif* to all managers/owner of *host_name*.
Looks up the host's owner + managers, resolves each user's
notification_channels, and dispatches. Silently does nothing if
no users are configured.
Returns a dict of {channel_name: bool} results.
""" """
from . import config as config_mod from . import users as users_mod
from . import hbdclass
# Get notification channels for this host
channels = config_mod.get_notification_channels_config(_config, hostname) if not users_mod.users_enabled():
if not channels:
logger.warning("No notification channels configured for host '%s'", hostname)
return {} return {}
# Dispatch to each channel # Collect recipient usernames: owner + managers
results = {} host = hbdclass.Host.hosts.get(host_name)
for channel_name, channel_config in channels: if host is None:
try: logger.debug("send_notification: host '%s' not found", host_name)
success = _dispatch_to_channel(channel_name, channel_config, msg, debug=debug) return {}
results[channel_name] = success
if success: recipients: set[str] = set()
logger.info("Notification sent to channel '%s': %s", channel_name, msg) owner = getattr(host, "owner", None)
else: if owner:
logger.warning("Failed to send notification to channel '%s'", channel_name) recipients.add(owner)
except Exception as e: for m in getattr(host, "managers", []):
logger.error("Error sending to channel '%s': %s", channel_name, e) recipients.add(m)
results[channel_name] = False
if not recipients:
logger.debug("send_notification: no owner/managers for '%s'", host_name)
return {}
# Fill url if not already set
if not notif.url:
notif.url = _build_url(host_name)
global_channels: dict = _config.get("notification_channels", {})
results: dict = {}
level = notif.level.upper()
is_alert = level in ("WARNING", "CRITICAL")
is_recover = level in ("RECOVER",)
# For RECOVER: send to every channel that previously fired an alert for this host,
# regardless of that channel's min_level.
if is_recover and host_name in _alerted_channels:
for channel_name in list(_alerted_channels[host_name]):
channel_cfg = global_channels.get(channel_name)
if not channel_cfg:
continue
try:
ok = await _dispatch_to_channel(channel_name, channel_cfg, notif)
results[channel_name] = ok
if ok:
logger.info("recover sent to channel '%s': %s", channel_name, notif.title)
except Exception as e:
logger.error("error sending recover to channel '%s': %s", channel_name, e)
del _alerted_channels[host_name]
return results
for username in recipients:
user = users_mod.get_user(username)
if user is None:
logger.debug("send_notification: user '%s' not found", username)
continue
for channel_name in user.notification_channels:
if channel_name in results:
continue
channel_cfg = global_channels.get(channel_name)
if not channel_cfg:
logger.warning("channel '%s' not defined in notification_channels", channel_name)
results[channel_name] = False
continue
try:
ok = await _dispatch_to_channel(channel_name, channel_cfg, notif)
results[channel_name] = ok
if ok:
logger.info("notification sent to channel '%s': %s", channel_name, notif.title)
if is_alert:
_alerted_channels.setdefault(host_name, set()).add(channel_name)
else:
logger.warning("failed to send notification to channel '%s'", channel_name)
except Exception as e:
logger.error("error sending to channel '%s': %s", channel_name, e)
results[channel_name] = False
return results return results
+254
View File
@@ -0,0 +1,254 @@
"""OAuth2 provider support.
Config shape (in ~/.hb.yaml):
oauth:
my-gitea: # route slug → /login/oauth/my-gitea
type: gitea # "gitea" | "github" | "nextcloud"
# omit type to default to "gitea"
url: https://git.example.com # required for gitea and nextcloud
client_id: <client-id>
client_secret: <client-secret>
label: "Work Gitea" # optional display name on login button
logo: https://example.com/logo.png # optional logo URL
github:
type: github
client_id: <client-id>
client_secret: <client-secret>
nextcloud:
type: nextcloud
url: https://cloud.example.com
client_id: <client-id>
client_secret: <client-secret>
Register the OAuth app with each provider and set the redirect URI to:
https://<hbd-host>/login/oauth/<name>/callback
"""
import logging
import secrets
import time
import urllib.parse
from dataclasses import dataclass
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."""
PROVIDER_DEFS: dict = {
"gitea": {
"authorize_url_tmpl": "{url}/login/oauth/authorize",
"token_url_tmpl": "{url}/login/oauth/access_token",
"profile_url_tmpl": "{url}/api/v1/user",
"scope": "user:email",
"field_map": {"username": "login", "full_name": "full_name", "avatar": "avatar_url"},
"profile_data_path": [],
"requires_url": True,
"default_label": "Gitea",
},
"github": {
"authorize_url_tmpl": "https://github.com/login/oauth/authorize",
"token_url_tmpl": "https://github.com/login/oauth/access_token",
"profile_url_tmpl": "https://api.github.com/user",
"scope": "read:user",
"field_map": {"username": "login", "full_name": "name", "avatar": "avatar_url"},
"profile_data_path": [],
"requires_url": False,
"default_label": "GitHub",
},
"nextcloud": {
"authorize_url_tmpl": "{url}/apps/oauth2/authorize",
"token_url_tmpl": "{url}/apps/oauth2/api/v1/token",
"profile_url_tmpl": "{url}/ocs/v2.php/cloud/user?format=json",
"scope": "",
"field_map": {"username": "id", "full_name": "display-name", "avatar": None},
"profile_data_path": ["ocs", "data"],
"requires_url": True,
"default_label": "Nextcloud",
},
}
@dataclass
class ResolvedProvider:
"""A fully resolved OAuth2 provider instance, ready to use."""
name: str
type: str
label: str
logo: str
authorize_url: str
token_url: str
profile_url: str
scope: str
client_id: str
client_secret: str
field_map: dict
profile_data_path: list
def get_providers(config: dict) -> list[ResolvedProvider]:
"""Return a ResolvedProvider for every valid entry in config['oauth'].
Entries with missing required fields or unknown types are skipped with
a warning log. Order follows config declaration order.
"""
result = []
oauth_cfg = config.get("oauth", {})
if not isinstance(oauth_cfg, dict):
return result
for name, entry in oauth_cfg.items():
if not isinstance(entry, dict):
continue
provider_type = entry.get("type", "gitea")
defn = PROVIDER_DEFS.get(provider_type)
if defn is None:
logger.warning("OAuth: unknown provider type %r for %r, skipping", provider_type, name)
continue
client_id = entry.get("client_id", "")
client_secret = entry.get("client_secret", "")
if not client_id or not client_secret:
logger.warning("OAuth: %r missing client_id or client_secret, skipping", name)
continue
url = entry.get("url", "").rstrip("/")
if defn["requires_url"] and not url:
logger.warning("OAuth: %r requires url but none configured, skipping", name)
continue
label = entry.get("label") or defn["default_label"]
logo = entry.get("logo", "")
result.append(ResolvedProvider(
name=name,
type=provider_type,
label=label,
logo=logo,
authorize_url=defn["authorize_url_tmpl"].format(url=url),
token_url=defn["token_url_tmpl"].format(url=url),
profile_url=defn["profile_url_tmpl"].format(url=url),
scope=defn["scope"],
client_id=client_id,
client_secret=client_secret,
field_map=dict(defn["field_map"]),
profile_data_path=list(defn["profile_data_path"]),
))
return result
def is_enabled(config: dict) -> bool:
"""Return True when at least one OAuth provider is fully configured."""
return bool(get_providers(config))
def build_auth_url(provider: ResolvedProvider, state: str, redirect_uri: str) -> str:
"""Return the provider's OAuth2 authorization URL to redirect the browser to."""
params: dict = {
"client_id": provider.client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"state": state,
}
if provider.scope:
params["scope"] = provider.scope
return f"{provider.authorize_url}?{urllib.parse.urlencode(params)}"
async def exchange_code(provider: ResolvedProvider, code: str, redirect_uri: str) -> str:
"""Exchange an authorization *code* for an access token.
Returns the access token string. Raises OAuthError on any failure.
"""
payload = {
"client_id": provider.client_id,
"client_secret": provider.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(
provider.token_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(provider: ResolvedProvider, token: str) -> dict:
"""Fetch the authenticated user's profile from the provider.
Returns a dict with keys: login, full_name, avatar_url.
Raises OAuthError on any failure.
"""
timeout = aiohttp.ClientTimeout(total=10)
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(
provider.profile_url,
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/json",
},
) 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
try:
for key in provider.profile_data_path:
data = data.get(key, {})
avatar_field = provider.field_map.get("avatar")
return {
"login": data.get(provider.field_map["username"], ""),
"full_name": data.get(provider.field_map["full_name"], ""),
"avatar_url": data.get(avatar_field, "") if avatar_field else "",
}
except AttributeError:
raise OAuthError(f"Unexpected profile response structure from {provider.type}")
+498
View File
@@ -0,0 +1,498 @@
"""Settings descriptor: maps config keys to display metadata.
``get_settings_sections(config)`` returns an ordered list of sections, each
containing a list of field descriptors. The template iterates this structure
generically, so adding editability later is a matter of:
1. Setting ``"editable": True`` on a field.
2. Adding the matching ``<input>``/``<select>`` in the template
(guided by ``"type"``).
3. Wiring a POST handler in http.py.
Field descriptor keys
---------------------
key str Config key (for future form POST matching)
label str Human-readable label
description str One-line help text shown below the value
value any Sanitized display value (secrets replaced with "•••")
type str One of: text | number | port | boolean | path | duration |
list | secret | size | select
editable bool Reserved for future use — currently always False
sensitive bool True when the raw value must never be shown
"""
# Credential field names that should always be masked.
_SECRET_KEYS = frozenset({
"password", "token", "user_key", "api_key", "secret",
"smtp_password", "smtp_user", "api_password", "access_token",
})
CHANNEL_TYPE_SCHEMAS = {
"pushover": {
"label": "Pushover",
"fields": [
{"key": "token", "label": "App token", "type": "secret", "required": True},
{"key": "user", "label": "User key", "type": "secret", "required": True},
{"key": "sound", "label": "Sound", "type": "text", "required": False},
],
},
"email": {
"label": "E-mail",
"fields": [
{"key": "recipients", "label": "Recipients (comma-separated)", "type": "list", "required": True},
{"key": "sender", "label": "From address", "type": "text", "required": True},
{"key": "smtp_server", "label": "SMTP server", "type": "text", "required": True},
{"key": "smtp_port", "label": "SMTP port", "type": "port", "required": False},
{"key": "smtp_user", "label": "SMTP username", "type": "text", "required": False},
{"key": "smtp_password", "label": "SMTP password", "type": "secret", "required": False},
],
},
"signal": {
"label": "Signal",
"fields": [
{"key": "user", "label": "Sender number", "type": "text", "required": True},
{"key": "recipient", "label": "Recipient number", "type": "text", "required": True},
{"key": "cli_path", "label": "signal-cli path", "type": "text", "required": False},
],
},
"matrix": {
"label": "Matrix",
"fields": [
{"key": "homeserver", "label": "Homeserver URL", "type": "text", "required": True},
{"key": "access_token", "label": "Access token", "type": "secret", "required": True},
{"key": "room_id", "label": "Room ID", "type": "text", "required": True},
],
},
"sms_voipms": {
"label": "SMS (voip.ms)",
"fields": [
{"key": "api_user", "label": "API username", "type": "text", "required": True},
{"key": "api_password", "label": "API password", "type": "secret", "required": True},
{"key": "did", "label": "DID (from)", "type": "text", "required": True},
{"key": "dst", "label": "Destination", "type": "text", "required": True},
],
},
"mattermost": {
"label": "Mattermost",
"fields": [
{"key": "host", "label": "Host", "type": "text", "required": True},
{"key": "token", "label": "Webhook token", "type": "secret", "required": True},
{"key": "channel", "label": "Channel", "type": "text", "required": True},
{"key": "username", "label": "Bot username", "type": "text", "required": False},
{"key": "icon", "label": "Icon URL", "type": "text", "required": False},
],
},
}
_CHANNEL_TYPE_LABELS = {k: v["label"] for k, v in CHANNEL_TYPE_SCHEMAS.items()}
def _mask(value):
"""Return a masked placeholder for sensitive values."""
if not value:
return ""
return "•••"
def _fmt_size(n):
"""Format a byte count as a human-readable string."""
try:
n = int(n)
except (TypeError, ValueError):
return str(n)
for unit in ("B", "KB", "MB", "GB"):
if n < 1024:
return f"{n} {unit}"
n //= 1024
return f"{n} TB"
def _fmt_duration(seconds):
"""Format seconds into a human-readable duration string."""
try:
s = int(seconds)
except (TypeError, ValueError):
return str(seconds)
if s < 60:
return f"{s}s"
if s < 3600:
m, sec = divmod(s, 60)
return f"{m}m {sec}s" if sec else f"{m}m"
h, rem = divmod(s, 3600)
m = rem // 60
return f"{h}h {m}m" if m else f"{h}h"
def _sanitize_channel(name, cfg):
"""Return a sanitized copy of a notification channel config."""
result = {}
for k, v in cfg.items():
if k in _SECRET_KEYS:
result[k] = _mask(v)
elif isinstance(v, list):
result[k] = v
else:
result[k] = v
return result
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def get_settings_sections(config: dict, threshold_checker=None) -> list:
"""Return ordered list of setting sections for the settings page.
Each section:
{
"title": str,
"description": str,
"fields": [ field_descriptor, ... ]
}
Each field_descriptor:
{
"key": str,
"label": str,
"description": str,
"value": display_value,
"raw": raw_config_value, # None for sensitive
"type": str,
"editable": bool,
"sensitive": bool,
}
"""
def field(key, label, ftype, description="", editable=False, sensitive=False):
raw = config.get(key)
if sensitive:
display = _mask(raw)
raw_out = None
elif ftype == "size":
display = _fmt_size(raw)
raw_out = raw
elif ftype == "duration":
display = _fmt_duration(raw)
raw_out = raw
elif ftype == "boolean":
display = bool(raw)
raw_out = raw
elif ftype == "list":
val = raw or []
display = list(val) if not isinstance(val, list) else val
raw_out = display
else:
display = raw if raw is not None else ""
raw_out = raw
return {
"key": key,
"label": label,
"description": description,
"value": display,
"raw": raw_out,
"type": ftype,
"editable": editable,
"sensitive": sensitive,
}
# ---- Notification channels (complex, built separately) ----------------
_METADATA_KEYS = {"type", "owner", "private", "min_level"}
notif_channels = []
for ch_name, ch_cfg in sorted((config.get("notification_channels") or {}).items()):
if not isinstance(ch_cfg, dict):
continue
ch_type = ch_cfg.get("type", "")
fields = []
for k, v in ch_cfg.items():
if k in _METADATA_KEYS:
continue
sensitive = k in _SECRET_KEYS
fields.append({
"key": k,
"label": k.replace("_", " ").title(),
"value": _mask(v) if sensitive else (
", ".join(v) if isinstance(v, list) else str(v)
),
"sensitive": sensitive,
})
notif_channels.append({
"name": ch_name,
"type": ch_type,
"type_label": _CHANNEL_TYPE_LABELS.get(ch_type, ch_type.title()),
"owner": ch_cfg.get("owner"),
"private": bool(ch_cfg.get("private", False)),
"min_level": ch_cfg.get("min_level", "WARNING"),
"fields": fields,
})
# ---- Users (show metadata only, never password hashes) ----------------
users_list = []
for username, attrs in (config.get("users") or {}).items():
if not isinstance(attrs, dict):
continue
users_list.append({
"username": username,
"full_name": attrs.get("full_name", ""),
"admin": bool(attrs.get("admin", False)),
"avatar": attrs.get("avatar", ""),
"notification_channels": attrs.get("notification_channels", []),
})
# ---- Threshold configurations -----------------------------------------
def _tc_to_row(tc):
return {
"metric": tc.metric_path,
"operator": tc.operator.value,
"warning": tc.warning,
"critical": tc.critical,
"hysteresis": tc.hysteresis,
"count": tc.count,
"enabled": tc.enabled,
"display": tc.display or "",
"grace": tc.grace,
}
threshold_config_list = []
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 = []
for hname, hcfg in sorted((config.get("hosts") or {}).items()):
if not isinstance(hcfg, dict):
continue
hosts_list.append({
"name": hname,
"watch": bool(hcfg.get("watch", True)),
"dyndns": bool(hcfg.get("dyndns", False)),
"owner": hcfg.get("owner", ""),
"managers": hcfg.get("managers", []),
"monitors": hcfg.get("monitors", []),
"threshold_configs": (
list(v) if isinstance(v := hcfg.get("threshold_config"), list)
else ([v] if v else [])
),
"notification_channels": hcfg.get("notification_channels", []),
})
# ---- OAuth providers -------------------------------------------------------
oauth_providers = []
for pname, pattrs in (config.get("oauth") or {}).items():
if not isinstance(pattrs, dict):
continue
cs = pattrs.get("client_secret", "")
oauth_providers.append({
"name": pname,
"type": pattrs.get("type", "gitea"),
"url": pattrs.get("url", ""),
"client_id": pattrs.get("client_id", ""),
"client_secret": "•••" if cs else "",
"label": pattrs.get("label", ""),
"logo": pattrs.get("logo", ""),
})
return [
{
"id": "network",
"title": "Network",
"description": "Ports and bind addresses for all server sockets.",
"section_mode": "form",
"api_section": "server",
"fields": [
field("hb_port", "Heartbeat UDP port", "port",
"UDP port the server listens on for heartbeat datagrams.", editable=True),
field("hbd_host", "HTTP bind address", "text",
"Interface to bind the HTTP server to. Empty = all interfaces.", editable=True),
field("hbd_port", "HTTP API port", "port",
"TCP port for the HTTP API and web UI.", editable=True),
field("ws_port", "WebSocket port", "port",
"TCP port for the plain WebSocket server.", editable=True),
field("wss_port", "Secure WebSocket port", "port",
"TCP port for WSS (TLS WebSocket). Leave empty to disable.", editable=True),
],
},
{
"id": "tls",
"title": "TLS / WebSocket Security",
"description": "Certificate paths used when wss_port is set.",
"section_mode": "form",
"api_section": None,
"fields": [
field("cert_path", "Certificate directory", "path",
"Directory containing the TLS certificate and key files."),
field("wss_pem", "Certificate file", "text",
"Filename of the TLS certificate chain (PEM format)."),
field("wss_key", "Key file", "text",
"Filename of the TLS private key (PEM format)."),
],
},
{
"id": "monitoring",
"title": "Monitoring",
"description": "Heartbeat timing and alert re-notification behaviour.",
"section_mode": "form",
"api_section": "server",
"fields": [
field("interval", "Heartbeat interval", "duration",
"Expected time between heartbeat messages from each client.", editable=True),
field("grace", "Grace period", "number",
"Extra seconds to wait after a missed heartbeat before sending notifications.", editable=True),
field("threshold_renotify_interval", "Re-notify interval", "duration",
"How often to re-send notifications for ongoing threshold alerts.", editable=True),
field("autosave_interval", "Autosave interval", "duration",
"How often the server saves its state to disk."),
field("base_url", "Base URL", "text",
"Base URL for notification links.", editable=True),
],
},
{
"id": "persistence",
"title": "Persistence & Logging",
"description": "State file and event log settings.",
"section_mode": "form",
"api_section": "server",
"fields": [
field("pickfile", "State file", "path",
"Path to the pickle file used to persist host state across restarts.", editable=True),
field("logfile", "Event log", "path",
"Path to the event log file.", editable=True),
],
},
{
"id": "journal",
"title": "Message Journal",
"description": "All received heartbeat and plugin messages are journalled here.",
"section_mode": "form",
"api_section": "server",
"fields": [
field("journal_enabled", "Enabled", "boolean",
"Turn journalling on or off.", editable=True),
field("journal_dir", "Journal directory","path",
"Directory where journal files are written.", editable=True),
field("journal_file", "Journal filename", "text",
"Base filename for the journal (rotated copies get a numeric suffix)."),
field("journal_max_size", "Max file size", "size",
"Rotate the journal when it exceeds this size.", editable=True),
field("journal_max_backups", "Backup count", "number",
"Number of rotated journal files to keep.", editable=True),
],
},
{
"id": "dns",
"title": "Dynamic DNS",
"description": "nsupdate-based DNS registration via nsupdate(8).",
"section_mode": "form",
"api_section": "dns",
"fields": [
field("nsupdate_bin", "nsupdate binary", "path",
"Path to the nsupdate binary.", editable=True),
field("rndc_key", "RNDC key file", "path",
"Path to the rndc key file used to authenticate DNS updates.", editable=True),
field("dyndomains", "Dynamic domains", "list",
"Domains updated via nsupdate when a host with dyndns: true reports in.",
editable=True),
],
},
{
"id": "users",
"title": "Users",
"description": "Accounts defined in the config file. Password hashes are never shown.",
"section_mode": "form",
"api_section": "users",
"users": users_list,
"fields": [
field("default_owner", "Default owner", "text",
"Username that owns hosts with no explicit owner. "
"Falls back to the first admin user.", editable=True),
],
},
{
"id": "oauth",
"title": "OAuth Providers",
"description": "OAuth2 login providers. Client secrets are masked.",
"section_mode": "form",
"api_section": "oauth",
"providers": oauth_providers,
"fields": [],
},
{
"id": "channels",
"title": "Notification Channels",
"description": "Named notification providers. Credentials are masked.",
"section_mode": "channels",
"api_section": "notification_channels",
"channels": notif_channels,
"fields": [
field("default_notification_channels", "Default channels", "list",
"Channels used when a host does not specify its own."),
],
},
{
"id": "hosts",
"title": "Hosts",
"description": "Host definitions loaded from the config file.",
"section_mode": "hosts",
"api_section": "hosts",
"hosts": hosts_list,
"fields": [],
},
{
"id": "thresholds",
"title": "Threshold Configurations",
"description": "Named alert threshold sets. Each defines warning/critical levels per metric.",
"section_mode": "thresholds",
"api_section": "thresholds",
"threshold_configs": threshold_config_list,
"fields": [
field("default_threshold_config", "Default config", "text",
"Threshold config used for hosts with no explicit mapping.", editable=True),
],
},
{
"id": "runtime",
"title": "Runtime",
"description": "Flags set at startup (require restart to change).",
"section_mode": "form",
"api_section": None,
"fields": [
field("foreground", "Foreground mode", "boolean",
"Run in the foreground instead of daemonising."),
field("verbose", "Verbose logging", "boolean",
"Enable verbose log output."),
field("debug", "Debug level", "number",
"0 = off. Higher values increase log verbosity."),
],
},
]
def get_settings_data(config: dict, threshold_checker=None) -> dict:
"""Return sections list + auxiliary data for the settings template."""
sections = get_settings_sections(config, threshold_checker=threshold_checker)
all_channel_names = sorted((config.get("notification_channels") or {}).keys())
all_usernames = sorted((config.get("users") or {}).keys())
all_threshold_configs = sorted((config.get("threshold_configs") or {}).keys())
return {
"sections": sections,
"all_channel_names": all_channel_names,
"all_usernames": all_usernames,
"all_threshold_configs": all_threshold_configs,
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 181 KiB

+66 -1
View File
@@ -139,4 +139,69 @@
font-size: 9px; font-size: 9px;
float: left; float: left;
} }
/* ── Responsive / mobile ── */
/* Suppress the global transition on mobile to avoid sluggish feel */
@media (max-width: 640px) {
* { transition: none !important; }
html, body {
overflow: auto;
height: auto;
font-size: 16px; /* prevent iOS auto-zoom on inputs */
}
/* Pages that use flex-column full-viewport layout need to relax on mobile */
body[style*="height: 100vh"],
body {
height: auto !important;
min-height: 100vh;
}
/* Containers: full width, no fixed heights */
.container {
max-width: 100% !important;
max-height: none !important;
overflow: visible !important;
padding: 8px !important;
}
/* Log section: fixed reasonable height instead of flex-grow */
.log-section {
flex: none !important;
max-height: 40vh !important;
overflow-y: auto !important;
}
/* Table section: allow vertical scroll, cap height */
.table-section {
max-height: 55vh !important;
overflow-y: auto !important;
overflow-x: auto !important;
padding: 8px !important;
}
/* Slightly larger tap targets in tables */
#ntable td, #ntable th {
padding: 4px 6px !important;
font-size: 1.00em !important;
}
/* Cards on plugin/alerts pages */
.host-card, .alert-card, .card {
padding: 10px !important;
margin-bottom: 8px !important;
}
/* Settings page tables */
table { width: 100%; }
h1 { font-size: 1.2em !important; }
h2 { font-size: 1em !important; }
}
/* Suppress nav-username text on very narrow screens — avatar/initials is enough */
@media (max-width: 400px) {
.nav-username { display: none; }
}
+212
View File
@@ -0,0 +1,212 @@
<!DOCTYPE html>
<html>
{% include 'head.html' %}
<style>
html, body { overflow: visible; }
.container {
max-width: 700px;
margin: 0 auto;
}
h1 {
color: #333;
margin-bottom: 4px;
font-size: 1.5em;
}
.subtitle {
color: #666;
margin-bottom: 24px;
font-size: 0.9em;
}
.section {
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 6px rgba(0,0,0,0.1);
padding: 20px 24px;
margin-bottom: 20px;
}
.section h2 {
font-size: 1em;
font-weight: 700;
color: #333;
margin: 0 0 16px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.info-row {
display: flex;
align-items: baseline;
padding: 8px 0;
border-bottom: 1px solid #f5f5f5;
font-size: 0.9em;
}
.info-row:last-child { border-bottom: none; }
.info-label {
width: 160px;
flex-shrink: 0;
color: #666;
font-size: 0.88em;
}
.info-value {
color: #222;
word-break: break-all;
}
.info-value a {
color: #0066cc;
text-decoration: none;
}
.info-value a:hover { text-decoration: underline; }
.version-badge {
display: inline-block;
padding: 3px 12px;
background: #e8f0fe;
color: #1a73e8;
border-radius: 12px;
font-size: 1.00em;
font-weight: 600;
font-family: monospace;
}
.hb-logo {
font-size: 2.5em;
font-weight: 700;
color: #0066cc;
letter-spacing: -1px;
margin-bottom: 6px;
}
.hb-tagline {
color: #555;
font-size: 0.95em;
}
.logo-section {
display: flex;
align-items: center;
gap: 20px;
padding: 8px 0 4px;
}
.logo-text { flex: 1; }
/* ── Dark mode ── */
html[data-theme="dark"] h1 { color: var(--text); }
html[data-theme="dark"] .subtitle { color: var(--text-sec); }
html[data-theme="dark"] .section { background: var(--surface); box-shadow: 0 1px 6px var(--shadow); }
html[data-theme="dark"] .section h2 { color: var(--text); border-bottom-color: var(--border); }
html[data-theme="dark"] .info-row { border-bottom-color: var(--border-4); }
html[data-theme="dark"] .info-label { color: var(--text-sec); }
html[data-theme="dark"] .info-value { color: var(--text); }
html[data-theme="dark"] .info-value a { color: var(--link); }
html[data-theme="dark"] .hb-logo { color: var(--link); }
html[data-theme="dark"] .hb-tagline { color: var(--text-sec); }
html[data-theme="dark"] .version-badge { background: #1a3255; color: #60a5fa; }
</style>
<body>
{% include 'nav.html' %}
<div class="container">
<h1>{{ header }}</h1>
<p class="subtitle">Heartbeat monitoring system</p>
<div class="section">
<div class="logo-section">
<div class="logo-text">
<div class="hb-logo">Heartbeat</div>
<div class="hb-tagline">Lightweight host monitoring over UDP</div>
</div>
<span class="version-badge">v{{ hbd_version }}</span>
</div>
</div>
<div class="section">
<h2>Version</h2>
<div class="info-row">
<span class="info-label">Server version</span>
<span class="info-value">{{ hbd_version }}</span>
</div>
<div class="info-row">
<span class="info-label">Python</span>
<span class="info-value">{{ python_version }}</span>
</div>
<div class="info-row">
<span class="info-label">License</span>
<span class="info-value">MIT</span>
</div>
</div>
<div class="section">
<h2>Runtime</h2>
<div class="info-row">
<span class="info-label">Host</span>
<span class="info-value">{{ server_hostname }}</span>
</div>
<div class="info-row">
<span class="info-label">Started</span>
<span class="info-value">{{ start_time_str }}</span>
</div>
<div class="info-row">
<span class="info-label">Uptime</span>
<span class="info-value" id="uptime-value">{{ uptime_str }}</span>
</div>
<div class="info-row">
<span class="info-label">Hosts monitored</span>
<span class="info-value">{{ host_count }}</span>
</div>
</div>
<div class="section">
<h2>Contact &amp; Source</h2>
<div class="info-row">
<span class="info-label">Author</span>
<span class="info-value">Andreas Wrede</span>
</div>
<div class="info-row">
<span class="info-label">Email</span>
<span class="info-value"><a href="mailto:aew.hbd@wrede.ca">aew.hbd@wrede.ca</a></span>
</div>
<div class="info-row">
<span class="info-label">Repository</span>
<span class="info-value"><a href="https://git.wrede.ca/andreas/heartbeat" target="_blank" rel="noopener">git.wrede.ca/andreas/heartbeat</a></span>
</div>
</div>
</div>
<script>
(function() {
var startEpoch = {{ start_epoch }};
var el = document.getElementById('uptime-value');
if (!el) return;
function fmt(s) {
var d = Math.floor(s / 86400);
var h = Math.floor((s % 86400) / 3600);
var m = Math.floor((s % 3600) / 60);
var sec = s % 60;
if (d > 0) return d + 'd ' + h + 'h ' + m + 'm';
if (h > 0) return h + 'h ' + m + 'm ' + sec + 's';
return m + 'm ' + sec + 's';
}
function tick() {
var up = Math.floor(Date.now() / 1000 - startEpoch);
el.textContent = fmt(up);
}
tick();
setInterval(tick, 1000);
})();
</script>
</body>
</html>
+119 -81
View File
@@ -3,33 +3,10 @@
{% include 'head.html' %} {% include 'head.html' %}
<style> <style>
body {
margin: 20px;
background: #f5f5f5;
}
.nav { html, body {
background: #fff; height: auto;
padding: 15px; overflow-y: auto;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
border-radius: 4px;
}
.nav a {
margin-right: 20px;
text-decoration: none;
color: #0066cc;
font-weight: 500;
}
.nav a:hover {
text-decoration: underline;
}
.nav a.active {
color: #333;
font-weight: bold;
} }
.container { .container {
@@ -37,10 +14,7 @@
margin: 0 auto; margin: 0 auto;
} }
h1 { h1 { color: #333; margin-bottom: 5px; margin-top: 15px; font-size: 1.5em; }
color: #333;
margin-bottom: 10px;
}
.subtitle { .subtitle {
color: #666; color: #666;
@@ -48,55 +22,40 @@
} }
.summary-cards { .summary-cards {
display: grid; display: flex;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); flex-wrap: wrap;
gap: 20px; gap: 10px;
margin-bottom: 30px; margin-bottom: 16px;
} }
.summary-card { .summary-card {
background: white; background: white;
border-radius: 8px; border-radius: 6px;
padding: 20px; padding: 6px 14px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1); box-shadow: 0 1px 4px rgba(0,0,0,0.1);
text-align: center; display: flex;
align-items: center;
gap: 8px;
border-left: 4px solid #ddd;
} }
.summary-card.critical { .summary-card.critical { border-left-color: #ea1e0f; }
border-left: 5px solid #f44336; .summary-card.warning { border-left-color: #ff9800; }
} .summary-card.ok { border-left-color: #4caf50; }
.summary-card.warning {
border-left: 5px solid #ff9800;
}
.summary-card.ok {
border-left: 5px solid #4caf50;
}
.summary-number { .summary-number {
font-size: 3em; font-size: 1.4em;
font-weight: bold; font-weight: bold;
margin: 10px 0; line-height: 1;
} }
.summary-number.critical { .summary-number.critical { color: #ea1e0f; }
color: #f44336; .summary-number.warning { color: #ff9800; }
} .summary-number.ok { color: #4caf50; }
.summary-number.warning {
color: #ff9800;
}
.summary-number.ok {
color: #4caf50;
}
.summary-label { .summary-label {
color: #666; color: #666;
text-transform: uppercase; font-size: 1.00em;
font-size: 0.9em;
letter-spacing: 1px;
} }
.filters { .filters {
@@ -135,6 +94,24 @@
border-color: #2196f3; 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 { .alerts-container {
background: white; background: white;
border-radius: 8px; border-radius: 8px;
@@ -155,7 +132,7 @@
} }
.alert-item.acknowledged { .alert-item.acknowledged {
opacity: 0.6; opacity: 0.8;
background: #f0f0f0; background: #f0f0f0;
} }
@@ -216,14 +193,18 @@
.alert-hostname { .alert-hostname {
font-weight: bold; font-weight: bold;
color: #333; color: #0066cc;
font-size: 1.1em; font-size: 1.1em;
text-decoration: none;
}
.alert-hostname:hover {
text-decoration: underline;
} }
.alert-metric { .alert-metric {
color: #666; color: #0066cc;
font-family: 'Courier New', monospace; font-size: 1.1em;
font-size: 0.9em; font-weight: normal;
} }
.alert-details { .alert-details {
@@ -240,7 +221,7 @@
.alert-duration { .alert-duration {
color: #999; color: #999;
font-size: 0.85em; font-size: 1.00em;
} }
.alert-actions { .alert-actions {
@@ -257,7 +238,7 @@
border: none; border: none;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-size: 0.85em; font-size: 1.00em;
transition: all 0.2s; transition: all 0.2s;
white-space: nowrap; white-space: nowrap;
} }
@@ -312,7 +293,7 @@
.refresh-info { .refresh-info {
text-align: center; text-align: center;
color: #999; color: #999;
font-size: 0.85em; font-size: 1.00em;
margin-top: 20px; margin-top: 20px;
padding-top: 20px; padding-top: 20px;
border-top: 1px solid #e0e0e0; border-top: 1px solid #e0e0e0;
@@ -324,14 +305,35 @@
text-align: right; text-align: right;
margin-bottom: 15px; margin-bottom: 15px;
} }
/* ── Dark mode ── */
html[data-theme="dark"] h1 { color: var(--text); }
html[data-theme="dark"] .subtitle { color: var(--text-sec); }
html[data-theme="dark"] .summary-card { background: var(--surface); }
html[data-theme="dark"] .summary-label { color: var(--text-sec); }
html[data-theme="dark"] .filters { background: var(--surface); }
html[data-theme="dark"] .filter-label { color: var(--text-sec); }
html[data-theme="dark"] .filter-button { background: var(--surface-2); border-color: var(--border); color: var(--text); }
html[data-theme="dark"] .filter-button.active { background: #2196f3; color: #fff; border-color: #2196f3; }
html[data-theme="dark"] .filter-input { background: var(--input-bg); border-color: var(--input-border); color: var(--text); }
html[data-theme="dark"] .alerts-container { background: var(--surface); }
html[data-theme="dark"] .alert-item { background: var(--surface-2); }
html[data-theme="dark"] .alert-item.acknowledged { background: var(--surface-3); }
html[data-theme="dark"] .alert-item.critical { background: #2e0a0a; border-left-color: #f44336; }
html[data-theme="dark"] .alert-item.warning { background: #2e1a00; border-left-color: #ff9800; }
html[data-theme="dark"] .alert-item.unknown { background: var(--surface-2); }
html[data-theme="dark"] .alert-hostname { color: var(--link); }
html[data-theme="dark"] .alert-details { color: var(--text-sec); }
html[data-theme="dark"] .alert-value { color: var(--text); }
html[data-theme="dark"] .alert-duration { color: var(--text-muted); }
html[data-theme="dark"] .last-update { color: var(--text-sec); }
html[data-theme="dark"] .refresh-info { color: var(--text-muted); border-top-color: var(--border); }
html[data-theme="dark"] .no-alerts,
html[data-theme="dark"] .loading { color: var(--text-muted); }
</style> </style>
<body> <body>
<div class="nav"> {% include 'nav.html' %}
<a href="/live">Live Dashboard</a>
<a href="/plugins">Plugin Metrics</a>
<a href="/alerts" class="active">Alerts</a>
</div>
<div class="container"> <div class="container">
<h1>{{ header }}</h1> <h1>{{ header }}</h1>
@@ -357,6 +359,7 @@
<button class="filter-button active" onclick="filterAlerts('all')">All</button> <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('critical')">Critical Only</button>
<button class="filter-button" onclick="filterAlerts('warning')">Warning 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>
<div class="alerts-container"> <div class="alerts-container">
@@ -373,6 +376,7 @@
<script> <script>
let currentFilter = 'all'; let currentFilter = 'all';
let allAlerts = []; let allAlerts = [];
let hostFilterRe = null;
async function loadAlerts() { async function loadAlerts() {
try { try {
@@ -407,10 +411,13 @@
// Filter alerts based on current filter // Filter alerts based on current filter
let filteredAlerts = alerts; let filteredAlerts = alerts;
if (currentFilter !== 'all') { if (currentFilter !== 'all') {
filteredAlerts = alerts.filter(alert => filteredAlerts = filteredAlerts.filter(alert =>
alert.level.toLowerCase() === currentFilter alert.level.toLowerCase() === currentFilter
); );
} }
if (hostFilterRe) {
filteredAlerts = filteredAlerts.filter(alert => hostFilterRe.test(alert.hostname));
}
if (filteredAlerts.length === 0) { if (filteredAlerts.length === 0) {
if (currentFilter === 'all' && alerts.length === 0) { if (currentFilter === 'all' && alerts.length === 0) {
@@ -450,6 +457,10 @@
} else if (alert.threshold_value !== undefined && alert.threshold_value !== null && alert.operator) { } 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>`; 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 // Build actions section
let actionsHtml = ''; let actionsHtml = '';
@@ -474,9 +485,9 @@
<div class="alert-main"> <div class="alert-main">
<div class="alert-header"> <div class="alert-header">
<span class="alert-level ${level}">${alert.level}</span> <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>
<div class="alert-metric">${alert.metric_path}</div>
<div class="alert-details"> <div class="alert-details">
<span>${valueText}</span> <span>${valueText}</span>
<span class="alert-duration">Active for ${duration}</span> <span class="alert-duration">Active for ${duration}</span>
@@ -575,9 +586,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 // Auto-refresh every 15 seconds
setInterval(loadAlerts, 15000); 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 // Initial load
loadAlerts(); loadAlerts();
</script> </script>
+1 -1
View File
@@ -1,5 +1,5 @@
<footer> <footer>
<div id="copyright"> <div id="copyright">
&copy;2002-2026 <A HREF="mailto:andreas@wrede.ca">Andreas Wrede</A> All Rights Reserved.</p> &copy;2002-2026 <A HREF="mailto:aew.hbd@wrede.ca">Andreas Wrede</A> All Rights Reserved.</p>
</div> </div>
</footer> </footer>
+380 -1
View File
@@ -1,7 +1,386 @@
<head> <head>
<meta http-equiv="content-type" content="text/html; charset=utf-8" /> <meta http-equiv="content-type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/static/style.css" type="text/css" /> <link rel="stylesheet" href="/static/style.css" type="text/css" />
<link rel="icon" href="/static/images/favicon.ico" sizes="32x32" /> <link rel="icon" href="/static/images/favicon.ico" sizes="32x32" />
<title>{{ title }}</title> <title>{{ title }}</title>
<script src="{{ extra_scripts }}"></script> {% if extra_scripts %}<script src="{{ extra_scripts }}"></script>{% endif %}
<script>
/* Apply saved theme before first paint to avoid flash */
(function() {
try {
var p = localStorage.getItem('hbd_theme') || 'auto';
var dark = p === 'dark' || (p === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (dark) document.documentElement.setAttribute('data-theme', 'dark');
} catch(e) {}
})();
</script>
<style>
/* ── Theme variables ── */
:root {
--bg: #f5f5f5;
--surface: #ffffff;
--surface-2: #f8f8f8;
--surface-3: #f5f5f5;
--text: #222222;
--text-2: #333333;
--text-3: #555555;
--text-sec: #666666;
--text-muted: #888888;
--text-dim: #aaaaaa;
--text-ghost: #cccccc;
--border: #e0e0e0;
--border-2: #eeeeee;
--border-3: #f0f0f0;
--border-4: #f5f5f5;
--link: #0066cc;
--nav-bg: #ffffff;
--input-bg: #ffffff;
--input-border: #cccccc;
--shadow-sm: rgba(0,0,0,.08);
--shadow: rgba(0,0,0,.10);
--shadow-nav: rgba(0,0,0,.10);
}
html[data-theme="dark"] {
color-scheme: dark;
--bg: #111827;
--surface: #1f2937;
--surface-2: #283447;
--surface-3: #374151;
--text: #e5e7eb;
--text-2: #d1d5db;
--text-3: #9ca3af;
--text-sec: #9ca3af;
--text-muted: #6b7280;
--text-dim: #4b5563;
--text-ghost: #374151;
--border: #374151;
--border-2: #2d3748;
--border-3: #253040;
--border-4: #1e2a38;
--link: #60a5fa;
--nav-bg: #1f2937;
--input-bg: #283447;
--input-border: #4b5563;
--shadow-sm: rgba(0,0,0,.30);
--shadow: rgba(0,0,0,.40);
--shadow-nav: rgba(0,0,0,.40);
}
/* ── Reset / shared baseline ── */
*, *::before, *::after { box-sizing: border-box; }
html {
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
font-size: 14px;
}
body {
margin: 0;
padding: 10px;
padding-top: 60px;
background: var(--bg);
color: var(--text);
}
h1 { font-size: 1.5em; color: var(--text-2); margin: 0 0 5px; }
h2 { font-size: 1.1em; color: var(--text-2); margin: 0 0 8px; }
p { margin: 0; }
/* Navigation bar — shared across all pages */
.nav {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 200;
background: var(--nav-bg);
padding: 6px 12px;
box-shadow: 0 2px 4px var(--shadow-nav);
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
}
.nav-links { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }
.nav a {
margin-right: 20px;
text-decoration: none;
color: var(--link);
font-weight: 500;
font-size: 0.9em;
}
.nav a:hover { text-decoration: underline; }
.nav a.active { color: var(--text-2); font-weight: bold; }
.nav-user {
display: flex;
align-items: center;
gap: 8px;
text-decoration: none;
color: var(--text-2);
font-size: 0.9em;
font-weight: 500;
padding: 4px 8px;
border-radius: 20px;
transition: background 0.15s;
}
.nav-user:hover { background: var(--surface-2); text-decoration: none; }
.nav-username {
max-width: 0;
overflow: hidden;
white-space: nowrap;
opacity: 0;
transition: max-width 0.2s ease, opacity 0.2s ease;
}
.nav-user:hover .nav-username {
max-width: 160px;
opacity: 1;
}
.nav-avatar {
width: 28px; height: 28px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.nav-initials {
width: 28px; height: 28px;
border-radius: 50%;
background: var(--link);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75em;
font-weight: 700;
flex-shrink: 0;
}
/* ── Mobile nav: hamburger toggle ── */
.nav-hamburger {
display: none;
flex-direction: column;
justify-content: space-between;
width: 26px; height: 20px;
cursor: pointer;
flex-shrink: 0;
background: none;
border: none;
padding: 0;
}
.nav-hamburger span {
display: block;
height: 3px;
background: var(--text-muted);
border-radius: 2px;
}
@media (max-width: 640px) {
.nav-hamburger { display: flex; }
.nav-links {
display: none;
width: 100%;
flex-direction: column;
align-items: flex-start;
padding-top: 8px;
border-top: 1px solid var(--border-2);
order: 3;
}
.nav-links.nav-open { display: flex; }
.nav-links a { margin-right: 0; padding: 6px 0; font-size: 1em; }
}
/* ── Global dark-mode: inputs ── */
html[data-theme="dark"] input:not([type=checkbox]):not([type=radio]),
html[data-theme="dark"] select,
html[data-theme="dark"] textarea {
background-color: var(--input-bg);
border-color: var(--input-border);
color: var(--text);
}
/* Pending config publish button */
.nav-publish-btn {
background: #e65100;
color: #fff;
border: none;
border-radius: 4px;
padding: 4px 10px;
font-size: 0.82em;
font-weight: 600;
cursor: pointer;
flex-shrink: 0;
white-space: nowrap;
margin-left: auto;
}
.nav-publish-btn:hover { background: #bf360c; }
.nav-publish-btn:disabled { opacity: 0.7; cursor: default; }
/* Swiss railway clock — nav */
.nav-pie {
flex-shrink: 0;
line-height: 0;
margin-left: auto;
padding: 4px 4px 4px 0;
}
#alert-pie { display: block; cursor: default; }
.nav-clock {
flex-shrink: 0;
line-height: 0;
padding: 4px 4px 4px 0;
cursor: pointer;
}
#swiss-clock { display: block; }
/* Swiss railway clock — full-page overlay */
#clock-overlay {
display: none;
position: fixed;
inset: 0;
z-index: 9999;
background: #1a1a1a;
align-items: center;
justify-content: center;
cursor: pointer;
}
#clock-overlay.visible { display: flex; }
#swiss-clock-overlay { display: block; }
</style>
<script>
/* ── Swiss Federal Railway (SBB) clock ── */
/* Draw one frame of the clock onto any canvas element. */
function drawSwissClock(canvas) {
var SIZE = canvas.width;
var R = SIZE / 2;
var ctx = canvas.getContext('2d');
var now = new Date();
var h = now.getHours() % 12;
var m = now.getMinutes();
var s = now.getSeconds();
var ms = now.getMilliseconds();
/* Seconds hand idles ~1.5 s at 12 before advancing (SBB behaviour) */
var sFrac = s + ms / 1000;
var sAngle = sFrac >= 58.5 ? 0 : (sFrac / 58.5) * Math.PI * 2;
ctx.clearRect(0, 0, SIZE, SIZE);
/* face */
ctx.beginPath();
ctx.arc(R, R, R - 1, 0, Math.PI * 2);
ctx.fillStyle = '#fff';
ctx.fill();
ctx.strokeStyle = '#333';
ctx.lineWidth = SIZE * 0.018;
ctx.stroke();
/* tick marks */
for (var i = 0; i < 60; i++) {
var a = (i / 60) * Math.PI * 2 - Math.PI / 2;
var isHour = (i % 5 === 0);
ctx.beginPath();
ctx.moveTo(R + Math.cos(a) * (isHour ? R * 0.72 : R * 0.88),
R + Math.sin(a) * (isHour ? R * 0.72 : R * 0.88));
ctx.lineTo(R + Math.cos(a) * R * 0.94,
R + Math.sin(a) * R * 0.94);
ctx.strokeStyle = '#222';
ctx.lineWidth = isHour ? SIZE * 0.027 : SIZE * 0.011;
ctx.lineCap = 'butt';
ctx.stroke();
}
/* hands */
function hand(angle, tip, tail, width, color) {
ctx.save();
ctx.translate(R, R);
ctx.rotate(angle);
ctx.beginPath();
ctx.moveTo(tail, 0);
ctx.lineTo(tip, 0);
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.lineCap = 'square';
ctx.stroke();
ctx.restore();
}
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 */
hand(sAngle - Math.PI / 2, R * 0.78, -R * 0.22,
SIZE * 0.013, '#e00'); /* second tail+tip */
/* round dot at tip of second hand */
var dotR = SIZE * 0.028;
ctx.save();
ctx.translate(R, R);
ctx.rotate(sAngle - Math.PI / 2);
ctx.beginPath();
ctx.arc(R * 0.78, 0, dotR, 0, Math.PI * 2);
ctx.fillStyle = '#e00';
ctx.fill();
ctx.restore();
/* centre cap */
ctx.beginPath();
ctx.arc(R, R, R * 0.04, 0, Math.PI * 2);
ctx.fillStyle = '#222';
ctx.fill();
}
/* Resize the overlay canvas to fit the viewport, keeping it square. */
function resizeOverlayClock() {
var oc = document.getElementById('swiss-clock-overlay');
if (!oc) return;
var size = Math.min(window.innerWidth, window.innerHeight) * 0.88;
size = Math.floor(size);
oc.width = size;
oc.height = size;
}
/* Main tick — redraws both nav clock and (if visible) overlay clock. */
function clockTick() {
var nav = document.getElementById('swiss-clock');
if (nav) drawSwissClock(nav);
var overlay = document.getElementById('clock-overlay');
if (overlay && overlay.classList.contains('visible')) {
var oc = document.getElementById('swiss-clock-overlay');
if (oc) drawSwissClock(oc);
}
var delay = 100 - (Date.now() % 100);
setTimeout(clockTick, delay);
}
/* Keep auto-theme in sync with system setting changes */
try {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
var pref = localStorage.getItem('hbd_theme') || 'auto';
if (pref === 'auto') {
if (e.matches) { document.documentElement.setAttribute('data-theme', 'dark'); }
else { document.documentElement.removeAttribute('data-theme'); }
}
});
} catch(e) {}
document.addEventListener('DOMContentLoaded', function() {
/* Start the shared tick loop */
clockTick();
/* Overlay toggle — clicking the nav clock opens it */
var navClock = document.querySelector('.nav-clock');
var overlay = document.getElementById('clock-overlay');
if (navClock && overlay) {
navClock.addEventListener('click', function() {
resizeOverlayClock();
overlay.classList.add('visible');
});
overlay.addEventListener('click', function() {
overlay.classList.remove('visible');
});
window.addEventListener('resize', function() {
if (overlay.classList.contains('visible')) resizeOverlayClock();
});
}
});
</script>
<script src="static/sorttable.js"></script>
</head> </head>
+247 -60
View File
@@ -4,47 +4,48 @@
<style> <style>
body { body {
margin: 10px; display: flex;
background: #f5f5f5; flex-direction: column;
height: 100vh;
overflow: hidden; overflow: hidden;
} }
.nav { @media (max-width: 640px) {
background: #fff; body {
padding: 10px 15px; height: auto;
margin-bottom: 10px; min-height: 100vh;
box-shadow: 0 2px 4px rgba(0,0,0,0.1); overflow: auto;
border-radius: 4px; flex-direction: column;
} }
.container {
.nav a { max-height: none;
margin-right: 20px; overflow: visible;
text-decoration: none; }
color: #0066cc; .table-section {
font-weight: 500; max-height: 55vh;
font-size: 0.9em; }
} .log-section {
flex: none;
.nav a:hover { max-height: 40vh;
text-decoration: underline; }
}
.nav a.active {
color: #333;
font-weight: bold;
} }
.container { .container {
flex: 1;
min-height: 0;
max-width: 1600px; max-width: 1600px;
width: 100%;
margin: 0 auto; margin: 0 auto;
max-height: calc(100vh - 120px); display: flex;
overflow-y: auto; flex-direction: column;
padding-right: 10px; gap: 15px;
overflow: hidden;
} }
h1 { h1 {
color: #333; color: #333;
margin-bottom: 5px; margin-bottom: 5px;
margin-top: 15px;
font-size: 1.5em; font-size: 1.5em;
} }
@@ -75,14 +76,18 @@
border-radius: 6px; border-radius: 6px;
padding: 15px; padding: 15px;
box-shadow: 0 1px 4px rgba(0,0,0,0.1); box-shadow: 0 1px 4px rgba(0,0,0,0.1);
overflow-x: auto;
overflow-y: auto;
max-height: 60vh;
} }
.log-section { .log-section {
flex: 1;
min-height: 0;
background: white; background: white;
border-radius: 6px; border-radius: 6px;
padding: 15px; padding: 15px;
box-shadow: 0 1px 4px rgba(0,0,0,0.1); box-shadow: 0 1px 4px rgba(0,0,0,0.1);
max-height: 400px;
overflow-y: auto; overflow-y: auto;
} }
@@ -96,7 +101,8 @@
#ntable th { #ntable th {
border: 1px solid #e0e0e0; border: 1px solid #e0e0e0;
text-align: left; text-align: left;
padding: 8px 10px; padding: 2px 4px;
white-space: nowrap;
} }
#ntable tr:nth-child(even) { #ntable tr:nth-child(even) {
@@ -107,8 +113,24 @@
background-color: #e3f2fd; background-color: #e3f2fd;
} }
#ntable tbody tr.row-warning {
background-color: #fff8c5;
}
#ntable tbody tr.row-critical {
background-color: #fde8e8;
}
#ntable tbody tr.row-warning:hover {
background-color: #fff0a0;
}
#ntable tbody tr.row-critical:hover {
background-color: #f9c8c8;
}
#ntable th { #ntable th {
padding: 12px 10px; padding: 6px 8px;
background-color: #2196f3; background-color: #2196f3;
color: white; color: white;
font-weight: 600; font-weight: 600;
@@ -137,39 +159,85 @@
} }
/* Scrollbar styling */ /* Scrollbar styling */
.container::-webkit-scrollbar,
.log-section::-webkit-scrollbar { .log-section::-webkit-scrollbar {
width: 8px; width: 8px;
} }
.container::-webkit-scrollbar-track,
.log-section::-webkit-scrollbar-track { .log-section::-webkit-scrollbar-track {
background: #f1f1f1; background: #f1f1f1;
border-radius: 4px; border-radius: 4px;
} }
.container::-webkit-scrollbar-thumb,
.log-section::-webkit-scrollbar-thumb { .log-section::-webkit-scrollbar-thumb {
background: #888; background: #888;
border-radius: 4px; border-radius: 4px;
} }
.container::-webkit-scrollbar-thumb:hover,
.log-section::-webkit-scrollbar-thumb:hover { .log-section::-webkit-scrollbar-thumb:hover {
background: #555; background: #555;
} }
/* Message styling */ /* Message styling */
#messages { #messages {
font-size: 0.85em; font-size: 1.00em;
line-height: 1.6; line-height: 1.0;
} }
#messages div { #messages .log-entry {
padding: 5px 0; padding: 5px 0;
border-bottom: 1px solid #f0f0f0; 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; }
.log-section-header {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 10px;
background: white;
border-radius: 6px;
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
padding: 8px 15px;
}
.log-section-title {
font-size: 1.2em;
font-weight: bold;
color: #333;
white-space: nowrap;
}
.log-filter-bar {
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
}
.log-filter-bar input[type="text"],
.log-filter-bar select {
padding: 3px 7px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 1.00em;
color: #333;
}
.log-filter-bar input[type="text"] { width: 110px; }
/* Modal for connection status messages */ /* Modal for connection status messages */
.connection-modal { .connection-modal {
display: none; display: none;
@@ -218,21 +286,72 @@
color: #ff9800; color: #ff9800;
font-weight: 700; font-weight: 700;
} }
#ntable a.host-link { color: inherit; text-decoration: none; }
#ntable a.host-link:hover { text-decoration: underline; }
/* ── Dark mode ── */
html[data-theme="dark"] h1,
html[data-theme="dark"] h2 { color: var(--text); }
html[data-theme="dark"] .subtitle { color: var(--text-sec); }
html[data-theme="dark"] h2,
html[data-theme="dark"] .table-section,
html[data-theme="dark"] .log-section,
html[data-theme="dark"] .log-section-header { background: var(--surface); }
html[data-theme="dark"] .log-section-title { color: var(--text); }
html[data-theme="dark"] #ntable td,
html[data-theme="dark"] #ntable th { border-color: var(--border); }
html[data-theme="dark"] #ntable tr:nth-child(even) { background: var(--surface-2); }
html[data-theme="dark"] #ntable tr:hover { background: #1e3a5f; }
html[data-theme="dark"] #ntable tbody tr.row-warning { background: #3a2800; }
html[data-theme="dark"] #ntable tbody tr.row-critical { background: #3a0a0a; }
html[data-theme="dark"] #ntable tbody tr.row-warning:hover { background: #4a3200; }
html[data-theme="dark"] #ntable tbody tr.row-critical:hover { background: #4a1010; }
html[data-theme="dark"] #messages .log-entry { border-bottom-color: var(--border-3); }
html[data-theme="dark"] .log-ts,
html[data-theme="dark"] .log-service { color: var(--text-muted); }
html[data-theme="dark"] .log-info .log-level { color: var(--text-sec); }
html[data-theme="dark"] .log-filter-bar input,
html[data-theme="dark"] .log-filter-bar select { color: var(--text); }
html[data-theme="dark"] .connection-modal-content { background: var(--surface); color: var(--text); }
</style> </style>
<script type="text/javascript"> <script type="text/javascript">
var cnt = 0; var cnt = 0;
var nTable = document; var nTable = document;
var name_idx = {}; var name_idx = {};
var c = 0; var c = 0;
var HBD_VERSION = "{{ hbd_version }}";
function hostNameHtml(data) {
var rawName = data.raw_name || data.name.replace(/<[^>]+>/g, '').replace('*', '').trim();
var nameHtml = data.name;
if (!data.hbc_version || data.hbc_version !== HBD_VERSION) {
nameHtml += ' 🥀';
}
var display = data.dyn ? '<b>' + nameHtml + '</b>' : nameHtml;
return '<a class="host-link" href="/plugins#' + encodeURIComponent(rawName) + '">' + display + '</a>';
}
function setup() { function setup() {
name_idx = {}; name_idx = {};
nTable = document.getElementById("ntable"); nTable = document.getElementById("ntable");
for (var i = 0, row; (row = nTable.rows[i]); i++) { for (var i = 0, row; (row = nTable.rows[i]); i++) {
if (i == 0) continue; if (i == 0) continue;
name = nTable.rows[i].cells[0].innerText; var cell = nTable.rows[i].cells[0];
var name = cell.dataset.name || cell.innerText.replace(/\s*🥀\s*$/, '').trim();
name_idx[name] = nTable.rows[i]; name_idx[name] = nTable.rows[i];
/* console.log("name_Id[" + name + "]: " + name_idx[name].innerText); */ }
}
function updateRowAlert(row, data) {
var criticalUnacked = data.alert_critical_unacked || 0;
var criticalAcked = data.alert_critical_acked || 0;
var warningUnacked = data.alert_warning_unacked || 0;
var warningAcked = data.alert_warning_acked || 0;
row.classList.remove('row-warning', 'row-critical');
if (criticalUnacked > 0 || criticalAcked > 0) {
row.classList.add('row-critical');
} else if (warningUnacked > 0 || warningAcked > 0) {
row.classList.add('row-warning');
} }
} }
@@ -270,11 +389,8 @@
row.appendChild(c_ipv6state); row.appendChild(c_ipv6state);
row.appendChild(c_ipv6latency); row.appendChild(c_ipv6latency);
row.appendChild(c_ipv6statets); row.appendChild(c_ipv6statets);
if (data.dyn) { c_name.dataset.name = data.name;
c_name.innerHTML = "<b>" + data.name + "</b>"; c_name.innerHTML = hostNameHtml(data);
} else {
c_name.innerHTML = data.name;
}
// Set alert counts in "x/y" format (unacked/acked) // Set alert counts in "x/y" format (unacked/acked)
var warningUnacked = data.alert_warning_unacked || 0; var warningUnacked = data.alert_warning_unacked || 0;
@@ -303,12 +419,31 @@
var table = document.getElementById("ntablebody"); // find table to append to var table = document.getElementById("ntablebody"); // find table to append to
table.appendChild(row); // append row to table table.appendChild(row); // append row to table
name_idx[c_name] = row; name_idx[c_name] = row;
updateRowAlert(row, data);
} }
function formatTS(ts) { function formatTS(ts) {
const milliseconds = ts * 1000; const now = new Date();
const dateObject = new Date(milliseconds); const d = new Date(ts * 1000);
return dateObject.toLocaleString("de-DE");
const pad = n => String(n).padStart(2, '0');
const timeStr = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
// Same calendar day → show time only
if (d.toDateString() === now.toDateString()) {
return timeStr;
}
// Within 8 days → show "-X d hh:mm:ss"
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const dStart = new Date(d.getFullYear(), d.getMonth(), d.getDate());
const diffDays = Math.round((todayStart - dStart) / 86400000);
if (diffDays < 8) {
return `-${diffDays}d ${timeStr}`;
}
// Older → date only
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
} }
function update_table(data) { function update_table(data) {
@@ -317,6 +452,11 @@
setup(); setup();
} }
// Update name cell (version indicator)
var nameCell = name_idx[data.name].cells[0];
nameCell.dataset.name = data.name;
nameCell.innerHTML = hostNameHtml(data);
// Update warning and critical counts in "x/y" format (unacked/acked) // Update warning and critical counts in "x/y" format (unacked/acked)
var warningUnacked = data.alert_warning_unacked || 0; var warningUnacked = data.alert_warning_unacked || 0;
var warningAcked = data.alert_warning_acked || 0; var warningAcked = data.alert_warning_acked || 0;
@@ -343,7 +483,7 @@
); );
if (data.connections[i].state == "up") { if (data.connections[i].state == "up") {
state = '<span class="state-up">up</span>'; state = '<span class="state-up">up</span>';
latency = Number.parseFloat(data.connections[i].rtts[0]).toFixed(2); latency = String(Math.round(Number.parseFloat(data.connections[i].rtts[0])));
} else { } else {
if (data.connections[i].state == "unknown") { if (data.connections[i].state == "unknown") {
state = ""; state = "";
@@ -364,6 +504,23 @@
name_idx[data.name].cells[4 + i * 4].innerHTML = state; name_idx[data.name].cells[4 + i * 4].innerHTML = state;
name_idx[data.name].cells[5 + i * 4].innerHTML = latency; name_idx[data.name].cells[5 + i * 4].innerHTML = latency;
} }
updateRowAlert(name_idx[data.name], data);
}
function applyLogFilters() {
var hostFilter = document.getElementById('filter-host').value.toLowerCase().trim();
var levelFilter = document.getElementById('filter-level').value;
var msgFilter = document.getElementById('filter-msg').value.toLowerCase().trim();
document.querySelectorAll('#messages .log-entry').forEach(function(entry) {
var show = true;
if (hostFilter && !(entry.dataset.host || '').toLowerCase().includes(hostFilter)) show = false;
if (levelFilter && entry.dataset.level !== levelFilter) show = false;
if (msgFilter) {
var msgEl = entry.querySelector('.log-msg');
if (!msgEl || !msgEl.textContent.toLowerCase().includes(msgFilter)) show = false;
}
entry.style.display = show ? '' : 'none';
});
} }
function WS_Connect() { function WS_Connect() {
@@ -394,7 +551,22 @@
update_table(state.data); update_table(state.data);
} else if (state.type == "message") { } else if (state.type == "message") {
var msgs = document.getElementById("messages"); 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 hostVal = msg.host || '';
var html = '<div class="log-entry log-' + lvl + '" data-level="' + lvl + '" data-host="' + hostVal.replace(/"/g, '&quot;') + '">';
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(state.history ? "beforeend" : "afterbegin", html);
applyLogFilters();
} }
cnt++; cnt++;
}; };
@@ -419,17 +591,15 @@
WS_Connect(); WS_Connect();
</script> </script>
<body> <body>
<div class="nav"> {% include 'nav.html' %}
<a href="/live" class="active">Live Dashboard</a>
<a href="/plugins">Plugin Metrics</a>
<a href="/alerts">Alerts</a>
</div>
{% include 'menu.html' %} {% include 'menu.html' %}
<div class="container"> <div class="container">
<h1>{{ header }}</h1> <div>
<p class="subtitle">Real-time host monitoring and event log</p> <h1>{{ header }}</h1>
<p class="subtitle">Real-time host monitoring and event log</p>
</div>
<div class="table-section"> <div class="table-section">
<table id="ntable" class="sortable"> <table id="ntable" class="sortable">
@@ -450,8 +620,8 @@
</thead> </thead>
<tbody id="ntablebody"> <tbody id="ntablebody">
{% for host in hosts %} {% for host in hosts %}
<tr> <tr class="{% if host.alert_critical_unacked > 0 or host.alert_critical_acked > 0 %}row-critical{% elif host.alert_warning_unacked > 0 or host.alert_warning_acked > 0 %}row-warning{% endif %}">
<td>{{ host.name }}</td> <td data-name="{{ host.name }}"><a class="host-link" href="/plugins#{{ host.raw_name | urlencode }}">{{ host.name }}{% if not host.hbc_version or host.hbc_version != hbd_version %} 🥀{% endif %}</a></td>
<td style="text-align: center; color: #ff9800; font-weight: bold;"> <td style="text-align: center; color: #ff9800; font-weight: bold;">
{%- set warning_unacked = host.alert_warning_unacked -%} {%- set warning_unacked = host.alert_warning_unacked -%}
{%- set warning_acked = host.alert_warning_acked -%} {%- set warning_acked = host.alert_warning_acked -%}
@@ -485,7 +655,21 @@
</div> </div>
<div class="log-section"> <div class="log-section">
<h2>Log of Events</h2> <div class="log-section-header">
<span class="log-section-title">Log of Events</span>
<div class="log-filter-bar">
<input type="text" id="filter-host" placeholder="Host…" title="Filter by host" />
<select id="filter-level" title="Filter by level">
<option value="">All levels</option>
<option value="info">INFO</option>
<option value="warning">WARNING</option>
<option value="critical">CRITICAL</option>
<option value="recover">RECOVER</option>
<option value="unknown">UNKNOWN</option>
</select>
<input type="text" id="filter-msg" placeholder="Message…" title="Filter by message text" />
</div>
</div>
<div id="messages"></div> <div id="messages"></div>
</div> </div>
</div> </div>
@@ -501,6 +685,9 @@
<script> <script>
setup(); setup();
document.getElementById('filter-host').addEventListener('input', applyLogFilters);
document.getElementById('filter-level').addEventListener('change', applyLogFilters);
document.getElementById('filter-msg').addEventListener('input', applyLogFilters);
</script> </script>
</body> </body>
</html> </html>
-1
View File
@@ -1,3 +1,2 @@
<!-- <label for="drawer-toggle" id="drawer-toggle-label"></label> <!-- <label for="drawer-toggle" id="drawer-toggle-label"></label>
s<header>{{ header }}</header> --> s<header>{{ header }}</header> -->
+134
View File
@@ -0,0 +1,134 @@
<div class="nav">
<button class="nav-hamburger" id="nav-hamburger-btn" aria-label="Menu" aria-expanded="false">
<span></span><span></span><span></span>
</button>
<div class="nav-links" id="nav-links">
<a href="/live"{% if active_page == "live" %} class="active"{% endif %}>Live Dashboard</a>
<a href="/plugins"{% if active_page == "plugins" %} class="active"{% endif %}>Host Overview</a>
<a href="/alerts"{% if active_page == "alerts" %} class="active"{% endif %}>Alerts</a>
{% if current_user and current_user.admin %}
<a href="/settings"{% if active_page == "settings" %} class="active"{% endif %}>Settings</a>
{% endif %}
<a href="/about"{% if active_page == "about" %} class="active"{% endif %}>About</a>
</div>
{% if current_user and current_user.admin %}
<button id="nav-publish-btn" class="nav-publish-btn" onclick="navPublishConfig()" style="display:none" title="Publish pending config changes to .hb.yaml">&#9888; Publish Config</button>
{% endif %}
<div class="nav-pie" title="Host alert status">
<canvas id="alert-pie" width="44" height="44"></canvas>
</div>
<div class="nav-clock" title="Click for full-screen clock">
<canvas id="swiss-clock" width="44" height="44"></canvas>
</div>
{% if current_user %}
<a href="/profile" class="nav-user{% if active_page == 'profile' %} active{% endif %}" title="{{ current_user.full_name or current_user.username }}">
{% if current_user.avatar %}
<img class="nav-avatar" src="{{ current_user.avatar_url }}" alt="{{ current_user.full_name or current_user.username }}">
{% else %}
<span class="nav-initials">{{ (current_user.full_name or current_user.username)[:1] | upper }}</span>
{% endif %}
<span class="nav-username">{{ current_user.full_name or current_user.username }}</span>
</a>
{% endif %}
</div>
<!-- Full-page clock overlay (click anywhere to dismiss) -->
<div id="clock-overlay">
<canvas id="swiss-clock-overlay" width="400" height="400"></canvas>
</div>
<script>
(function() {
var btn = document.getElementById('nav-hamburger-btn');
var links = document.getElementById('nav-links');
if (btn && links) {
btn.addEventListener('click', function() {
var open = links.classList.toggle('nav-open');
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
});
}
})();
function drawAlertPie(critical, warning, ok) {
var canvas = document.getElementById('alert-pie');
if (!canvas) return;
var ctx = canvas.getContext('2d');
var SIZE = canvas.width;
var R = SIZE / 2;
ctx.clearRect(0, 0, SIZE, SIZE);
var total = critical + warning + ok;
if (total === 0) {
ctx.beginPath();
ctx.arc(R, R, R - 1, 0, Math.PI * 2);
ctx.fillStyle = '#ccc';
ctx.fill();
return;
}
var slices = [
{ value: critical, color: '#e53935' },
{ value: warning, color: '#ffb300' },
{ value: ok, color: '#43a047' }
];
var start = -Math.PI / 2;
slices.forEach(function(s) {
if (s.value === 0) return;
var sweep = (s.value / total) * Math.PI * 2;
ctx.beginPath();
ctx.moveTo(R, R);
ctx.arc(R, R, R - 1, start, start + sweep);
ctx.closePath();
ctx.fillStyle = s.color;
ctx.fill();
start += sweep;
});
}
function updateAlertPie() {
fetch('/api/0/alert_summary').then(function(r) {
if (!r.ok) return;
return r.json();
}).then(function(d) {
if (d) drawAlertPie(d.critical || 0, d.warning || 0, d.ok || 0);
}).catch(function() {});
}
document.addEventListener('DOMContentLoaded', function() {
updateAlertPie();
setInterval(updateAlertPie, 30000);
navCheckPendingConfig();
window.addEventListener('storage', navCheckPendingConfig);
});
function navCheckPendingConfig() {
var btn = document.getElementById('nav-publish-btn');
if (!btn) return;
btn.style.display = localStorage.getItem('hbd_pending_config') ? '' : 'none';
}
async function navPublishConfig() {
var btn = document.getElementById('nav-publish-btn');
var pending = localStorage.getItem('hbd_pending_config');
if (!pending) return;
var staged;
try { staged = JSON.parse(pending); } catch(e) { return; }
if (btn) { btn.disabled = true; btn.textContent = 'Saving…'; }
try {
var resp = await fetch('/api/0/config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: pending
});
if (resp.ok) {
localStorage.removeItem('hbd_pending_config');
window.location.reload();
} else {
var err = await resp.json().catch(function() { return {}; });
alert('Error: ' + (err.error || resp.statusText));
if (btn) { btn.disabled = false; btn.textContent = '⚠ Publish Config'; }
}
} catch(e) {
alert('Network error: ' + e.message);
if (btn) { btn.disabled = false; btn.textContent = '⚠ Publish Config'; }
}
}
</script>
File diff suppressed because it is too large Load Diff
+842
View File
@@ -0,0 +1,842 @@
<!DOCTYPE html>
<html>
{% include 'head.html' %}
<style>
html, body { overflow: visible; }
.container {
max-width: 900px;
margin: 0 auto;
}
h1 {
color: #333;
margin-bottom: 4px;
font-size: 1.5em;
}
.subtitle {
color: #666;
margin-bottom: 24px;
font-size: 0.9em;
}
/* ---- Profile card ---- */
.profile-card {
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 6px rgba(0,0,0,0.1);
padding: 28px 32px;
margin-bottom: 24px;
display: flex;
align-items: center;
gap: 28px;
}
.avatar-large {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.avatar-initials-large {
width: 80px;
height: 80px;
border-radius: 50%;
background: #0066cc;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 2em;
font-weight: 700;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.profile-info { flex: 1; }
.profile-name {
font-size: 1.4em;
font-weight: 700;
color: #222;
margin-bottom: 4px;
}
.profile-username {
font-size: 0.9em;
color: #666;
margin-bottom: 10px;
}
.badge {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 0.78em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.badge-admin { background: #e8f0fe; color: #1a73e8; }
.badge-user { background: #f1f3f4; color: #555; }
.profile-logout {
margin-top: 14px;
}
.btn-logout {
display: inline-block;
padding: 6px 16px;
border-radius: 4px;
background: #f44336;
color: #fff;
font-size: 1.00em;
font-weight: 500;
text-decoration: none;
transition: background 0.15s;
}
.btn-logout:hover { background: #d32f2f; text-decoration: none; }
/* ---- Section cards ---- */
.section {
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 6px rgba(0,0,0,0.1);
padding: 20px 24px;
margin-bottom: 20px;
}
.section h2 {
font-size: 1em;
font-weight: 700;
color: #333;
margin: 0 0 16px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* ---- Settings rows ---- */
.settings-row {
display: flex;
align-items: baseline;
padding: 8px 0;
border-bottom: 1px solid #f5f5f5;
font-size: 0.9em;
}
.settings-row:last-child { border-bottom: none; }
.settings-label {
width: 180px;
flex-shrink: 0;
color: #666;
font-size: 0.88em;
}
.settings-value { color: #222; }
.settings-empty { color: #aaa; font-style: italic; }
/* ---- Host lists ---- */
.host-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.host-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: 16px;
font-size: 1.00em;
font-weight: 500;
text-decoration: none;
}
.host-chip.owner { background: #e8f5e9; color: #2e7d32; }
.host-chip.manager { background: #e3f2fd; color: #1565c0; }
.host-chip.monitor { background: #f3e5f5; color: #6a1b9a; }
.host-chip-dot {
width: 7px; height: 7px; border-radius: 50%;
}
.owner .host-chip-dot { background: #2e7d32; }
.manager .host-chip-dot { background: #1565c0; }
.monitor .host-chip-dot { background: #6a1b9a; }
.no-hosts {
color: #aaa;
font-size: 0.9em;
font-style: italic;
}
/* ---- Notification channels ---- */
.channel-row {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 0;
border-bottom: 1px solid #f5f5f5;
font-size: 0.9em;
}
.channel-row:last-child { border-bottom: none; }
.channel-type {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 0.78em;
font-weight: 600;
text-transform: uppercase;
background: #f1f3f4;
color: #555;
min-width: 70px;
text-align: center;
}
.channel-name { color: #333; }
.edit-section { margin-top: 20px; }
.edit-section h4 { font-size: .88em; font-weight: 600; color: #333; margin: 0 0 10px; text-transform: uppercase; letter-spacing: .04em; border-bottom: 1px solid #eee; padding-bottom: 6px; }
.edit-field { margin-bottom: 10px; }
.edit-field label { display: block; font-size: .82em; color: #666; margin-bottom: 3px; }
.edit-input { width: 100%; border: 1px solid #ccc; border-radius: 4px; padding: 5px 8px; font-size: .88em; box-sizing: border-box; }
.edit-input:focus { border-color: #0066cc; outline: none; }
.status-msg { font-size: .82em; margin-left: 8px; }
.save-row { display: flex; align-items: center; margin-top: 8px; }
.btn-save { background: #0066cc; color: #fff; border: none; border-radius: 4px; padding: 5px 14px; font-size: .85em; cursor: pointer; }
.btn-save:hover { background: #0055aa; }
/* ---- Channel chip picker ---- */
.ch-picker { }
.ch-picker-label { font-size: .8em; font-weight: 600; color: #888; text-transform: uppercase; letter-spacing: .04em; margin-bottom: 6px; }
.ch-chips { display: flex; flex-wrap: wrap; gap: 6px; min-height: 32px; margin-bottom: 10px; }
.ch-chip {
display: inline-flex; align-items: center; gap: 5px;
padding: 4px 10px; border-radius: 14px; font-size: .85em; font-weight: 500; cursor: pointer;
border: none; font-family: inherit;
}
.ch-chip.selected { background: #e3f2fd; color: #1565c0; }
.ch-chip.selected:hover { background: #bbdefb; }
.ch-chip.available { background: #f1f3f4; color: #555; }
.ch-chip.available:hover { background: #e8eaf6; color: #283593; }
.ch-chip-x { font-size: .9em; line-height: 1; color: inherit; opacity: .7; }
/* ---- My Channels card list ---- */
.my-ch-card {
border: 1px solid #e8eaf6; border-radius: 6px; margin-bottom: 8px; overflow: hidden;
}
.my-ch-header {
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
background: #f8f9ff; border-bottom: 1px solid #e8eaf6;
}
.my-ch-name { font-weight: 600; font-size: .9em; color: #222; }
.my-ch-type { padding: 2px 7px; border-radius: 8px; font-size: .72em; font-weight: 600; background: #e8eaf6; color: #3949ab; }
.my-ch-private { padding: 2px 7px; border-radius: 8px; font-size: .72em; font-weight: 600; background: #fce4ec; color: #c62828; }
.my-ch-actions { margin-left: auto; display: flex; gap: 5px; }
.btn-sm-edit { background: #888; color: #fff; border: none; border-radius: 4px; padding: 2px 8px; font-size: .78em; cursor: pointer; }
.btn-sm-edit:hover { background: #666; }
.btn-sm-del { background: transparent; color: #c62828; border: 1px solid #e0e0e0; border-radius: 4px; padding: 2px 7px; font-size: .78em; cursor: pointer; }
.btn-sm-del:hover { background: #fce4ec; }
/* ---- Theme picker ---- */
.theme-btns { display: flex; gap: 6px; }
.theme-btn {
padding: 5px 14px;
border: 1px solid var(--border, #e0e0e0);
border-radius: 4px;
background: var(--surface-3, #f5f5f5);
color: var(--text-sec, #666);
cursor: pointer;
font-size: .88em;
font-family: inherit;
}
.theme-btn:hover { border-color: var(--link, #0066cc); color: var(--link, #0066cc); }
.theme-btn.active { background: var(--link, #0066cc); color: #fff; border-color: var(--link, #0066cc); }
/* ── Dark mode ── */
html[data-theme="dark"] h1 { color: var(--text); }
html[data-theme="dark"] .subtitle { color: var(--text-sec); }
html[data-theme="dark"] .profile-card { background: var(--surface); box-shadow: 0 1px 6px var(--shadow); }
html[data-theme="dark"] .profile-name { color: var(--text); }
html[data-theme="dark"] .profile-username { color: var(--text-sec); }
html[data-theme="dark"] .badge-admin { background: #1a3255; color: #7aa8f0; }
html[data-theme="dark"] .badge-user { background: var(--surface-3); color: var(--text-sec); }
html[data-theme="dark"] .section { background: var(--surface); box-shadow: 0 1px 6px var(--shadow); }
html[data-theme="dark"] .section h2 { color: var(--text); border-bottom-color: var(--border); }
html[data-theme="dark"] .settings-row { border-bottom-color: var(--border-4); }
html[data-theme="dark"] .settings-label { color: var(--text-sec); }
html[data-theme="dark"] .settings-value { color: var(--text); }
html[data-theme="dark"] .settings-empty { color: var(--text-dim); }
html[data-theme="dark"] .edit-section h4 { color: var(--text); border-bottom-color: var(--border); }
html[data-theme="dark"] .edit-field label { color: var(--text-sec); }
html[data-theme="dark"] .edit-input { background: var(--input-bg); border-color: var(--input-border); color: var(--text); }
html[data-theme="dark"] .channel-row { border-bottom-color: var(--border-4); }
html[data-theme="dark"] .channel-name { color: var(--text); }
html[data-theme="dark"] .ch-picker-label { color: var(--text-sec); }
html[data-theme="dark"] .ch-chip.selected { background: #1a3255; color: #60a5fa; }
html[data-theme="dark"] .ch-chip.available { background: var(--surface-3); color: var(--text-sec); }
html[data-theme="dark"] .ch-chip.available:hover { background: var(--border); color: var(--link); }
html[data-theme="dark"] .my-ch-card { border-color: var(--border); }
html[data-theme="dark"] .my-ch-header { background: var(--surface-2); border-bottom-color: var(--border); }
html[data-theme="dark"] .my-ch-name { color: var(--text); }
html[data-theme="dark"] .host-chip.owner { background: #0d2e17; color: #66bb6a; }
html[data-theme="dark"] .host-chip.manager { background: #0d1f40; color: #64b5f6; }
html[data-theme="dark"] .host-chip.monitor { background: #1e0d30; color: #ba68c8; }
html[data-theme="dark"] .no-hosts { color: var(--text-dim); }
html[data-theme="dark"] .ch-modal-box { background: var(--surface); color: var(--text); }
html[data-theme="dark"] .ch-modal-box h3 { color: var(--text); }
html[data-theme="dark"] .ch-form-row label { color: var(--text-sec); }
html[data-theme="dark"] .ch-form-divider { color: var(--text-muted); border-top-color: var(--border); }
/* ---- Channel modal (for My Channels CRUD) ---- */
.ch-modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,.4);
display: flex; align-items: center; justify-content: center; z-index: 1001;
}
.ch-modal-box {
background: #fff; border-radius: 8px; padding: 24px;
min-width: 360px; max-width: 520px; width: 95%;
box-shadow: 0 8px 32px rgba(0,0,0,.2);
}
.ch-modal-box h3 { margin: 0 0 16px; font-size: 1em; }
.ch-form-row { margin-bottom: 12px; }
.ch-form-row label { display: block; font-size: .83em; font-weight: 600; color: #555; margin-bottom: 3px; }
.ch-form-row input[type=text], .ch-form-row input[type=password], .ch-form-row select {
width: 100%; border: 1px solid #ccc; border-radius: 4px; padding: 5px 8px;
font-size: .88em; box-sizing: border-box; font-family: inherit;
}
.ch-form-row input:focus, .ch-form-row select:focus { border-color: #0066cc; outline: none; }
.ch-form-divider { font-size: .78em; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: #888; margin: 14px 0 8px; border-top: 1px solid #eee; padding-top: 10px; }
.ch-modal-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
.ch-modal-status { font-size: .83em; margin-top: 8px; }
</style>
<body>
{% include 'nav.html' %}
<div class="container">
<h1>{{ header }}</h1>
<p class="subtitle">Your account settings and host access</p>
<!-- Profile card -->
<div class="profile-card">
{% if current_user and current_user.avatar %}
<img class="avatar-large" src="{{ current_user.avatar_url }}" alt="">
{% else %}
<div class="avatar-initials-large">
{{ ((current_user.full_name if current_user else '') or (current_user.username if current_user else '?'))[:1] | upper }}
</div>
{% endif %}
<div class="profile-info">
<div class="profile-name">{{ current_user.full_name if current_user and current_user.full_name else (current_user.username if current_user else '—') }}</div>
<div class="profile-username">@{{ current_user.username if current_user else '—' }}</div>
{% if current_user and current_user.admin %}
<span class="badge badge-admin">Admin</span>
{% else %}
<span class="badge badge-user">User</span>
{% endif %}
<div class="profile-logout">
<a href="/logout" class="btn-logout">Sign out</a>
</div>
</div>
</div>
<!-- Account settings -->
<div class="section">
<h2>Account</h2>
<div class="settings-row">
<span class="settings-label">Username</span>
<span class="settings-value">{{ current_user.username if current_user else '—' }}</span>
</div>
<div class="settings-row">
<span class="settings-label">Full name</span>
{% if current_user and current_user.full_name %}
<span class="settings-value">{{ current_user.full_name }}</span>
{% else %}
<span class="settings-empty">Not set</span>
{% endif %}
</div>
<div class="settings-row">
<span class="settings-label">Role</span>
<span class="settings-value">{{ 'Administrator' if current_user and current_user.admin else 'User' }}</span>
</div>
<div class="settings-row">
<span class="settings-label">Avatar</span>
{% if current_user and current_user.avatar %}
<span class="settings-value" style="word-break:break-all;">{{ current_user.avatar }}</span>
{% else %}
<span class="settings-empty">Not set (initials used)</span>
{% endif %}
</div>
</div>
{% if current_user %}
<!-- ---- Editable identity ---- -->
<div class="section edit-section">
<h4>Identity</h4>
<div class="edit-field">
<label for="profile-fullname">Display name</label>
<input id="profile-fullname" class="edit-input" type="text" value="{{ current_user.full_name | e }}" placeholder="Full name">
</div>
<div class="edit-field">
<label for="profile-avatar">Avatar URL or path</label>
<input id="profile-avatar" class="edit-input" type="text" value="{{ current_user.avatar | e }}" placeholder="/path/to/avatar.png or https://…">
</div>
<div class="save-row">
<button class="btn-save" onclick="saveIdentity()">Save</button>
<span id="identity-status" class="status-msg"></span>
</div>
</div>
<!-- ---- Change password ---- -->
<div class="section edit-section">
<h4>Change password</h4>
<div class="edit-field">
<label for="profile-current-pw">Current password</label>
<input id="profile-current-pw" class="edit-input" type="password" autocomplete="current-password">
</div>
<div class="edit-field">
<label for="profile-new-pw">New password</label>
<input id="profile-new-pw" class="edit-input" type="password" autocomplete="new-password">
</div>
<div class="save-row">
<button class="btn-save" onclick="changePassword()">Change password</button>
<span id="password-status" class="status-msg"></span>
</div>
</div>
{% endif %}
<!-- Notification channels — chip picker -->
<div class="section">
<h2>Notification Channels</h2>
{% if current_user %}
<p style="font-size:.82em;color:#888;margin:0 0 12px">Click a channel to add or remove it from your alert list.</p>
{% if all_channels %}
<div class="ch-picker">
<div class="ch-picker-label">Selected</div>
<div id="selected-chips" class="ch-chips">
{% for ch in all_channels %}
{% if ch.name in (current_user.notification_channels or []) %}
<button class="ch-chip selected" data-ch="{{ ch.name | e }}" onclick="toggleChip(this)">
{{ ch.name | e }} <span class="ch-chip-x">×</span>
</button>
{% endif %}
{% endfor %}
{% set selected_set = current_user.notification_channels or [] %}
{% set has_selected = selected_set | length > 0 %}
{% if not has_selected %}
<span style="font-size:.83em;color:#bbb;font-style:italic;align-self:center">None selected</span>
{% endif %}
</div>
<div class="ch-picker-label">Available</div>
<div id="available-chips" class="ch-chips">
{% for ch in all_channels %}
{% if ch.name not in (current_user.notification_channels or []) %}
<button class="ch-chip available" data-ch="{{ ch.name | e }}" onclick="toggleChip(this)">
+ {{ ch.name | e }}
</button>
{% endif %}
{% endfor %}
</div>
</div>
{% else %}
<p style="font-size:.83em;color:#bbb;font-style:italic">No notification channels available. You can create your own below.</p>
{% endif %}
<div class="save-row">
<button class="btn-save" onclick="saveChannels()">Save channels</button>
<span id="channels-status" class="status-msg"></span>
</div>
{% else %}
<span class="no-hosts">Log in to manage notification channels.</span>
{% endif %}
</div>
<!-- My Channels — create/edit/delete own channels -->
{% if current_user %}
<div class="section">
<h2>My Channels</h2>
<p style="font-size:.82em;color:#888;margin:0 0 12px">Channels you own. Public channels are available to all users; private channels are visible only to you.</p>
<div id="my-channels-list">
{% set my_channels = all_channels | selectattr('owner', 'equalto', current_user.username) | list %}
{% for ch in my_channels %}
<div class="my-ch-card" id="mychcard-{{ ch.name | e }}">
<div class="my-ch-header">
<span class="my-ch-name">{{ ch.name | e }}</span>
<span class="my-ch-type">{{ ch.type | e }}</span>
{% if ch.private %}<span class="my-ch-private">private</span>{% endif %}
<span class="my-ch-actions">
<button class="btn-sm-edit" onclick="openMyChModal('{{ ch.name | e }}')">Edit</button>
<button class="btn-sm-del" onclick="deleteMyChannel('{{ ch.name | e }}')"></button>
</span>
</div>
</div>
{% endfor %}
{% if not my_channels %}
<p id="my-channels-empty" style="font-size:.83em;color:#bbb;font-style:italic">No channels yet.</p>
{% endif %}
</div>
<div class="save-row" style="margin-top:8px">
<button class="btn-save" onclick="openMyChModal()">+ New channel</button>
</div>
</div>
<!-- My Channels modal -->
<div id="my-ch-modal" class="ch-modal-overlay" style="display:none" onclick="if(event.target===this)closeMyChModal()">
<div class="ch-modal-box">
<h3 id="my-ch-modal-title">New Channel</h3>
<div class="ch-form-row">
<label>Channel name</label>
<input type="text" id="my-ch-name" placeholder="e.g. my_pushover" autocomplete="off">
</div>
<div class="ch-form-row">
<label>Type</label>
<select id="my-ch-type" onchange="onMyChTypeChange()">
<option value="">— select —</option>
</select>
</div>
<div id="my-ch-type-fields"></div>
<div class="ch-form-divider">Options</div>
<div class="ch-form-row">
<label>Minimum alert level</label>
<select id="my-ch-min-level">
<option value="WARNING">WARNING (and above)</option>
<option value="CRITICAL">CRITICAL only</option>
</select>
</div>
<div class="ch-form-row">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
<input type="checkbox" id="my-ch-private"> Private — visible only to you
</label>
</div>
<div id="my-ch-modal-status" class="ch-modal-status"></div>
<div class="ch-modal-footer">
<button class="btn-save" style="background:#888" onclick="closeMyChModal()">Cancel</button>
<button class="btn-save" onclick="saveMyChannel()">Save</button>
</div>
</div>
</div>
{% endif %}
<!-- Appearance -->
<div class="section">
<h2>Appearance</h2>
<div class="settings-row">
<span class="settings-label">Theme</span>
<div class="theme-btns">
<button class="theme-btn" data-theme-val="auto" onclick="setTheme('auto')">Auto</button>
<button class="theme-btn" data-theme-val="light" onclick="setTheme('light')">Light</button>
<button class="theme-btn" data-theme-val="dark" onclick="setTheme('dark')">Dark</button>
</div>
</div>
</div>
<!-- Host access -->
<div class="section">
<h2>Host Access</h2>
<div class="settings-row" style="align-items: flex-start; padding-bottom: 14px;">
<span class="settings-label" style="padding-top: 2px;">Owner</span>
<div class="host-grid">
{% if owned_hosts %}
{% for h in owned_hosts %}
<span class="host-chip owner"><span class="host-chip-dot"></span>{{ h }}</span>
{% endfor %}
{% else %}
<span class="no-hosts">None</span>
{% endif %}
</div>
</div>
<div class="settings-row" style="align-items: flex-start; padding-bottom: 14px;">
<span class="settings-label" style="padding-top: 2px;">Manager</span>
<div class="host-grid">
{% if managed_hosts %}
{% for h in managed_hosts %}
<span class="host-chip manager"><span class="host-chip-dot"></span>{{ h }}</span>
{% endfor %}
{% else %}
<span class="no-hosts">None</span>
{% endif %}
</div>
</div>
<div class="settings-row" style="align-items: flex-start; padding-bottom: 4px;">
<span class="settings-label" style="padding-top: 2px;">Monitor</span>
<div class="host-grid">
{% if monitored_hosts %}
{% for h in monitored_hosts %}
<span class="host-chip monitor"><span class="host-chip-dot"></span>{{ h }}</span>
{% endfor %}
{% else %}
<span class="no-hosts">None</span>
{% endif %}
</div>
</div>
</div>
</div>
<script>
// ---- Theme ----
function applyTheme(pref) {
var dark = pref === 'dark' ||
(pref === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (dark) { document.documentElement.setAttribute('data-theme', 'dark'); }
else { document.documentElement.removeAttribute('data-theme'); }
}
function setTheme(pref) {
try { localStorage.setItem('hbd_theme', pref); } catch(e) {}
applyTheme(pref);
document.querySelectorAll('.theme-btn').forEach(function(b) {
b.classList.toggle('active', b.dataset.themeVal === pref);
});
}
(function() {
var pref = 'auto';
try { pref = localStorage.getItem('hbd_theme') || 'auto'; } catch(e) {}
document.querySelectorAll('.theme-btn').forEach(function(b) {
b.classList.toggle('active', b.dataset.themeVal === pref);
});
})();
// ---- Identity ----
async function saveIdentity() {
const full_name = document.getElementById('profile-fullname').value;
const avatar = document.getElementById('profile-avatar').value;
const resp = await fetch('/api/0/users/me', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({full_name, avatar}),
});
if (resp.ok) {
showStatus('identity-status', 'Saved', '#2e7d32');
} else {
const err = await resp.json().catch(() => ({}));
showStatus('identity-status', err.error || 'Error saving', '#c62828');
}
}
// ---- Password ----
async function changePassword() {
const current = document.getElementById('profile-current-pw').value;
const newpw = document.getElementById('profile-new-pw').value;
if (!current || !newpw) {
showStatus('password-status', 'Both fields are required', '#c62828');
return;
}
const resp = await fetch('/api/0/users/me', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({password: {current, new: newpw}}),
});
if (resp.ok) {
document.getElementById('profile-current-pw').value = '';
document.getElementById('profile-new-pw').value = '';
showStatus('password-status', 'Password changed', '#2e7d32');
} else {
const err = await resp.json().catch(() => ({}));
showStatus('password-status', err.error || 'Error', '#c62828');
}
}
// ---- Channel chip picker ----
function toggleChip(btn) {
const name = btn.dataset.ch;
const isSelected = btn.classList.contains('selected');
if (isSelected) {
// Move to available
btn.classList.remove('selected');
btn.classList.add('available');
btn.innerHTML = '+ ' + escHtml(name);
btn.onclick = function() { toggleChip(this); };
document.getElementById('available-chips').appendChild(btn);
// Remove "None selected" placeholder if it exists
} else {
// Move to selected
btn.classList.remove('available');
btn.classList.add('selected');
btn.innerHTML = escHtml(name) + ' <span class="ch-chip-x">×</span>';
btn.onclick = function() { toggleChip(this); };
document.getElementById('selected-chips').appendChild(btn);
}
// Update placeholder visibility
const sel = document.getElementById('selected-chips');
const placeholder = sel.querySelector('span[style]');
const hasChips = sel.querySelectorAll('.ch-chip.selected').length > 0;
if (placeholder) placeholder.style.display = hasChips ? 'none' : '';
}
async function saveChannels() {
const notification_channels = [
...document.querySelectorAll('#selected-chips .ch-chip.selected')
].map(b => b.dataset.ch);
const resp = await fetch('/api/0/users/me', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({notification_channels}),
});
if (resp.ok) {
showStatus('channels-status', 'Saved', '#2e7d32');
} else {
const err = await resp.json().catch(() => ({}));
showStatus('channels-status', err.error || 'Error saving', '#c62828');
}
}
// ---- My Channels CRUD ----
let _myChSchemas = {};
let _myChEditName = null;
async function _loadMyChSchemas() {
try {
const r = await fetch('/api/0/notification_channel_types');
_myChSchemas = await r.json();
const sel = document.getElementById('my-ch-type');
if (!sel) return;
Object.entries(_myChSchemas).forEach(([k, v]) => {
const opt = document.createElement('option');
opt.value = k; opt.textContent = v.label;
sel.appendChild(opt);
});
} catch(e) { console.warn('Could not load channel schemas', e); }
}
function onMyChTypeChange() {
const type = document.getElementById('my-ch-type').value;
const container = document.getElementById('my-ch-type-fields');
container.innerHTML = '';
if (!type || !_myChSchemas[type]) return;
const divider = document.createElement('div');
divider.className = 'ch-form-divider';
divider.textContent = _myChSchemas[type].label + ' settings';
container.appendChild(divider);
(_myChSchemas[type].fields || []).forEach(sf => {
const row = document.createElement('div');
row.className = 'ch-form-row';
const lbl = document.createElement('label');
lbl.textContent = sf.label + (sf.required ? ' *' : '');
const inp = document.createElement('input');
inp.type = sf.type === 'secret' ? 'password' : 'text';
inp.id = 'mychf-' + sf.key;
inp.placeholder = sf.required ? '(required)' : '(optional)';
inp.autocomplete = 'off';
row.appendChild(lbl);
row.appendChild(inp);
container.appendChild(row);
});
}
async function openMyChModal(name) {
_myChEditName = name || null;
document.getElementById('my-ch-modal-status').textContent = '';
document.getElementById('my-ch-modal-title').textContent = name ? 'Edit Channel' : 'New Channel';
document.getElementById('my-ch-name').value = name || '';
document.getElementById('my-ch-name').disabled = !!name;
document.getElementById('my-ch-type').value = '';
document.getElementById('my-ch-type-fields').innerHTML = '';
document.getElementById('my-ch-min-level').value = 'WARNING';
document.getElementById('my-ch-private').checked = false;
if (name) {
try {
const r = await fetch('/api/0/notification_channels');
const channels = await r.json();
const ch = channels.find(c => c.name === name);
if (ch) {
document.getElementById('my-ch-type').value = ch.type;
onMyChTypeChange();
document.getElementById('my-ch-min-level').value = ch.min_level || 'WARNING';
document.getElementById('my-ch-private').checked = ch.private || false;
(ch.fields || []).forEach(f => {
const inp = document.getElementById('mychf-' + f.key);
if (inp) inp.value = f.value || '';
});
}
} catch(e) { console.warn('Failed to load channel', e); }
}
document.getElementById('my-ch-modal').style.display = 'flex';
}
function closeMyChModal() {
document.getElementById('my-ch-modal').style.display = 'none';
}
async function saveMyChannel() {
const name = document.getElementById('my-ch-name').value.trim();
const type = document.getElementById('my-ch-type').value;
const minLevel = document.getElementById('my-ch-min-level').value;
const isPrivate = document.getElementById('my-ch-private').checked;
const statusEl = document.getElementById('my-ch-modal-status');
statusEl.textContent = '';
if (!name) { statusEl.textContent = 'Name is required.'; statusEl.style.color = '#c62828'; return; }
if (!type) { statusEl.textContent = 'Please select a type.'; statusEl.style.color = '#c62828'; return; }
const body = { name, type, min_level: minLevel, private: isPrivate };
if (_myChSchemas[type]) {
(_myChSchemas[type].fields || []).forEach(sf => {
const inp = document.getElementById('mychf-' + sf.key);
if (inp) body[sf.key] = inp.value;
});
}
const isEdit = !!_myChEditName;
const url = isEdit
? '/api/0/notification_channels/' + encodeURIComponent(_myChEditName)
: '/api/0/notification_channels';
const method = isEdit ? 'PUT' : 'POST';
try {
const r = await fetch(url, { method, headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) });
if (r.ok) {
closeMyChModal();
window.location.reload();
} else {
const err = await r.json().catch(() => ({}));
statusEl.textContent = err.error || 'Error saving.';
statusEl.style.color = '#c62828';
}
} catch(e) {
statusEl.textContent = 'Network error: ' + e.message;
statusEl.style.color = '#c62828';
}
}
async function deleteMyChannel(name) {
if (!confirm('Delete channel "' + name + '"?')) return;
try {
const r = await fetch('/api/0/notification_channels/' + encodeURIComponent(name), { method: 'DELETE' });
if (r.ok) {
window.location.reload();
} else {
const err = await r.json().catch(() => ({}));
alert('Error: ' + (err.error || 'Could not delete.'));
}
} catch(e) { alert('Network error: ' + e.message); }
}
// ---- Utilities ----
function showStatus(id, msg, color) {
const el = document.getElementById(id);
if (!el) return;
el.textContent = msg;
el.style.color = color;
setTimeout(() => { el.textContent = ''; }, 3000);
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
document.addEventListener('DOMContentLoaded', _loadMyChSchemas);
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+685 -203
View File
File diff suppressed because it is too large Load Diff
+291 -78
View File
@@ -1,9 +1,14 @@
"""UDP listener and datagram processing.""" """UDP listener and datagram processing."""
import asyncio import asyncio
import socket
import struct
import time
import zlib import zlib
import logging import logging
from platform import system as platform_system
from ..common.proto import stodict, oldmtodict from ..common.proto import stodict, oldmtodict
from ..common.utils import dur from ..common.utils import dur
from . import notify as notify_mod from . import notify as notify_mod
@@ -11,6 +16,108 @@ from . import notify as notify_mod
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
eventlog = notify_mod.eventlog eventlog = notify_mod.eventlog
# SO_TIMESTAMP: kernel attaches a struct timeval to each received datagram.
# Supported on Linux, FreeBSD, and macOS. The constant is not exposed by
# Python's socket module on all platforms
platform = platform_system()
if platform == "Darwin":
_SO_TIMESTAMP = 1024 # SO_TIMESTAMP on macOS (not in Python's socket module)
elif platform == "Linux":
_SO_TIMESTAMP = 29 # Linux value (not in older Python versions)
elif platform == "FreeBSD":
_SO_TIMESTAMP = 32 # FreeBSD value (not in older Python versions)
else:
logger.warning("SO_TIMESTAMP may not be supported on this platform (%s)", platform)
_SO_TIMESTAMP = None
# struct timeval uses two native C longs: tv_sec and tv_usec
_TIMEVAL = struct.Struct('@ll')
def enable_kernel_timestamps(sock) -> bool:
"""Try to enable SO_TIMESTAMP on *sock*.
Returns True if the kernel will supply receive timestamps, False otherwise
(unsupported platform, older kernel, or insufficient permissions).
"""
try:
sock.setsockopt(socket.SOL_SOCKET, _SO_TIMESTAMP, 1)
return True
except OSError:
return False
def _extract_kernel_ts(ancdata) -> float | None:
"""Parse recvmsg ancillary data and return the kernel receive time.
Returns seconds as a float, or None if no SO_TIMESTAMP cmsg is present.
"""
for cmsg_level, cmsg_type, cmsg_data in ancdata:
if cmsg_level == socket.SOL_SOCKET and cmsg_type == _SO_TIMESTAMP:
if len(cmsg_data) >= _TIMEVAL.size:
sec, usec = _TIMEVAL.unpack_from(cmsg_data)
return sec + usec * 1e-6
return None
class RecvmsgTransport:
"""Thin wrapper used when SO_TIMESTAMP is active (add_reader path).
Exposes the same sendto() / close() interface as asyncio's DatagramTransport
so the rest of the code does not need to know which path is in use.
"""
def __init__(self, loop, sock):
self._loop = loop
self._sock = sock
def sendto(self, data, addr):
try:
self._sock.sendto(data, addr)
except Exception as e:
logger.debug("sendto failed: %s", e)
def close(self):
try:
self._loop.remove_reader(self._sock.fileno())
except Exception:
pass
try:
self._sock.close()
except Exception:
pass
def make_recvmsg_reader(sock, handler, transport):
"""Return a callback suitable for loop.add_reader().
Reads one datagram per call using recvmsg() so that kernel timestamps in
the ancillary data are accessible. Falls back to time.time() if the
cmsg is missing.
handler(msg, addr, transport, kernel_ts) same signature as udp_handler
in main.py with the optional kernel_ts argument.
"""
BUFSIZE = 65536
ANCBUFSIZE = 128 # enough for one struct timespec cmsg
def _read():
try:
data, ancdata, _, addr = sock.recvmsg(BUFSIZE, ANCBUFSIZE)
except BlockingIOError:
return
except OSError as e:
logger.warning("recvmsg error: %s", e)
return
try:
kernel_ts = _extract_kernel_ts(ancdata)
msg = parse_message(data)
if msg:
handler(msg, addr, transport, kernel_ts)
except Exception:
logger.exception("Error processing datagram from %s", addr)
return _read
class EchoServerProtocol(asyncio.DatagramProtocol): class EchoServerProtocol(asyncio.DatagramProtocol):
def __init__(self, config=None, handler=None): def __init__(self, config=None, handler=None):
@@ -61,6 +168,126 @@ def dicttos(ID, d):
return opk return opk
DROPOVERDUE = 7 * 24 * 3600 # seconds before an overdue host becomes UNKNOWN
def _set_connectivity_alert(host, afam, level_name):
"""Update (or clear) a connectivity alert_state entry for a host/address-family.
level_name is "CRITICAL", "WARNING", or "OK". "OK" removes the entry so
that recovered hosts don't clutter the Alerts Dashboard.
"""
from .threshold import AlertState, AlertLevel
metric_path = f"connectivity.{afam}"
level = getattr(AlertLevel, level_name, AlertLevel.OK)
if level == AlertLevel.OK:
host.alert_states.pop(metric_path, None)
return
if metric_path not in host.alert_states:
host.alert_states[metric_path] = AlertState(metric_path)
state = host.alert_states[metric_path]
state.update(level, level_name)
def _make_timer_callbacks(uname, host, ctx):
"""Return (on_overdue, on_unknown) async callbacks for connection timer logic.
Captured values are bound at call time so callbacks are safe to use in loops.
"""
msg_to_websockets = ctx.get("msg_to_websockets")
threshold_checker = ctx.get("threshold_checker")
cfg = ctx.get("config", {})
async def on_unknown(connection):
connection.newstate(connection.__class__.UNKNOWN, connection.lastbeat)
# Keep connectivity alert active when host transitions to unknown
if msg_to_websockets:
msg_to_websockets("host", host.stateinfo())
async def on_overdue(connection):
if connection.getstate() != connection.__class__.UP:
return
now = time.time()
connection.newstate(connection.__class__.OVERDUE, now, cfg.get("grace", 2))
msg = f"{connection.afam} overdue"
eventlog(uname, "CRITICAL", msg)
if host.watched:
asyncio.create_task(notify_mod.send_notification(
uname,
notify_mod.Notification(title=f"[CRITICAL] {uname}", body=msg, level="CRITICAL"),
))
# Track in alert_states so the Alerts Dashboard shows this
_set_connectivity_alert(host, connection.afam, "CRITICAL")
if threshold_checker:
threshold_checker.check_value(
host_name=uname,
metric_path="rtt",
value=float("inf"),
alert_states=host.alert_states,
)
if msg_to_websockets:
msg_to_websockets("host", host.stateinfo())
connection.reset_overdue_timer(DROPOVERDUE, on_unknown)
return on_overdue, on_unknown
def restore_connection_timers(hbdclass, ctx):
"""Restore overdue timers for all loaded connections after a pickle restore.
For UP connections, the remaining time until overdue is calculated from
lastbeat so that clients that vanished during hbd's downtime are detected.
For OVERDUE connections, the UNKNOWN drop timer is restored.
"""
now = time.time()
cfg = ctx.get("config", {})
grace = cfg.get("grace", 2)
restored = 0
for uname, host in list(hbdclass.Host.hosts.items()):
interval = host.interval
for afam, conn in list(host.connections.items()):
state = conn.getstate()
if state == hbdclass.Connection.DOWN:
continue
on_overdue, on_unknown = _make_timer_callbacks(uname, host, ctx)
if state == hbdclass.Connection.UP and interval > 0:
elapsed = now - conn.lastbeat
# Give hosts one full (interval + grace) of extra time on startup
# so hosts that were silent while hbd was down are not immediately
# flagged as overdue before they have a chance to check in.
startup_grace = interval + grace
remaining = max(startup_grace, 2 * startup_grace - elapsed)
conn.reset_overdue_timer(remaining, on_overdue)
logger.debug(
"Restored UP timer %s/%s: %.0fs remaining (elapsed %.0fs, startup grace %.0fs)",
uname, afam, remaining, elapsed, startup_grace,
)
restored += 1
elif state == hbdclass.Connection.OVERDUE:
elapsed_overdue = now - conn.statetime
remaining = DROPOVERDUE - elapsed_overdue
if remaining <= 1:
# Already past the drop window — mark UNKNOWN immediately
conn.newstate(hbdclass.Connection.UNKNOWN, conn.lastbeat)
logger.info(
"Marking %s/%s UNKNOWN (overdue %.1f days)",
uname, afam, elapsed_overdue / 86400,
)
else:
conn.reset_overdue_timer(remaining, on_unknown)
logger.debug(
"Restored OVERDUE timer %s/%s: %.0fs remaining",
uname, afam, remaining,
)
restored += 1
logger.info("Restored timers for %d connection(s)", restored)
def handle_datagram(msg: dict, addr, transport, ctx: dict): def handle_datagram(msg: dict, addr, transport, ctx: dict):
"""Handle a parsed datagram message. """Handle a parsed datagram message.
@@ -74,7 +301,7 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
""" """
if not msg: if not msg:
return return
now = __import__("time").time() now = ctx.get("recv_ts") or time.time()
# Log message to journal # Log message to journal
msg_journal = ctx.get("msg_journal") msg_journal = ctx.get("msg_journal")
@@ -89,7 +316,6 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
cfg = ctx.get("config", {}) cfg = ctx.get("config", {})
hbdcls = ctx.get("hbdclass") hbdcls = ctx.get("hbdclass")
log = ctx.get("log")
msg_to_websockets = ctx.get("msg_to_websockets") msg_to_websockets = ctx.get("msg_to_websockets")
DEBUG = ctx.get("DEBUG", 0) DEBUG = ctx.get("DEBUG", 0)
verbose = ctx.get("verbose", False) verbose = ctx.get("verbose", False)
@@ -107,16 +333,17 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
# Use new config function to check dyndns # Use new config function to check dyndns
dyndnshosts = config_mod.get_dyndnshosts(cfg) dyndnshosts = config_mod.get_dyndnshosts(cfg)
host.dyn = uname in dyndnshosts host.dyn = uname in dyndnshosts
if verbose: watchhosts = config_mod.get_watchhosts(cfg)
print(("XX: New host, num now %s" % (len(hbdcls.Host.hosts)))) host.watched = uname in watchhosts
# Apply user-access settings from config
access = config_mod.get_host_access(cfg, uname)
host.apply_access(access["owner"], access["managers"], access["monitors"])
logger.info("New host signed on: %s (dyn=%s, access=%s)", uname, host.dyn, access)
newh = True newh = True
else: else:
host = hbdcls.Host.hosts[uname] host = hbdcls.Host.hosts[uname]
newh = False newh = False
# Get watchhosts once for use throughout message handling
watchhosts = config_mod.get_watchhosts(cfg)
cid = msg.get("id", 0) cid = msg.get("id", 0)
try: try:
rtt = float(msg.get("rtt")) rtt = float(msg.get("rtt"))
@@ -125,8 +352,10 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
if msg.get("ID") == "HTB": if msg.get("ID") == "HTB":
host.doesack = msg.get("acks", -1) host.doesack = msg.get("acks", -1)
# send ACK back # send ACK back; ask client to resend plugin info when we have none yet
rmsg = {"time": __import__("time").time()} rmsg = {"time": time.time()}
if not host.plugin_data:
rmsg["request_update"] = 1
opkt = dicttos("ACK", rmsg) opkt = dicttos("ACK", rmsg)
try: try:
transport.sendto(opkt, addr) transport.sendto(opkt, addr)
@@ -138,10 +367,19 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
# Handle plugin data message # Handle plugin data message
plugin_name = msg.get("plugin") plugin_name = msg.get("plugin")
if plugin_name: if plugin_name:
# Extract all fields except ID and plugin name # Extract plugin fields, dropping protocol metadata fields
plugin_data = {k: v for k, v in msg.items() if k not in ["ID", "plugin"]} plugin_data = {k: v for k, v in msg.items()
if k not in ("ID", "plugin", "id", "name")}
# Store plugin data with timestamp # Store plugin data with timestamp
host.add_plugin_data(plugin_name, plugin_data, timestamp=now) host.add_plugin_data(plugin_name, plugin_data, timestamp=now)
# If 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: if DEBUG > 1:
print(f"Stored plugin data for {uname}: {plugin_name}") print(f"Stored plugin data for {uname}: {plugin_name}")
@@ -181,8 +419,11 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
if res: if res:
eventlog(uname, "WARNING", res) eventlog(uname, "WARNING", res)
if uname in watchhosts: if host.watched:
notify_mod.pushmsg_for_host(uname, "%s %s" % (host.name, res)) asyncio.create_task(notify_mod.send_notification(
uname,
notify_mod.Notification(title=f"[WARNING] {uname}", body=res, level="WARNING"),
))
interval = int(msg.get("interval", 0) or 0) interval = int(msg.get("interval", 0) or 0)
shutdown = msg.get("shutdown", 0) shutdown = msg.get("shutdown", 0)
@@ -192,24 +433,36 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
if boot: if boot:
eventlog(uname, "INFO", "booted") eventlog(uname, "INFO", "booted")
if uname in watchhosts: if host.watched:
m = "%s booted" % (host.name) asyncio.create_task(notify_mod.send_notification(
notify_mod.pushmsg_for_host(uname, m) uname,
notify_mod.Notification(title=f"[INFO] {uname}", body=f"{host.name} booted", level="INFO"),
))
if message: if message:
eventlog(uname, "INFO", "msg: %s" % message, service=service) eventlog(uname, "INFO", "msg: %s" % message, service=service)
if uname in watchhosts:
notify_mod.pushmsg_for_host(uname, message)
if conn.getstate() != hbdcls.Connection.UP: if conn.getstate() != hbdcls.Connection.UP:
lasts = conn.state lasts = conn.state
d = conn.newstate(hbdcls.Connection.UP, now) d = conn.newstate(hbdcls.Connection.UP, now)
if d == 0 or lasts == "unknown": # Clear connectivity alert now that the host is back up
m = "%s is up" % (conn.afam) _set_connectivity_alert(host, conn.afam, "OK")
else: # Don't log/notify RECOVER for a brand-new host seen for the first time —
m = "%s back after being %s for %s" % (conn.afam, lasts, dur(d)) # it was never down, it just hasn't been seen before.
eventlog(uname, "RECOVER", m) if not newh:
if uname in watchhosts: if d == 0 or lasts == "unknown":
notify_mod.pushmsg_for_host(uname, "%s %s is back" % (uname, conn.afam)) m = "%s is up" % (conn.afam)
elif d < 4:
# Transient blip (likely client restart) — skip log and notification
m = None
else:
m = "%s back after being %s for %s" % (conn.afam, lasts, dur(d))
if m:
eventlog(uname, "RECOVER", m)
if host.watched:
asyncio.create_task(notify_mod.send_notification(
uname,
notify_mod.Notification(title=f"[RECOVER] {uname}", body=m, level="RECOVER"),
))
if boot or newh: if boot or newh:
host.upcount = host.doesack host.upcount = host.doesack
@@ -217,63 +470,25 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
host.upcount += 1 host.upcount += 1
if shutdown: if shutdown:
eventlog(uname, "INFO", "%s shutdown" % conn.afam) m = "%s shutdown" % conn.afam
if uname in watchhosts: eventlog(uname, "INFO", m)
notify_mod.pushmsg_for_host(uname, "%s %s shutdown" % (uname, conn.afam)) if host.watched:
asyncio.create_task(notify_mod.send_notification(
uname,
notify_mod.Notification(title=f"[INFO] {uname}", body=m, level="INFO"),
))
conn.newstate(hbdcls.Connection.DOWN, now) conn.newstate(hbdcls.Connection.DOWN, now)
_set_connectivity_alert(host, conn.afam, "CRITICAL")
if interval > 0: if interval > 0:
host.interval = interval host.interval = interval
# Timer-based reachability monitoring # Timer-based reachability monitoring
# Reset overdue timer on every heartbeat # Reset overdue timer on every heartbeat
if interval > 0 and conn.getstate() != hbdcls.Connection.DOWN: if interval > 0 and conn.getstate() != hbdcls.Connection.DOWN:
grace = cfg.get("grace", 2) grace = cfg.get("grace", 2)
timeout_seconds = (interval + grace) if interval > 0 else 30 timeout_seconds = interval + grace
on_overdue, _ = _make_timer_callbacks(uname, host, ctx)
# Create callback for timer expiration
async def on_overdue(connection):
"""Called when connection timer expires (no heartbeat received)."""
import time
now = time.time()
# Only mark as overdue if still in UP state (not already marked)
if connection.getstate() == hbdcls.Connection.UP:
connection.newstate(hbdcls.Connection.OVERDUE, now, cfg.get("grace", 2))
msg = f"{connection.afam} overdue"
eventlog(uname, "CRITICAL" if uname in watchhosts else "WARNING", msg)
if uname in watchhosts:
notify_mod.pushmsg_for_host(uname, f"{uname} {msg}")
# Check RTT thresholds with infinite RTT for overdue hosts
threshold_checker = ctx.get("threshold_checker")
if threshold_checker:
metric_path = "rtt"
threshold_checker.check_value(
host_name=uname,
metric_path=metric_path,
value=float('inf'),
alert_states=host.alert_states
)
# Notify websockets
if msg_to_websockets:
msg_to_websockets("host", host.stateinfo())
# Set a longer timer for marking as UNKNOWN (7 days)
DROPOVERDUE = 7 * 24 * 3600
async def on_unknown(connection):
"""Mark connection as unknown after extended absence."""
connection.newstate(hbdcls.Connection.UNKNOWN, connection.lastbeat)
if msg_to_websockets:
msg_to_websockets("host", host.stateinfo())
connection.reset_overdue_timer(DROPOVERDUE, on_unknown)
# Reset the timer
conn.reset_overdue_timer(timeout_seconds, on_overdue) conn.reset_overdue_timer(timeout_seconds, on_overdue)
# Check RTT thresholds using the threshold checker # Check RTT thresholds using the threshold checker
@@ -295,12 +510,10 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
op, rmsg = host.cmds[0] op, rmsg = host.cmds[0]
if op == "CMD": if op == "CMD":
del host.cmds[0] del host.cmds[0]
if log: eventlog(uname, "INFO", "command sent")
log(uname, "command sent")
elif op == "UPD": elif op == "UPD":
del host.cmds[0] del host.cmds[0]
if log: eventlog(uname, "INFO", "update initiated")
log(uname, "update initiated")
opkt = dicttos(op, rmsg) opkt = dicttos(op, rmsg)
try: try:
transport.sendto(opkt, addr) transport.sendto(opkt, addr)
+271
View File
@@ -0,0 +1,271 @@
"""User management: loading, authentication, and session tracking.
Users are defined in the config file under the ``users`` key:
users:
alice:
full_name: Alice Smith
avatar: /path/to/avatar.png # file path, URL, or base64 data URI
password: pbkdf2:sha256:... # generated with: hbd passwd
admin: true # optional server-level admin
notification_channels: [pushover_standard]
Roles are assigned per-host:
hosts:
webserver01:
owner: alice
managers: [bob]
monitors: [carol]
If no users are defined the server runs in unauthenticated mode (backwards
compatible). When users are defined every API call must carry a valid session
token in an ``Authorization: Bearer <token>`` or ``X-Auth-Token`` header,
obtained via ``POST /api/0/auth/login``.
"""
import hashlib
import hmac
import logging
import secrets
import time
logger = logging.getLogger(__name__)
# Session lifetime in seconds (24 hours).
SESSION_TTL = 86400
# Global session store: token -> {"username": str, "expires": float, "created": float}
_sessions: dict = {}
# ---------------------------------------------------------------------------
# User class
# ---------------------------------------------------------------------------
class User:
def __init__(
self,
username: str,
full_name: str = "",
avatar: str = "",
password_hash: str = "",
admin: bool = False,
notification_channels: list | None = None,
):
self.username = username
self.full_name = full_name
self.avatar = avatar
self.password_hash = password_hash
self.admin = admin
self.notification_channels: list = notification_channels or []
def check_password(self, password: str) -> bool:
if not self.password_hash:
return False
return _verify_password(password, self.password_hash)
def avatar_is_local(self) -> bool:
"""Return True when the avatar is a local filesystem path (starts with '/')."""
return bool(self.avatar and self.avatar.startswith("/"))
def avatar_url(self) -> str:
"""Return the URL to use as an <img src>.
Local file paths are served via the /api/0/users/{username}/avatar
endpoint. External URLs and data URIs are returned as-is.
"""
if self.avatar_is_local():
return f"/api/0/users/{self.username}/avatar"
return self.avatar
def to_dict(self) -> dict:
return {
"username": self.username,
"full_name": self.full_name,
"avatar": self.avatar,
"avatar_url": self.avatar_url(),
"admin": self.admin,
"notification_channels": self.notification_channels,
}
# ---------------------------------------------------------------------------
# Password hashing (PBKDF2-HMAC-SHA256, stdlib only)
# ---------------------------------------------------------------------------
def hash_password(password: str) -> str:
"""Return a storable hash for *password*.
Format: ``pbkdf2:sha256:<iterations>:<salt>:<hex-digest>``
Use this to generate the ``password`` value in the config file::
python -c "from hbd.server.users import hash_password; print(hash_password('secret'))"
Or via the CLI::
hbd passwd
"""
salt = secrets.token_hex(16)
iterations = 260_000
dk = hashlib.pbkdf2_hmac(
"sha256", password.encode("utf-8"), salt.encode("utf-8"), iterations
)
return f"pbkdf2:sha256:{iterations}:{salt}:{dk.hex()}"
def _verify_password(password: str, stored_hash: str) -> bool:
"""Return True if *password* matches *stored_hash*."""
try:
parts = stored_hash.split(":")
if len(parts) != 5 or parts[0] != "pbkdf2" or parts[1] != "sha256":
return False
_, _, iterations_str, salt, expected_hex = parts
iterations = int(iterations_str)
dk = hashlib.pbkdf2_hmac(
"sha256", password.encode("utf-8"), salt.encode("utf-8"), iterations
)
return hmac.compare_digest(dk.hex(), expected_hex)
except Exception:
return False
# ---------------------------------------------------------------------------
# Global user registry
# ---------------------------------------------------------------------------
# username -> User
users: dict = {}
def load_users(config: dict) -> dict:
"""Populate the global user registry from *config*.
Called once at startup and again on SIGHUP config reload.
Returns the new ``users`` dict.
"""
global users
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 = {}
for username, attrs in users_cfg.items():
if not isinstance(attrs, dict):
logger.warning("Skipping user %r: expected a mapping", username)
continue
result[username] = User(
username=username,
full_name=attrs.get("full_name", ""),
avatar=attrs.get("avatar", ""),
password_hash=attrs.get("password", ""),
admin=bool(attrs.get("admin", False)),
notification_channels=attrs.get("notification_channels", []),
)
users = result
# 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
def users_enabled() -> bool:
"""Return True if at least one user is configured (auth-required mode)."""
return bool(users)
def get_user(username: str) -> "User | None":
return users.get(username)
def authenticate(username: str, password: str) -> "User | None":
"""Return the User if credentials are valid, else None."""
user = users.get(username)
if user and user.check_password(password):
return user
return None
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
# ---------------------------------------------------------------------------
def create_session(username: str) -> str:
"""Create a new session for *username* and return the opaque token."""
_purge_expired_sessions()
token = secrets.token_hex(32)
_sessions[token] = {
"username": username,
"expires": time.time() + SESSION_TTL,
"created": time.time(),
}
return token
def get_session_user(token: str) -> "User | None":
"""Return the User for a valid *token*, or None if missing/expired."""
if not token:
return None
session = _sessions.get(token)
if not session:
return None
if session["expires"] < time.time():
del _sessions[token]
return None
return get_user(session["username"])
def delete_session(token: str) -> None:
"""Invalidate *token* (logout)."""
_sessions.pop(token, None)
def _purge_expired_sessions() -> None:
now = time.time()
expired = [t for t, s in list(_sessions.items()) if s["expires"] < now]
for t in expired:
del _sessions[t]
def save_sessions() -> dict:
"""Return a snapshot of non-expired sessions suitable for pickling."""
_purge_expired_sessions()
return dict(_sessions)
def load_sessions(snapshot: dict) -> None:
"""Restore sessions from a pickled snapshot, dropping any that have expired."""
global _sessions
now = time.time()
_sessions = {t: s for t, s in snapshot.items() if s.get("expires", 0) > now}
logger.debug("Restored %d session(s) from pickle", len(_sessions))
+130 -125
View File
@@ -1,7 +1,8 @@
"""WebSocket server and broadcast helpers for hbd. """WebSocket handler and broadcast helpers for hbd.
Provides an asyncio-based WebSocket server and a thread-safe broadcast WebSocket connections are served through the regular HTTP port via the
function that other threads or synchronous code can call. /ws route registered in http.py (aiohttp WebSocketResponse upgrade).
The separate standalone WebSocket server on ws_port is no longer used.
""" """
import asyncio import asyncio
@@ -10,144 +11,148 @@ import logging
from typing import Callable, Iterable, Optional from typing import Callable, Iterable, Optional
from . import data from . import data
import websockets
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
_connections = set() # Map of WebSocket → User object (or None when auth is disabled)
_connections: dict = {}
_loop: Optional[asyncio.AbstractEventLoop] = None _loop: Optional[asyncio.AbstractEventLoop] = None
_get_hosts: Optional[Callable[[], Iterable]] = None _get_hosts: Optional[Callable[[], Iterable]] = None
#_get_msgs: Optional[Callable[[], Iterable]] = None _verbose: bool = False
_verbose = False
async def _handler(websocket, path=None): def setup(
_connections.add(websocket) loop: asyncio.AbstractEventLoop,
remote_address = websocket.remote_address get_hosts: Optional[Callable[[], Iterable]] = None,
if path is None: verbose: bool = False,
path = getattr(websocket, "path", None)
logger.info("WebSocket connection from %s: %s", remote_address, path)
try:
# send initial hosts
if _get_hosts:
try:
hosts = list(_get_hosts())
logger.debug("Sending %d hosts to new WebSocket client", len(hosts))
for h in hosts:
jmsg = json.dumps({"type": "host", "data": h})
await websocket.send(jmsg)
except Exception as e:
logger.error("Error sending initial hosts: %s", e, exc_info=True)
# send recent messages
if data.msgs:
try:
# msgs = list(_get_msgs())[-100:]
logger.debug("Sending %d recent messages to new WebSocket client", len(data.msgs))
for m in data.msgs:
jmsg = json.dumps({"type": "message", "data": m})
await websocket.send(jmsg)
except Exception as e:
logger.error("Error sending initial messages: %s", e, exc_info=True)
# keep connection open until client disconnects
async for _ in websocket:
# we don't expect meaningful incoming messages besides the initial
# client 'hello' that some clients send; ignore for now
if _verbose:
logger.debug("received ws data: %s", _)
except (
websockets.exceptions.ConnectionClosedOK,
websockets.exceptions.ConnectionClosedError,
) as e:
logger.info("WebSocket closed from %s: %r", remote_address, e)
except Exception as e:
logger.exception("WebSocket handler exception from %s: %s", remote_address, e)
finally:
logger.debug("Removing WebSocket connection from %s", remote_address)
try:
_connections.remove(websocket)
except KeyError:
pass
await websocket.wait_closed()
async def start(
host: str,
ws_port: int,
wss_port: Optional[int] = None,
ssl_context=None,
get_hosts: Optional[Callable] = None,
# get_msgs: Optional[Callable] = None,
config: dict = {},
): ):
"""Start WebSocket servers and block until cancelled. """Register the running loop and initial-state callback.
This is intended to be awaited inside the main asyncio event loop. Call this once from _run_async before starting the HTTP server.
If `wss_port` and `ssl_context` are provided, a WSS server will also be
started.
""" """
global _loop, _get_hosts, _verbose global _loop, _get_hosts, _verbose
_loop = asyncio.get_running_loop() _loop = loop
_get_hosts = get_hosts _get_hosts = get_hosts
_verbose = config.get("verbose", False), _verbose = verbose
_debug = config.get("debug", 0),
servers = []
# plain WebSocket
websockets_logger = logging.getLogger("websockets.server")
#if _debug > 2:
# websockets_logger.setLevel(logging.DEBUG)
#else:
# websockets_logger.setLevel(logging.INFO)
# regular WebSocket
ws_server = websockets.serve(_handler, host, ws_port) # , subprotocols=["hbd"])
servers.append(ws_server)
# secure WebSocket (optional)
if wss_port and ssl_context:
wss_server = websockets.serve(
_handler, host, wss_port, ssl=ssl_context
) # , subprotocols=["hbd"])
servers.append(wss_server)
# await starting of all servers
for srv in servers:
await srv
logger.info(
"WebSocket server(s) started on port %s (wss %s)", ws_port, wss_port
)
# block forever (until loop is stopped or cancelled)
await asyncio.Future()
def broadcast(typ: str, data) -> bool: def _user_can_see_host(user, host_name: str) -> bool:
"""Thread-safe broadcast helper. """Return True if *user* may see updates for *host_name* (manager or higher)."""
from . import hbdclass, users as users_mod
if user is None or not users_mod.users_enabled():
return True
if user.admin:
return True
host = hbdclass.Host.hosts.get(host_name)
if host is None:
return False
return host.is_manager(user.username)
Schedules coroutine(s) on the running loop to send message to all
connected websockets. Returns False if server was not running. def _get_token(request) -> str:
"""Extract session token from request (mirrors logic in http.py)."""
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
return auth[7:].strip()
token = request.headers.get("X-Auth-Token", "")
if token:
return token
return request.cookies.get("hbd_session", "")
async def handler(request):
"""aiohttp WebSocket upgrade handler — register as GET /ws."""
from aiohttp import web
from . import users as users_mod
ws = web.WebSocketResponse()
await ws.prepare(request)
token = _get_token(request)
user = users_mod.get_session_user(token) if token else None
_connections[ws] = user
remote = request.remote
logger.info("WebSocket connected from %s", remote)
try:
# Send current host state, filtered to hosts this user may see
if _get_hosts:
try:
for h in list(_get_hosts()):
host_name = h.get("raw_name") or h.get("name", "")
if _user_can_see_host(user, host_name):
await ws.send_str(json.dumps({"type": "host", "data": h}))
except Exception as e:
logger.error("Error sending initial hosts: %s", e)
# Send recent messages newest-first so the client can append them in
# display order without reordering on arrival (tagged history=True so
# the client knows to append rather than prepend).
if data.msgs:
try:
for m in reversed(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, "history": True}))
except Exception as e:
logger.error("Error sending initial messages: %s", e)
# Keep connection open, ignore incoming frames
async for msg in ws:
from aiohttp import WSMsgType
if msg.type == WSMsgType.TEXT:
if _verbose:
logger.debug("ws recv from %s: %s", remote, msg.data)
elif msg.type in (WSMsgType.ERROR, WSMsgType.CLOSE):
break
except Exception as e:
logger.exception("WebSocket handler error from %s: %s", remote, e)
finally:
_connections.pop(ws, None)
logger.info("WebSocket disconnected from %s", remote)
return ws
def broadcast(typ: str, payload) -> bool:
"""Thread-safe broadcast to all connected WebSocket clients.
For host and plugin updates, only sends to clients whose user has
manager-or-higher access to that host. Other message types are
broadcast to all clients.
Can be called from any thread; schedules sends on the event loop.
Returns False if the loop is not running yet.
""" """
if not _loop: if not _loop:
return False return False
jmsg = json.dumps({"type": typ, "data": data})
to_close = [] # Determine the host name for access-filtered message types
for ws in list(_connections): host_name: Optional[str] = None
if ws.state != websockets.protocol.State.OPEN: if typ in ("host", "plugin"):
to_close.append(ws) host_name = payload.get("raw_name") or payload.get("host") or payload.get("name")
continue elif typ == "message" and isinstance(payload, dict):
try: host_name = payload.get("host")
asyncio.run_coroutine_threadsafe(ws.send(jmsg), _loop)
except Exception: jmsg = json.dumps({"type": typ, "data": payload})
to_close.append(ws)
logger.debug("ws.send exception: closed") async def _send_all():
for ws in to_close: dead = set()
try: for ws, user in list(_connections.items()):
asyncio.run_coroutine_threadsafe(ws.wait_closed(), _loop) try:
except Exception: if ws.closed:
pass dead.add(ws)
if ws in _connections: continue
_connections.remove(ws) if host_name is not None and not _user_can_see_host(user, host_name):
continue
await ws.send_str(jmsg)
except Exception:
dead.add(ws)
for ws in dead:
_connections.pop(ws, None)
asyncio.run_coroutine_threadsafe(_send_all(), _loop)
return True return True
+28 -8
View File
@@ -4,20 +4,32 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "hbd" name = "hbd"
version = "5.0.8" version = "5.3.8"
description = "Heartbeat monitoring system — client (hbc) and server (hbd)" description = "Heartbeat monitoring system — client (hbc) and server (hbd)"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
license = "MIT"
keywords = ["heartbeat", "monitoring", "dns", "websocket", "system-monitoring"]
authors = [
{ name = "heartbeat contributors" }
]
# Core dependencies (required for both client and server)
dependencies = [ dependencies = [
"PyYAML>=6.0", "PyYAML>=6.0",
] ]
license = "MIT"
license-files = ["LICENSE.md"]
keywords = ["heartbeat", "monitoring", "dns", "websocket", "system-monitoring"]
authors = [
{ name = "Andreas Wrede" }
]
classifiers = [
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Operating System :: POSIX :: Linux",
"Operating System :: POSIX :: BSD",
"Topic :: System :: Monitoring",
"Topic :: System :: Networking :: Monitoring",
]
[project.urls]
Repository = "https://git.wrede.ca/andreas/heartbeat"
[project.optional-dependencies] [project.optional-dependencies]
# Client-only dependencies (hbc - system monitoring client) # Client-only dependencies (hbc - system monitoring client)
@@ -31,8 +43,13 @@ server = [
"mattermostdriver>=7.3.0", "mattermostdriver>=7.3.0",
"aiohttp>=3.11", "aiohttp>=3.11",
"Jinja2>=3.1.6", "Jinja2>=3.1.6",
"matrix-nio>=0.24",
"ruamel.yaml>=0.18",
] ]
# Minimal client — hbc_mini only, no external dependencies
mini = []
# Install both client and server # Install both client and server
all = [ all = [
"hbd[client,server]", "hbd[client,server]",
@@ -53,6 +70,9 @@ dev = [
hbd = "hbd.server.cli:main" hbd = "hbd.server.cli:main"
hbc = "hbd.client.main:main" hbc = "hbd.client.main:main"
[tool.setuptools]
script-files = ["scripts/hb_install.sh", "scripts/hbc_mini.py"]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["."] where = ["."]
include = ["hbd*"] include = ["hbd*"]
-4
View File
@@ -1,4 +0,0 @@
key "rndc-key" {
algorithm hmac-md5;
secret "qlGa+AYKtyOgWNuozqECMw==";
};
+40
View File
@@ -0,0 +1,40 @@
async def send_sms(hass, user, password, sender_did, call):
"""Send SMS message using multipart form-data like MMS."""
_LOGGER = logging.getLogger(__name__)
recipient = call.data.get("recipient")
message = call.data.get("message")
if not recipient or not message:
_LOGGER.error("Recipient or message missing.")
return
# Build form data dictionary
form_data = {
'api_username': str(user),
'api_password': str(password),
'did': str(sender_did),
'dst': str(recipient),
'message': str(message),
'method': 'sendSMS'
}
async with aiohttp.ClientSession() as session:
with aiohttp.MultipartWriter("form-data") as mp:
for key, value in form_data.items():
part = mp.append(value)
part.set_content_disposition('form-data', name=key)
_LOGGER.error("voipms_sms: sending SMS: %s", mp)
async with session.post(REST_ENDPOINT, data=mp) as response:
response_text = await response.text()
if response.status == 200:
response_json = json.loads(response_text)
if response_json['status'] == "success":
_LOGGER.info("voipms_sms: SMS sent successfully: %s", response_text)
else:
_LOGGER.error("voipms_sms: SMS not sent: %s", response_text)
else:
_LOGGER.error("voipms_sms: Failed to send SMS. Status: %s, Response: %s", response.status, response_text)
+3 -1
View File
@@ -4,12 +4,14 @@ set -e
uv version --bump patch uv version --bump patch
VER=$(uv version --short) VER=$(uv version --short)
sed -i".bak" "s/__version__ = \"[0-9.]*\"\(.*\)$/__version__ = \"$VER\"\1/" hbd/__init__.py sed -i".bak" "s/__version__ = \"[0-9.]*\"\(.*\)$/__version__ = \"$VER\"\1/" hbd/__init__.py
sed -i".bak" "s/__version__ = \"[0-9.]*\"\(.*\)$/__version__ = \"$VER\"\1/" scripts/hbc_mini.py
# commit pyproject.toml # commit pyproject.toml
git commit -m "version $VER" pyproject.toml hbd/__init__.py git commit -m "version $VER" pyproject.toml hbd/__init__.py scripts/hbc_mini.py
git push git push
# tag version # tag version
git tag -a v$VER -m "Version $VER" git tag -a v$VER -m "Version $VER"
git push --tags git push --tags
rm hbd/__init__.py.bak rm hbd/__init__.py.bak
rm scripts/hbc_mini.py.bak
+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
+1436
View File
File diff suppressed because it is too large Load Diff
+115
View File
@@ -0,0 +1,115 @@
#!/bin/sh
# Helper script to install the heartbeat tools. By default, it will only
# install the heartbeat client, hbc. The server is installed when the arg 'server' is passed
# to the script. The script will install the heartbeat tools in a python
# virtual environment in ~/venvs/hbd. The hbd and hbc commands will be
# installed from the wheel and symlinked to ~/bin/hbd and ~/bin/hbc,
# respectively. If the virtual environment already exists, it will be
# reused. The script will also remove any existing symlinks for hbd and hbc
# in ~/bin before creating new ones.
set -e
what=$1
on_ha=0
where=""
venv=""
[ "$2" = "HA" ] && on_ha=1
[ -z "$what" ] && what="client"
if [ -d /homeassistant ]; then # if running from HA command line
echo "HA, running \"docker exec homeassistant /config/bin/hb_install.sh $@\""
docker exec homeassistant /config/bin/hb_install.sh $@ HA
rc=$?
if [ $rc -ne 0 ]; then
echo "Failed to install heartbeat in HA, please check the logs for more details"
exit 1
fi
exit 0
fi
if [ $on_ha -eq 1 ] || [ -r /.dockerenv ] && [ -d /config/bin ]; then
# Installing under docker on Home Assistant OS, using /config/bin for executables and /config/venvs for virtual environments
echo "Home Assistant OS detected, installing under docker"
where="/config/bin"
venv="/config/venvs"
else
if [ ! -d $HOME/.local/bin ] && [ ! -d $HOME/bin ]; then
echo "No suitable bin directory found in PATH, please add either $HOME/.local/bin or $HOME/bin to your PATH"
exit 1
fi
for where in $HOME/bin $HOME/.local/bin notset ; do
if echo ":$PATH:" | grep -q ":$where:" ; then
break
fi
done
if [ "$where" = "notset" ]; then
echo "No suitable bin directory found in PATH, please add either $HOME/.local/bin or $HOME/bin to your PATH"
exit 1
fi
if [ "$what" = "mini" ]; then
venv=""
else
venv="$HOME/venvs"
fi
fi
echo "Installing $what to $where"
if [ ! -z "$venv" ]; then
echo "Using virtual environment at $venv/hbd"
fi
if [ "$venv" != "" ] && [ ! -d $venv/hbd ]; then
arg=""
have_pip=$(python3 -c "import pip" 2>/dev/null &> /dev/null && echo "Installed" || echo "Not Installed")
if [ "$have_pip" = "Not Installed" ]; then
# some systems do not have pip installed by default, so we need to fetch get-pip.py and install pip
echo "pip is not installed, fetching get-pip.py and installing pip"
arg="--without-pip"
fi
mkdir -p $venv
have_venv=$(python3 -c "import venv" 2>/dev/null &> /dev/null && echo "Installed" || echo "Not Installed")
if [ "$have_venv" = "Not Installed" ]; then
if [ "$have_pip" = "Not Installed" ]; then
echo "python has no venv, and no pip to install virtualenv, cannot continue"
exit 1
fi
echo "python venv module not found, installing virtualenv"
python3 -m pip install --user virtualenv
python3 -m virtualenv $venv/hbd --system-site-packages $arg
else
python3 -m venv $venv/hbd --system-site-packages $arg
fi
. $venv/hbd/bin/activate
if [ -n "$arg" ]; then
curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py && python3 get-pip.py
fi
deactivate
fi
if [ ! -z "$venv" ]; then
. $venv/hbd/bin/activate
fi
if [ "$what" = "mini" ]; then
curl -s -o $where/hbc_mini https://git.wrede.ca/andreas/heartbeat/raw/branch/master/scripts/hbc_mini.py
chmod +x $where/hbc_mini
else
python3 -mpip install --upgrade --index-url https://git.wrede.ca/api/packages/andreas/pypi/simple/ --extra-index-url https://pypi.org/simple hbd[$what]
fi
if [ ! -z "$venv" ]; then
echo "linking executables to $where"
if [ "$what" = "server" ]; then
rm -f $where/hbd
ln -sf $(which hbd) $where/hbd
elif [ "$what" = "client" ]; then
rm -f $where/hbc
ln -sf $(which hbc) $where/hbc
fi
rm -f $where/hb_install.sh
ln -sf $(which hb_install.sh) $where/hb_install.sh
fi
echo "Installation complete. To upgrade, run the following:"
echo " $where/hb_install.sh $what"
echo "To install on another machine, run the following obtain the install script and run it:"
echo "from https://git.wrede.ca/andreas/heartbeat/raw/branch/master/scripts/hb_install.sh"
echo "and then run sh hb_install.sh [mini|client]"
+1203
View File
File diff suppressed because it is too large Load Diff
-15
View File
@@ -1,15 +0,0 @@
#!/bin/sh
# install hbd/hbc from wheel and create symlinks for hbd and hbc in ~/bin
set -e
if [ ! -d ~/venvs/hbd ]; then
mkdir -p ~/venvs
python3 -m venv ~/venvs/hbd --system-site-packages
fi
. ~/venvs/hbd/bin/activate
pip install 'git+ssh://git@git.wrede.ca/andreas/heartbeat.git'
rm -f ~/bin/hbd
rm -f ~/bin/hbc
ln -sf $(which hbd) ~/bin/hbd
ln -sf $(which hbc) ~/bin/hbc
+1 -2
View File
@@ -68,8 +68,7 @@ async def test_nagios_runner():
print(f" ✓ Collected {len(data)} data points") print(f" ✓ Collected {len(data)} data points")
print(f"\n4. Results:") print(f"\n4. Results:")
print(f" Overall Status: {data.get('overall_status')} (code: {data.get('overall_status_code')})") print(f" Data points collected: {len(data)}")
print(f" Plugins Executed: {data.get('plugin_count')}")
# Show individual plugin results # Show individual plugin results
print(f"\n5. Individual Plugin Results:") print(f"\n5. Individual Plugin Results:")
+162
View File
@@ -0,0 +1,162 @@
import glob
import os
import pytest
from hbd.server import configio
SAMPLE_YAML = """\
# Server configuration
hbd_port: 50004 # HTTP API port
interval: 20
users:
alice:
full_name: Alice Smith
admin: true
notification_channels:
pushover_ops:
type: pushover
token: abc123
"""
def test_read_roundtrip_loads_values(tmp_path):
f = tmp_path / ".hb.yaml"
f.write_text(SAMPLE_YAML)
data = configio.read_roundtrip(str(f))
assert data["hbd_port"] == 50004
assert data["interval"] == 20
assert data["users"]["alice"]["full_name"] == "Alice Smith"
def test_write_config_creates_backup(tmp_path):
f = tmp_path / ".hb.yaml"
f.write_text(SAMPLE_YAML)
data = configio.read_roundtrip(str(f))
data["interval"] = 30
configio.write_config(str(f), data)
backups = configio.list_backups(str(f))
assert len(backups) == 1
assert ".bak." in backups[0]
def test_write_config_preserves_comments(tmp_path):
f = tmp_path / ".hb.yaml"
f.write_text(SAMPLE_YAML)
data = configio.read_roundtrip(str(f))
data["interval"] = 30
configio.write_config(str(f), data)
content = f.read_text()
assert "# Server configuration" in content
assert "# HTTP API port" in content
def test_write_config_atomically_replaces_file(tmp_path):
f = tmp_path / ".hb.yaml"
f.write_text(SAMPLE_YAML)
data = configio.read_roundtrip(str(f))
data["interval"] = 99
configio.write_config(str(f), data)
assert not (tmp_path / ".hb.yaml.tmp").exists()
data2 = configio.read_roundtrip(str(f))
assert data2["interval"] == 99
def test_write_config_backup_rotation(tmp_path):
cfg = tmp_path / ".hb.yaml"
cfg.write_text(SAMPLE_YAML)
# Pre-create 10 existing backups with old timestamps
for i in range(10):
(tmp_path / f".hb.yaml.bak.20260101-{i:06d}").write_text("old")
data = configio.read_roundtrip(str(cfg))
configio.write_config(str(cfg), data)
backups = configio.list_backups(str(cfg))
assert len(backups) == 10
assert not (tmp_path / ".hb.yaml.bak.20260101-000000").exists()
def test_list_backups_newest_first(tmp_path):
cfg = tmp_path / ".hb.yaml"
cfg.write_text(SAMPLE_YAML)
for i in range(3):
(tmp_path / f".hb.yaml.bak.20260101-{i:02d}0000").write_text("b")
backups = configio.list_backups(str(cfg))
assert len(backups) == 3
assert backups == sorted(backups, reverse=True)
def test_apply_structured_section_server_updates_keys(tmp_path):
f = tmp_path / ".hb.yaml"
f.write_text(SAMPLE_YAML)
data = configio.read_roundtrip(str(f))
configio.apply_structured_section(data, "server", {"interval": 60, "hbd_port": 8080})
assert data["interval"] == 60
assert data["hbd_port"] == 8080
def test_apply_structured_section_server_ignores_unknown_keys(tmp_path):
f = tmp_path / ".hb.yaml"
f.write_text(SAMPLE_YAML)
data = configio.read_roundtrip(str(f))
configio.apply_structured_section(data, "server", {"interval": 60, "not_a_key": "x"})
assert "not_a_key" not in data
def test_apply_structured_section_users_replaces_dict(tmp_path):
f = tmp_path / ".hb.yaml"
f.write_text(SAMPLE_YAML)
data = configio.read_roundtrip(str(f))
new_users = {"bob": {"full_name": "Bob Jones", "admin": False}}
configio.apply_structured_section(data, "users", new_users)
assert "alice" not in data["users"]
assert data["users"]["bob"]["full_name"] == "Bob Jones"
def test_apply_yaml_section_notification_channels(tmp_path):
f = tmp_path / ".hb.yaml"
f.write_text(SAMPLE_YAML)
data = configio.read_roundtrip(str(f))
new_yaml = "email_ops:\n type: email\n recipients: [ops@example.com]\n"
configio.apply_yaml_section(data, "notification_channels", new_yaml)
assert "email_ops" in data["notification_channels"]
assert "pushover_ops" not in data["notification_channels"]
def test_apply_yaml_section_thresholds_maps_to_threshold_configs(tmp_path):
f = tmp_path / ".hb.yaml"
f.write_text(SAMPLE_YAML)
data = configio.read_roundtrip(str(f))
configio.apply_yaml_section(data, "thresholds", "default:\n cpu: 80\n")
assert "threshold_configs" in data
assert data["threshold_configs"]["default"]["cpu"] == 80
def test_apply_yaml_section_dns_replaces_each_key(tmp_path):
f = tmp_path / ".hb.yaml"
f.write_text(SAMPLE_YAML)
data = configio.read_roundtrip(str(f))
configio.apply_yaml_section(
data, "dns",
"nsupdate_bin: /usr/bin/nsupdate\ndyndomains: [dyn.example.com]\n"
)
assert data["nsupdate_bin"] == "/usr/bin/nsupdate"
assert data["dyndomains"] == ["dyn.example.com"]
def test_apply_yaml_section_unknown_raises(tmp_path):
f = tmp_path / ".hb.yaml"
f.write_text(SAMPLE_YAML)
data = configio.read_roundtrip(str(f))
with pytest.raises(ValueError, match="Unknown YAML section"):
configio.apply_yaml_section(data, "nope", "x: 1\n")
def test_apply_structured_section_unknown_raises(tmp_path):
f = tmp_path / ".hb.yaml"
f.write_text(SAMPLE_YAML)
data = configio.read_roundtrip(str(f))
with pytest.raises(ValueError, match="Unknown structured section"):
configio.apply_structured_section(data, "nope", {"x": 1})
def test_read_roundtrip_missing_file_raises(tmp_path):
with pytest.raises(FileNotFoundError):
configio.read_roundtrip(str(tmp_path / "nonexistent.yaml"))
+1 -1
View File
@@ -20,7 +20,7 @@ def test_handle_cmd_sends_command():
import hbdclass import hbdclass
ctx = { ctx = {
"config": {"watchhosts": [], "dyndnshosts": []}, "config": {"watchhosts": []},
"hbdclass": hbdclass, "hbdclass": hbdclass,
"log": dummy_noop, "log": dummy_noop,
"email": dummy_noop, "email": dummy_noop,
+173
View File
@@ -0,0 +1,173 @@
"""Tests for the config read/write API helpers in http.py."""
import pytest
from hbd.server import http
def test_mask_config_for_api_masks_user_passwords():
config = {
"hbd_port": 50004,
"interval": 20,
"users": {
"alice": {"full_name": "Alice", "admin": True, "password": "pbkdf2:sha256:abc"},
},
"oauth": {},
}
result = http._mask_config_for_api(config)
assert result["users"]["alice"]["password"] == "•••"
assert result["users"]["alice"]["full_name"] == "Alice"
def test_mask_config_for_api_masks_oauth_client_secret():
config = {
"hbd_port": 50004,
"interval": 20,
"users": {},
"oauth": {
"gitea": {"type": "gitea", "url": "https://git.example.com",
"client_id": "cid", "client_secret": "verysecret"},
},
}
result = http._mask_config_for_api(config)
assert result["oauth"]["gitea"]["client_secret"] == "•••"
assert result["oauth"]["gitea"]["client_id"] == "cid"
def test_mask_config_for_api_includes_server_keys():
config = {"hbd_port": 50004, "interval": 20, "users": {}, "oauth": {}}
result = http._mask_config_for_api(config)
assert result["server"]["hbd_port"] == 50004
assert result["server"]["interval"] == 20
def test_mask_config_for_api_no_password_in_users_leaves_no_key():
config = {
"hbd_port": 50004,
"users": {"bob": {"full_name": "Bob", "admin": False}},
"oauth": {},
}
result = http._mask_config_for_api(config)
assert "password" not in result["users"]["bob"]
# ---- configio integration for write path ----
def test_write_path_applies_server_section(tmp_path):
cfg = tmp_path / ".hb.yaml"
cfg.write_text("hbd_port: 50004\ninterval: 20\nusers: {}\n")
from hbd.server import configio
data = configio.read_roundtrip(str(cfg))
configio.apply_structured_section(data, "server", {"interval": 60})
configio.write_config(str(cfg), data)
data2 = configio.read_roundtrip(str(cfg))
assert data2["interval"] == 60
assert data2["hbd_port"] == 50004 # unchanged
def test_write_path_applies_yaml_section(tmp_path):
cfg = tmp_path / ".hb.yaml"
cfg.write_text(
"hbd_port: 50004\nnotification_channels:\n old_ch:\n type: email\n"
)
from hbd.server import configio
data = configio.read_roundtrip(str(cfg))
configio.apply_yaml_section(data, "notification_channels", "new_ch:\n type: pushover\n")
configio.write_config(str(cfg), data)
data2 = configio.read_roundtrip(str(cfg))
assert "new_ch" in data2["notification_channels"]
assert "old_ch" not in data2["notification_channels"]
def test_write_path_hashes_plaintext_password(tmp_path):
cfg = tmp_path / ".hb.yaml"
cfg.write_text("hbd_port: 50004\nusers:\n alice:\n full_name: Alice\n admin: true\n password: pbkdf2:sha256:old\n")
from hbd.server import configio
from hbd.server import users as users_mod
data = configio.read_roundtrip(str(cfg))
# Simulate what the POST handler does: hash plaintext password
new_users = {"alice": {"full_name": "Alice", "admin": True, "password": "newplaintext"}}
for username, attrs in new_users.items():
pw = attrs.get("password", "")
if pw and not pw.startswith("pbkdf2:"):
attrs["password"] = users_mod.hash_password(pw)
configio.apply_structured_section(data, "users", new_users)
configio.write_config(str(cfg), data)
data2 = configio.read_roundtrip(str(cfg))
assert data2["users"]["alice"]["password"].startswith("pbkdf2:")
assert data2["users"]["alice"]["password"] != "newplaintext"
def test_rollback_restores_backup(tmp_path):
cfg = tmp_path / ".hb.yaml"
cfg.write_text("hbd_port: 50004\ninterval: 20\n")
from hbd.server import configio
# Make a change to create a backup
data = configio.read_roundtrip(str(cfg))
data["interval"] = 99
configio.write_config(str(cfg), data)
backups = configio.list_backups(str(cfg))
assert len(backups) == 1
# Read the backup and write it back (simulating rollback)
backup_data = configio.read_roundtrip(backups[0])
configio.write_config(str(cfg), backup_data)
restored = configio.read_roundtrip(str(cfg))
assert restored["interval"] == 20
def test_write_path_preserves_masked_password(tmp_path):
"""The "•••" sentinel must preserve the existing hash, not write "•••" to disk."""
cfg = tmp_path / ".hb.yaml"
original_hash = "pbkdf2:sha256:original_hash"
cfg.write_text(
f"hbd_port: 50004\nusers:\n alice:\n full_name: Alice\n admin: true\n password: {original_hash}\n"
)
from hbd.server import configio
from hbd.server import users as users_mod
data = configio.read_roundtrip(str(cfg))
# Simulate what api_config_post does when client sends "•••" back
existing_users = data.get("users") or {}
users_payload = {"alice": {"full_name": "Alice", "admin": True, "password": "•••"}}
for username, attrs in users_payload.items():
pw = attrs.get("password", "")
if pw and pw != "•••" and not pw.startswith("pbkdf2:"):
attrs["password"] = users_mod.hash_password(pw)
elif not pw or pw == "•••":
existing_hash = (existing_users.get(username) or {}).get("password", "")
if existing_hash:
attrs["password"] = existing_hash
else:
attrs.pop("password", None)
configio.apply_structured_section(data, "users", users_payload)
configio.write_config(str(cfg), data)
data2 = configio.read_roundtrip(str(cfg))
assert data2["users"]["alice"]["password"] == original_hash, (
f"Expected original hash preserved, got: {data2['users']['alice']['password']!r}"
)
def test_write_path_preserves_oauth_client_secret(tmp_path):
"""The "•••" sentinel for oauth client_secret must preserve the existing secret."""
cfg = tmp_path / ".hb.yaml"
original_secret = "real_client_secret_value"
cfg.write_text(
f"hbd_port: 50004\noauth:\n gitea:\n type: gitea\n url: https://git.example.com\n"
f" client_id: cid123\n client_secret: {original_secret}\n"
)
from hbd.server import configio
data = configio.read_roundtrip(str(cfg))
# Simulate what api_config_post does when client sends "•••" back for client_secret
existing_oauth = data.get("oauth") or {}
new_oauth = {"gitea": {"type": "gitea", "url": "https://git.example.com", "client_id": "cid123", "client_secret": "•••"}}
for name, attrs in new_oauth.items():
cs = attrs.get("client_secret", "")
if not cs or cs == "•••":
existing_cs = (existing_oauth.get(name) or {}).get("client_secret", "")
if existing_cs:
attrs["client_secret"] = existing_cs
else:
attrs.pop("client_secret", None)
data["oauth"] = new_oauth
configio.write_config(str(cfg), data)
data2 = configio.read_roundtrip(str(cfg))
assert data2["oauth"]["gitea"]["client_secret"] == original_secret, (
f"Expected original secret preserved, got: {data2['oauth']['gitea']['client_secret']!r}"
)
+174
View File
@@ -0,0 +1,174 @@
"""Tests for _build_host_info helper in http.py."""
import pytest
from unittest.mock import MagicMock
from hbd.server.http import _build_host_info
class _FakeConn:
def __init__(self, lastbeat):
self.lastbeat = lastbeat
class _FakeHost:
def __init__(self, name="myhost", owner=None, managers=None,
connections=None, os_data=None, plugin_data=None):
self.name = name
self.owner = owner
self.managers = managers or []
self.connections = connections or {}
self._os_data = os_data
self.plugin_data = plugin_data or {}
def get_latest_plugin_data(self, plugin_name):
if plugin_name == "os_info" and self._os_data is not None:
return (1234567890.0, self._os_data)
return None
def test_build_host_info_basic_fields():
host = _FakeHost(owner="alice", managers=["bob", "carol"])
result = _build_host_info(host)
assert result["owner"] == "alice"
assert result["managers"] == ["bob", "carol"]
assert result["hbc_version"] is None
assert result["hbc_type"] is None
assert result["last_packet"] is None
assert result["thresholds"] is None
def test_build_host_info_no_owner():
host = _FakeHost()
result = _build_host_info(host)
assert result["owner"] is None
assert result["managers"] == []
def test_build_host_info_reads_hbc_from_os_info():
host = _FakeHost(os_data={"hbc_version": "5.3.0", "hbc_type": "full"})
result = _build_host_info(host)
assert result["hbc_version"] == "5.3.0"
assert result["hbc_type"] == "full"
def test_build_host_info_hbc_none_when_no_os_info():
host = _FakeHost(os_data=None)
result = _build_host_info(host)
assert result["hbc_version"] is None
assert result["hbc_type"] is None
def test_build_host_info_last_packet_is_max_lastbeat():
host = _FakeHost(connections={
"IPv4": _FakeConn(1000.0),
"IPv6": _FakeConn(2000.0),
})
result = _build_host_info(host)
assert result["last_packet"] == 2000.0
def test_build_host_info_last_packet_none_when_no_connections():
host = _FakeHost(connections={})
result = _build_host_info(host)
assert result["last_packet"] is None
def test_build_host_info_thresholds_none_without_checker():
host = _FakeHost()
result = _build_host_info(host, threshold_checker=None)
assert result["thresholds"] is None
def test_build_host_info_thresholds_sorted_by_metric():
from hbd.server.threshold import ThresholdConfig
tc_cpu = ThresholdConfig("cpu_monitor.cpu_percent", warning=80.0, critical=95.0)
tc_mem = ThresholdConfig("memory_monitor.memory_percent", warning=85.0, critical=98.0)
checker = MagicMock()
checker.get_thresholds_for_host.return_value = {
"memory_monitor.memory_percent": tc_mem,
"cpu_monitor.cpu_percent": tc_cpu,
}
host = _FakeHost()
result = _build_host_info(host, threshold_checker=checker)
assert result["thresholds"] is not None
assert len(result["thresholds"]) == 2
assert result["thresholds"][0]["metric"] == "cpu_monitor.cpu_percent"
assert result["thresholds"][0]["warning"] == 80.0
assert result["thresholds"][0]["critical"] == 95.0
assert result["thresholds"][0]["operator"] == ">"
assert result["thresholds"][1]["metric"] == "memory_monitor.memory_percent"
def test_build_host_info_thresholds_empty_list_when_no_thresholds():
checker = MagicMock()
checker.get_thresholds_for_host.return_value = {}
host = _FakeHost()
result = _build_host_info(host, threshold_checker=checker)
assert result["thresholds"] == []
def test_build_host_info_threshold_null_warning_critical():
from hbd.server.threshold import ThresholdConfig
tc = ThresholdConfig("rtt.myhost", warning=None, critical=500.0)
checker = MagicMock()
checker.get_thresholds_for_host.return_value = {"rtt.myhost": tc}
host = _FakeHost()
result = _build_host_info(host, threshold_checker=checker)
assert result["thresholds"][0]["warning"] is None
assert result["thresholds"][0]["critical"] == 500.0
def test_build_host_info_nagios_operator_serialized():
from hbd.server.threshold import ThresholdConfig
tc = ThresholdConfig("nagios_runner.check_http", operator="nagios")
checker = MagicMock()
checker.get_thresholds_for_host.return_value = {"nagios_runner.check_http": tc}
host = _FakeHost()
result = _build_host_info(host, threshold_checker=checker)
assert result["thresholds"][0]["operator"] == "nagios"
def test_build_host_info_covers_suffix_matched_metrics():
"""memory_monitor.percent threshold covers swap_percent via suffix match."""
from hbd.server.threshold import ThresholdConfig
tc_pct = ThresholdConfig("memory_monitor.percent", warning=85.0, critical=95.0)
checker = MagicMock()
checker.get_thresholds_for_host.return_value = {"memory_monitor.percent": tc_pct}
host = _FakeHost(
connections={},
os_data=None,
)
# Simulate plugin_data with both percent and swap_percent fields
host.plugin_data = {
"memory_monitor": [(1234567890.0, {
"percent": 80.0,
"swap_percent": 25.0,
"available_mb": 2000,
})]
}
result = _build_host_info(host, threshold_checker=checker)
assert result["thresholds"] is not None
t = result["thresholds"][0]
assert t["metric"] == "memory_monitor.percent"
assert t["covers"] == ["memory_monitor.swap_percent"]
def test_build_host_info_covers_empty_when_exact_matches_only():
"""No covers when all plugin fields match their threshold exactly."""
from hbd.server.threshold import ThresholdConfig
tc_pct = ThresholdConfig("memory_monitor.percent", warning=85.0, critical=95.0)
checker = MagicMock()
checker.get_thresholds_for_host.return_value = {"memory_monitor.percent": tc_pct}
host = _FakeHost()
host.plugin_data = {
"memory_monitor": [(1234567890.0, {"percent": 80.0})]
}
result = _build_host_info(host, threshold_checker=checker)
t = result["thresholds"][0]
assert t["covers"] == []
+123
View File
@@ -0,0 +1,123 @@
"""Tests for PUT /api/0/users/me logic."""
import pytest
from hbd.server import users as users_mod
def test_hash_password_roundtrip():
h = users_mod.hash_password("mysecret")
assert h.startswith("pbkdf2:sha256:")
assert users_mod.authenticate.__doc__ is not None # module loaded
def test_password_change_requires_correct_current(tmp_path):
cfg = tmp_path / ".hb.yaml"
initial_hash = users_mod.hash_password("oldpass")
cfg.write_text(
f"hbd_port: 50004\nusers:\n alice:\n full_name: Alice\n admin: true\n password: {initial_hash}\n"
)
users_mod.load_users({"users": {"alice": {"full_name": "Alice", "admin": True, "password": initial_hash}}})
# Correct current password authenticates
assert users_mod.authenticate("alice", "oldpass") is not None
# Wrong current password does not authenticate
assert users_mod.authenticate("alice", "wrongpass") is None
def test_put_users_me_writes_new_fields(tmp_path):
"""Simulate the write path: read config, update user, write back."""
initial_hash = users_mod.hash_password("secret")
yaml_content = (
"hbd_port: 50004\n"
f"users:\n alice:\n full_name: Old Name\n admin: true\n password: {initial_hash}\n"
)
cfg = tmp_path / ".hb.yaml"
cfg.write_text(yaml_content)
from hbd.server import configio
data = configio.read_roundtrip(str(cfg))
# Simulate handler updating full_name and avatar
user_entry = dict(data["users"]["alice"])
user_entry["full_name"] = "New Name"
user_entry["avatar"] = "/img/alice.png"
data["users"]["alice"] = user_entry
configio.write_config(str(cfg), data)
result = configio.read_roundtrip(str(cfg))
assert result["users"]["alice"]["full_name"] == "New Name"
assert result["users"]["alice"]["avatar"] == "/img/alice.png"
assert result["users"]["alice"]["password"] == initial_hash # unchanged
def test_put_users_me_changes_password(tmp_path):
initial_hash = users_mod.hash_password("oldpass")
cfg = tmp_path / ".hb.yaml"
cfg.write_text(
f"hbd_port: 50004\nusers:\n alice:\n full_name: Alice\n password: {initial_hash}\n"
)
from hbd.server import configio
data = configio.read_roundtrip(str(cfg))
new_hash = users_mod.hash_password("newpass")
data["users"]["alice"]["password"] = new_hash
configio.write_config(str(cfg), data)
result = configio.read_roundtrip(str(cfg))
# Load users from new config and authenticate with new password
new_config = {"users": dict(result["users"])}
users_mod.load_users(new_config)
assert users_mod.authenticate("alice", "newpass") is not None
assert users_mod.authenticate("alice", "oldpass") is None
def test_put_users_me_notification_channels(tmp_path):
cfg = tmp_path / ".hb.yaml"
cfg.write_text(
"hbd_port: 50004\n"
"notification_channels:\n pushover_ops:\n type: pushover\n"
"users:\n alice:\n full_name: Alice\n notification_channels: []\n"
)
from hbd.server import configio
data = configio.read_roundtrip(str(cfg))
data["users"]["alice"]["notification_channels"] = ["pushover_ops"]
configio.write_config(str(cfg), data)
result = configio.read_roundtrip(str(cfg))
assert result["users"]["alice"]["notification_channels"] == ["pushover_ops"]
def test_visible_channels_excludes_private_from_others():
"""Private channels owned by another user must not appear in the visible set."""
from hbd.server import settings as settings_mod
config = {
"notification_channels": {
"public_ch": {"type": "pushover", "token": "t", "user": "u"},
"alice_priv": {"type": "email", "owner": "alice", "private": True,
"recipients": ["a@b.com"], "sender": "s@b.com", "smtp_server": "s"},
"bob_priv": {"type": "email", "owner": "bob", "private": True,
"recipients": ["b@b.com"], "sender": "s@b.com", "smtp_server": "s"},
}
}
class FakeUser:
def __init__(self, username, admin=False):
self.username = username
self.admin = admin
alice = FakeUser("alice")
bob = FakeUser("bob")
admin = FakeUser("admin", admin=True)
# Simulate _visible_channels_for_user logic (mirrors http.py implementation)
def visible(user):
all_channels = config.get("notification_channels") or {}
if user.admin:
return set(all_channels.keys())
return {
name for name, cfg in all_channels.items()
if not cfg.get("private") or cfg.get("owner") == user.username
}
assert visible(alice) == {"public_ch", "alice_priv"}
assert visible(bob) == {"public_ch", "bob_priv"}
assert visible(admin) == {"public_ch", "alice_priv", "bob_priv"}
+99
View File
@@ -0,0 +1,99 @@
import asyncio
import logging
import os
import stat
from hbd.client.plugins.nagios_runner import (
NagiosRunnerPlugin,
NAGIOS_OK,
NAGIOS_WARNING,
NAGIOS_CRITICAL,
NAGIOS_UNKNOWN,
)
def test_no_commands_sets_skip_reason():
plugin = NagiosRunnerPlugin(config={"commands": []})
result = asyncio.run(plugin.initialize())
assert result is False
assert plugin.skip_reason is not None
assert "nagios_runner.commands" in plugin.skip_reason
def test_stderr_used_when_stdout_empty(tmp_path):
script = tmp_path / "check_err.sh"
script.write_text("#!/bin/sh\necho 'error from stderr' >&2\nexit 2\n")
script.chmod(script.stat().st_mode | stat.S_IEXEC)
config = {"commands": [{"name": "t", "command": str(script)}], "timeout": 5}
plugin = NagiosRunnerPlugin(config=config)
asyncio.run(plugin.initialize())
data = asyncio.run(plugin._collect_metrics())
assert "error from stderr" in data["t_output"]
assert data["t_status_code"] == NAGIOS_CRITICAL
def test_stderr_appended_when_both_present(tmp_path):
script = tmp_path / "check_both.sh"
script.write_text("#!/bin/sh\necho 'OK - all good'\necho 'extra detail' >&2\nexit 0\n")
script.chmod(script.stat().st_mode | stat.S_IEXEC)
config = {"commands": [{"name": "t", "command": str(script)}], "timeout": 5}
plugin = NagiosRunnerPlugin(config=config)
asyncio.run(plugin.initialize())
data = asyncio.run(plugin._collect_metrics())
assert "OK - all good" in data["t_output"]
assert "extra detail" in data["t_output"]
assert data["t_status_code"] == NAGIOS_OK
def test_negative_returncode_maps_to_unknown():
# kill -9 $$ kills the shell itself; asyncio sees returncode -9
config = {"commands": [{"name": "t", "command": "kill -9 $$"}], "timeout": 5}
plugin = NagiosRunnerPlugin(config=config)
asyncio.run(plugin.initialize())
data = asyncio.run(plugin._collect_metrics())
assert data["t_status_code"] == NAGIOS_UNKNOWN
assert "signal" in data["t_output"].lower()
def test_absolute_path_not_found_warns(caplog):
fake_cmd = "/nonexistent_hbc_test_path/check_something"
config = {"commands": [{"name": "t", "command": fake_cmd}]}
plugin = NagiosRunnerPlugin(config=config)
with caplog.at_level(logging.WARNING, logger="plugin.nagios_runner"):
asyncio.run(plugin.initialize())
assert any("not found" in r.message for r in caplog.records)
def test_absolute_path_not_executable_warns(caplog, tmp_path):
non_exec = tmp_path / "check_test"
non_exec.write_text("#!/bin/sh\necho OK\n")
non_exec.chmod(0o644) # readable but not executable
config = {"commands": [{"name": "t", "command": str(non_exec)}]}
plugin = NagiosRunnerPlugin(config=config)
with caplog.at_level(logging.WARNING, logger="plugin.nagios_runner"):
asyncio.run(plugin.initialize())
assert any("not executable" in r.message for r in caplog.records)
def test_relative_path_not_checked(caplog):
# Relative paths (resolved via PATH) must not generate warnings
config = {"commands": [{"name": "t", "command": "echo OK"}]}
plugin = NagiosRunnerPlugin(config=config)
with caplog.at_level(logging.WARNING, logger="plugin.nagios_runner"):
asyncio.run(plugin.initialize())
assert not any(
"not found" in r.message or "not executable" in r.message
for r in caplog.records
)
+178
View File
@@ -0,0 +1,178 @@
"""Tests for notification channel CRUD via configio helpers and visibility logic."""
import pytest
from hbd.server import configio, settings as settings_mod
SAMPLE_YAML = """\
hbd_port: 50004
notification_channels:
pushover_ops:
type: pushover
token: abc123
user: usr456
"""
# ---------------------------------------------------------------------------
# configio helpers
# ---------------------------------------------------------------------------
def test_apply_channel_adds_new_entry(tmp_path):
f = tmp_path / ".hb.yaml"
f.write_text(SAMPLE_YAML)
data = configio.read_roundtrip(str(f))
configio.apply_channel(data, "email_ops", {"type": "email", "recipients": ["ops@example.com"]})
assert "email_ops" in data["notification_channels"]
assert data["notification_channels"]["email_ops"]["type"] == "email"
# Existing channel preserved
assert "pushover_ops" in data["notification_channels"]
def test_apply_channel_updates_existing(tmp_path):
f = tmp_path / ".hb.yaml"
f.write_text(SAMPLE_YAML)
data = configio.read_roundtrip(str(f))
configio.apply_channel(data, "pushover_ops", {"type": "pushover", "token": "new_tok", "user": "new_usr"})
assert data["notification_channels"]["pushover_ops"]["token"] == "new_tok"
def test_apply_channel_creates_section_if_absent():
data = {"hbd_port": 50004}
configio.apply_channel(data, "test_ch", {"type": "pushover", "token": "t", "user": "u"})
assert "notification_channels" in data
assert "test_ch" in data["notification_channels"]
def test_delete_channel_removes_entry(tmp_path):
f = tmp_path / ".hb.yaml"
f.write_text(SAMPLE_YAML)
data = configio.read_roundtrip(str(f))
configio.delete_channel(data, "pushover_ops")
assert "pushover_ops" not in data["notification_channels"]
def test_delete_channel_noop_for_missing():
data = {"notification_channels": {"ch1": {"type": "pushover"}}}
configio.delete_channel(data, "nonexistent") # must not raise
assert "ch1" in data["notification_channels"]
def test_delete_channel_noop_when_no_section():
data = {}
configio.delete_channel(data, "anything") # must not raise
def test_apply_channel_persisted_after_write(tmp_path):
f = tmp_path / ".hb.yaml"
f.write_text(SAMPLE_YAML)
data = configio.read_roundtrip(str(f))
configio.apply_channel(data, "signal_ops", {"type": "signal", "user": "+1", "recipient": "+2"})
configio.write_config(str(f), data)
result = configio.read_roundtrip(str(f))
assert "signal_ops" in result["notification_channels"]
assert result["notification_channels"]["signal_ops"]["user"] == "+1"
# Original channel preserved
assert "pushover_ops" in result["notification_channels"]
def test_delete_channel_persisted_after_write(tmp_path):
f = tmp_path / ".hb.yaml"
f.write_text(SAMPLE_YAML)
data = configio.read_roundtrip(str(f))
configio.delete_channel(data, "pushover_ops")
configio.write_config(str(f), data)
result = configio.read_roundtrip(str(f))
assert "pushover_ops" not in (result.get("notification_channels") or {})
# ---------------------------------------------------------------------------
# Visibility logic (mirrors http.py _visible_channels_for_user)
# ---------------------------------------------------------------------------
def _visible(config, user):
"""Local copy of the visibility helper for unit testing without the HTTP layer."""
all_channels = config.get("notification_channels") or {}
if user.get("admin"):
return set(all_channels.keys())
username = user["username"]
return {
name for name, cfg in all_channels.items()
if isinstance(cfg, dict) and (not cfg.get("private") or cfg.get("owner") == username)
}
CONFIG_VISIBILITY = {
"notification_channels": {
"pub_ch": {"type": "pushover", "token": "t", "user": "u"},
"alice_priv": {"type": "email", "owner": "alice", "private": True,
"recipients": ["a@a.com"], "sender": "s@a.com", "smtp_server": "s"},
"bob_priv": {"type": "signal", "owner": "bob", "private": True,
"user": "+1", "recipient": "+2"},
"admin_owned": {"type": "pushover", "token": "t2", "user": "u2", "owner": "adminuser"},
}
}
def test_public_channel_visible_to_all():
for uname in ("alice", "bob", "carol"):
user = {"username": uname, "admin": False}
assert "pub_ch" in _visible(CONFIG_VISIBILITY, user)
def test_private_channel_visible_only_to_owner():
alice = {"username": "alice", "admin": False}
bob = {"username": "bob", "admin": False}
carol = {"username": "carol", "admin": False}
assert "alice_priv" in _visible(CONFIG_VISIBILITY, alice)
assert "alice_priv" not in _visible(CONFIG_VISIBILITY, bob)
assert "alice_priv" not in _visible(CONFIG_VISIBILITY, carol)
assert "bob_priv" in _visible(CONFIG_VISIBILITY, bob)
assert "bob_priv" not in _visible(CONFIG_VISIBILITY, alice)
def test_admin_sees_all_channels():
admin = {"username": "adminuser", "admin": True}
visible = _visible(CONFIG_VISIBILITY, admin)
assert visible == {"pub_ch", "alice_priv", "bob_priv", "admin_owned"}
def test_admin_owned_channel_is_public_by_default():
alice = {"username": "alice", "admin": False}
assert "admin_owned" in _visible(CONFIG_VISIBILITY, alice)
# ---------------------------------------------------------------------------
# Channel type schemas
# ---------------------------------------------------------------------------
def test_all_required_types_in_schema():
for t in ("pushover", "email", "signal", "matrix", "sms_voipms"):
assert t in settings_mod.CHANNEL_TYPE_SCHEMAS
def test_schema_fields_have_required_keys():
for type_id, schema in settings_mod.CHANNEL_TYPE_SCHEMAS.items():
assert "label" in schema, f"{type_id} missing label"
assert "fields" in schema, f"{type_id} missing fields"
for f in schema["fields"]:
for k in ("key", "label", "type", "required"):
assert k in f, f"{type_id} field missing {k!r}"
def test_secret_fields_use_secret_type():
"""Known secret fields must be typed 'secret' so the UI masks them."""
secret_keys = {"token", "user_key", "api_key", "api_password",
"smtp_password", "access_token"}
for type_id, schema in settings_mod.CHANNEL_TYPE_SCHEMAS.items():
for f in schema["fields"]:
if f["key"] in secret_keys:
assert f["type"] == "secret", (
f"{type_id}.{f['key']} should be type 'secret'"
)
def test_channel_labels_not_empty():
for type_id, schema in settings_mod.CHANNEL_TYPE_SCHEMAS.items():
assert schema["label"].strip(), f"{type_id} has empty label"
+602
View File
@@ -0,0 +1,602 @@
import logging
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_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
# ---------------------------------------------------------------------------
# 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."""
p = _gitea_provider()
redirect_uri = "https://hbd.example.com/login/oauth/gitea/callback"
state = oauth.make_state()
assert oauth.validate_state(state) is True
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(p, "authcode", redirect_uri)
profile = await oauth.fetch_user(p, token)
assert token == "flow_token"
assert profile["login"] == "flowuser"
_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
# ---------------------------------------------------------------------------
# get_providers()
# ---------------------------------------------------------------------------
CFG_GITHUB = {
"oauth": {
"github": {"type": "github", "client_id": "ghid", "client_secret": "ghs"},
}
}
CFG_NEXTCLOUD = {
"oauth": {
"nc": {
"type": "nextcloud",
"url": "https://nc.example.com",
"client_id": "ncid",
"client_secret": "ncs",
}
}
}
CFG_MULTI = {
"oauth": {
"mygitea": {
"type": "gitea",
"url": "https://git.example.com",
"client_id": "cid",
"client_secret": "cs",
"label": "Work Gitea",
"logo": "https://example.com/logo.png",
},
"github": {"type": "github", "client_id": "ghid", "client_secret": "ghs"},
"nc": {
"type": "nextcloud",
"url": "https://nc.example.com",
"client_id": "ncid",
"client_secret": "ncs",
},
}
}
def test_get_providers_backward_compat_no_type_field():
"""Old config without 'type' defaults to gitea."""
providers = oauth.get_providers(CFG_ON)
assert len(providers) == 1
p = providers[0]
assert p.name == "gitea"
assert p.type == "gitea"
assert p.label == "Gitea"
assert p.client_id == "cid"
assert p.authorize_url == "https://git.example.com/login/oauth/authorize"
assert p.token_url == "https://git.example.com/login/oauth/access_token"
assert p.profile_url == "https://git.example.com/api/v1/user"
assert p.scope == "user:email"
assert p.profile_data_path == []
def test_get_providers_multiple():
providers = oauth.get_providers(CFG_MULTI)
assert len(providers) == 3
names = [p.name for p in providers]
assert "mygitea" in names
assert "github" in names
assert "nc" in names
def test_get_providers_custom_label_and_logo():
providers = oauth.get_providers(CFG_MULTI)
gitea = next(p for p in providers if p.name == "mygitea")
assert gitea.label == "Work Gitea"
assert gitea.logo == "https://example.com/logo.png"
def test_get_providers_github_default_label():
providers = oauth.get_providers(CFG_GITHUB)
assert providers[0].label == "GitHub"
assert providers[0].logo == ""
def test_get_providers_github_fixed_urls():
providers = oauth.get_providers(CFG_GITHUB)
p = providers[0]
assert p.authorize_url == "https://github.com/login/oauth/authorize"
assert p.token_url == "https://github.com/login/oauth/access_token"
assert p.profile_url == "https://api.github.com/user"
assert p.scope == "read:user"
def test_get_providers_nextcloud_urls_and_path():
providers = oauth.get_providers(CFG_NEXTCLOUD)
p = providers[0]
assert p.authorize_url == "https://nc.example.com/apps/oauth2/authorize"
assert p.token_url == "https://nc.example.com/apps/oauth2/api/v1/token"
assert p.profile_url == "https://nc.example.com/ocs/v2.php/cloud/user?format=json"
assert p.profile_data_path == ["ocs", "data"]
assert p.scope == ""
def test_get_providers_skips_missing_client_id(caplog):
cfg = {"oauth": {"gitea": {"url": "https://git.example.com", "client_secret": "cs"}}}
with caplog.at_level(logging.WARNING, logger="hbd.server.oauth"):
result = oauth.get_providers(cfg)
assert result == []
assert "missing" in caplog.text.lower()
def test_get_providers_skips_missing_client_secret(caplog):
cfg = {"oauth": {"gitea": {"url": "https://git.example.com", "client_id": "cid"}}}
with caplog.at_level(logging.WARNING, logger="hbd.server.oauth"):
result = oauth.get_providers(cfg)
assert result == []
assert "missing" in caplog.text.lower()
def test_get_providers_skips_missing_url_for_gitea(caplog):
cfg = {"oauth": {"gitea": {"type": "gitea", "client_id": "cid", "client_secret": "cs"}}}
with caplog.at_level(logging.WARNING, logger="hbd.server.oauth"):
result = oauth.get_providers(cfg)
assert result == []
assert "url" in caplog.text.lower()
def test_get_providers_skips_missing_url_for_nextcloud(caplog):
cfg = {"oauth": {"nc": {"type": "nextcloud", "client_id": "cid", "client_secret": "cs"}}}
with caplog.at_level(logging.WARNING, logger="hbd.server.oauth"):
result = oauth.get_providers(cfg)
assert result == []
assert "url" in caplog.text.lower()
def test_get_providers_github_no_url_required():
providers = oauth.get_providers(CFG_GITHUB)
assert len(providers) == 1
def test_get_providers_skips_unknown_type(caplog):
cfg = {"oauth": {"mystery": {"type": "saml", "client_id": "cid", "client_secret": "cs"}}}
import logging
with caplog.at_level(logging.WARNING, logger="hbd.server.oauth"):
result = oauth.get_providers(cfg)
assert result == []
assert "saml" in caplog.text
def test_get_providers_empty_config():
assert oauth.get_providers({}) == []
assert oauth.get_providers(CFG_OFF) == []
# ---------------------------------------------------------------------------
# build_auth_url / exchange_code / fetch_user (generic, ResolvedProvider-based)
# ---------------------------------------------------------------------------
def _gitea_provider() -> oauth.ResolvedProvider:
return oauth.get_providers(CFG_ON)[0]
def _github_provider() -> oauth.ResolvedProvider:
return oauth.get_providers(CFG_GITHUB)[0]
def _nextcloud_provider() -> oauth.ResolvedProvider:
return oauth.get_providers(CFG_NEXTCLOUD)[0]
def test_build_auth_url_gitea():
p = _gitea_provider()
url = oauth.build_auth_url(p, "teststate", "https://hbd.example.com/login/oauth/gitea/callback")
parsed = urlparse(url)
qs = parse_qs(parsed.query)
assert parsed.netloc == "git.example.com"
assert parsed.path == "/login/oauth/authorize"
assert qs["client_id"] == ["cid"]
assert qs["state"] == ["teststate"]
assert qs["scope"] == ["user:email"]
assert qs["response_type"] == ["code"]
assert qs["redirect_uri"] == ["https://hbd.example.com/login/oauth/gitea/callback"]
def test_build_auth_url_github():
p = _github_provider()
url = oauth.build_auth_url(p, "st", "https://hbd.example.com/login/oauth/github/callback")
parsed = urlparse(url)
qs = parse_qs(parsed.query)
assert parsed.netloc == "github.com"
assert qs["scope"] == ["read:user"]
def test_build_auth_url_nextcloud_no_scope_param():
"""Nextcloud scope is empty — the 'scope' key must be absent from the URL."""
p = _nextcloud_provider()
url = oauth.build_auth_url(p, "st", "https://hbd.example.com/login/oauth/nc/callback")
qs = parse_qs(urlparse(url).query)
assert "scope" not in qs
@pytest.mark.asyncio
async def test_exchange_code_generic_returns_token():
p = _gitea_provider()
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(p, "mycode", redirect_uri)
assert token == "tok123"
@pytest.mark.asyncio
async def test_exchange_code_sends_accept_json():
"""Accept: application/json must be present for all providers (required by GitHub)."""
p = _github_provider()
captured_headers = {}
mock_response = AsyncMock()
mock_response.status = 200
mock_response.json = AsyncMock(return_value={"access_token": "ghtoken"})
mock_session = MagicMock()
def capture_post(url, **kwargs):
captured_headers.update(kwargs.get("headers", {}))
return AsyncMock(
__aenter__=AsyncMock(return_value=mock_response),
__aexit__=AsyncMock(return_value=False),
)
mock_session.post = capture_post
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_session),
__aexit__=AsyncMock(return_value=False),
)):
await oauth.exchange_code(p, "code", "https://hbd.example.com/login/oauth/github/callback")
assert captured_headers.get("Accept") == "application/json"
@pytest.mark.asyncio
async def test_exchange_code_raises_on_error_status():
p = _gitea_provider()
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(p, "badcode", "https://hbd.example.com/login/oauth/gitea/callback")
@pytest.mark.asyncio
async def test_exchange_code_raises_when_no_access_token():
p = _gitea_provider()
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(p, "mycode", "https://hbd.example.com/login/oauth/gitea/callback")
@pytest.mark.asyncio
async def test_fetch_user_gitea_returns_profile():
p = _gitea_provider()
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(p, "tok123")
assert profile == {
"login": "alice",
"full_name": "Alice Smith",
"avatar_url": "https://git.example.com/avatars/alice.png",
}
@pytest.mark.asyncio
async def test_fetch_user_github_maps_name_field():
p = _github_provider()
mock_response = AsyncMock()
mock_response.status = 200
mock_response.json = AsyncMock(return_value={
"login": "bobgh",
"name": "Bob GitHub",
"avatar_url": "https://avatars.githubusercontent.com/u/1",
})
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(p, "ghtoken")
assert profile["login"] == "bobgh"
assert profile["full_name"] == "Bob GitHub"
assert profile["avatar_url"] == "https://avatars.githubusercontent.com/u/1"
@pytest.mark.asyncio
async def test_fetch_user_nextcloud_nested_extraction():
"""Nextcloud profile is nested under ocs.data; avatar is absent."""
p = _nextcloud_provider()
mock_response = AsyncMock()
mock_response.status = 200
mock_response.json = AsyncMock(return_value={
"ocs": {
"meta": {"status": "ok", "statuscode": 200},
"data": {
"id": "ncuser",
"display-name": "NC User",
"email": "nc@example.com",
},
}
})
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(p, "nctoken")
assert profile["login"] == "ncuser"
assert profile["full_name"] == "NC User"
assert profile["avatar_url"] == "" # Nextcloud has no avatar field
@pytest.mark.asyncio
async def test_fetch_user_raises_on_error_status():
p = _gitea_provider()
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(p, "badtoken")
def test_is_enabled_with_valid_provider():
assert oauth.is_enabled(CFG_ON) is True
def test_is_enabled_false_when_no_providers():
assert oauth.is_enabled(CFG_OFF) is False
def test_is_enabled_false_partial_config():
assert oauth.is_enabled(CFG_PARTIAL) is False
+83
View File
@@ -0,0 +1,83 @@
import asyncio
import logging
import textwrap
from hbd.client.plugin import PluginLoader, PluginRegistry
def test_plugin_skip_reason_defaults_none(tmp_path):
plugin_code = textwrap.dedent("""
from hbd.client.plugin import MonitorPlugin
class MinimalPlugin(MonitorPlugin):
name = "minimal"
version = "1.0.0"
interval = 60
async def initialize(self):
return True
async def _collect_metrics(self):
return {}
""")
(tmp_path / "minimal.py").write_text(plugin_code)
registry = PluginRegistry()
loader = PluginLoader(registry)
asyncio.run(loader.load_from_directory(tmp_path))
plugin = registry.get("minimal")
assert plugin is not None
assert plugin.skip_reason is None
def test_loader_logs_info_when_skip_reason_set(tmp_path, caplog):
plugin_code = textwrap.dedent("""
from hbd.client.plugin import MonitorPlugin
class SkippablePlugin(MonitorPlugin):
name = "skippable"
version = "1.0.0"
interval = 60
async def initialize(self):
self.skip_reason = "not configured in yaml"
return False
async def _collect_metrics(self):
return {}
""")
(tmp_path / "skippable.py").write_text(plugin_code)
registry = PluginRegistry()
loader = PluginLoader(registry)
with caplog.at_level(logging.INFO, logger="plugin.loader"):
count = asyncio.run(loader.load_from_directory(tmp_path))
assert count == 0
assert any("skipped: not configured in yaml" in r.message for r in caplog.records)
assert not any("failed initialization" in r.message for r in caplog.records)
def test_loader_logs_warning_when_no_skip_reason(tmp_path, caplog):
plugin_code = textwrap.dedent("""
from hbd.client.plugin import MonitorPlugin
class FailPlugin(MonitorPlugin):
name = "fail"
version = "1.0.0"
interval = 60
async def initialize(self):
return False
async def _collect_metrics(self):
return {}
""")
(tmp_path / "fail_plugin.py").write_text(plugin_code)
registry = PluginRegistry()
loader = PluginLoader(registry)
with caplog.at_level(logging.WARNING, logger="plugin.loader"):
count = asyncio.run(loader.load_from_directory(tmp_path))
assert count == 0
assert any("failed initialization" in r.message for r in caplog.records)
+114
View File
@@ -0,0 +1,114 @@
import pytest
from hbd.server import settings as settings_mod
CFG = {
"hbd_port": 50004,
"interval": 20,
"grace": 2,
"users": {
"alice": {"full_name": "Alice Smith", "admin": True, "password": "pbkdf2:sha256:abc",
"notification_channels": ["pushover_ops"]},
},
"oauth": {
"gitea": {"type": "gitea", "url": "https://git.example.com",
"client_id": "cid", "client_secret": "csec", "label": "Sign in with Gitea"},
},
"notification_channels": {
"pushover_ops": {"type": "pushover", "token": "tok", "user": "usr"},
},
"hosts": {},
}
def test_sections_have_section_mode():
sections = settings_mod.get_settings_sections(CFG)
for s in sections:
assert "section_mode" in s, f"Section {s['id']} missing section_mode"
assert s["section_mode"] in ("form", "yaml", "channels", "hosts")
def test_sections_have_api_section():
sections = settings_mod.get_settings_sections(CFG)
for s in sections:
assert "api_section" in s, f"Section {s['id']} missing api_section"
def test_network_section_has_editable_fields():
sections = settings_mod.get_settings_sections(CFG)
network = next(s for s in sections if s["id"] == "network")
assert network["section_mode"] == "form"
assert network["api_section"] == "server"
editable = [f for f in network["fields"] if f["editable"]]
assert len(editable) >= 2 # hbd_port, ws_port at minimum
def test_yaml_sections_have_correct_mode():
sections = settings_mod.get_settings_sections(CFG)
yaml_sections = {s["id"]: s for s in sections if s["section_mode"] == "yaml"}
assert "channels" not in yaml_sections # now uses "channels" mode
assert "hosts" not in yaml_sections # now uses "hosts" mode
assert "thresholds" in yaml_sections
assert "dns" in yaml_sections
assert yaml_sections["thresholds"]["api_section"] == "thresholds"
assert yaml_sections["dns"]["api_section"] == "dns"
def test_hosts_section_uses_hosts_mode():
sections = settings_mod.get_settings_sections(CFG)
hosts_sec = next(s for s in sections if s["id"] == "hosts")
assert hosts_sec["section_mode"] == "hosts"
assert hosts_sec["api_section"] == "hosts"
def test_channels_section_uses_channels_mode():
sections = settings_mod.get_settings_sections(CFG)
ch_sec = next(s for s in sections if s["id"] == "channels")
assert ch_sec["section_mode"] == "channels"
assert ch_sec["api_section"] == "notification_channels"
assert len(ch_sec["channels"]) == 1
ch = ch_sec["channels"][0]
assert ch["name"] == "pushover_ops"
assert ch["type"] == "pushover"
assert "owner" in ch
assert "private" in ch
def test_channel_type_schemas_exported():
assert hasattr(settings_mod, "CHANNEL_TYPE_SCHEMAS")
for required_type in ("pushover", "email", "signal", "matrix", "sms_voipms"):
assert required_type in settings_mod.CHANNEL_TYPE_SCHEMAS
schema = settings_mod.CHANNEL_TYPE_SCHEMAS[required_type]
assert "label" in schema
assert "fields" in schema
for f in schema["fields"]:
assert "key" in f
assert "type" in f
assert "required" in f
def test_oauth_section_exists():
sections = settings_mod.get_settings_sections(CFG)
oauth = next((s for s in sections if s["id"] == "oauth"), None)
assert oauth is not None
assert oauth["section_mode"] == "form"
assert oauth["api_section"] == "oauth"
assert len(oauth["providers"]) == 1
assert oauth["providers"][0]["name"] == "gitea"
assert oauth["providers"][0]["client_secret"] == "•••"
def test_all_channel_names_returned():
result = settings_mod.get_settings_data(CFG)
assert "all_channel_names" in result
assert "pushover_ops" in result["all_channel_names"]
def test_users_section_has_user_list():
sections = settings_mod.get_settings_sections(CFG)
users_sec = next(s for s in sections if s["id"] == "users")
assert users_sec["section_mode"] == "form"
assert users_sec["api_section"] == "users"
assert len(users_sec["users"]) == 1
assert users_sec["users"][0]["username"] == "alice"
# Password hash never exposed
assert "password" not in users_sec["users"][0]
+1 -2
View File
@@ -1,9 +1,8 @@
[tox] [tox]
envlist = py, lint, mypy envlist = py, lint, mypy
skipsdist = True
[testenv] [testenv]
deps = -rrequirements-dev.txt extras = dev
commands = commands =
pytest -q pytest -q