Compare commits

...
269 Commits
Author SHA1 Message Date
andreas 13f9916693 version 5.4.7
Release / release (push) Successful in 46s
2026-10-28 05:11:04 -04:00
andreasandClaude Sonnet 5.5 80b830fb07 chore: match black's line length to flake8 (165)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 05:11:03 -04:00
andreasandClaude Sonnet 5.5 6790ff53a8 chore: raise the flake8 line-length limit to 165
The longest existing line is 164 characters; flake8 is now clean.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 05:09:47 -04:00
andreasandClaude Sonnet 5.5 445ff6b474 fix: clear all mypy errors
Annotate untyped containers and Optional attributes, type the stdlib-shaped
metric dicts in the client plugins, and add types-PyYAML/types-psutil to the
dev extras plus a mypy override for nio/mattermostdriver (no stubs).

Behaviour fixes found on the way: enable_kernel_timestamps() now returns False
when SO_TIMESTAMP is unknown instead of raising TypeError, send_shutdown is
defined at module level so cleanup() cannot hit a NameError, and a failed
subprocess returncode falls back to UNKNOWN.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 05:04:54 -04:00
andreasandClaude Sonnet 5.5 748349a7b9 style: clear whitespace, unused-import and blank-line lint
Strip trailing whitespace, drop unused imports/variables/global statements,
split multi-imports, fix E3xx/E1xx spacing and E712 comparisons. No behaviour
change; only E501 long lines remain in flake8.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 04:59:34 -04:00
andreasandClaude Sonnet 5.5 85c6a4eebe fix(server): honour the legacy thresholds: config; repair the stale test suite
Since the built-in defaults were seeded into threshold_configs, the legacy
top-level thresholds: format was silently ignored. get_thresholds_for_host
now uses it whenever it was configured.

Tests: point imports at hbd.server/hbd.common, rewrite the DNS worker,
proto, datagram and threshold tests for the current API, add pytest-asyncio
to the dev extras and set testpaths so a bare pytest runs cleanly.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 04:53:43 -04:00
andreasandClaude Sonnet 5.5 952adb68f8 fix(server): ignore datagrams that are not named HTB/PLG messages
A scanner's SIP INVITE on the public UDP port was parsed as a message without
a name and signed on a host called "unknown". Non-client datagrams are still
journaled but no longer create hosts or get an ACK.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 04:46:40 -04:00
andreas 3e2d343fe7 version 5.4.6
Release / release (push) Successful in 46s
2026-10-28 04:25:06 -04:00
andreasandClaude Sonnet 5.5 885d5dc937 feat(client): re-resolve the server hostnames at the DNS TTL
hbc, hbc_mini.py and hbc_mini.c resolved the server once at startup, so an
address change needed a client restart. They now re-resolve every TTL seconds
(smallest A/AAAA TTL, floor 30s, 300s when unknown) and re-point, add or drop
connections to follow the new addresses; a failed lookup keeps the current ones.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 04:23:55 -04:00
andreasandClaude Sonnet 5 e8d9c4a958 docs: complete the 5.4.5 changelog entry
Release / release (push) Successful in 44s
The release script dropped every scoped feat:/fix: commit from the
generated entry (fixed in the prior commit); this backfills the mini
client presence feature and both C-client fixes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uc9V1v7iXruubpoLiSE24M
2026-09-22 05:48:03 -04:00
andreasandClaude Sonnet 5 328f248c43 fix: bumpminor.sh changelog grep drops scoped feat/fix commits
The grep/sed only matched unscoped conventional-commit subjects
("feat: ...", "fix: ..."), so every scoped commit ("feat(server): ...")
was silently dropped from the generated changelog entry. Both 5.4.4 and
5.4.5 needed a manual follow-up fix for this. Broaden the pattern to
accept an optional (scope) and drop it from the changelog line.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uc9V1v7iXruubpoLiSE24M
2026-09-22 05:47:52 -04:00
andreas 747a8c27c9 version 5.4.5
Release / release (push) Successful in 45s
2026-09-22 05:26:12 -04:00
andreasandClaude Sonnet 5 5243f0a834 fix: shutdown ordering, silent probe failures, and unused import in the mini clients
- hbc_mini.c: move the probe-thread pthread_join to after the shutdown HTB send,
  so the clean-shutdown notification is no longer delayed behind (and possibly
  lost to a SIGKILL racing) a probe thread blocked in popen()/ping.
- hbc_mini.c: log a LOGW when pthread_create() fails to start the probe thread,
  and when probe_v4/probe_v6 are not IP literals (probe_init), so both previously
  silent no-op failure modes are now diagnosable. Diagnostic only — no behavior
  change to the PROBE_UNKNOWN fallback path.
- hbc_mini.py: remove the unused 'import struct' (F401).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uc9V1v7iXruubpoLiSE24M
2026-09-22 05:16:36 -04:00
andreasandClaude Sonnet 5 3c6f7fc868 docs: document the connectivity probe config for hbc_mini.py and hbc_mini.c
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uc9V1v7iXruubpoLiSE24M
2026-09-22 05:01:42 -04:00
andreasandClaude Sonnet 5 adbd2f9b46 feat(mini-c): send p4/p6 presence flags and connectivity metric from hbc_mini.c
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uc9V1v7iXruubpoLiSE24M
2026-09-22 04:50:17 -04:00
andreasandClaude Sonnet 5 248b17edca feat(mini-c): connectivity probe core with unit tests
Adds the pure/core probe logic for the standalone C client (hbc_mini.c):
gateway text parsers for /proc/net/route, /proc/net/ipv6_route and BSD
`route -n get`, a ping-output RTT parser, and the probe_step state machine
(probe_t/probe_fam_t/probe_ops_t) plus probe_flags and probe_metric_json.
config_t gains probe_enabled/probe_interval/probe_v4/probe_v6.

Temporary stub functions (sys_local_address, sys_ping, sys_gateway,
mono_ts) stand in for real syscalls until Task 4 wires them into main().

New scripts/c/test_probe.c test harness (#include's hbc_mini.c with main
renamed out of the way) plus a `make test` target; scripts/c/.gitignore
now ignores the test_probe binary.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uc9V1v7iXruubpoLiSE24M
2026-09-22 04:38:42 -04:00
andreasandClaude Sonnet 5 3639464ba6 feat(mini): send p4/p6 presence flags and connectivity metric from hbc_mini.py
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uc9V1v7iXruubpoLiSE24M
2026-09-22 04:31:22 -04:00
andreasandClaude Sonnet 5 997279dce8 feat(mini): per-protocol connectivity probe in hbc_mini.py
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uc9V1v7iXruubpoLiSE24M
2026-09-22 04:24:08 -04:00
andreasandClaude Sonnet 5 00d6ba607e docs: complete the 5.4.4 changelog entry
Release / release (push) Successful in 45s
The release script only picks up unscoped feat:/fix: subjects, so the
presence/simplex feature was missing from the generated entry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uc9V1v7iXruubpoLiSE24M
2026-09-21 10:01:52 -04:00
andreas cd5de7f205 version 5.4.4
Release / release (push) Successful in 48s
2026-09-21 10:00:43 -04:00
andreasandClaude Sonnet 5 95355374f8 fix: never park the last present family as absent
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uc9V1v7iXruubpoLiSE24M
2026-09-21 09:50:43 -04:00
andreasandClaude Sonnet 5 8122c8d7dc feat(ui): show host status, simplex and absent protocols
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uc9V1v7iXruubpoLiSE24M
2026-09-21 09:36:25 -04:00
andreasandClaude Sonnet 5 3970f4cdde fix(server): restore keeps connectivity alert for connections dropped to unknown
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uc9V1v7iXruubpoLiSE24M
2026-09-21 09:34:38 -04:00
andreasandClaude Sonnet 5 c3736f47ee feat(server): monitor only present protocols; host-level overdue and simplex alerts
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uc9V1v7iXruubpoLiSE24M
2026-09-21 09:28:26 -04:00
andreasandClaude Sonnet 5 2f1cad9156 feat(server): host presence, absent state and host-level status
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uc9V1v7iXruubpoLiSE24M
2026-09-21 09:25:33 -04:00
andreasandClaude Sonnet 5 1898bc52e5 feat(client): report per-protocol presence in HTB and network_monitor
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uc9V1v7iXruubpoLiSE24M
2026-09-21 09:23:33 -04:00
andreasandClaude Sonnet 5 c61a571995 fix(client): keep ping-less family unknown after route returns
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uc9V1v7iXruubpoLiSE24M
2026-09-21 09:22:03 -04:00
andreasandClaude Sonnet 5 9e8d68e712 feat(client): per-protocol connectivity probe
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uc9V1v7iXruubpoLiSE24M
2026-09-21 09:19:48 -04:00
andreasandClaude Sonnet 5 ad19d2596a fix(client): default_gateway never raises
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uc9V1v7iXruubpoLiSE24M
2026-09-21 09:17:48 -04:00
andreasandClaude Sonnet 5 82ba69d827 feat(client): best-effort default gateway lookup
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uc9V1v7iXruubpoLiSE24M
2026-09-21 09:15:17 -04:00
andreasandClaude Sonnet 5 9519169b47 refactor: extract ping helpers into pingutil
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uc9V1v7iXruubpoLiSE24M
2026-09-21 09:12:53 -04:00
andreas f838920f7c version 5.4.3
Release / release (push) Successful in 1m16s
2026-08-17 13:54:02 -04:00
andreasandClaude Sonnet 5 45d11a66b5 fix: fix RTT y-axis label clipping, add x-axis tickmarks, enlarge charts
Y-axis labels on the RTT chart were truncated above 99ms because the
left margin was a fixed guess; it's now sized to the widest tick
label. The x-axis only showed start/end timestamps; added evenly
spaced intermediate tickmarks and labels. Plot area enlarged 15%.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 13:53:57 -04:00
andreas a42d783c0c version 5.4.2
Release / release (push) Successful in 1m10s
2026-08-17 09:24:23 -04:00
andreasandClaude Sonnet 5 18e33656c0 fix: preserve chart history across reconnects, show gaps for missing data
Only wipe real (non-RTT) plugin data on an actual client reboot (boot
flag), not on every ordinary OVERDUE/DOWN -> UP recovery. A transient
network blip no longer erases CPU/memory/etc. history.

Also split the shared time-series chart into separate line/area segments
wherever the gap between samples is much larger than the typical spacing,
so missing data (host overdue, or history that simply hasn't accumulated
across a drop) renders as a visual gap instead of an interpolated line.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEimzMv4Q5EjFg3hoiZ69T
2026-08-17 09:13:49 -04:00
andreas 4ac5a34a43 version 5.4.1
Release / release (push) Successful in 1m19s
2026-08-17 08:16:25 -04:00
andreasandClaude Sonnet 5 6d39c5be47 fix: correct empty-state copy on Host Overview page
Since RTT history keys make host.plugin_data non-empty after the very
first heartbeat, the "no hosts" empty state can now only trigger when
zero hosts have ever connected, not when hosts exist but haven't sent
plugin metrics yet (that in-between state no longer exists). Update
the copy to describe the actual current trigger condition.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEimzMv4Q5EjFg3hoiZ69T
2026-08-17 08:07:23 -04:00
andreasandClaude Sonnet 5 cdb2183989 fix: stop synthetic rtt_* keys from masking real plugin-data checks
host.plugin_data now always holds rtt_ipv4/rtt_ipv6 history after the
first heartbeat, which broke two pre-existing behaviors that assumed
plugin_data reflects only real client-collected data:

- The request_update ACK gate in handle_datagram() never fired again
  after a host's first heartbeat, so stale OS/agent-version info was
  never refreshed after a reconnect (only a client restart fixed it).
- plugin_data.clear() on every UP transition wiped rtt_* history too,
  so a flaky host could never accumulate a useful RTT graph.

Add _is_rtt_key()/_has_real_plugin_data() helpers so both spots treat
rtt_* keys as synthetic: the gate now looks only at real plugin data,
and the recovery clear only drops real plugin keys, preserving RTT
history across reconnects.

Also fixes two pre-existing E127 continuation-indent flake8 issues in
tests/test_udp_rtt_history.py.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEimzMv4Q5EjFg3hoiZ69T
2026-08-17 08:07:19 -04:00
andreasandClaude Sonnet 5 41c78e8e34 feat: live-refresh Connectivity section and RTT charts every 30s
Add fetchHostInfo() call to the 30-second auto-refresh setInterval loop
for expanded host cards. This ensures the Connectivity table and RTT
charts update in real-time without requiring the user to collapse and
re-expand the host card.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEimzMv4Q5EjFg3hoiZ69T
2026-08-17 07:44:14 -04:00
andreas acf1eb8804 feat: render RTT history chart per connection in Connectivity section 2026-08-17 07:39:17 -04:00
andreasandClaude Sonnet 5 49b631f02d refactor: extract shared time-series chart renderer from CPU chart
Pulls the CPU chart's SVG-drawing logic into a parameterized
renderTimeSeriesChart(elId, pts, opts) helper (yDomain, colorFor,
clipId, unitSuffix), keeping renderCpuChart's signature and behavior
unchanged. Task 5 will reuse this for the RTT chart.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEimzMv4Q5EjFg3hoiZ69T
2026-08-17 07:33:22 -04:00
andreas 8204737ea6 fix: use the latest RTT sample, not the oldest, in host info API 2026-08-17 07:28:36 -04:00
andreas 5b2fc8fd46 fix: hide synthetic rtt history keys from plugin accordion list 2026-08-17 07:25:17 -04:00
andreasandClaude Sonnet 5 70c3e93684 fix: remove extra blank line to pass flake8 E303 check
Removed extraneous blank line after conndata() try/except block
that was left during code relocation. Fixes E303 linting error.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEimzMv4Q5EjFg3hoiZ69T
2026-08-17 07:22:33 -04:00
andreasandClaude Sonnet 5 52968201fa feat: record RTT history per heartbeat for charting
Capture RTT on every heartbeat using the existing plugin_data history
mechanism, recording under rtt_ipv4 or rtt_ipv6 keys. Maintains 100-sample
retention per host and address family.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEimzMv4Q5EjFg3hoiZ69T
2026-08-17 07:18:01 -04:00
andreasandClaude Sonnet 5 a7b698e6b9 fix: clear flap state when a host is dropped
flap._state persists at module level keyed by (host, service) and only
clears via a RECOVER-triggered quiet window. A host dropped mid-flap with
no RECOVER ever received leaves ok_since permanently None, so the
flapping flag could never clear on its own.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 08:35:10 -04:00
andreas 136b10239f update 2026-08-07 07:30:41 -04:00
Andreas WredeandClaude Sonnet 5 1e46f33648 fix: correct plugin import path in docs, wire up orphaned footer template
docs/PLUGIN_DEVELOPMENT.md referenced a nonexistent hbd/plugin.py and
hbd/plugins/ directory; the real module is hbd/client/plugin.py with
plugins under hbd/client/plugins/, as README.md and the actual code
already use. Anyone following the doc would hit ModuleNotFoundError.

foot.html was never included by any template (confirmed via git grep
and template audit) despite being actively maintained; wired it into
about.html. menu.html was fully commented out and unreferenced, so
removed it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 09:51:36 -04:00
andreas 4c3a63e645 update claude guideline 2026-07-31 09:22:57 -04:00
Andreas Wrede 1f4dd7cb1f version 5.4.0
Release / release (push) Successful in 44s
2026-07-23 13:17:33 -07:00
Andreas WredeandClaude Opus 4.8 ae3f2fc70f feat: gate remote command execution behind allow_remote_command
CMD packets arrive as unauthenticated UDP datagrams, yet every hbc client
executed the shell command they carry without any opt-in. Add an
allow_remote_command config key, default false: when off, the command is
logged and refused with "Refused: allow_remote_command is false" (visible in
the server event log under the command service), and subprocess is never
reached. When on, the client warns at startup that it will execute CMD
packets.

Applied to all four clients that handle CMD — hbc, hbc_windows.py,
hbc_mini.py, and the C hbc_mini — since gating only one leaves the others
wide open. The C client reads the same key from ~/.hbc.json and needs a
rebuild to pick it up. UPD (self-update) is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 13:10:41 -07:00
Andreas WredeandClaude Opus 4.8 4414967bdc feat: flapping detection suppresses notification storms
A (host, service) pair is flapping once it exceeds flap_count warning or
critical notifications within flap_interval minutes. The notification that
trips the state carries "Now flapping!! No more messages!" and every later
one is dropped, including RECOVER. The state ends silently flap_interval
minutes after a RECOVER, provided no further alert arrived meanwhile.

Hooked into notify.send_notification, the single choke point for channel
delivery, so only outbound notifications are suppressed — eventlog keeps
recording, leaving the journal and /log with the full history of the flap.

Threshold alerts key on their metric path, so a flapping disk check cannot
silence a CPU alert; connectivity, boot and shutdown events key on the host
itself. State lives at module level in flap.py and is never pickled.

Flapping pairs surface in Host.stateinfo() and render as an amber badge on
the live dashboard. Config: flap_count (5), flap_interval (10 minutes),
0 in either disables; both editable on the settings page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 09:53:46 -07:00
Andreas Wrede e3b0e5041f add: info on deplyment 2026-07-23 09:51:32 -07:00
andreasandClaude Fable 5 92982c0ca7 fix: harden events log — non-dict ring entries, off-loop journal reads, no default-config singleton
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:51:34 -04:00
andreasandClaude Fable 5 00fdbcaa5b fix: skip journal scheduling when the event loop is not running
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:39:42 -04:00
andreasandClaude Fable 5 600e68b509 feat: remove log section from live dashboard (moved to /log)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:29:39 -04:00
andreasandClaude Fable 5 b26e853a1c fix: dedup log rows between API seed and websocket tail
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:25:01 -04:00
andreasandClaude Fable 5 36adad5b3b feat: dedicated /log page with journal-backed history and nav item
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:20:17 -04:00
andreasandClaude Fable 5 7591268a89 feat: GET /api/0/log — paged, filtered events journal API
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:15:46 -04:00
andreasandClaude Fable 5 7afc06a329 fix: journal startup/shutdown lifecycle events (fired outside journal lifetime)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:11:30 -04:00
andreasandClaude Fable 5 a3f303e6ba feat: eventlog writes to dedicated events journal; init/backfill/close wiring
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:03:48 -04:00
andreasandClaude Fable 5 16c2922bea feat: events journal read path with filters and backward pagination
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 14:57:32 -04:00
andreasandClaude Fable 5 0343eb4a24 fix: drop typing names not yet used from journal.py import
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 14:53:24 -04:00
andreasandClaude Fable 5 fb5570155b feat: events journal write primitives (log_event, backfill, get_events_journal)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 14:47:42 -04:00
andreasandClaude Fable 5 8d268adf70 feat: Live Dashboard redesign — 6 columns, hover details, last-alert column
Replaces the 11-column table with severity-sorted record rows: host,
combined status chip, latency, alert chips, time-in-state, and the most
recent alert message. Per-family address/state/latency/last-change move
to a hover card on the status chip and permanently to a new Connectivity
table in the Host Overview info section (host info API now includes
connections). Last-alert is seeded from /api/0/alerts and kept fresh
from the event stream; the event log below uses the same row idiom with
its filters intact. WS reconnect state shows in the toolbar instead of
a modal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-10 13:45:03 -04:00
andreasandClaude Fable 5 5d9f161706 feat: extend the record-row design system to Host Overview, Alerts, and About
Extract the settings redesign's tokens and components into shared
static/hbd-ui.css + hbd-ui.js and dedupe settings.html against them.
About becomes yaml-key sections with kv rows; Alerts gets stat tiles,
chip filters, and alert record rows (same fetch/ack logic); Host
Overview keeps its DOM and live-update JS but is re-skinned to the
token system with a page toolbar. Live Dashboard intentionally
untouched pending its own redesign.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-10 09:37:58 -04:00
andreasandClaude Fable 5 067feacd59 fix: settings toolbar sticks below the site nav instead of scrolling away
Sticky offsets (toolbar, rail, mobile chips, anchor margins) derive from
the measured nav/toolbar heights via CSS variables, since the fixed nav's
height varies with viewport width.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-10 09:19:21 -04:00
andreasandClaude Fable 5 c564fafe73 fix: settings page not scrollable — restore html/body overflow override
static/style.css pins html/body to height:100% with overflow:hidden for
the dashboard pages; the redesign dropped the counter-override the old
template carried.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-10 08:20:21 -04:00
andreasandClaude Fable 5 8eeae48964 feat: unified record-row redesign of the settings page
One idiom for every section: mono identifier, badges, fact chips, inline
accordion editor. Scalar config sections merge into one grouped 'server'
section. Sidebar becomes YAML-key nav (mobile: horizontal chips); sticky
toolbar carries pending-changes state and Publish. Staging, publish,
channel CRUD, and permission gating are unchanged — same payload shapes
and endpoints.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-10 08:08:58 -04:00
andreas 880dc0e33f version 5.3.12
Release / release (push) Successful in 47s
2026-07-09 20:43:59 -04:00
andreasandClaude Fable 5 f06b2ef9e8 fix: users loop clobbered requesting username in settings filtering
The users-section loop reused 'username' as its loop variable, overwriting
the requesting user's name so host/threshold filtering compared against the
last user in the config. Rename to 'uname' and add a regression test with a
user that is not last in the users dict.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-09 18:48:05 -04:00
andreasandClaude Fable 5 447b9574a3 fix: type annotations for mypy parity with master
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-09 17:52:02 -04:00
andreasandClaude Fable 5 e98cca22f7 docs: ownership rules and settings access for owners/managers
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-09 17:50:24 -04:00
andreasandClaude Fable 5 5fe069a1a2 feat: retire per-channel private flag on profile page
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-09 16:53:55 -04:00
andreasandClaude Fable 5 ca2868a888 feat: settings UI renders per-user edit rights and owner controls
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-09 16:52:44 -04:00
andreasandClaude Fable 5 ddb1b2b875 feat: settings page accessible to all authenticated users
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-09 16:50:01 -04:00
andreasandClaude Fable 5 39c4d45bd7 feat: per-user filtering of settings sections
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-09 16:48:58 -04:00
andreasandClaude Fable 5 07fefab861 feat: scoped non-admin saves through POST /api/0/config
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-09 16:46:47 -04:00
andreasandClaude Fable 5 6b4524b30d feat: threshold form payload carries per-config owner
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-09 16:46:10 -04:00
andreasandClaude Fable 5 a149a1e285 feat: channel ownership rule — owner-presence means private, admin promote/demote
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-09 16:45:21 -04:00
andreasandClaude Fable 5 dd939ab86e feat: scoped threshold-config merge for non-admin saves
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-09 16:43:56 -04:00
andreasandClaude Fable 5 5c6f462081 feat: scoped host merge for non-admin config saves
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-09 16:43:08 -04:00
andreasandClaude Fable 5 98b50dbbf4 feat: ownership visibility helpers for config entities
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-09 16:42:00 -04:00
andreasandClaude Opus 4.8 960822918a fix: don't re-announce boot/shutdown on SIGHUP restart
Setting args.boot = False after sending the boot message was dead code: the
SIGHUP restart re-execs via os.execv(sys.argv[0], sys.argv) with the original
argv, so the re-launched process re-parsed -b and announced a boot again. The
exiting process also sent a spurious shutdown (send_shutdown armed by -b), so
each config reload looked like a host reboot to the server.

Strip -b/--boot from the argv passed to execv, and skip the shutdown message
in cleanup() when dorestart is set. A real SIGTERM/SIGINT shutdown still sends
shutdown as before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 15:46:09 -04:00
andreas a376ebba8d version 5.3.11
Release / release (push) Successful in 48s
2026-06-26 11:39:23 -04:00
andreasandClaude Opus 4.8 f603ef9bd2 fix: declare plugin interval so stale data waits two full intervals
The server inferred each plugin's collection interval from the gap between
the last two received PLG samples, then expired data at interval * 3. After
an outage this guess was wrong: the request_update re-send of collect-once
InfoPlugins produced two close samples, yielding a tiny inferred interval
that purged permanent info data minutes after recovery.

Clients now declare each plugin's interval in the PLG message (_interval).
The server uses it directly (from the first post-recovery sample), expiring
at interval * 3 so live data survives at least two full intervals; interval
0 marks collect-once InfoPlugins that never go stale. Legacy clients omit
the field and fall back to the previous inferred-gap behavior.

Adds _interval to all three clients: hbc, hbc_mini.py, hbc_mini.c.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 11:37:48 -04:00
andreasandClaude Opus 4.8 731545dd0e fix: cap event buffer and replay only recent messages on dashboard connect
data.msgs grew without bound (eventlog appended forever, also persisted in
the pickle), and ws.py replayed the entire history to every dashboard
client on connect - one JSON frame per message - making page reloads
progressively sluggish. Cap the buffer in eventlog() (configurable via
msg_buffer_size, default 500) and slice the WebSocket replay to the last
30, matching the window the HTTP render already uses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 11:37:40 -04:00
andreasandClaude Opus 4.8 356c77b0b8 fix: strip plugin timers when pickling Host to prevent save failure
Host.plugin_timers holds asyncio TimerHandle objects (and their lambda
callbacks) which are not picklable, causing the 5-minute state save to
fail with "Can't pickle local object reset_plugin_timer.<locals>.<lambda>".
Add Host.__getstate__ to reset plugin_timers to {} before pickling,
mirroring Connection.__getstate__; timers are recreated on the next PLG.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 11:37:35 -04:00
andreasandClaude Sonnet 4.6 6282077fe0 fix: correct zero-safe pathconf checks and connectivity prefix match
- Use `is not None` for pathconf values so 0 is not silently dropped
- Broaden connectivity prefix check to catch bare "connectivity" key

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 13:07:54 -04:00
Andreas WredeandClaude Sonnet 4.6 ddd857173b fix: address security vulnerabilities from audit
- Path traversal: confine avatar file serving to avatar_dir (defaults to
  config file directory); validate on both read and write
- UDP owner injection: server-configured owner now takes precedence over
  UDP-supplied value, matching the documented intent
- Open redirect: reject non-relative next= values after login
- Stored XSS: enable Jinja2 autoescape on all template environments;
  add escHtml() helper in live.html and apply to all innerHTML sinks
  sourced from network data (host names, addrs, states, log messages)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 13:06:05 -04:00
Andreas WredeandClaude Sonnet 4.6 f46f725d12 feat: add Windows hbc client with PyInstaller spec and NSSM install script
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 07:53:57 -04:00
Andreas WredeandClaude Sonnet 4.6 3da6976b53 fix: don't purge connectivity/rtt alerts in purge_stale_alerts
These entries are set by the connection state machine, not by threshold
config, so they have no threshold entry and were being deleted on every
startup. Guard them explicitly so overdue/down alerts survive the purge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:45:47 -04:00
Andreas WredeandClaude Sonnet 4.6 3a0c48e32b fix: restore connectivity alerts for overdue/unknown/down hosts on startup
restore_connection_timers now calls _set_connectivity_alert("CRITICAL")
for DOWN, OVERDUE, and UNKNOWN connections, ensuring alerts are present
even if hbd was shut down before the transition callbacks recorded them.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:40:04 -04:00
Andreas WredeandClaude Sonnet 4.6 cf6e19704f fix: clear plugin data and timers on connection UP transition
Moves the plugin-state purge from the boot flag to the UP transition,
so stale history and alerts are cleared on any reconnect (reboot, or
recovery from overdue/unknown) not just detected reboots.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:35:58 -04:00
Andreas WredeandClaude Sonnet 4.6 b0addd7c67 feat: clear alerts for individual plugin metrics that disappear between samples
When a PLG message arrives with fewer keys than the previous sample,
alert states for the missing metrics are removed immediately. Handles
nagios checks removed from configuration while the runner plugin continues.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 11:32:38 -04:00
Andreas WredeandClaude Sonnet 4.6 32680d34a4 feat: show alerts for all hosts on Alerts page, not just watched
Notifications are still gated by host.watched; only the listing changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 11:24:33 -04:00
Andreas WredeandClaude Sonnet 4.6 a7abdcb5c5 fix: restore host link from Dashboard to Host Overview
live.html used host.raw_name which stateinfo() never included — the
hash was always empty. Use host.name (the raw hostname stateinfo()
does include). Also exclude plugin_timers from stateinfo() to prevent
asyncio handles from breaking jsons().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 11:15:27 -04:00
Andreas WredeandClaude Sonnet 4.6 7bab15ae52 fix: don't set stale timer until two plugin samples establish real interval
Avoids false-stale firing for slow plugins (e.g. nagios_runner at 300 s)
when the heartbeat interval is much shorter. On the first sample cancel
any leftover timer; arm the 3× stale timer only after the second sample.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 09:00:09 -04:00
Andreas Wrede e0443293e9 Merge branch 'master' of git.wrede.ca:andreas/heartbeat
Release / release (push) Successful in 44s
2026-06-06 08:31:26 -04:00
Andreas Wrede 39670f4e63 version 5.3.10 2026-06-06 08:28:43 -04:00
Andreas WredeandClaude Sonnet 4.6 2e88ee2269 feat: clear stale plugin data and persist OAuth users to config
- hbdclass: add per-plugin stale timers; clear history and alerts after
  3× heartbeat interval with no PLG data received
- udp: wire stale timer on every PLG message via _make_plugin_stale_callback
- http: persist new OAuth users to config file on first login

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 08:27:20 -04:00
andreas 2ef7d473c3 Merge pull request 'hbc_mini.c: make it compile on NetBSD' (#1) from woods/heartbeat:master into master
Merge pull request: hbc_mini.c: make it compile on NetBSD
2026-06-03 12:05:29 -04:00
woods 862a9cdea0 hbc_mini.c: make it work on NetBSD
This fixes the numbers by using the correct MIB to match the struct.
2026-06-02 13:42:11 -07:00
woods 9351938b15 hbc_mini.c: make it compile on NetBSD
Use the public "struct uvmexp_sysctl" instead of "struct uvmexp".

The numbers from the memory_monitor are wonky, but it builds and runs.
2026-06-02 12:05:42 -07:00
andreas b6ef2fe065 Merge branch 'master' of git.wrede.ca:andreas/heartbeat
sequencing
2026-06-02 08:01:47 -04:00
andreas d5d2f066b3 fix: don't use pusbover title 2026-06-02 08:01:32 -04:00
Andreas Wrede d9563392c3 fix: remove bak file in bumpminor.sh 2026-06-01 08:34:07 -04:00
andreasandClaude Sonnet 4.6 5f090b9d96 feat: auto-scale CPU history graph Y axis
Y axis now fits the actual data range with 10% padding rather than
fixed 0-100%. Grid lines use nice tick steps (1/2/5/10 × magnitude).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 07:59:54 -04:00
andreas 3cc1d92eb4 Merge branch 'master' of git.wrede.ca:andreas/heartbeat 2026-06-01 07:56:02 -04:00
andreasandClaude Sonnet 4.6 2ddba203df feat: add CPU usage history graph to CPU Monitor section
Renders an SVG line chart above the CPU Usage row using all available
history samples (up to 100). Color adapts green/orange/red by load level.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 07:55:55 -04:00
Andreas Wrede 8a1f412d1d version 5.3.9
Release / release (push) Successful in 43s
2026-05-31 20:58:58 -04:00
Andreas WredeandClaude Sonnet 4.6 40c44f53f1 feat: auto-update CHANGELOG and README in bumpminor.sh
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 20:58:46 -04:00
andreas a6fe8546a8 Update README.md 2026-05-31 20:38:03 -04:00
Andreas Wrede e56660454d tidy up what commited 2026-05-30 15:17:36 -04:00
Andreas WredeandClaude Sonnet 4.6 9cbf0ecb13 docs: update CHANGELOG for 5.3.7 and 5.3.8
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 15:15:25 -04:00
Andreas Wrede 313bbd37ac version 5.3.8
Release / release (push) Successful in 42s
2026-05-30 15:06:46 -04:00
Andreas WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 e58530df7d docs: add MIT license
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 23:45:55 -04:00
Andreas WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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 WredeandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 53fb10fdf5 scripts/c: remove committed binary
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 12:24:05 -04:00
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
andreasandClaude Sonnet 4.6 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
124 changed files with 18635 additions and 4658 deletions
+24 -12
View File
@@ -10,36 +10,48 @@ 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: |
python3 -m pip install --upgrade pip python3 -m venv .venv
python3 -m pip install build twine .venv/bin/pip install --upgrade pip
.venv/bin/pip install build twine
- name: Build package - name: Build package
run: python3 -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 - name: Upload to Gitea PyPI registry
env: env:
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
run: | run: |
python3 -m twine upload --repository-url https://git.wrede.ca/api/packages/andreas/pypi dist/* .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
@@ -48,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 }}"
+5
View File
@@ -5,6 +5,7 @@ __pycache__/
*.pyo *.pyo
.flake8 .flake8
.venv/ .venv/
.continue/
test/ test/
build/ build/
dist/ dist/
@@ -12,3 +13,7 @@ dist/
ssl/ ssl/
uv.lock uv.lock
.hb.yaml .hb.yaml
.superpowers/
rndc-key
docs/superpowers/
graphify-out/
+605
View File
@@ -0,0 +1,605 @@
# Changelog
All notable changes to this project are documented here, organized by release.
## [5.4.7]
### Fixed
- clear all mypy errors
- honour the legacy thresholds: config; repair the stale test suite
- ignore datagrams that are not named HTB/PLG messages
---
## [5.4.6]
### Added
- re-resolve the server hostnames at the DNS TTL
---
## [5.4.5]
### Added
- `hbc_mini.py` and `hbc_mini.c` (the dependency-free clients) get the same per-protocol connectivity probe as `hbc`: `p4`/`p6` flags on every heartbeat and a `connectivity` metric (presence, own address, gateway, RTT) via `network_monitor`, with gateway lookup on Linux, FreeBSD/NetBSD/DragonFly (both clients) and macOS/Windows (`hbc_mini.py` only)
- `scripts/c/test_probe.c` and a `make test` target — the C client's first automated test suite
### Fixed
- `hbc_mini.c`: the clean-shutdown heartbeat could be delayed or lost behind an in-flight connectivity probe on SIGTERM; it is now sent before the probe thread is joined
- `hbc_mini.c`: a connectivity probe that fails to start, or is configured with a non-IP-literal target, now logs a warning instead of failing silently
- `hbc_mini.py`: removed an unused import
---
## [5.4.4]
### Added
- client reports per-protocol (IPv4/IPv6) internet presence: a connectivity probe (route check every ~5s, ICMP echo every ~60s, debounced) sends `p4`/`p6` flags on every heartbeat and a `connectivity` metric (presence, own address, gateway, RTT) via `network_monitor`
- server monitors only the protocols a client reports as present; a protocol reported absent is shown as `absent` and raises no alert
- host status (`up`, `simplex`, `overdue`, `down`, `unknown`) computed on the server: a host is overdue only when every present protocol is overdue; `simplex` (both present, one silent) has the same severity as `up` and is logged without notification
- live and plugins pages show the host status, `simplex`/`absent` protocols and a Connectivity table
- `hbc` config keys under `network_monitor`: `probe_enabled`, `probe_v4`, `probe_v6`, `probe_interval`
### Changed
- overdue and recovery alerts are raised per host, not per protocol: a single CRITICAL when the last present protocol goes overdue; a protocol coming back while another stayed up is logged as INFO instead of a RECOVER notification (also applies to clients that do not report presence)
### Fixed
- never park the last present family as absent, so a host that reports no protocol as present still alerts when it dies
- keep the connectivity alert for a host overdue past the drop window across an `hbd` restart
- `default_gateway` never raises; a protocol whose probe cannot run stays unknown rather than absent
---
## [5.4.3]
### Fixed
- fix RTT y-axis label clipping, add x-axis tickmarks, enlarge charts
---
## [5.4.2]
### Fixed
- preserve chart history across reconnects, show gaps for missing data
---
## [5.4.1]
### Added
- live-refresh Connectivity section and RTT charts every 30s
- render RTT history chart per connection in Connectivity section
- record RTT history per heartbeat for charting
### Fixed
- correct empty-state copy on Host Overview page
- stop synthetic rtt_* keys from masking real plugin-data checks
- use the latest RTT sample, not the oldest, in host info API
- hide synthetic rtt history keys from plugin accordion list
- remove extra blank line to pass flake8 E303 check
- clear flap state when a host is dropped
- correct plugin import path in docs, wire up orphaned footer template
---
## [5.4.0]
### Added
- gate remote command execution behind allow_remote_command
- flapping detection suppresses notification storms
- remove log section from live dashboard (moved to /log)
- dedicated /log page with journal-backed history and nav item
- GET /api/0/log — paged, filtered events journal API
- eventlog writes to dedicated events journal; init/backfill/close wiring
- events journal read path with filters and backward pagination
- events journal write primitives (log_event, backfill, get_events_journal)
- Live Dashboard redesign — 6 columns, hover details, last-alert column
- extend the record-row design system to Host Overview, Alerts, and About
- unified record-row redesign of the settings page
### Fixed
- harden events log — non-dict ring entries, off-loop journal reads, no default-config singleton
- skip journal scheduling when the event loop is not running
- dedup log rows between API seed and websocket tail
- journal startup/shutdown lifecycle events (fired outside journal lifetime)
- drop typing names not yet used from journal.py import
- settings toolbar sticks below the site nav instead of scrolling away
- settings page not scrollable — restore html/body overflow override
---
## [5.3.12]
### Added
- retire per-channel private flag on profile page
- settings UI renders per-user edit rights and owner controls
- settings page accessible to all authenticated users
- per-user filtering of settings sections
- scoped non-admin saves through POST /api/0/config
- threshold form payload carries per-config owner
- channel ownership rule — owner-presence means private, admin promote/demote
- scoped threshold-config merge for non-admin saves
- scoped host merge for non-admin config saves
- ownership visibility helpers for config entities
### Fixed
- users loop clobbered requesting username in settings filtering
- type annotations for mypy parity with master
- don't re-announce boot/shutdown on SIGHUP restart
---
## [5.3.11]
### Added
- add Windows hbc client with PyInstaller spec and NSSM install script
- clear alerts for individual plugin metrics that disappear between samples
- show alerts for all hosts on Alerts page, not just watched
### Fixed
- declare plugin interval so stale data waits two full intervals
- cap event buffer and replay only recent messages on dashboard connect
- strip plugin timers when pickling Host to prevent save failure
- correct zero-safe pathconf checks and connectivity prefix match
- address security vulnerabilities from audit
- don't purge connectivity/rtt alerts in purge_stale_alerts
- restore connectivity alerts for overdue/unknown/down hosts on startup
- clear plugin data and timers on connection UP transition
- restore host link from Dashboard to Host Overview
- don't set stale timer until two plugin samples establish real interval
---
## [5.3.10]
### Added
- clear stale plugin data and persist OAuth users to config
- auto-scale CPU history graph Y axis
- add CPU usage history graph to CPU Monitor section
### Fixed
- remove bak file in bumpminor.sh
---
## [5.3.9]
### Added
- auto-update CHANGELOG and README in bumpminor.sh
---
## [5.3.8]
### Added
- Wiki home page with overview and getting started guide
### Fixed
- Release workflow: use `GITHUB_REF`/`GITHUB_OUTPUT` (Gitea Actions uses GitHub-compatible variable names)
- Release workflow: replace `head -1` with `grep -m 1` to avoid SIGPIPE (exit 141) in changelog step
---
## [5.3.7]
### Added
- Dark mode with light/dark/auto theme setting
- UNKNOWN level filter in Log of Events
- Per-metric grace period input in threshold settings
- Replace Dynamic DNS YAML editor with a web form
- Sort hosts, thresholds, and channels alphabetically on settings page
- Suppress alerts for unwatched hosts
### Fixed
- Preserve log message order when replaying history on connect
---
## [5.3.6]
### Added
- MIT license
### Fixed
- Correct ZFS pool status threshold operator and add per-metric grace
- Normalize email and domain fields
- Move dependencies back under `[project]` in pyproject.toml
---
## [5.3.4]
### Fixed
- Run full reload after HTTP config publish, not just `config.reload()`
---
## [5.3.3]
### Added
- Replace YAML threshold editor with a form-based UI
- Replace multi-select fields with dual-panel picker on settings page
- Nav bar button to publish pending config changes
- Host, level, and message filters in Log of Events
### Fixed
- Remove container max-width; stop stretching inputs on settings page
### Removed
- Legacy `dyndnshosts`/`drophosts` config keys
---
## [5.3.2]
### Added
- Retry DNS resolution indefinitely; add `-4`/`-6` address-family flags to `hbc` and `hbc_mini`
- Replace YAML hosts editor with form-based CRUD table
- Replace YAML notification channel editor with form-based UI
### Fixed
- Support list-valued `threshold_config` in hosts table
- Derive hosts threshold config list from config file keys
- Replace channel checkboxes in Users table with multi-select
- Support plugin-level `enabled: false` in threshold config
- Always populate glance strip for all hosts on page load
- Fetch host info on initial page load
---
## [5.3.1]
### Added
- Host info section in Host Overview (fetched and rendered on card expand)
- `GET /api/0/hosts/{hostname}/info` endpoint
- Show suffix-matched metric coverage in host info threshold table
- Move `hbc_version` and `hbc_type` out of `os_info` into the host info section
### Fixed
- Correct `THRESHOLD_DEFAULTS` metric keys and add missing defaults
---
## [5.3.0]
### Added
- Profile page self-service: change identity, password, and notification channels
- Settings page editor with form sections, YAML editors, stage/publish/rollback workflow
- Config read API: `GET /api/0/config`, `/section/{name}`, `/backups`
- Config write API: `POST /api/0/config`, `POST /api/0/config/rollback`
- `configio` module for comment-preserving YAML round-trip writes
- Multi-provider OAuth2 login page and generic provider routes
- Log login/logout events to the event log with auth source
### Fixed
- ZFS monitor alerts dropped on restart with wildcard pool thresholds
- Preserve OAuth users across config reload
- Config API error handling, consistent 403 messages, deduplicated key lists
- Validate password body type; coerce `notification_channels` to strings in profile API
- Preserve OAuth `client_secret` on roundtrip; harden rollback path validation
---
## [5.2.6]
### Added
- Alerts host-filter field with URL query parameter and notify URL
- Optional logo on Gitea OAuth login button
### Fixed
- Show human-readable duration in re-notification messages
---
## [5.2.5]
### Added
- Alert CRITICAL on degraded or suspended ZFS pools (ONLINE=OK, DEGRADED=WARNING, all else=CRITICAL)
- Sign in with Gitea button on login page with OAuth2 redirect/callback routes
- OAuth2 CSRF state management
- Host owner shown in glance strip for admin users
- C port of `hbc_mini` (single-file client in `scripts/c/`)
### Fixed
- Use `base_url` config for OAuth redirect URI to handle reverse proxy deployments
- Preserve OAuth users across config reload
- Escape HTML in login page error display
---
## [5.2.4]
### Added
- `hbc`/`hbc_mini`: `owner` config field included in `os_info`; server applies to host record
- Server requests InfoPlugin refresh when a host has no plugin data
- Event log stores structured dicts; filter by user
### Fixed
- Strip `_status_code` suffix from displayed metric names in threshold alerts
- Use plain URL in Mattermost plugin metrics link
- Fall back to `default_owner` when `os_info` has no owner
---
## [5.2.3]
### Added
- `hbc`/`hbc_mini`: log name and version at startup
- Show metric name inline with hostname in alerts and notifications
### Fixed
- Send shutdown message only if a boot message was previously sent; suppress both on restart
---
## [5.2.2]
### Fixed
- Retry connection on network error instead of permanently dropping it
- Silence `aiohttp.access` log; strip plugin prefix in alerts UI
---
## [5.2.1]
### Fixed
- Threshold and logging improvements
---
## [5.2.0]
### Added
- `nagios` operator for direct exit-code severity mapping
### Fixed
- Always show `THRESHOLD_DEFAULTS` in Settings threshold config
---
## [5.1.21]
### Added
- `nagios_runner` improvements and alerts page fixes
---
## [5.1.20]
### Added
- Generic threshold matching for `nagios_runner` with `{check_name}` display support
### Fixed
- Reduce default hysteresis from 10% to 2%
- Show recovery threshold in alerts UI
---
## [5.1.19]
### Added
- Exclude ZFS ARC from `memory_percent`
- Add `uptime_seconds` to `cpu_monitor`
### Fixed
- Send boot/shutdown message on the first open connection, not blindly on the first in list
---
## [5.1.18]
### Added
- Fetch-based Update/Delete buttons with toast notifications on Host Overview
### Fixed
- Settings thresholds show correct per-config metrics; miscellaneous `hbc` fixes
---
## [5.1.17]
### Added
- Owner Update/Delete buttons on Host Overview; purge stale alerts on reload
- Retry `AsyncConnection.open()` indefinitely; drop IPv6 only on early startup failure
- Alert pie chart in the nav bar
### Fixed
- Make Alerts page scrollable
---
## [5.1.16]
### Added
- Generic `ping_monitor` thresholds; round RTT to nearest ms
---
## [5.1.15]
### Added
- Link hostnames in Live Dashboard to Host Overview
- Threshold Configurations section on settings page
### Fixed
- Suppress notifications on alert de-escalation (e.g. CRITICAL→WARNING)
- Suppress recover messages for down durations under 4 seconds
---
## [5.1.14]
### Added
- ZFS pool renderer in Host Overview
---
## [5.1.13]
### Added
- ZFS monitor plugin
- Host-level watch flag to suppress notifications
- Filter Live Dashboard and Host Overview by owner/manager
- Composable `threshold_config` list for per-host threshold layering
- Restart on SIGHUP in `hbc` and `hbc_mini`
### Fixed
- Mask `api_password` and `access_token` in settings page
---
## [5.1.12]
Internal release — no user-visible changes.
---
## [5.1.11]
### Fixed
- Install under Docker
- Clean up install script
---
## [5.1.10]
### Fixed
- Synchronize version in `hbc_mini`
- Install script no longer overwrites itself
---
## [5.1.9]
### Added
- Install `hbc_mini` via package or install script
---
## [5.1.8]
### Added
- Track `hbc` type and version
### Fixed
- Nav bar position
---
## [5.1.7]
### Added
- `hbc_mini`: single-file heartbeat client
### Fixed
- Drop dead connections on protocol error
---
## [5.1.6]
### Fixed
- Simplify event log usage; fix argument handling
---
## [5.1.5]
### Added
- Update `hbc` via `hb_install.sh` instead of code patching
---
## [5.1.4]
### Added
- Redesign Plugin Metrics page as Host Overview
---
## [5.1.3]
### Added
- Validate absolute command paths at `nagios_runner` init
- Async subprocess in `nagios_runner` with stderr capture and signal handling
- `skip_reason` field on `Plugin`; surface in `PluginLoader` init messaging
### Fixed
- Use `shlex.split()` for `nagios_runner` path validation to handle quoted paths
- Reconfigure logging to syslog after `daemonize()`
---
## [5.1.2]
### Fixed
- Plugin config lookup shadowed by `CLIENT_DEFAULTS` plugins key
- Apply grace period to all threshold alerts before logging/notifying
- RECOVER routing: use consistent level name and route via alerted channel
- Early reminder notifications and lost recovery notifications
- Non-alerting of overdue hosts
### Added
- Swiss clock widget in the UI
---
## [5.1.1]
### Added
- SMS and Matrix notification channels
- CLI commands `stop`, `restart`, and `reload` for `hbd`
- WebSocket endpoint at `http://.../ws`
- Mobile HTML pages
### Fixed
- Profile not updating
- Sortable columns in tables
---
## [5.1.0]
### Added
- Ping monitor plugin
- Persist state to pickle file; restart timers on server restart
- SIGHUP config reload for `hbd`
- Renotify on CRITICAL only; persistent user sessions
- RTT count threshold
### Fixed
- Bogus notification on new clients
- Show "overdue" in alerts instead of null
---
## [5.0.12]
### Added
- User management and settings page
---
## [5.0.10]
### Added
- Publish package to Gitea PyPI registry
---
## [5.0.9]
### Added
- Use `SO_TIMESTAMP` for RTT measurement (Linux, FreeBSD, macOS)
- Persist state to pickle file; restart timers on restart
---
## [5.0.6]
### Added
- Major codebase refactoring: restructured into client/server components
- Per-client threshold configuration
- Display and acknowledge alerts in the UI
- Proper `hbc` termination; `hbd` config reloadable at runtime
-4
View File
@@ -1,4 +0,0 @@
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.
+97
View File
@@ -0,0 +1,97 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Working principles
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.
## Commands
```bash
# Run tests
pytest -q # all tests
pytest tests/test_threshold.py # single file
pytest -k test_name # single test
# Lint and type check
tox -e lint # flake8 over hbd/ and tests/
tox -e mypy # mypy over hbd/
# Install for development
pip install -e ".[all,dev]"
# Run server
python -m hbd.server.cli serve -c .hb.yaml -f -v
# Generate a password hash
hbd passwd <username>
```
Tests live in `tests/` (pytest, imported as a package). A second directory `test/` contains only TLS fixtures (`.pem` files), not test code.
Line length: `black` and `flake8` both use 165 characters (set in `pyproject.toml`, `tox.ini` and `.flake8`).
## Architecture
The system has two components: `hbc` (client) and `hbd` (server), both in the `hbd` Python package.
```
hbd/
common/ # proto.py (encode/decode), utils.py
client/ # hbc: plugins/, main.py, config.py, plugin.py
server/ # hbd: all server modules
```
### Server module map
| Module | Role |
|---|---|
| `cli.py` | Argument parsing, entry point, daemon startup |
| `main.py` | Asyncio runtime: starts UDP, HTTP, WebSocket; pickle save/load |
| `hbdclass.py` | `Host` and `Connection` domain objects; DNS queue |
| `udp.py` | UDP datagram listener; processes `HTB` and `PLG` messages; sets/resets overdue timers |
| `http.py` | aiohttp web server; all REST API routes (`/api/0/`) and page routes |
| `ws.py` | WebSocket broadcast to connected dashboard clients |
| `notify.py` | Notification dispatch (Pushover, email, Mattermost, Matrix, Signal, SMS); `eventlog()` is the single choke-point for all alert events |
| `threshold.py` | Threshold evaluation against plugin metrics; state transitions (OK/WARNING/CRITICAL/UNKNOWN) |
| `monitor.py` | Timer cleanup on shutdown (reachability is event-driven via timers in `udp.py`, not polled) |
| `data.py` | Shared in-memory message ring buffer |
| `journal.py` | JSONL message journal with size-based rotation |
| `users.py` | Session management, password hashing (PBKDF2), role checks |
| `config.py` | Config loading and defaults |
| `configio.py` | Config file read/write via `ruamel.yaml` (preserves comments) |
| `settings.py` | Settings sections for the web UI settings page |
| `dns.py` | `nsupdate` integration for dynamic DNS |
| `oauth.py` | OAuth2 login (Gitea) |
### Key data flows
**Heartbeat received:** `udp.py` decodes the `HTB` datagram → updates `Connection` state in `hbdclass.py` → resets overdue asyncio timer → broadcasts via `ws.py` → `notify.py` fires connectivity alerts on state change.
**Plugin data received:** `udp.py` decodes `PLG` datagram → stores on `Host` → `threshold.py` evaluates against configured thresholds → `notify.py` fires threshold alerts on state transitions.
**State persistence:** `Host.hosts` dict + `data.msgs` ring + active sessions are pickled every 5 minutes and on clean shutdown. Asyncio timers are stripped before pickling (`Connection.__getstate__`).
**Config reload:** SIGHUP → `configio.py` re-reads YAML → live-updates hosts, thresholds, users, notification channels. Port/cert/pickle/journal changes require a full restart.
### Client plugin system
Plugins in `hbd/client/plugins/` subclass `InfoPlugin` (collected once, on demand) or `MonitorPlugin` (periodic). `initialize()` returns `False` to self-disable. Data is sent as `PLG` UDP messages.
`hbc_mini.py` (scripts/) and `hbc_mini.c` (scripts/c/) are standalone single-file clients with no external dependencies.
### Protocol
All UDP messages: `!<ID>: <zlib-compressed key=value payload>`. Encoding in `hbd/common/proto.py`. Lists/dicts encoded as JSON with `@` prefix; booleans as `1`/`0`.
### Web UI
Jinja2 templates in `hbd/server/templates/`. Static assets in `hbd/server/static/`. Live pages (`/live`, `/plugins`) use WebSocket connections for real-time push.
### CI
Gitea Actions workflow at `.gitea/workflows/release.yml`.
+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.
+669 -557
View File
File diff suppressed because it is too large Load Diff
+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.
+106
View File
@@ -53,6 +53,17 @@ See [User Management](USERS.md) for full authentication documentation.
|--------|------|-------------|------| |--------|------|-------------|------|
| `GET` | `/api/0/users` | List all users | Admin | | `GET` | `/api/0/users` | List all users | Admin |
| `GET` | `/api/0/users/me` | Own profile | Authenticated | | `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
@@ -203,6 +214,101 @@ Changes take effect immediately but are not written back to the config file. Upd
--- ---
---
### 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
+15
View File
@@ -406,6 +406,21 @@ Potential improvements for future versions:
- Journal file encryption - Journal file encryption
- Signed journal entries - Signed journal entries
## Events journal
Alert/connectivity events shown on the `/log` page are written to a second,
dedicated journal (one JSON object per line, same rotation mechanism):
| Key | Default | Meaning |
|---|---|---|
| `events_journal_file` | `events.journal` | Filename inside `journal_dir` |
| `events_journal_max_size` | 10 MB | Rotation threshold |
| `events_journal_max_backups` | 10 | Rotated files kept |
`journal_dir` and `journal_enabled` are shared with the message journal.
On startup, if the events journal file is empty, it is seeded once from the
pickled in-memory message ring so history survives the upgrade.
## See Also ## See Also
- [Configuration Guide](../hbd/config.py) - Full configuration options - [Configuration Guide](../hbd/config.py) - Full configuration options
-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
+78 -7
View File
@@ -9,6 +9,7 @@ Notifications are dispatched to the **owner and managers** of a host, each via t
``` ```
Alert event (udp.py / threshold.py) Alert event (udp.py / threshold.py)
└─ notify.send_notification(host_name, Notification) └─ notify.send_notification(host_name, Notification)
├─ flap.observe(host, service, level) → pass | trip | suppress
├─ look up host.owner + host.managers ├─ look up host.owner + host.managers
├─ for each user → user.notification_channels ├─ for each user → user.notification_channels
└─ for each channel → _dispatch_to_channel (filtered by min_level) └─ for each channel → _dispatch_to_channel (filtered by min_level)
@@ -19,6 +20,7 @@ Every notification carries:
- **body** — detail message (metric value, threshold, duration) - **body** — detail message (metric value, threshold, duration)
- **url** — link to the plugin metrics page (`{base_url}/plugins#{hostname}`) - **url** — link to the plugin metrics page (`{base_url}/plugins#{hostname}`)
- **level** — `RECOVER | WARNING | CRITICAL | INFO` - **level** — `RECOVER | WARNING | CRITICAL | INFO`
- **service** — flap-detection key within the host (empty = the host itself)
## Configuration ## Configuration
@@ -30,9 +32,18 @@ Set `base_url` so notification links point to your hbd instance:
base_url: https://hbd.example.com base_url: https://hbd.example.com
``` ```
### Global channel definitions ### Channel definitions
Define channels once; reference them by name from user configs: Channels are defined under `notification_channels`. Each entry specifies a delivery type and its credentials. Ownership is the single visibility signal:
| Field | Default | Description |
|---|---|---|
| `owner` | *(absent)* | Owning username. Present = private to that user; absent = global. |
| `min_level` | `WARNING` | Minimum alert level this channel receives. |
(The former `private` flag is retired; leftover `private` keys are ignored and dropped on the next edit.)
**Global channels** (no `owner`; set in the config file or by an admin) can be selected by all users but edited only by admins:
```yaml ```yaml
notification_channels: notification_channels:
@@ -41,7 +52,7 @@ notification_channels:
type: pushover type: pushover
token: your-app-token token: your-app-token
user: your-user-key user: your-user-key
min_level: WARNING # optional, default: WARNING min_level: WARNING
email_ops: email_ops:
type: email type: email
@@ -58,14 +69,14 @@ notification_channels:
homeserver: https://matrix.example.org homeserver: https://matrix.example.org
access_token: syt_xxx access_token: syt_xxx
room_id: "!abc:matrix.example.org" room_id: "!abc:matrix.example.org"
min_level: CRITICAL # only send critical alerts to this room min_level: CRITICAL
sms_oncall: sms_oncall:
type: sms_voipms type: sms_voipms
api_user: me@example.com api_user: me@example.com
api_password: secret api_password: secret
did: "5551234567" # your voip.ms DID number did: "5551234567"
dst: "5559876543" # destination number dst: "5559876543"
min_level: CRITICAL min_level: CRITICAL
signal_ops: signal_ops:
@@ -82,9 +93,31 @@ notification_channels:
username: heartbeat-bot username: heartbeat-bot
``` ```
**User-created channels** are written by authenticated users through the API, their profile page, or the settings page. They carry an `owner` field and are private to that user:
```yaml
notification_channels:
alice_personal:
type: pushover
token: personal-token
user: personal-key
owner: alice # private to alice
```
### Channel visibility
| Channel | Who can see / select it | Who can edit it |
|---|---|---|
| No `owner` (global) | All users | Admins |
| `owner` set (private) | Only the `owner` | The owner |
| Any channel | Admins always see everything | Admins |
Admins can **promote** a private channel to global by clearing its owner on the settings page, or **demote** a global channel by assigning an owner.
### Users with notification channels ### Users with notification channels
Each user lists which global channels they receive notifications on: Each user lists which channels they receive notifications on. Users can manage their own selection from the profile page:
```yaml ```yaml
users: users:
@@ -237,6 +270,43 @@ min_level: WARNING
Reminder notifications (re-notify) are sent only for CRITICAL level alerts. Reminder notifications (re-notify) are sent only for CRITICAL level alerts.
## Flap detection
A check that toggles between OK and alerting produces a notification per swing. Flap
detection silences it after the first few.
A **`(host, service)`** pair is flapping once it exceeds `flap_count` WARNING/CRITICAL
notifications within `flap_interval` minutes. Threshold alerts key on their metric path,
so a flapping disk check does not silence an unrelated CPU alert; connectivity, boot and
shutdown events key on the host itself.
```yaml
flap_count: 5 # notifications within the window that trip flapping (0 disables)
flap_interval: 10 # minutes — both the counting window and the quiet window
```
Lifecycle:
| Event | Effect |
|---|---|
| Alerts 1..`flap_count` within the window | Delivered normally |
| Alert `flap_count + 1` | Delivered with ` Now flapping!! No more messages!` appended to the body |
| Every notification after that | Dropped — including RECOVER and INFO |
| RECOVER while flapping | Dropped, and starts the `flap_interval` quiet window |
| WARNING/CRITICAL during the quiet window | Restarts the quiet window; still flapping |
| Quiet window elapses | Flapping ends **silently** — no notification |
Only outbound notifications are suppressed. `notify.eventlog` keeps recording every event,
so the journal and the `/log` page retain the full history of the flap.
Flapping pairs appear in each host's `stateinfo()` under `flapping` (a list of service
keys; `""` means the host itself) and render as an amber **flapping** badge next to the
host name on the live dashboard, with the affected services in its tooltip.
State lives at module level in `hbd/server/flap.py` and is never pickled — a server restart
starts every check with a clean slate. Hosts with `watch: false` never reach the
notification path, so they never flap.
## API reference ## API reference
### `send_notification(host_name, notif) -> dict` ### `send_notification(host_name, notif) -> dict`
@@ -270,6 +340,7 @@ Called once at startup from `main.py`. Pass the running asyncio event loop so Ma
- Check that the host has an `owner` or `managers` set - Check that the host has an `owner` or `managers` set
- Check that users have `notification_channels` listed - Check that users have `notification_channels` listed
- Check that the channel names in user config match keys under `notification_channels:` - Check that the channel names in user config match keys under `notification_channels:`
- If a user can't select a channel, check whether it has an `owner` other than that user
**min_level filtering too aggressive:** **min_level filtering too aggressive:**
- Default is `WARNING` — both WARNING and CRITICAL are sent - Default is `WARNING` — both WARNING and CRITICAL are sent
+33 -10
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)
@@ -20,7 +21,7 @@ Heartbeat's plugin system is designed to be simple yet powerful. Plugins are Pyt
### Key Concepts ### Key Concepts
- **Plugin Registry**: Central registry that manages all loaded plugins - **Plugin Registry**: Central registry that manages all loaded plugins
- **Plugin Loader**: Automatically discovers and loads plugins from the `hbd/plugins/` directory - **Plugin Loader**: Automatically discovers and loads plugins from the `hbd/client/plugins/` directory
- **Plugin Types**: InfoPlugin (static data) and MonitorPlugin (periodic metrics) - **Plugin Types**: InfoPlugin (static data) and MonitorPlugin (periodic metrics)
- **Async/Await**: All plugin methods are async for non-blocking operation - **Async/Await**: All plugin methods are async for non-blocking operation
@@ -63,7 +64,7 @@ Decide whether your plugin collects static information (InfoPlugin) or dynamic m
### Step 2: Create Plugin File ### Step 2: Create Plugin File
Create a new Python file in `hbd/plugins/` directory: Create a new Python file in `hbd/client/plugins/` directory:
```python ```python
""" """
@@ -81,7 +82,7 @@ try:
except ImportError: except ImportError:
psutil = None psutil = None
from hbd.plugin import MonitorPlugin # or InfoPlugin from hbd.client.plugin import MonitorPlugin # or InfoPlugin
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -192,7 +193,7 @@ from pathlib import Path
# Add parent directory to path # Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent)) sys.path.insert(0, str(Path(__file__).parent))
from hbd.plugins.my_awesome_plugin import MyAwesomePlugin from hbd.client.plugins.my_awesome_plugin import MyAwesomePlugin
async def test(): async def test():
# Create plugin instance # Create plugin instance
@@ -223,7 +224,7 @@ Understanding the plugin lifecycle helps you implement plugins correctly:
``` ```
1. Plugin Discovery 1. Plugin Discovery
└─> Loader scans hbd/plugins/ directory └─> Loader scans hbd/client/plugins/ directory
└─> Finds Python files (except those starting with _) └─> Finds Python files (except those starting with _)
└─> Imports modules └─> Imports modules
@@ -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
@@ -355,7 +378,7 @@ Document your plugin thoroughly:
### Example 1: Simple InfoPlugin ### Example 1: Simple InfoPlugin
```python ```python
from hbd.plugin import InfoPlugin from hbd.client.plugin import InfoPlugin
import platform import platform
class SimpleInfoPlugin(InfoPlugin): class SimpleInfoPlugin(InfoPlugin):
@@ -383,7 +406,7 @@ plugin = SimpleInfoPlugin
### Example 2: MonitorPlugin with State ### Example 2: MonitorPlugin with State
```python ```python
from hbd.plugin import MonitorPlugin from hbd.client.plugin import MonitorPlugin
import time import time
class CounterPlugin(MonitorPlugin): class CounterPlugin(MonitorPlugin):
@@ -419,7 +442,7 @@ plugin = CounterPlugin
### Example 3: Plugin with External Command ### Example 3: Plugin with External Command
```python ```python
from hbd.plugin import MonitorPlugin from hbd.client.plugin import MonitorPlugin
import asyncio import asyncio
class CommandPlugin(MonitorPlugin): class CommandPlugin(MonitorPlugin):
@@ -538,7 +561,7 @@ python -m hbd.hbc -c test_config.yaml --verbose
## Further Reading ## Further Reading
- [Plugin Framework Source](../hbd/plugin.py) - Core plugin implementation - [Plugin Framework Source](../hbd/client/plugin.py) - Core plugin implementation
- [Built-in Plugins](../hbd/plugins/) - Examples of working plugins - [Built-in Plugins](../hbd/client/plugins/) - Examples of working plugins
- [Nagios Integration](NAGIOS_INTEGRATION.md) - Running external plugins - [Nagios Integration](NAGIOS_INTEGRATION.md) - Running external plugins
- [Configuration Guide](../hbd/config_example.yaml) - Full configuration reference - [Configuration Guide](../hbd/config_example.yaml) - Full configuration reference
+50 -27
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
@@ -1110,33 +1160,6 @@ hosts:
db-02: db-02:
threshold_config: [tight_memory, db_disk] threshold_config: [tight_memory, db_disk]
``` ```
### Backward Compatibility
The legacy single threshold configuration is fully supported:
```yaml
# Old format - still works
thresholds:
cpu_monitor:
cpu_percent:
warning: 80.0
critical: 90.0
```
This is equivalent to:
```yaml
# New format
threshold_configs:
default:
thresholds:
cpu_monitor:
cpu_percent:
warning: 80.0
critical: 90.0
```
### Configuration Priority ### Configuration Priority
1. **Host `threshold_config` (list)**: Layer each named config's overrides left-to-right on top of the defaults 1. **Host `threshold_config` (list)**: Layer each named config's overrides left-to-right on top of the defaults
+54 -1
View File
@@ -19,6 +19,15 @@ Users are defined in the server config file. Each host can have an **owner**, ze
`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. `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.
### Settings page access
All authenticated users may open `/settings`. Admins see every section; non-admins see only **Notification Channels**, **Hosts**, and **Threshold Configurations**, filtered to global items plus what they own or manage:
- **Owners** may add hosts, delete their hosts, edit host settings, and change access lists (managers/monitors, ownership transfer).
- **Managers** may edit host settings (watch, dyndns, channel and threshold assignments) but not access lists, and may not delete hosts.
- Anyone may create private notification channels and threshold configs (owned by them) and assign them — or global ones — to their hosts.
- Admins promote a private channel/threshold config to global by clearing its owner, or demote by assigning one.
--- ---
## Configuration ## Configuration
@@ -36,7 +45,7 @@ users:
bob: bob:
full_name: Bob Smith full_name: Bob Smith
password: pbkdf2:sha256:... password: pbkdf2:sha256:...
notification_channels: [pushover_standard] notification_channels: [pushover_standard] # channels bob has selected
carol: carol:
full_name: Carol Jones full_name: Carol Jones
@@ -46,6 +55,24 @@ default_owner: andreas # owns hosts with no explicit owner
# falls back to the first admin user if omitted # 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 ### Assigning roles to hosts
```yaml ```yaml
@@ -170,6 +197,32 @@ Return the currently authenticated user's profile.
--- ---
#### 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 (global + own) 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 ### Host Access
#### GET /api/0/hosts/{hostname}/access #### GET /api/0/hosts/{hostname}/access
@@ -1,602 +0,0 @@
# Plugin Error Checking Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Improve plugin error checking in hbc, especially for nagios_runner, and fix logger messages silently discarded in daemon mode.
**Architecture:** Three focused changes across three files: (1) `hbd/client/plugin.py` gains a `skip_reason` attribute on Plugin and updated PluginLoader messaging; (2) `hbd/client/plugins/nagios_runner.py` gains async subprocess execution, stderr capture, signal-killed process handling, and init-time command path validation; (3) `hbd/client/main.py` gains proper post-fork logging reconfiguration to syslog.
**Tech Stack:** Python 3.11+, asyncio, `logging.handlers.SysLogHandler`, pytest
---
## File Map
| Action | Path | What changes |
|---|---|---|
| Modify | `hbd/client/plugin.py` | `Plugin.__init__` gains `skip_reason`; `PluginLoader` checks it |
| Modify | `hbd/client/plugins/nagios_runner.py` | async subprocess, stderr, signal codes, init validation, `skip_reason` |
| Modify | `hbd/client/main.py` | `_reconfigure_logging_for_daemon()` helper; remove redundant syslog calls |
| Create | `tests/test_plugin.py` | PluginLoader messaging tests |
| Create | `tests/test_nagios_runner.py` | NagiosRunnerPlugin behaviour tests |
Run tests throughout with:
```bash
python -m pytest tests/test_plugin.py tests/test_nagios_runner.py -v
```
---
## Task 1: Plugin.skip_reason + PluginLoader messaging
**Files:**
- Modify: `hbd/client/plugin.py:40-48` (Plugin.__init__)
- Modify: `hbd/client/plugin.py:369-381` (PluginLoader.load_from_directory)
- Create: `tests/test_plugin.py`
- [ ] **Step 1: Write failing tests**
Create `tests/test_plugin.py`:
```python
import asyncio
import logging
import textwrap
from hbd.client.plugin import Plugin, 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)
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
python -m pytest tests/test_plugin.py -v
```
Expected: `test_plugin_skip_reason_defaults_none` FAILS (attribute missing), others may error.
- [ ] **Step 3: Add `skip_reason` to `Plugin.__init__`**
In `hbd/client/plugin.py`, in `Plugin.__init__` (around line 46), add one line:
```python
def __init__(self, config: Optional[Dict[str, Any]] = None):
self.config = config or {}
self.logger = logging.getLogger(f"plugin.{self.name}")
self._initialized = False
self.skip_reason: Optional[str] = None
```
- [ ] **Step 4: Update PluginLoader messaging**
In `hbd/client/plugin.py`, replace the `if not initialized:` block (around line 372):
```python
if not initialized:
if plugin.skip_reason:
self.logger.info(
f"Plugin {plugin.name} skipped: {plugin.skip_reason}"
)
else:
self.logger.warning(
f"Plugin {plugin.name} failed initialization, skipping"
)
continue
```
- [ ] **Step 5: Run tests to verify they pass**
```bash
python -m pytest tests/test_plugin.py -v
```
Expected: all 3 tests PASS.
- [ ] **Step 6: Commit**
```bash
git add hbd/client/plugin.py tests/test_plugin.py
git commit -m "feat: add skip_reason to Plugin; improve PluginLoader init messaging"
```
---
## Task 2: NagiosRunnerPlugin — skip_reason when no commands
**Files:**
- Modify: `hbd/client/plugins/nagios_runner.py:88-105` (initialize)
- Modify: `tests/test_nagios_runner.py` (create)
- [ ] **Step 1: Write failing test**
Create `tests/test_nagios_runner.py`:
```python
import asyncio
import logging
import os
import stat
import pytest
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
```
- [ ] **Step 2: Run test to verify it fails**
```bash
python -m pytest tests/test_nagios_runner.py::test_no_commands_sets_skip_reason -v
```
Expected: FAIL — `plugin.skip_reason` is `None`.
- [ ] **Step 3: Set skip_reason in NagiosRunnerPlugin.initialize()**
In `hbd/client/plugins/nagios_runner.py`, replace the early-return block in `initialize()` (around line 96):
```python
if not self.commands:
self.skip_reason = "no commands configured (add nagios_runner.commands to config)"
self.logger.info("No Nagios commands configured")
return False
```
- [ ] **Step 4: Run test to verify it passes**
```bash
python -m pytest tests/test_nagios_runner.py::test_no_commands_sets_skip_reason -v
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add hbd/client/plugins/nagios_runner.py tests/test_nagios_runner.py
git commit -m "feat: set skip_reason on nagios_runner when no commands configured"
```
---
## Task 3: NagiosRunnerPlugin — async subprocess, stderr capture, negative return codes
**Files:**
- Modify: `hbd/client/plugins/nagios_runner.py` (imports + `_run_nagios_plugin`)
- Modify: `tests/test_nagios_runner.py`
- [ ] **Step 1: Write failing tests**
Append to `tests/test_nagios_runner.py`:
```python
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()
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
python -m pytest tests/test_nagios_runner.py::test_stderr_used_when_stdout_empty \
tests/test_nagios_runner.py::test_stderr_appended_when_both_present \
tests/test_nagios_runner.py::test_negative_returncode_maps_to_unknown -v
```
Expected: all FAIL — current implementation ignores stderr and doesn't handle negative codes.
- [ ] **Step 3: Update imports in nagios_runner.py**
Replace the import block at the top of `hbd/client/plugins/nagios_runner.py`:
```python
import asyncio
import os
import re
from typing import Any, Dict, List, Optional, Tuple
from hbd.client.plugin import MonitorPlugin
```
(Remove `import subprocess`; add `import asyncio` and `import os`.)
- [ ] **Step 4: Upgrade collection log level from DEBUG to INFO**
In `hbd/client/plugins/nagios_runner.py`, in `_collect_metrics()`, change the debug log (around line 144) so results are visible at INFO level:
```python
self.logger.info(
f"Executed {name}: {STATUS_NAMES.get(status_code, 'UNKNOWN')} - {output[:50]}"
)
```
- [ ] **Step 5: Replace `_run_nagios_plugin` with async implementation**
Replace the entire `_run_nagios_plugin` method in `hbd/client/plugins/nagios_runner.py`:
```python
async def _run_nagios_plugin(
self,
command: str
) -> Tuple[int, str, Dict[str, Any]]:
"""Execute a Nagios plugin and parse its output."""
try:
proc = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout_bytes, stderr_bytes = await asyncio.wait_for(
proc.communicate(), timeout=self.timeout
)
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:
status_code = NAGIOS_UNKNOWN
stdout = stdout_bytes.decode(errors="replace").strip()
stderr = stderr_bytes.decode(errors="replace").strip()
# Parse perfdata from stdout before mixing in stderr
perfdata = self._parse_perfdata(stdout)
# 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:
output_msg = status_part
return status_code, output_msg, perfdata
except Exception as e:
self.logger.error(f"Error executing command: {e}")
return NAGIOS_UNKNOWN, f"Execution error: {str(e)}", {}
```
Also remove the now-unused `self.shell` line from `__init__` (the `shell` config key is no longer used since `create_subprocess_shell` always uses a shell):
In `NagiosRunnerPlugin.__init__`, remove:
```python
self.shell: bool = config.get("shell", True) if config else True
```
- [ ] **Step 6: Run tests to verify they pass**
```bash
python -m pytest tests/test_nagios_runner.py -v
```
Expected: all tests PASS including the 3 new ones.
- [ ] **Step 7: Commit**
```bash
git add hbd/client/plugins/nagios_runner.py tests/test_nagios_runner.py
git commit -m "feat: async subprocess in nagios_runner with stderr capture and signal handling"
```
---
## Task 4: NagiosRunnerPlugin — command path validation at init
**Files:**
- Modify: `hbd/client/plugins/nagios_runner.py` (initialize)
- Modify: `tests/test_nagios_runner.py`
- [ ] **Step 1: Write failing tests**
Append to `tests/test_nagios_runner.py`:
```python
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
)
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
python -m pytest tests/test_nagios_runner.py::test_absolute_path_not_found_warns \
tests/test_nagios_runner.py::test_absolute_path_not_executable_warns \
tests/test_nagios_runner.py::test_relative_path_not_checked -v
```
Expected: `test_absolute_path_not_found_warns` and `test_absolute_path_not_executable_warns` FAIL (no warnings logged); `test_relative_path_not_checked` may pass.
- [ ] **Step 3: Add command path validation to `initialize()`**
In `hbd/client/plugins/nagios_runner.py`, extend `initialize()` by adding validation after the existing "log each command" loop (after line 103, before `return True`):
```python
# 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
exe = command.split()[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}"
)
```
- [ ] **Step 4: Run full test suite to verify all pass**
```bash
python -m pytest tests/test_plugin.py tests/test_nagios_runner.py -v
```
Expected: all tests PASS.
- [ ] **Step 5: Commit**
```bash
git add hbd/client/plugins/nagios_runner.py tests/test_nagios_runner.py
git commit -m "feat: validate absolute command paths at nagios_runner init"
```
---
## Task 5: Daemon mode logging — route to syslog after fork
**Files:**
- Modify: `hbd/client/main.py` (new helper + updated daemon block)
No automated test for daemonization itself (fork behaviour is hard to unit-test). Manual verification steps are provided below.
- [ ] **Step 1: Add `_reconfigure_logging_for_daemon` helper**
In `hbd/client/main.py`, add this function just before `def build_parser()` (around line 589):
```python
def _reconfigure_logging_for_daemon(log_level: int) -> None:
"""Replace StreamHandlers (now writing to /dev/null) with a SysLogHandler."""
from logging.handlers import SysLogHandler
root = logging.getLogger()
for handler in root.handlers[:]:
root.removeHandler(handler)
handler.close()
try:
syslog_handler = SysLogHandler(
address="/dev/log",
facility=SysLogHandler.LOG_DAEMON,
)
except OSError:
syslog_handler = SysLogHandler(
address=("localhost", 514),
facility=SysLogHandler.LOG_DAEMON,
)
# Attach the fallback first so the warning reaches syslog
syslog_handler.setFormatter(
logging.Formatter("hbc[%(process)d]: %(name)s %(levelname)s: %(message)s")
)
root.addHandler(syslog_handler)
root.setLevel(log_level)
logging.warning("/dev/log not found, using syslog UDP localhost:514")
return
syslog_handler.setFormatter(
logging.Formatter("hbc[%(process)d]: %(name)s %(levelname)s: %(message)s")
)
root.addHandler(syslog_handler)
root.setLevel(log_level)
```
- [ ] **Step 2: Update the daemon block in `main()`**
In `hbd/client/main.py`, replace the entire `if args.daemon:` block (lines 664–675):
```python
if args.daemon:
print("Daemonizing...")
daemonize()
_reconfigure_logging_for_daemon(log_level)
logging.info(f"hbc starting, sending heartbeat to {', '.join(args.hosts)}")
```
This removes the `import syslog`, `syslog.openlog()`, and `syslog.syslog()` calls (now handled by the logging system) and removes the no-op second `logging.basicConfig()` call.
- [ ] **Step 3: Run existing test suite to confirm no regressions**
```bash
python -m pytest tests/test_plugin.py tests/test_nagios_runner.py -v
```
Expected: all tests still PASS.
- [ ] **Step 4: Manual smoke test — verify syslog output in daemon mode**
```bash
# In one terminal, tail syslog
sudo journalctl -f -t hbc
# In another terminal, start hbc in daemon mode (replace HOST with a real or dummy host)
python -m hbd.client.main -d -v localhost
# Expected in journalctl output:
# hbc[<pid>]: hbc.main INFO: Starting hbc for <hostname> -> ['localhost']
# hbc[<pid>]: hbc.main INFO: hbc starting, sending heartbeat to localhost
# hbc[<pid>]: plugin.loader INFO: ...
# Stop the daemon
pkill -f "hbd.client.main"
```
- [ ] **Step 5: Commit**
```bash
git add hbd/client/main.py
git commit -m "fix: reconfigure logging to syslog after daemonize() instead of no-op basicConfig"
```
@@ -1,92 +0,0 @@
# Plugin Error Checking & Daemon Logging — Design Spec
**Date:** 2026-04-25
**Scope:** hbc client — daemon mode logging, nagios_runner plugin robustness, PluginLoader messaging
**Files affected:** `hbd/client/main.py`, `hbd/client/plugins/nagios_runner.py`, `hbd/client/plugin.py`
---
## 1. Daemon Mode Logging
### Problem
In `main()`, `logging.basicConfig()` is called before `daemonize()` (establishing a StreamHandler to stderr), then called again after `daemonize()`. The second call is a no-op — Python ignores `basicConfig()` when handlers are already configured. After daemonization, stderr is redirected to `/dev/null`, so all subsequent log output is silently discarded.
The existing `syslog.openlog()` / `syslog.syslog()` calls (lines 666–668) write a single startup message but do not integrate with the `logging` system, so plugin and connection log messages never reach syslog.
### Fix
After `daemonize()`, explicitly reconfigure the root logger:
1. Remove all existing handlers (they now write to `/dev/null`).
2. Add `logging.handlers.SysLogHandler(address='/dev/log', facility=LOG_DAEMON)`.
3. Set formatter: `hbc[%(process)d]: %(name)s %(levelname)s: %(message)s`
4. Preserve the `log_level` already determined from `-v`/`-x` CLI flags.
Remove the redundant `syslog.openlog()` / `syslog.syslog()` calls — the logging system handles routing.
**Fallback:** If `/dev/log` does not exist (containers, some BSDs), fall back to `SysLogHandler(address=('localhost', 514))`. Log one warning (to stderr, before handlers are replaced) so the operator knows.
---
## 2. Nagios Runner Improvements
### 2a — Async Subprocess
`_run_nagios_plugin()` is declared `async def` but calls `subprocess.run()` synchronously, blocking the event loop for the full command duration.
**Fix:** Replace with `asyncio.create_subprocess_shell()` + `await proc.communicate()`. Enforce timeout with `asyncio.wait_for(..., timeout=self.timeout)` and catch `asyncio.TimeoutError`.
### 2b — Stderr Capture
Subprocess stderr is currently discarded (`capture_output=True` only captures stdout in the sync call; stderr content is lost).
**Fix:** Pass `stderr=asyncio.subprocess.PIPE` to `create_subprocess_shell`. After `communicate()`, if stdout is empty but stderr has content, use stderr as the output message. If both have content, append stderr to the output for visibility.
### 2c — Negative Return Codes
A negative `returncode` means the process was killed by a signal (SIGKILL, OOM, etc.). The current code treats these as-is, which may produce unexpected status values.
**Fix:** If `returncode < 0`, map to `NAGIOS_UNKNOWN` with message `"Process killed by signal {-returncode}"`.
### 2d — Command Path Validation at Init
`initialize()` currently only checks that the commands list is non-empty.
**Fix:** For each command entry during `initialize()`:
- Warn and skip the entry if `name` or `command` is missing.
- Extract the executable (first whitespace-delimited token of the command string).
- If the executable is an absolute path, check `os.path.isfile()` and `os.access(..., os.X_OK)`. Log a `WARNING` if either check fails.
- Commands with relative paths or shell builtins are not checked (they may be on PATH) — just noted.
- Validation warns only; all original entries in `self.commands` are retained and still attempted at collection time (where the existing missing-name/command guard already skips them). The plugin initializes successfully as long as the commands list is non-empty.
---
## 3. PluginLoader Messaging
### Problem
When `initialize()` returns `False`, the loader always logs:
> `WARNING: Plugin X failed initialization, skipping`
This is alarming when the real reason is simply "no commands configured". There is no API to distinguish "not configured" from "genuinely broken".
### Fix
Add an optional `skip_reason` attribute to `Plugin.__init__()` (defaults to `None`).
In `PluginLoader.load_from_directory()`, after `initialize()` returns `False`:
- If `plugin.skip_reason` is set → `logger.info(f"Plugin {plugin.name} skipped: {plugin.skip_reason}")`
- If `plugin.skip_reason` is `None` → `logger.warning(f"Plugin {plugin.name} failed initialization, skipping")` (existing behaviour)
In `NagiosRunnerPlugin.initialize()`, when no commands are configured:
```python
self.skip_reason = "no commands configured (add nagios_runner.commands to config)"
return False
```
Genuine failures (exceptions) continue to go through the existing `except` block in the loader, logging at `ERROR` with traceback — unchanged.
---
## Decisions
| Topic | Decision |
|---|---|
| Daemon log destination | syslog only (LOG_DAEMON facility) |
| Syslog fallback | localhost:514 UDP if `/dev/log` absent |
| Nagios result log level | INFO for all statuses (OK/WARNING/CRITICAL/UNKNOWN) |
| Invalid command handling at init | Warn and continue; still attempt at collection time |
| PluginLoader API change | `skip_reason` attribute on Plugin base class, checked by loader |
+2 -2
View File
@@ -4,7 +4,7 @@ This package contains both the heartbeat client (hbc) and server (hbd) component
organized into separate subpackages: organized into separate subpackages:
- hbd.client: Client component with system monitoring plugins - hbd.client: Client component with system monitoring plugins
- hbd.server: Server/daemon component with web UI and notifications - hbd.server: Server/daemon component with web UI and notifications
- hbd.common: Shared utilities and protocol definitions - hbd.common: Shared utilities and protocol definitions
Install options: Install options:
@@ -14,4 +14,4 @@ Install options:
""" """
__all__ = ["__version__"] __all__ = ["__version__"]
__version__ = "5.1.18" __version__ = "5.4.7"
+1 -1
View File
@@ -1,3 +1,3 @@
"""HeartBeat Client (hbc) - System monitoring client.""" """HeartBeat Client (hbc) - System monitoring client."""
from hbd import __version__ from hbd import __version__ # noqa: F401
+13 -7
View File
@@ -2,25 +2,31 @@
import logging import logging
import os import os
import logging from typing import Any, Dict
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
try: try:
import yaml import yaml
except Exception: except Exception:
yaml = None yaml = None # type: ignore[assignment]
CLIENT_DEFAULTS = { CLIENT_DEFAULTS: Dict[str, Any] = {
# 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
# Security
"allow_remote_command": False, # Execute shell commands received in CMD packets from 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
@@ -31,10 +37,10 @@ def load_config(path=None):
"""Load configuration from a YAML file and merge with client defaults. """Load configuration from a YAML file and merge with client defaults.
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: ~/.hbc.yaml) path: Path to YAML config file (default: ~/.hbc.yaml)
Returns: Returns:
Dictionary with configuration Dictionary with configuration
""" """
+204
View File
@@ -0,0 +1,204 @@
"""Per-protocol internet presence probe.
Tier 1 (every tier1_interval seconds, sends no packets): a UDP connect() to the probe
address reveals whether a route exists and which source address would be used.
Tier 2 (every probe_interval seconds, sooner after a failure or a change of address or
gateway): one ICMP echo to the probe address. Absence needs fail_limit consecutive
failures (or no route at all); presence needs one success.
The result is read by the heartbeat sender (p4/p6 flags on every HTB) and by the
network_monitor plugin (connectivity metric) via the shared instance.
"""
import asyncio
import logging
import socket
import sys
import time
from dataclasses import asdict, dataclass, replace
from typing import Any, Awaitable, Callable, Dict, Optional, Set, Tuple
from hbd.client import gateway as gateway_mod
from hbd.client.pingutil import PingUnavailable, run_ping
logger = logging.getLogger("hbc.connectivity")
FAMILIES = ("ipv4", "ipv6")
FLAG_KEYS = {"ipv4": "p4", "ipv6": "p6"}
DEFAULT_PROBES = {"ipv4": "1.1.1.1", "ipv6": "2606:4700:4700::1111"}
class ProbeUnavailable(Exception):
"""The address family cannot be probed on this host (e.g. no IPv6 support)."""
@dataclass
class FamilyState:
present: Optional[bool] = None # None = unknown (probe cannot run yet/at all)
addr: Optional[str] = None
gateway: Optional[str] = None
rtt: Optional[float] = None
def local_address(family: str, target: str) -> Optional[str]:
"""Source address the kernel would use to reach target, or None if there is no route.
A UDP connect() sends nothing. Raises ProbeUnavailable if the family is unsupported.
"""
af = socket.AF_INET if family == "ipv4" else socket.AF_INET6
try:
sock = socket.socket(af, socket.SOCK_DGRAM)
except OSError as e:
raise ProbeUnavailable(str(e)) from e
try:
sock.connect((target, 9))
return str(sock.getsockname()[0])
except OSError:
return None
finally:
sock.close()
async def ping_rtt(target: str, family: str) -> Optional[float]:
"""One ICMP echo; returns RTT in ms or None if no reply."""
result = await run_ping(target, 1, 2, 4 if family == "ipv4" else 6)
rtt = result["rtt_avg"]
return None if rtt == float("inf") else rtt
class Connectivity:
def __init__(
self,
probes: Optional[Dict[str, str]] = None,
probe_interval: float = 60.0,
retry_interval: float = 10.0,
tier1_interval: float = 5.0,
fail_limit: int = 2,
local_address_fn: Callable[[str, str], Optional[str]] = local_address,
ping_fn: Callable[[str, str], Awaitable[Optional[float]]] = ping_rtt,
gateway_fn: Callable[[str], Awaitable[Optional[str]]] = gateway_mod.default_gateway,
clock: Callable[[], float] = time.monotonic,
):
self.probes = {**DEFAULT_PROBES, **(probes or {})}
self.probe_interval = probe_interval
self.retry_interval = retry_interval
self.tier1_interval = tier1_interval
self.fail_limit = fail_limit
self._local_address = local_address_fn
self._ping = ping_fn
self._gateway_fn = gateway_fn
self._clock = clock
# Reading the gateway is a cheap /proc read on Linux but a subprocess elsewhere,
# so elsewhere it is refreshed only when a tier-2 probe is due.
self._cheap_gateway = sys.platform.startswith("linux")
self.states: Dict[str, FamilyState] = {f: FamilyState() for f in FAMILIES}
self._fails: Dict[str, int] = {f: 0 for f in FAMILIES}
self._next_ping: Dict[str, float] = {f: 0.0 for f in FAMILIES}
self._last_key: Dict[str, Optional[Tuple[Optional[str], Optional[str]]]] = {
f: None for f in FAMILIES}
self._ping_disabled: Set[str] = set()
@classmethod
def from_config(cls, cfg: Optional[Dict[str, Any]]) -> "Connectivity":
cfg = cfg or {}
probes = {}
if cfg.get("probe_v4"):
probes["ipv4"] = str(cfg["probe_v4"])
if cfg.get("probe_v6"):
probes["ipv6"] = str(cfg["probe_v6"])
return cls(probes=probes, probe_interval=float(cfg.get("probe_interval", 60)))
# ---- readers (cheap; safe to call on every heartbeat) --------------------------
def snapshot(self) -> Dict[str, FamilyState]:
return {f: replace(s) for f, s in self.states.items()}
def flags(self) -> Dict[str, bool]:
"""HTB flags for families whose presence is known."""
return {FLAG_KEYS[f]: s.present for f, s in self.states.items() if s.present is not None}
def as_metric(self) -> Dict[str, Dict[str, Any]]:
return {f: asdict(s) for f, s in self.states.items()}
# ---- probing -------------------------------------------------------------------
async def refresh(self) -> None:
for fam in FAMILIES:
try:
await self._refresh_family(fam)
except Exception:
logger.exception("connectivity probe failed for %s", fam)
async def run(self, stop: asyncio.Event) -> None:
while not stop.is_set():
await self.refresh()
try:
await asyncio.wait_for(stop.wait(), timeout=self.tier1_interval)
except asyncio.TimeoutError:
pass
async def _gateway(self, fam: str) -> Optional[str]:
try:
return await self._gateway_fn(fam)
except Exception:
return None
async def _refresh_family(self, fam: str) -> None:
st = self.states[fam]
try:
addr = self._local_address(fam, self.probes[fam])
except ProbeUnavailable:
st.present = None
return
if addr is None: # tier 1: no route => absent immediately
self._fails[fam] = 0
self._next_ping[fam] = 0.0
self._last_key[fam] = None
st.present, st.addr, st.gateway, st.rtt = False, None, None, None
return
now = self._clock()
gw = await self._gateway(fam) if self._cheap_gateway else st.gateway
key = (addr, gw)
due = key != self._last_key[fam] or now >= self._next_ping[fam]
if due and not self._cheap_gateway:
gw = await self._gateway(fam)
key = (addr, gw)
st.addr, st.gateway = addr, gw
self._last_key[fam] = key
if fam in self._ping_disabled:
st.present = None # cannot probe: unknown, never absent, while a route exists
elif due:
await self._tier2(fam, st, now)
async def _tier2(self, fam: str, st: FamilyState, now: float) -> None:
try:
rtt = await self._ping(self.probes[fam], fam)
except PingUnavailable:
logger.warning("ping unavailable; %s presence stays unknown", fam)
self._ping_disabled.add(fam)
st.present = None
return
except (asyncio.TimeoutError, OSError):
rtt = None
if rtt is None:
self._fails[fam] += 1
self._next_ping[fam] = now + self.retry_interval
if self._fails[fam] >= self.fail_limit:
st.present, st.rtt = False, None
else:
self._fails[fam] = 0
self._next_ping[fam] = now + self.probe_interval
st.present, st.rtt = True, rtt
# ---- shared instance (heartbeat sender and network_monitor plugin) -----------------
_shared: Optional[Connectivity] = None
def set_shared(conn: Optional[Connectivity]) -> None:
global _shared
_shared = conn
def get_shared() -> Optional[Connectivity]:
return _shared
+170
View File
@@ -0,0 +1,170 @@
"""Re-resolution of the server hostnames at the TTL of their A/AAAA records.
getaddrinfo() hides the TTL, so a minimal stub-resolver query (resolv.conf nameservers, search
list) is made only to learn it. The addresses themselves still come from getaddrinfo(). When
the TTL cannot be learned (IP literal, /etc/hosts name, no resolv.conf, resolver timeout)
DEFAULT_TTL is used.
"""
import ipaddress
import logging
import os
import socket
import struct
from typing import List, Optional, Tuple
logger = logging.getLogger("hbc.dns")
DEFAULT_TTL = 300 # used when the real TTL is unknown
MIN_TTL = 30 # a TTL of 0 must not turn into a resolution loop
FAILED_RETRY = 60 # re-try delay after a failed resolution
QUERY_TIMEOUT = 2.0
TYPE_A, TYPE_CNAME, TYPE_AAAA = 1, 5, 28
def parse_resolv_conf(path: str = "/etc/resolv.conf") -> Tuple[List[str], List[str]]:
"""Return (nameservers, search domains)."""
servers: List[str] = []
search: List[str] = []
try:
with open(path) as f:
for line in f:
parts = line.split()
if len(parts) < 2:
continue
if parts[0] == "nameserver":
servers.append(parts[1].split("%")[0])
elif parts[0] in ("search", "domain"):
search = parts[1:]
except OSError:
pass
return servers, search
def build_query(name: str, qtype: int, qid: int) -> bytes:
out = struct.pack("!HHHHHH", qid, 0x0100, 1, 0, 0, 0)
for label in name.rstrip(".").split("."):
out += bytes([len(label)]) + label.encode("idna")
return out + b"\x00" + struct.pack("!HH", qtype, 1)
def _skip_name(buf: bytes, off: int) -> int:
while True:
n = buf[off]
if n == 0:
return off + 1
if n & 0xC0:
return off + 2
off += n + 1
def min_answer_ttl(resp: bytes, qid: int, qtype: int) -> Optional[int]:
"""Smallest TTL among the answer RRs (CNAMEs included), or None if there is no qtype answer."""
try:
rid, flags, qd, an = struct.unpack("!HHHH", resp[:8])
if rid != qid or flags & 0x000F:
return None
off = 12
for _ in range(qd):
off = _skip_name(resp, off) + 4
ttls: List[int] = []
found = False
for _ in range(an):
off = _skip_name(resp, off)
rtype, _cls, ttl, rdlen = struct.unpack("!HHIH", resp[off:off + 10])
off += 10 + rdlen
if rtype in (qtype, TYPE_CNAME):
ttls.append(ttl)
found = found or rtype == qtype
return min(ttls) if found else None
except (struct.error, IndexError):
return None
def _query_ttl(server: str, name: str, qtype: int) -> Optional[int]:
qid = struct.unpack("!H", os.urandom(2))[0]
try:
af = socket.AF_INET6 if ":" in server else socket.AF_INET
with socket.socket(af, socket.SOCK_DGRAM) as s:
s.settimeout(QUERY_TIMEOUT)
s.sendto(build_query(name, qtype, qid), (server, 53))
return min_answer_ttl(s.recv(4096), qid, qtype)
except (OSError, UnicodeError):
return None
def lookup_ttl(host: str, af_filter: int = 0) -> Optional[int]:
"""Blocking. Smallest A/AAAA TTL for host, or None when it cannot be determined."""
try:
ipaddress.ip_address(host)
return None
except ValueError:
pass
servers, search = parse_resolv_conf()
if host.endswith("."):
names = [host]
elif "." in host:
names = [host] + [f"{host}.{d}" for d in search]
else:
names = [f"{host}.{d}" for d in search] + [host]
qtypes = [t for t, af in ((TYPE_A, socket.AF_INET), (TYPE_AAAA, socket.AF_INET6))
if af_filter in (0, af)]
for name in names:
ttls = []
for qtype in qtypes:
for server in servers:
ttl = _query_ttl(server, name, qtype)
if ttl is not None:
ttls.append(ttl)
break
if ttls:
return min(ttls)
return None
def refresh_delay(ttls: List[Optional[int]]) -> int:
"""Seconds until the next resolution: the smallest known TTL, floored at MIN_TTL."""
known = [t for t in ttls if t is not None]
return max(min(known), MIN_TTL) if known else DEFAULT_TTL
def resolve(hosts: List[str], port: int, af_filter: int) -> Optional[List[Tuple[int, str]]]:
"""Blocking. Every (family, address) the hosts resolve to, deduplicated, or None if any lookup fails."""
found: List[Tuple[int, str]] = []
for host in hosts:
try:
infos = socket.getaddrinfo(host, port, af_filter, 0, socket.SOL_UDP)
except socket.gaierror as e:
logger.warning(f"Cannot resolve {host}: {e}")
return None
for info in infos:
entry = (info[0], str(info[4][0]))
if entry not in found:
found.append(entry)
return found or None
def retarget(connections: list, resolved: List[Tuple[int, str]]) -> Tuple[List[Tuple[int, str]], list]:
"""Point the connections at the resolved addresses, in place.
A connection whose address is gone is re-pointed at an address that is new, in list order, so
the first connection (the one plugin data uses) survives as long as anything resolves.
Returns (new addresses still needing a connection, connections to drop).
"""
current = {c.addr for c in connections}
wanted = {addr for _, addr in resolved}
new = [(af, addr) for af, addr in resolved if addr not in current]
gone = [c for c in connections if c.addr not in wanted]
paired = min(len(gone), len(new))
for conn, (af, addr) in zip(gone, new):
logger.info(f"Server address {conn.addr} -> {addr}")
conn.set_target(addr, af)
drop = gone[paired:]
if connections and connections[0] in drop:
# plugin data goes out on the first connection: hand it a surviving address and drop that one instead
keep = next(c for c in connections if c not in gone)
connections[0].set_target(keep.addr, keep.af)
drop.remove(connections[0])
drop.append(keep)
return new[paired:], drop
+103
View File
@@ -0,0 +1,103 @@
"""Best-effort default-gateway lookup (informational only; never affects presence)."""
import asyncio
import re
import socket
import struct
import sys
from typing import List, Optional, Tuple
_RTF_GATEWAY = 0x2
_RTF_REJECT = 0x200
def parse_proc_route_v4(text: str) -> Optional[str]:
"""Default gateway from /proc/net/route (lowest metric wins)."""
best: Optional[Tuple[int, str]] = None
for line in text.splitlines()[1:]:
f = line.split()
if len(f) < 8:
continue
_iface, dest, gw, flags = f[0], f[1], f[2], f[3] # noqa: F841
if dest != "00000000" or not int(flags, 16) & _RTF_GATEWAY:
continue
metric = int(f[6])
if best is None or metric < best[0]:
best = (metric, socket.inet_ntoa(struct.pack("<L", int(gw, 16))))
return best[1] if best else None
def parse_proc_route_v6(text: str) -> Optional[str]:
"""Default gateway from /proc/net/ipv6_route (lowest metric wins)."""
best: Optional[Tuple[int, str]] = None
for line in text.splitlines():
f = line.split()
if len(f) < 10:
continue
dest, plen, nexthop, metric, flags, iface = f[0], f[1], f[4], f[5], f[8], f[9]
if dest != "0" * 32 or plen != "00" or nexthop == "0" * 32 or iface == "lo":
continue
fl = int(flags, 16)
if not fl & _RTF_GATEWAY or fl & _RTF_REJECT:
continue
m = int(metric, 16)
if best is None or m < best[0]:
best = (m, socket.inet_ntop(socket.AF_INET6, bytes.fromhex(nexthop)))
return best[1] if best else None
def parse_macos_route(text: str) -> Optional[str]:
"""Gateway from `route -n get [-inet6] default`."""
m = re.search(r"^\s*gateway:\s*(\S+)", text, re.MULTILINE)
return m.group(1) if m else None
def parse_windows_route(text: str, family: str) -> Optional[str]:
"""Default gateway from `route print -4` / `route print -6` (lowest metric wins)."""
best: Optional[Tuple[int, str]] = None
for line in text.splitlines():
if family == "ipv4":
f = line.split()
if len(f) >= 5 and f[0] == "0.0.0.0" and f[1] == "0.0.0.0" and f[2] != "On-link":
cand = (int(f[4]), f[2])
else:
continue
else:
m = re.match(r"^\s*\d+\s+(\d+)\s+::/0\s+(\S+)\s*$", line)
if not m or m.group(2) == "On-link":
continue
cand = (int(m.group(1)), m.group(2))
if best is None or cand[0] < best[0]:
best = cand
return best[1] if best else None
async def _run(cmd: List[str]) -> str:
proc = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
)
try:
out, _ = await asyncio.wait_for(proc.communicate(), timeout=5)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
raise
return out.decode(errors="replace")
async def default_gateway(family: str) -> Optional[str]:
"""Return the default gateway address for "ipv4"/"ipv6", or None on any failure."""
v4 = family == "ipv4"
try:
if sys.platform.startswith("linux"):
with open("/proc/net/route" if v4 else "/proc/net/ipv6_route") as fh:
text = fh.read()
return parse_proc_route_v4(text) if v4 else parse_proc_route_v6(text)
if sys.platform == "darwin":
cmd = ["route", "-n", "get"] + ([] if v4 else ["-inet6"]) + ["default"]
return parse_macos_route(await _run(cmd))
if sys.platform == "win32":
return parse_windows_route(await _run(["route", "print", "-4" if v4 else "-6"]), family)
except Exception:
return None
return None
+254 -131
View File
@@ -16,14 +16,17 @@ import sys
import time import time
from logging.handlers import SysLogHandler from logging.handlers import SysLogHandler
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional from typing import 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
from . import dnsttl
from .connectivity import Connectivity, set_shared
# Constants # Constants
PORT = 50003 PORT = 50003
@@ -33,31 +36,37 @@ MAXRECV = 32767
# Global state # Global state
running = True running = True
dorestart = False dorestart = False
send_shutdown = False
shutdown_event: Optional[asyncio.Event] = None shutdown_event: Optional[asyncio.Event] = None
active_tasks: List[asyncio.Task] = [] active_tasks: List[asyncio.Task] = []
# Set from config in async_main. Off by default: a CMD packet is an unauthenticated
# UDP datagram, so executing one must be opted into per host.
allow_remote_command = False
class AsyncConnection: class AsyncConnection:
"""Async UDP connection to a heartbeat server.""" """Async UDP connection to a heartbeat server."""
def __init__(self, conn_id: int, addr: str, port: int, af: int, name: str): def __init__(self, conn_id: int, addr: str, port: int, af: int, name: str):
self.conn_id = conn_id self.conn_id = conn_id
self.addr = addr self.addr = addr
self.port = port self.port = port
self.af = af self.af = af
self.name = name self.name = name
self.ackcount = 0 self.ackcount = 0
self.lastack = 0.0 self.lastack = 0.0
self.send_count = 0 self.send_count = 0
self.lastsend = 0.0 self.lastsend = 0.0
self.rtts = [0.0] self.rtts = [0.0]
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._dead = False
self._ever_opened = False self._ever_opened = False
self._open_fail_count = 0 # consecutive failures before first success 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}")
@@ -81,17 +90,25 @@ class AsyncConnection:
except Exception as e: except Exception as e:
self.logger.error(f"Failed to open connection: {e}") self.logger.error(f"Failed to open connection: {e}")
return False return False
def close(self): def close(self):
"""Close the connection.""" """Close the connection."""
if self.transport: if self.transport:
self.transport.close() self.transport.close()
self.transport = None self.transport = None
self.protocol = None self.protocol = None
def set_target(self, addr: str, af: int):
"""Re-point at a new server address; the socket is reopened on the next send if the family changed."""
if af != self.af:
self.close()
self.af = af
self.addr = addr
self.logger = logging.getLogger(f"hbc.conn.{addr}")
async def sendto(self, msg: dict, msg_id: str = "HTB"): async def sendto(self, msg: dict, msg_id: str = "HTB"):
"""Send a message to the server. """Send a message to the server.
Args: Args:
msg: Message dictionary msg: Message dictionary
msg_id: Message ID (HTB, PLG, etc.) msg_id: Message ID (HTB, PLG, etc.)
@@ -105,47 +122,50 @@ class AsyncConnection:
if not self.transport: if not self.transport:
self.logger.error("Cannot send - no transport") self.logger.error("Cannot send - no transport")
return return
# Add standard fields # Add standard fields
msg["name"] = shortname(self.name) msg["name"] = shortname(self.name)
msg["id"] = self.conn_id msg["id"] = self.conn_id
msg["time"] = time.time() msg["time"] = time.time()
# Encode message # Encode message
data = dicttos(msg_id, msg) data = dicttos(msg_id, msg)
# Send # Send
self.transport.sendto(data, (self.addr, self.port)) self.transport.sendto(data, (self.addr, self.port))
self.send_count += 1 self.send_count += 1
self.lastsend = time.time() self.lastsend = time.time()
self.logger.debug(f"Sent {msg_id} message ({len(data)} bytes)") self.logger.debug(f"Sent {msg_id} message ({len(data)} bytes)")
def handle_ack(self, msg: dict, now: float): def handle_ack(self, msg: dict, now: float):
"""Handle ACK message from server. """Handle ACK message from server.
RTT is calculated as: (time ACK received) - (time HTB sent) RTT is calculated as: (time ACK received) - (time HTB sent)
""" """
self.lastack = now self.lastack = now
# Calculate RTT: time ACK received minus time HTB sent # Calculate RTT: time ACK received minus time HTB sent
rtt = (now - self.lastsend) * 1000.0 # Convert to ms rtt = (now - self.lastsend) * 1000.0 # Convert to ms
self.rtts.append(rtt) self.rtts.append(rtt)
if len(self.rtts) > 10: if len(self.rtts) > 10:
self.rtts.pop(0) self.rtts.pop(0)
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):
"""Protocol handler for incoming UDP messages.""" """Protocol handler for incoming UDP messages."""
def __init__(self, connection: AsyncConnection): def __init__(self, connection: AsyncConnection):
self.connection = connection self.connection = connection
self.logger = logging.getLogger("hbc.protocol") self.logger = logging.getLogger("hbc.protocol")
def datagram_received(self, data: bytes, addr): def datagram_received(self, data: bytes, addr):
"""Handle incoming datagram.""" """Handle incoming datagram."""
try: try:
@@ -153,10 +173,10 @@ class HeartbeatProtocol(asyncio.DatagramProtocol):
if not msg: if not msg:
self.logger.warning(f"Failed to parse message from {addr}") self.logger.warning(f"Failed to parse message from {addr}")
return return
now = time.time() now = time.time()
msg_id = msg.get("ID") msg_id = msg.get("ID")
if msg_id == "ACK": if msg_id == "ACK":
self.connection.handle_ack(msg, now) self.connection.handle_ack(msg, now)
elif msg_id == "CMD": elif msg_id == "CMD":
@@ -167,28 +187,36 @@ class HeartbeatProtocol(asyncio.DatagramProtocol):
asyncio.create_task(handle_update(self.connection, msg)) asyncio.create_task(handle_update(self.connection, msg))
else: else:
self.logger.warning(f"Unknown message type: {msg_id}") self.logger.warning(f"Unknown message type: {msg_id}")
except Exception as e: except Exception as e:
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.warning(f"Protocol error on {self.connection.addr}: {exc} — dropping connection") self.logger.warning(f"Protocol error on {self.connection.addr}: {exc} — will retry")
self.connection._dead = True
self.connection.close() self.connection.close()
async def handle_command(conn: AsyncConnection, msg: dict): async def handle_command(conn: AsyncConnection, msg: dict):
"""Execute a command received from server.""" """Execute a command received from server, if allow_remote_command is set."""
import subprocess import subprocess
cmd = msg.get("cmd", "") cmd = msg.get("cmd", "")
if not cmd: if not cmd:
return return
logger = logging.getLogger("hbc.command") logger = logging.getLogger("hbc.command")
if not allow_remote_command:
logger.warning(f"Refused command (allow_remote_command is false): {cmd}")
await conn.sendto({
"service": "command",
"msg": "Refused: allow_remote_command is false",
})
return
logger.info(f"Executing command: {cmd}") logger.info(f"Executing command: {cmd}")
try: try:
result = subprocess.check_output( result = subprocess.check_output(
cmd, shell=True, stderr=subprocess.STDOUT, timeout=30 cmd, shell=True, stderr=subprocess.STDOUT, timeout=30
@@ -203,7 +231,7 @@ async def handle_command(conn: AsyncConnection, msg: dict):
except Exception as e: except Exception as e:
result = str(e) result = str(e)
status = "Error" status = "Error"
# Send response # Send response
response = { response = {
"service": "command", "service": "command",
@@ -264,7 +292,16 @@ async def handle_update(conn: AsyncConnection, _msg: dict): # pyright: ignore[r
stop() stop()
async def heartbeat_sender(conn: AsyncConnection, interval: int): def build_heartbeat_msg(conn, interval: int, connectivity: Optional[Connectivity] = None) -> dict:
"""HTB payload; carries p4/p6 presence flags when the probe knows them."""
msg = {"acks": conn.ackcount, "rtt": conn.rtts[-1], "interval": interval}
if connectivity is not None:
msg.update(connectivity.flags())
return msg
async def heartbeat_sender(conn: AsyncConnection, interval: int,
connectivity: Optional[Connectivity] = None):
"""Send periodic heartbeats, retrying the connection if it is not open. """Send periodic heartbeats, retrying the connection if it is not open.
IPv6 connections that fail to open before their first successful send are IPv6 connections that fail to open before their first successful send are
@@ -274,6 +311,7 @@ async def heartbeat_sender(conn: AsyncConnection, interval: int):
Args: Args:
conn: Connection to send on conn: Connection to send on
interval: Heartbeat interval in seconds interval: Heartbeat interval in seconds
connectivity: Optional probe whose presence flags are attached to every heartbeat
""" """
logger = logging.getLogger("hbc.heartbeat") logger = logging.getLogger("hbc.heartbeat")
IPV6_EARLY_FAIL_LIMIT = 3 IPV6_EARLY_FAIL_LIMIT = 3
@@ -311,11 +349,7 @@ async def heartbeat_sender(conn: AsyncConnection, interval: int):
continue continue
try: try:
msg = { msg = build_heartbeat_msg(conn, interval, connectivity)
"acks": conn.ackcount,
"rtt": conn.rtts[-1],
"interval": interval
}
await conn.sendto(msg, "HTB") await conn.sendto(msg, "HTB")
except asyncio.CancelledError: except asyncio.CancelledError:
@@ -338,55 +372,73 @@ 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, "_interval": plugin.interval}, "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:
try: try:
data = await plugin.collect() data = await plugin.collect()
if data: if data:
# Create PLG message with plugin name # Create PLG message with plugin name and declared interval
plugin_msg = {"plugin": plugin.name, **data} plugin_msg = {"plugin": plugin.name, **data, "_interval": plugin.interval}
await conn.sendto(plugin_msg, "PLG") await conn.sendto(plugin_msg, "PLG")
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(
@@ -395,21 +447,21 @@ async def plugin_collector_interval(
interval: int interval: int
): ):
"""Collect plugins on a specific interval. """Collect plugins on a specific interval.
Args: Args:
conn: Connection to send on conn: Connection to send on
plugins: List of plugins to collect plugins: List of plugins to collect
interval: Collection interval in seconds interval: Collection interval in seconds
""" """
logger = logging.getLogger(f"hbc.plugins.{interval}s") logger = logging.getLogger(f"hbc.plugins.{interval}s")
while running: while running:
for plugin in plugins: for plugin in plugins:
try: try:
data = await plugin.collect() data = await plugin.collect()
if data: if data:
# Don't use encode_plugin_data - create dict directly # Don't use encode_plugin_data - create dict directly
plugin_msg = {"plugin": plugin.name, **data} plugin_msg = {"plugin": plugin.name, **data, "_interval": plugin.interval}
await conn.sendto(plugin_msg, "PLG") await conn.sendto(plugin_msg, "PLG")
logger.debug(f"Sent {plugin.name} data") logger.debug(f"Sent {plugin.name} data")
except asyncio.CancelledError: except asyncio.CancelledError:
@@ -420,7 +472,7 @@ async def plugin_collector_interval(
f"Error collecting {plugin.name}: {e}", f"Error collecting {plugin.name}: {e}",
exc_info=True 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:
@@ -447,118 +499,172 @@ def stop():
"""Stop the event loop.""" """Stop the event loop."""
global running global running
running = False running = False
# Set shutdown event to wake up sleeping tasks # Set shutdown event to wake up sleeping tasks
if shutdown_event: if shutdown_event:
shutdown_event.set() shutdown_event.set()
# Cancel all active tasks # Cancel all active tasks
for task in active_tasks: for task in active_tasks:
if not task.done(): if not task.done():
task.cancel() task.cancel()
async def dns_refresher(connections: List[AsyncConnection], hosts: List[str], port: int, af_filter: int,
name: str, interval: int, connectivity: Optional[Connectivity], next_id: int):
"""Re-resolve the server hosts every TTL seconds and follow address changes.
A failed lookup leaves the connections untouched. Addresses that disappear are re-pointed at
new ones where possible; the rest are retired, and surplus new ones get a connection.
"""
logger = logging.getLogger("hbc.dns")
loop = asyncio.get_running_loop()
delay = await loop.run_in_executor(None, _ttl_delay, hosts, af_filter)
while running:
try:
if shutdown_event:
await asyncio.wait_for(shutdown_event.wait(), timeout=delay)
break
await asyncio.sleep(delay)
except asyncio.TimeoutError:
pass
resolved = await loop.run_in_executor(None, dnsttl.resolve, hosts, port, af_filter)
if resolved is None:
delay = dnsttl.FAILED_RETRY
continue
new, gone = dnsttl.retarget(connections, resolved)
for conn in gone:
if len(connections) > 1:
logger.info(f"Dropping server address {conn.addr}")
conn._dead = True
conn.close()
connections.remove(conn)
for af, addr in new:
conn = AsyncConnection(next_id, addr, port, af, name)
next_id += 1
logger.info(f"Adding server address {addr}")
connections.append(conn)
active_tasks.append(asyncio.create_task(heartbeat_sender(conn, interval, connectivity)))
delay = await loop.run_in_executor(None, _ttl_delay, hosts, af_filter)
def _ttl_delay(hosts: List[str], af_filter: int) -> int:
return dnsttl.refresh_delay([dnsttl.lookup_ttl(h, af_filter) for h in hosts])
async def cleanup(connections: List[AsyncConnection]): async def cleanup(connections: List[AsyncConnection]):
"""Cleanup connections on shutdown.""" """Cleanup connections on shutdown."""
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)
# A SIGHUP restart is not a host shutdown, so don't announce one.
if target and send_shutdown and not dorestart:
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()
break # Only send shutdown on first connection to avoid duplicates
# Give messages time to send # Give messages time to send
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
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 shutdown_event, active_tasks, send_shutdown, allow_remote_command
# Create shutdown event # Create shutdown event
shutdown_event = asyncio.Event() shutdown_event = asyncio.Event()
active_tasks = [] active_tasks = []
allow_remote_command = bool(config.get("allow_remote_command", False))
logger = logging.getLogger("hbc.main") logger = logging.getLogger("hbc.main")
# Setup # Setup
iam = socket.gethostname() iam = socket.gethostname()
if args.name: if args.name:
iam = args.name iam = args.name
hb_hosts = args.hosts hb_hosts = args.hosts
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") if allow_remote_command:
logger.warning("allow_remote_command is true — CMD packets from the server will be executed")
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:
addrs = socket.getaddrinfo(host, hb_port, 0, 0, socket.SOL_UDP)
except socket.gaierror as e:
logger.error(f"Cannot resolve {host}: {e}")
continue
for addr_info in addrs:
af = addr_info[0]
addr = addr_info[4][0]
conn = AsyncConnection(conn_id, addr, hb_port, af, iam) while running and not connections:
if not await conn.open(): for host in hb_hosts:
logger.warning(f"Initial open to {addr} failed, heartbeat sender will retry") try:
connections.append(conn) addrs = socket.getaddrinfo(host, hb_port, af_filter, 0, socket.SOL_UDP)
conn_id += 1 except socket.gaierror as e:
logger.warning(f"Cannot resolve {host}: {e} — retrying in {_retry_delay}s")
continue
for addr_info in addrs:
af = addr_info[0]
addr = addr_info[4][0]
conn = AsyncConnection(conn_id, addr, hb_port, af, iam)
if not await conn.open():
logger.warning(f"Initial open to {addr} failed, heartbeat sender will retry")
connections.append(conn)
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 (DNS resolution failed for all hosts)")
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
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)
break # Only send message on first connection to avoid duplicates
if args.message and not args.daemon: if args.message and not args.daemon:
# Message-only mode # Message-only mode
await cleanup(connections) await cleanup(connections)
return 0 return 0
# Load plugins # Load plugins
registry = PluginRegistry() registry = PluginRegistry()
loader = PluginLoader(registry) loader = PluginLoader(registry)
plugin_dir = Path(__file__).parent / "plugins" plugin_dir = Path(__file__).parent / "plugins"
if plugin_dir.exists(): if plugin_dir.exists():
count = await loader.load_from_directory(plugin_dir, config) count = await loader.load_from_directory(plugin_dir, config)
logger.info(f"Loaded {count} plugins") logger.info(f"Loaded {count} plugins")
else: else:
logger.warning(f"Plugin directory not found: {plugin_dir}") logger.warning(f"Plugin directory not found: {plugin_dir}")
# Setup signal handlers # Setup signal handlers
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
for sig in (signal.SIGTERM, signal.SIGINT): for sig in (signal.SIGTERM, signal.SIGINT):
@@ -570,29 +676,41 @@ async def async_main(args, config):
stop() stop()
loop.add_signal_handler(signal.SIGHUP, _sighup) loop.add_signal_handler(signal.SIGHUP, _sighup)
# Start async tasks # Start async tasks
# Per-protocol internet presence probe (shared with the network_monitor plugin)
net_cfg = dict((config.get("plugins") or {}).get("network_monitor")
or config.get("network_monitor") or {})
connectivity = None
if net_cfg.get("probe_enabled", True):
connectivity = Connectivity.from_config(net_cfg)
set_shared(connectivity)
active_tasks.append(asyncio.create_task(connectivity.run(shutdown_event)))
# Heartbeat senders (one per connection) # Heartbeat senders (one per connection)
for conn in connections: for conn in connections:
task = asyncio.create_task(heartbeat_sender(conn, interval)) task = asyncio.create_task(heartbeat_sender(conn, interval, connectivity))
active_tasks.append(task) active_tasks.append(task)
active_tasks.append(asyncio.create_task(
dns_refresher(connections, hb_hosts, hb_port, af_filter, iam, interval, connectivity, conn_id)))
# Plugin collector (uses all connections, but we'll use first one) # Plugin collector (uses all connections, but we'll use first one)
if connections and registry.get_enabled(): if connections and registry.get_enabled():
task = asyncio.create_task(plugin_collector(connections[0], registry)) task = asyncio.create_task(plugin_collector(connections[0], registry))
active_tasks.append(task) active_tasks.append(task)
# Wait for stop or tasks to complete # Wait for stop or tasks to complete
try: try:
await asyncio.gather(*active_tasks, return_exceptions=True) await asyncio.gather(*active_tasks, return_exceptions=True)
except asyncio.CancelledError: except asyncio.CancelledError:
logger.info("Tasks cancelled") logger.info("Tasks cancelled")
# Cleanup # Cleanup
logger.info("Shutting down...") logger.info("Shutting down...")
await cleanup(connections) await cleanup(connections)
await loader.unload_all() await loader.unload_all()
return 0 return 0
@@ -610,11 +728,11 @@ def daemonize(
except OSError as e: except OSError as e:
sys.stderr.write(f"fork #1 failed: {e}\n") sys.stderr.write(f"fork #1 failed: {e}\n")
os._exit(1) os._exit(1)
os.chdir(working_dir) os.chdir(working_dir)
os.setsid() os.setsid()
os.umask(0) os.umask(0)
try: try:
pid = os.fork() pid = os.fork()
if pid > 0: if pid > 0:
@@ -622,14 +740,14 @@ def daemonize(
except OSError as e: except OSError as e:
sys.stderr.write(f"fork #2 failed: {e}\n") sys.stderr.write(f"fork #2 failed: {e}\n")
sys.exit(1) sys.exit(1)
sys.stdout.flush() sys.stdout.flush()
sys.stderr.flush() sys.stderr.flush()
si = open(stdin, "r") si = open(stdin, "r")
so = open(stdout, "a+") so = open(stdout, "a+")
se = open(stderr, "a+") se = open(stderr, "a+")
os.dup2(si.fileno(), sys.stdin.fileno()) os.dup2(si.fileno(), sys.stdin.fileno())
os.dup2(so.fileno(), sys.stdout.fileno()) os.dup2(so.fileno(), sys.stdout.fileno())
os.dup2(se.fileno(), sys.stderr.fileno()) os.dup2(se.fileno(), sys.stderr.fileno())
@@ -708,6 +826,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="+",
@@ -718,18 +839,17 @@ def build_parser():
def main(argv=None): def main(argv=None):
"""Main entry point.""" """Main entry point."""
global running, dorestart
parser = build_parser() parser = build_parser()
args = parser.parse_args(argv) args = parser.parse_args(argv)
# Setup logging # Setup logging
log_level = logging.WARNING log_level = logging.WARNING
if args.verbose: if args.verbose:
log_level = logging.INFO log_level = logging.INFO
if args.debug: if args.debug:
log_level = logging.DEBUG log_level = logging.DEBUG
logging.basicConfig( logging.basicConfig(
level=log_level, level=log_level,
format="%(asctime)s %(name)s %(levelname)s: %(message)s", format="%(asctime)s %(name)s %(levelname)s: %(message)s",
@@ -738,14 +858,14 @@ def main(argv=None):
# Load config # Load config
config = load_config(args.configfile) config = load_config(args.configfile)
# Daemonize if requested # Daemonize if requested
if args.daemon: if args.daemon:
logging.info("Daemonizing...") logging.info("Daemonizing...")
daemonize() daemonize()
_reconfigure_logging_for_daemon(log_level) _reconfigure_logging_for_daemon(log_level)
logging.info(f"hbc starting, sending heartbeat to {', '.join(args.hosts)}") logging.info(f"hbc starting, sending heartbeat to {', '.join(args.hosts)}")
# Run async main # Run async main
try: try:
exit_code = asyncio.run(async_main(args, config)) exit_code = asyncio.run(async_main(args, config))
@@ -755,12 +875,15 @@ def main(argv=None):
except Exception as e: except Exception as e:
logging.error(f"Fatal error: {e}", exc_info=True) logging.error(f"Fatal error: {e}", exc_info=True)
exit_code = 1 exit_code = 1
# Handle restart # Handle restart
if dorestart: if dorestart:
logging.info("Restarting...") logging.info("Restarting...")
os.execv(sys.argv[0], sys.argv) # Drop -b/--boot so the re-exec'd process doesn't re-announce a boot;
# a SIGHUP restart is not a host reboot.
restart_argv = [sys.argv[0]] + [a for a in sys.argv[1:] if a not in ("-b", "--boot")]
os.execv(restart_argv[0], restart_argv)
sys.exit(exit_code) sys.exit(exit_code)
+73
View File
@@ -0,0 +1,73 @@
"""System ping helpers shared by ping_monitor and the connectivity probe."""
import asyncio
import re
import sys
from typing import Dict, List, Optional
_INF = float("inf")
class PingUnavailable(Exception):
"""The system ping binary could not be executed."""
def ping_command(host: str, count: int, timeout: int, family: Optional[int] = None) -> List[str]:
"""Build the platform ping command. family is 4, 6 or None (let ping decide)."""
if sys.platform == "win32":
cmd = ["ping", "-n", str(count), "-w", str(timeout * 1000)]
if family:
cmd.append(f"-{family}")
return cmd + [host]
if sys.platform == "darwin" and family:
if family == 6:
return ["ping6", "-c", str(count), host]
return ["ping", "-c", str(count), "-W", str(timeout * 1000), host]
cmd = ["ping", "-c", str(count), "-W", str(timeout)]
if family:
cmd.append(f"-{family}")
return cmd + [host]
def parse_ping_output(output: str) -> Dict[str, float]:
"""Parse rtt_min/avg/max/loss from ping output (Linux, macOS, BSD)."""
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))
# Linux: "rtt min/avg/max/mdev = x/x/x/x ms"; macOS: "round-trip min/avg/max/stddev = ..."
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,
}
return {"rtt_min": _INF, "rtt_avg": _INF, "rtt_max": _INF, "loss": loss}
async def run_ping(host: str, count: int, timeout: int, family: Optional[int] = None) -> Dict[str, float]:
"""Run ping and return parsed metrics.
Raises PingUnavailable if the ping binary is missing and asyncio.TimeoutError
(after killing the process) if it hangs.
"""
cmd = ping_command(host, count, timeout, family)
try:
proc = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
)
except FileNotFoundError as e:
raise PingUnavailable(str(e)) from e
try:
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout * count + 2)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
raise
return parse_ping_output(stdout.decode(errors="replace"))
+83 -80
View File
@@ -17,7 +17,7 @@ import logging
import sys import sys
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Type from typing import Any, Dict, List, Optional
class Plugin(ABC): class Plugin(ABC):
@@ -31,13 +31,13 @@ class Plugin(ABC):
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. skip_reason: Set by plugin before returning False from initialize(); causes loader to log INFO instead of WARNING.
""" """
name: str = "" name: str = ""
version: str = "1.0.0" version: str = "1.0.0"
description: str = "" description: str = ""
interval: int = 0 interval: int = 0
enabled: bool = True enabled: bool = True
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.
@@ -48,51 +48,51 @@ class Plugin(ABC):
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 self.skip_reason: Optional[str] = None
@abstractmethod @abstractmethod
async def initialize(self) -> bool: async def initialize(self) -> bool:
"""Initialize plugin (load resources, check dependencies). """Initialize plugin (load resources, check dependencies).
Called once when plugin is loaded. Plugins should validate dependencies Called once when plugin is loaded. Plugins should validate dependencies
(e.g., check if psutil is available) and prepare any resources. (e.g., check if psutil is available) and prepare any resources.
Returns: Returns:
True if initialization succeeded, False otherwise True if initialization succeeded, False otherwise
""" """
pass pass
@abstractmethod @abstractmethod
async def collect(self) -> Dict[str, Any]: async def collect(self) -> Dict[str, Any]:
"""Collect data from the system. """Collect data from the system.
This is the main method called on each collection interval. Should return This is the main method called on each collection interval. Should return
a dictionary of key-value pairs representing the collected data. a dictionary of key-value pairs representing the collected data.
Keys should be strings (metric names). Values can be: Keys should be strings (metric names). Values can be:
- Scalars: int, float, str, bool - Scalars: int, float, str, bool
- Lists/dicts (will be serialized appropriately) - Lists/dicts (will be serialized appropriately)
Returns: Returns:
Dictionary of collected metrics, or empty dict on error Dictionary of collected metrics, or empty dict on error
""" """
pass pass
async def cleanup(self) -> None: async def cleanup(self) -> None:
"""Cleanup plugin resources before shutdown. """Cleanup plugin resources before shutdown.
Called when plugin is being unloaded or on system shutdown. Called when plugin is being unloaded or on system shutdown.
Override to release resources, close connections, etc. Override to release resources, close connections, etc.
""" """
pass pass
def validate_data(self, data: Dict[str, Any]) -> bool: def validate_data(self, data: Dict[str, Any]) -> bool:
"""Validate collected data before sending to server. """Validate collected data before sending to server.
Override to implement custom validation logic. Override to implement custom validation logic.
Args: Args:
data: Data returned from collect() data: Data returned from collect()
Returns: Returns:
True if data is valid, False otherwise True if data is valid, False otherwise
""" """
@@ -101,46 +101,46 @@ class Plugin(ABC):
class InfoPlugin(Plugin): class InfoPlugin(Plugin):
"""Plugin for collecting static or rarely-changing information. """Plugin for collecting static or rarely-changing information.
InfoPlugins collect data that doesn't change frequently: InfoPlugins collect data that doesn't change frequently:
- OS name and version - OS name and version
- Hardware specifications (CPU model, RAM size) - Hardware specifications (CPU model, RAM size)
- Network interface MAC addresses - Network interface MAC addresses
Characteristics: Characteristics:
- interval = 0 (collected once at startup by default) - interval = 0 (collected once at startup by default)
- Can specify interval > 0 for periodic refresh (e.g., check for hardware changes) - Can specify interval > 0 for periodic refresh (e.g., check for hardware changes)
- Data is cached and reused until next collection - Data is cached and reused until next collection
""" """
interval: int = 0 # Collect once at startup interval: int = 0 # Collect once at startup
def __init__(self, config: Optional[Dict[str, Any]] = None): def __init__(self, config: Optional[Dict[str, Any]] = None):
super().__init__(config) super().__init__(config)
self._cached_data: Optional[Dict[str, Any]] = None self._cached_data: Optional[Dict[str, Any]] = None
async def get_cached_data(self) -> Optional[Dict[str, Any]]: async def get_cached_data(self) -> Optional[Dict[str, Any]]:
"""Get cached data if available (avoids re-collection). """Get cached data if available (avoids re-collection).
Returns: Returns:
Cached data dict, or None if not yet collected Cached data dict, or None if not yet collected
""" """
return self._cached_data return self._cached_data
async def collect(self) -> Dict[str, Any]: async def collect(self) -> Dict[str, Any]:
"""Collect and cache static information.""" """Collect and cache static information."""
if self._cached_data is None: if self._cached_data is None:
self._cached_data = await self._collect_info() self._cached_data = await self._collect_info()
return self._cached_data return self._cached_data
@abstractmethod @abstractmethod
async def _collect_info(self) -> Dict[str, Any]: async def _collect_info(self) -> Dict[str, Any]:
"""Internal method to perform actual data collection. """Internal method to perform actual data collection.
Override this method instead of collect() for InfoPlugins. Override this method instead of collect() for InfoPlugins.
""" """
pass pass
def invalidate_cache(self) -> None: def invalidate_cache(self) -> None:
"""Force re-collection on next collect() call.""" """Force re-collection on next collect() call."""
self._cached_data = None self._cached_data = None
@@ -148,33 +148,33 @@ class InfoPlugin(Plugin):
class MonitorPlugin(Plugin): class MonitorPlugin(Plugin):
"""Plugin for collecting periodic monitoring data. """Plugin for collecting periodic monitoring data.
MonitorPlugins collect time-series metrics that change frequently: MonitorPlugins collect time-series metrics that change frequently:
- CPU usage percentage - CPU usage percentage
- Memory consumption - Memory consumption
- Disk I/O statistics - Disk I/O statistics
- Network traffic - Network traffic
Characteristics: Characteristics:
- interval > 0 (e.g., 30 seconds for CPU, 60 for disk) - interval > 0 (e.g., 30 seconds for CPU, 60 for disk)
- Collected continuously on schedule - Collected continuously on schedule
- Data includes timestamps for time-series tracking - Data includes timestamps for time-series tracking
""" """
interval: int = 30 # Default: collect every 30 seconds interval: int = 30 # Default: collect every 30 seconds
def __init__(self, config: Optional[Dict[str, Any]] = None): def __init__(self, config: Optional[Dict[str, Any]] = None):
super().__init__(config) super().__init__(config)
self._last_reading: Optional[Dict[str, Any]] = None self._last_reading: Optional[Dict[str, Any]] = None
def get_last_reading(self) -> Optional[Dict[str, Any]]: def get_last_reading(self) -> Optional[Dict[str, Any]]:
"""Get the last collected reading. """Get the last collected reading.
Returns: Returns:
Last reading dict with timestamp, or None if not yet collected Last reading dict with timestamp, or None if not yet collected
""" """
return self._last_reading return self._last_reading
async def collect(self) -> Dict[str, Any]: async def collect(self) -> Dict[str, Any]:
"""Collect monitoring data and store as last reading.""" """Collect monitoring data and store as last reading."""
data = await self._collect_metrics() data = await self._collect_metrics()
@@ -184,11 +184,11 @@ class MonitorPlugin(Plugin):
data['_timestamp'] = time.time() data['_timestamp'] = time.time()
self._last_reading = data self._last_reading = data
return data return data
@abstractmethod @abstractmethod
async def _collect_metrics(self) -> Dict[str, Any]: async def _collect_metrics(self) -> Dict[str, Any]:
"""Internal method to perform actual metric collection. """Internal method to perform actual metric collection.
Override this method instead of collect() for MonitorPlugins. Override this method instead of collect() for MonitorPlugins.
""" """
pass pass
@@ -196,38 +196,38 @@ class MonitorPlugin(Plugin):
class PluginRegistry: class PluginRegistry:
"""Registry for managing loaded plugins. """Registry for managing loaded plugins.
Maintains a collection of loaded plugins and provides methods to Maintains a collection of loaded plugins and provides methods to
query plugins by name, type, or interval. query plugins by name, type, or interval.
""" """
def __init__(self): def __init__(self):
self._plugins: Dict[str, Plugin] = {} self._plugins: Dict[str, Plugin] = {}
self.logger = logging.getLogger("plugin.registry") self.logger = logging.getLogger("plugin.registry")
def register(self, plugin: Plugin) -> bool: def register(self, plugin: Plugin) -> bool:
"""Register a plugin instance. """Register a plugin instance.
Args: Args:
plugin: Plugin instance to register plugin: Plugin instance to register
Returns: Returns:
True if registered successfully, False if name conflict True if registered successfully, False if name conflict
""" """
if plugin.name in self._plugins: if plugin.name in self._plugins:
self.logger.error(f"Plugin '{plugin.name}' already registered") self.logger.error(f"Plugin '{plugin.name}' already registered")
return False return False
self._plugins[plugin.name] = plugin self._plugins[plugin.name] = plugin
self.logger.info(f"Registered plugin: {plugin.name} v{plugin.version}") self.logger.info(f"Registered plugin: {plugin.name} v{plugin.version}")
return True return True
def unregister(self, name: str) -> bool: def unregister(self, name: str) -> bool:
"""Unregister a plugin by name. """Unregister a plugin by name.
Args: Args:
name: Plugin name to unregister name: Plugin name to unregister
Returns: Returns:
True if unregistered, False if not found True if unregistered, False if not found
""" """
@@ -236,43 +236,43 @@ class PluginRegistry:
self.logger.info(f"Unregistered plugin: {name}") self.logger.info(f"Unregistered plugin: {name}")
return True return True
return False return False
def get(self, name: str) -> Optional[Plugin]: def get(self, name: str) -> Optional[Plugin]:
"""Get plugin by name. """Get plugin by name.
Args: Args:
name: Plugin name name: Plugin name
Returns: Returns:
Plugin instance or None if not found Plugin instance or None if not found
""" """
return self._plugins.get(name) return self._plugins.get(name)
def get_all(self) -> List[Plugin]: def get_all(self) -> List[Plugin]:
"""Get all registered plugins.""" """Get all registered plugins."""
return list(self._plugins.values()) return list(self._plugins.values())
def get_enabled(self) -> List[Plugin]: def get_enabled(self) -> List[Plugin]:
"""Get all enabled plugins.""" """Get all enabled plugins."""
return [p for p in self._plugins.values() if p.enabled] return [p for p in self._plugins.values() if p.enabled]
def get_by_type(self, plugin_type: Type[Plugin]) -> List[Plugin]: def get_by_type(self, plugin_type: type) -> List[Plugin]:
"""Get all plugins of a specific type. """Get all plugins of a specific type.
Args: Args:
plugin_type: Plugin class (InfoPlugin or MonitorPlugin) plugin_type: Plugin class (InfoPlugin or MonitorPlugin)
Returns: Returns:
List of plugins matching the type List of plugins matching the type
""" """
return [p for p in self._plugins.values() if isinstance(p, plugin_type)] return [p for p in self._plugins.values() if isinstance(p, plugin_type)]
def get_by_interval(self, interval: int) -> List[Plugin]: def get_by_interval(self, interval: int) -> List[Plugin]:
"""Get all plugins with a specific collection interval. """Get all plugins with a specific collection interval.
Args: Args:
interval: Interval in seconds (0 for one-time collection) interval: Interval in seconds (0 for one-time collection)
Returns: Returns:
List of plugins with matching interval List of plugins with matching interval
""" """
@@ -281,51 +281,51 @@ class PluginRegistry:
class PluginLoader: class PluginLoader:
"""Load plugins from filesystem and instantiate them. """Load plugins from filesystem and instantiate them.
Scans plugin directories for Python modules containing Plugin subclasses, Scans plugin directories for Python modules containing Plugin subclasses,
loads them dynamically, and registers them with the PluginRegistry. loads them dynamically, and registers them with the PluginRegistry.
""" """
def __init__(self, registry: PluginRegistry): def __init__(self, registry: PluginRegistry):
self.registry = registry self.registry = registry
self.logger = logging.getLogger("plugin.loader") self.logger = logging.getLogger("plugin.loader")
self._loaded_modules: Dict[str, Any] = {} self._loaded_modules: Dict[str, Any] = {}
async def load_from_directory( async def load_from_directory(
self, self,
directory: Path, directory: Path,
config: Optional[Dict[str, Any]] = None config: Optional[Dict[str, Any]] = None
) -> int: ) -> int:
"""Load all plugins from a directory. """Load all plugins from a directory.
Scans for .py files, imports them, finds Plugin subclasses, Scans for .py files, imports them, finds Plugin subclasses,
instantiates them with config, initializes, and registers. instantiates them with config, initializes, and registers.
Args: Args:
directory: Path to plugin directory directory: Path to plugin directory
config: Configuration dict (may contain per-plugin config) config: Configuration dict (may contain per-plugin config)
Returns: Returns:
Number of plugins successfully loaded Number of plugins successfully loaded
""" """
if not directory.exists() or not directory.is_dir(): if not directory.exists() or not directory.is_dir():
self.logger.warning(f"Plugin directory not found: {directory}") self.logger.warning(f"Plugin directory not found: {directory}")
return 0 return 0
loaded_count = 0 loaded_count = 0
raw_config = config or {} raw_config = config or {}
# Per-plugin config lives under the 'plugins' key or at top-level. # Per-plugin config lives under the 'plugins' key or at top-level.
# CLIENT_DEFAULTS seeds "plugins": {} so the key always exists; check # CLIENT_DEFAULTS seeds "plugins": {} so the key always exists; check
# both the subdict and top-level so that either layout in .hbc.yaml works. # both the subdict and top-level so that either layout in .hbc.yaml works.
plugins_subconfig = raw_config.get("plugins", {}) 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"):
if plugin_file.name.startswith("_"): if plugin_file.name.startswith("_"):
continue # Skip __init__.py and private modules continue # Skip __init__.py and private modules
self.logger.debug(f"Processing plugin file: {plugin_file.name}") self.logger.debug(f"Processing plugin file: {plugin_file.name}")
try: try:
# Load module dynamically # Load module dynamically
module_name = f"plugins.{plugin_file.stem}" module_name = f"plugins.{plugin_file.stem}"
@@ -333,17 +333,17 @@ class PluginLoader:
if not spec or not spec.loader: if not spec or not spec.loader:
self.logger.warning(f"Could not create spec for {plugin_file}") self.logger.warning(f"Could not create spec for {plugin_file}")
continue continue
module = importlib.util.module_from_spec(spec) module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module sys.modules[module_name] = module
spec.loader.exec_module(module) spec.loader.exec_module(module)
self._loaded_modules[module_name] = module self._loaded_modules[module_name] = module
self.logger.debug(f"Loaded module: {module_name}") self.logger.debug(f"Loaded module: {module_name}")
# Track which plugin classes we've already processed to avoid duplicates # Track which plugin classes we've already processed to avoid duplicates
processed_classes = set() processed_classes = set()
# Find Plugin subclasses in module # Find Plugin subclasses in module
for name, obj in inspect.getmembers(module, inspect.isclass): for name, obj in inspect.getmembers(module, inspect.isclass):
# Skip base classes and non-Plugin classes # Skip base classes and non-Plugin classes
@@ -353,20 +353,23 @@ class PluginLoader:
if not issubclass(obj, Plugin): if not issubclass(obj, Plugin):
self.logger.debug(f"Skipping non-Plugin class: {name}") self.logger.debug(f"Skipping non-Plugin class: {name}")
continue continue
# Skip if we've already processed this class (handles module-level aliases) # Skip if we've already processed this class (handles module-level aliases)
if id(obj) in processed_classes: if id(obj) in processed_classes:
self.logger.debug(f"Skipping duplicate reference to: {obj.__name__}") self.logger.debug(f"Skipping duplicate reference to: {obj.__name__}")
continue continue
processed_classes.add(id(obj)) processed_classes.add(id(obj))
self.logger.debug(f"Found plugin class: {name}") self.logger.debug(f"Found plugin class: {name}")
# Instantiate plugin with config — check plugins subdict first, # Instantiate plugin with config — check plugins subdict first,
# then top-level keys (e.g. nagios_runner: ... at root of config). # then top-level keys (e.g. nagios_runner: ... at root of config).
plugin_instance_config = plugins_subconfig.get(obj.name) or raw_config.get(obj.name, {}) plugin_instance_config = dict(plugins_subconfig.get(obj.name) or raw_config.get(obj.name) or {})
# Propagate top-level owner so os_info (and any future plugin) can report it.
if "owner" in raw_config and "owner" not in plugin_instance_config:
plugin_instance_config["owner"] = raw_config["owner"]
plugin = obj(config=plugin_instance_config) plugin = obj(config=plugin_instance_config)
# Initialize plugin # Initialize plugin
try: try:
initialized = await plugin.initialize() initialized = await plugin.initialize()
@@ -386,7 +389,7 @@ class PluginLoader:
exc_info=True exc_info=True
) )
continue continue
# Register with registry # Register with registry
if self.registry.register(plugin): if self.registry.register(plugin):
loaded_count += 1 loaded_count += 1
@@ -394,15 +397,15 @@ class PluginLoader:
f"Loaded plugin: {plugin.name} v{plugin.version} " f"Loaded plugin: {plugin.name} v{plugin.version} "
f"(interval: {plugin.interval}s)" f"(interval: {plugin.interval}s)"
) )
except Exception as e: except Exception as e:
self.logger.error( self.logger.error(
f"Error loading plugin from {plugin_file}: {e}", f"Error loading plugin from {plugin_file}: {e}",
exc_info=True exc_info=True
) )
return loaded_count return loaded_count
async def unload_all(self) -> None: async def unload_all(self) -> None:
"""Unload all plugins and cleanup resources.""" """Unload all plugins and cleanup resources."""
for plugin in self.registry.get_all(): for plugin in self.registry.get_all():
@@ -414,7 +417,7 @@ class PluginLoader:
exc_info=True exc_info=True
) )
self.registry.unregister(plugin.name) self.registry.unregister(plugin.name)
# Remove loaded modules # Remove loaded modules
for module_name in self._loaded_modules: for module_name in self._loaded_modules:
if module_name in sys.modules: if module_name in sys.modules:
+26 -21
View File
@@ -5,8 +5,6 @@ load average, and process counts.
""" """
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
import sys
from pathlib import Path
# Import from parent package # Import from parent package
from hbd.client.plugin import MonitorPlugin from hbd.client.plugin import MonitorPlugin
@@ -14,7 +12,7 @@ from hbd.client.plugin import MonitorPlugin
class CPUMonitorPlugin(MonitorPlugin): class CPUMonitorPlugin(MonitorPlugin):
"""Monitor CPU usage and load. """Monitor CPU usage and load.
Collects: Collects:
- Overall CPU usage percentage - Overall CPU usage percentage
- Per-core CPU usage (if enabled in config) - Per-core CPU usage (if enabled in config)
@@ -22,28 +20,28 @@ class CPUMonitorPlugin(MonitorPlugin):
- Process count - Process count
- CPU frequency (if available) - CPU frequency (if available)
""" """
name = "cpu_monitor" name = "cpu_monitor"
version = "1.0.0" version = "1.0.0"
description = "CPU usage and load monitoring" description = "CPU usage and load monitoring"
interval = 300 # MonitorPlugin: collect every 5 minutes by default interval = 300 # MonitorPlugin: collect every 5 minutes by default
def __init__(self, config: Optional[Dict[str, Any]] = None): def __init__(self, config: Optional[Dict[str, Any]] = None):
super().__init__(config) super().__init__(config)
self.psutil = None self.psutil: Any = None
self.per_core = config.get("per_core", False) if config else False self.per_core = config.get("per_core", False) if config else False
self.interval = config.get("interval", 300) if config else 300 self.interval = config.get("interval", 300) if config else 300
async def initialize(self) -> bool: async def initialize(self) -> bool:
"""Initialize the CPU monitor plugin. """Initialize the CPU monitor plugin.
Checks if psutil is available. Checks if psutil is available.
Returns: Returns:
True if psutil is available, False otherwise True if psutil is available, False otherwise
""" """
self.logger.info(f"Initializing {self.name} plugin") self.logger.info(f"Initializing {self.name} plugin")
try: try:
import psutil import psutil
self.psutil = psutil self.psutil = psutil
@@ -54,23 +52,23 @@ class CPUMonitorPlugin(MonitorPlugin):
"psutil module not available. Install with: pip install psutil" "psutil module not available. Install with: pip install psutil"
) )
return False return False
async def _collect_metrics(self) -> Dict[str, Any]: async def _collect_metrics(self) -> Dict[str, Any]:
"""Collect CPU metrics. """Collect CPU metrics.
Returns: Returns:
Dictionary with CPU metrics Dictionary with CPU metrics
""" """
if not self.psutil: if not self.psutil:
return {} return {}
try: try:
data = {} data = {}
# Overall CPU usage percentage (non-blocking, interval=1 for accuracy) # Overall CPU usage percentage (non-blocking, interval=1 for accuracy)
# Note: first call to cpu_percent() returns 0.0, subsequent calls work correctly # Note: first call to cpu_percent() returns 0.0, subsequent calls work correctly
data["cpu_percent"] = self.psutil.cpu_percent(interval=1) data["cpu_percent"] = self.psutil.cpu_percent(interval=1)
# Per-core CPU usage (if enabled) # Per-core CPU usage (if enabled)
if self.per_core: if self.per_core:
per_core_percents = self.psutil.cpu_percent(interval=0, percpu=True) per_core_percents = self.psutil.cpu_percent(interval=0, percpu=True)
@@ -79,7 +77,7 @@ class CPUMonitorPlugin(MonitorPlugin):
else: else:
# Just report core count # Just report core count
data["cpu_core_count"] = self.psutil.cpu_count() data["cpu_core_count"] = self.psutil.cpu_count()
# Load average (Unix-like systems only) # Load average (Unix-like systems only)
try: try:
load_avg = self.psutil.getloadavg() load_avg = self.psutil.getloadavg()
@@ -89,13 +87,13 @@ class CPUMonitorPlugin(MonitorPlugin):
except (AttributeError, OSError): except (AttributeError, OSError):
# Not available on Windows # Not available on Windows
pass pass
# Process count # Process count
try: try:
data["process_count"] = len(self.psutil.pids()) data["process_count"] = len(self.psutil.pids())
except Exception as e: except Exception as e:
self.logger.warning(f"Could not get process count: {e}") self.logger.warning(f"Could not get process count: {e}")
# CPU frequency (if available) # CPU frequency (if available)
try: try:
freq = self.psutil.cpu_freq() freq = self.psutil.cpu_freq()
@@ -107,7 +105,7 @@ class CPUMonitorPlugin(MonitorPlugin):
# Not available on all systems, or may fail on FreeBSD with sysctl issues # Not available on all systems, or may fail on FreeBSD with sysctl issues
self.logger.debug(f"CPU frequency not available: {e}") self.logger.debug(f"CPU frequency not available: {e}")
pass pass
# CPU times (user, system, idle, etc.) # CPU times (user, system, idle, etc.)
try: try:
cpu_times = self.psutil.cpu_times_percent(interval=0) cpu_times = self.psutil.cpu_times_percent(interval=0)
@@ -118,12 +116,19 @@ 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"
) )
return data return data
except Exception as e: except Exception as e:
self.logger.error(f"Error collecting CPU metrics: {e}", exc_info=True) self.logger.error(f"Error collecting CPU metrics: {e}", exc_info=True)
return {} return {}
+34 -34
View File
@@ -5,12 +5,12 @@ Collects disk usage and I/O statistics using psutil.
""" """
import logging import logging
from typing import Dict, Any, Optional, List from typing import Dict, Any, Optional
try: try:
import psutil import psutil
except ImportError: except ImportError:
psutil = None psutil = None # type: ignore[assignment]
from hbd.client.plugin import MonitorPlugin from hbd.client.plugin import MonitorPlugin
@@ -20,27 +20,27 @@ logger = logging.getLogger(__name__)
class DiskMonitorPlugin(MonitorPlugin): class DiskMonitorPlugin(MonitorPlugin):
""" """
Monitor disk usage and I/O statistics. Monitor disk usage and I/O statistics.
Collects: Collects:
- Disk partition information - Disk partition information
- Disk usage per partition (total, used, free, percent) - Disk usage per partition (total, used, free, percent)
- Disk I/O counters (read/write bytes, read/write count) - Disk I/O counters (read/write bytes, read/write count)
- Disk I/O time statistics - Disk I/O time statistics
Configuration: Configuration:
interval: Collection interval in seconds (default: 300) interval: Collection interval in seconds (default: 300)
partitions: List of mount points to monitor (default: all) partitions: List of mount points to monitor (default: all)
include_io: Include disk I/O statistics (default: True) include_io: Include disk I/O statistics (default: True)
exclude_types: List of filesystem types to exclude (default: tmpfs, devtmpfs, squashfs) exclude_types: List of filesystem types to exclude (default: tmpfs, devtmpfs, squashfs)
""" """
name = "disk_monitor" name = "disk_monitor"
interval = 300 # Collect every 5 minutes by default interval = 300 # Collect every 5 minutes by default
def __init__(self, config: Optional[Dict[str, Any]] = None): def __init__(self, config: Optional[Dict[str, Any]] = None):
""" """
Initialize the disk monitor plugin. Initialize the disk monitor plugin.
Args: Args:
config: Optional configuration dict with keys: config: Optional configuration dict with keys:
- interval: Collection interval in seconds (default: 300) - interval: Collection interval in seconds (default: 300)
@@ -53,34 +53,34 @@ class DiskMonitorPlugin(MonitorPlugin):
self.include_io = self.config.get('include_io', True) self.include_io = self.config.get('include_io', True)
self.exclude_types = set(self.config.get('exclude_types', ['tmpfs', 'devtmpfs', 'squashfs'])) self.exclude_types = set(self.config.get('exclude_types', ['tmpfs', 'devtmpfs', 'squashfs']))
self.interval = self.config.get('interval', 300) self.interval = self.config.get('interval', 300)
if psutil is None: if psutil is None:
raise ImportError("psutil library is required for disk_monitor plugin") raise ImportError("psutil library is required for disk_monitor plugin")
# Store previous I/O counters for delta calculation # Store previous I/O counters for delta calculation
self._prev_io = {} self._prev_io: Dict[str, Any] = {}
async def initialize(self): async def initialize(self):
"""Initialize the plugin (check psutil availability).""" """Initialize the plugin (check psutil availability)."""
if psutil is None: if psutil is None:
logger.error("psutil not available - disk_monitor cannot run") logger.error("psutil not available - disk_monitor cannot run")
return False return False
logger.info(f"Disk monitor initialized (interval: {self.interval}s, io: {self.include_io})") logger.info(f"Disk monitor initialized (interval: {self.interval}s, io: {self.include_io})")
# Initialize I/O counters if available # Initialize I/O counters if available
if self.include_io: if self.include_io:
try: try:
self._prev_io = psutil.disk_io_counters(perdisk=True) self._prev_io = psutil.disk_io_counters(perdisk=True)
except Exception as e: except Exception as e:
logger.warning(f"Could not initialize disk I/O counters: {e}") logger.warning(f"Could not initialize disk I/O counters: {e}")
return True return True
async def collect(self) -> Dict[str, Any]: async def collect(self) -> Dict[str, Any]:
""" """
Collect current disk statistics. Collect current disk statistics.
Returns: Returns:
Dictionary with disk metrics organized by partition: Dictionary with disk metrics organized by partition:
- partitions: Dict of partition data, keyed by mount point - partitions: Dict of partition data, keyed by mount point
@@ -103,7 +103,7 @@ class DiskMonitorPlugin(MonitorPlugin):
if psutil is None: if psutil is None:
logger.error("psutil not available") logger.error("psutil not available")
return {} return {}
try: try:
data = await self._collect_metrics() data = await self._collect_metrics()
logger.debug(f"Collected disk metrics: {len(data.get('partitions', {}))} partitions") logger.debug(f"Collected disk metrics: {len(data.get('partitions', {}))} partitions")
@@ -111,24 +111,24 @@ class DiskMonitorPlugin(MonitorPlugin):
except Exception as e: except Exception as e:
logger.error(f"Error collecting disk metrics: {e}") logger.error(f"Error collecting disk metrics: {e}")
return {"error": str(e)} return {"error": str(e)}
async def _collect_metrics(self) -> Dict[str, Any]: async def _collect_metrics(self) -> Dict[str, Any]:
"""Collect disk metrics from psutil.""" """Collect disk metrics from psutil."""
metrics = {} metrics: Dict[str, Any] = {}
# Collect partition usage # Collect partition usage
partitions_data = {} partitions_data = {}
partitions = psutil.disk_partitions(all=False) partitions = psutil.disk_partitions(all=False)
for partition in partitions: for partition in partitions:
# Skip unwanted filesystem types # Skip unwanted filesystem types
if partition.fstype in self.exclude_types: if partition.fstype in self.exclude_types:
continue continue
# Skip if we're only monitoring specific partitions # Skip if we're only monitoring specific partitions
if self.partitions and partition.mountpoint not in self.partitions: if self.partitions and partition.mountpoint not in self.partitions:
continue continue
try: try:
usage = psutil.disk_usage(partition.mountpoint) usage = psutil.disk_usage(partition.mountpoint)
partitions_data[partition.mountpoint] = { partitions_data[partition.mountpoint] = {
@@ -145,15 +145,15 @@ class DiskMonitorPlugin(MonitorPlugin):
except Exception as e: except Exception as e:
logger.warning(f"Error reading {partition.mountpoint}: {e}") logger.warning(f"Error reading {partition.mountpoint}: {e}")
continue continue
metrics['partitions'] = partitions_data metrics['partitions'] = partitions_data
# Collect I/O statistics # Collect I/O statistics
if self.include_io: if self.include_io:
try: try:
io_counters = psutil.disk_io_counters(perdisk=True) io_counters = psutil.disk_io_counters(perdisk=True)
io_data = {} io_data = {}
for disk_name, counters in io_counters.items(): for disk_name, counters in io_counters.items():
disk_stats = { disk_stats = {
'read_count': counters.read_count, 'read_count': counters.read_count,
@@ -161,7 +161,7 @@ class DiskMonitorPlugin(MonitorPlugin):
'read_bytes': counters.read_bytes, 'read_bytes': counters.read_bytes,
'write_bytes': counters.write_bytes, 'write_bytes': counters.write_bytes,
} }
# Add time statistics if available # Add time statistics if available
if hasattr(counters, 'read_time'): if hasattr(counters, 'read_time'):
disk_stats['read_time'] = counters.read_time disk_stats['read_time'] = counters.read_time
@@ -169,7 +169,7 @@ class DiskMonitorPlugin(MonitorPlugin):
disk_stats['write_time'] = counters.write_time disk_stats['write_time'] = counters.write_time
if hasattr(counters, 'busy_time'): if hasattr(counters, 'busy_time'):
disk_stats['busy_time'] = counters.busy_time disk_stats['busy_time'] = counters.busy_time
# Calculate deltas from previous collection # Calculate deltas from previous collection
if disk_name in self._prev_io: if disk_name in self._prev_io:
prev = self._prev_io[disk_name] prev = self._prev_io[disk_name]
@@ -177,19 +177,19 @@ class DiskMonitorPlugin(MonitorPlugin):
disk_stats['write_bytes_delta'] = counters.write_bytes - prev.write_bytes disk_stats['write_bytes_delta'] = counters.write_bytes - prev.write_bytes
disk_stats['read_count_delta'] = counters.read_count - prev.read_count disk_stats['read_count_delta'] = counters.read_count - prev.read_count
disk_stats['write_count_delta'] = counters.write_count - prev.write_count disk_stats['write_count_delta'] = counters.write_count - prev.write_count
io_data[disk_name] = disk_stats io_data[disk_name] = disk_stats
metrics['io_counters'] = io_data metrics['io_counters'] = io_data
# Store current counters for next delta calculation # Store current counters for next delta calculation
self._prev_io = io_counters self._prev_io = io_counters
except Exception as e: except Exception as e:
logger.warning(f"Could not collect disk I/O statistics: {e}") logger.warning(f"Could not collect disk I/O statistics: {e}")
return metrics return metrics
async def cleanup(self): async def cleanup(self):
"""Cleanup (nothing to do for this plugin).""" """Cleanup (nothing to do for this plugin)."""
logger.info("Disk monitor cleanup") logger.info("Disk monitor cleanup")
+31 -31
View File
@@ -10,7 +10,7 @@ from typing import Dict, Any, Optional
try: try:
import psutil import psutil
except ImportError: except ImportError:
psutil = None psutil = None # type: ignore[assignment]
from hbd.client.plugin import InfoPlugin from hbd.client.plugin import InfoPlugin
@@ -20,29 +20,29 @@ logger = logging.getLogger(__name__)
class FilesystemInfoPlugin(InfoPlugin): class FilesystemInfoPlugin(InfoPlugin):
""" """
Collect filesystem and partition information. Collect filesystem and partition information.
This is an InfoPlugin that collects static information once during startup. This is an InfoPlugin that collects static information once during startup.
By default, only reports physical mounted filesystems (e.g., ext4, xfs, btrfs). By default, only reports physical mounted filesystems (e.g., ext4, xfs, btrfs).
Set include_pseudo=True to also include pseudo filesystems (proc, sysfs, tmpfs, etc.). Set include_pseudo=True to also include pseudo filesystems (proc, sysfs, tmpfs, etc.).
Collects: Collects:
- List of mounted filesystems - List of mounted filesystems
- Partition details (device, mount point, filesystem type, options) - Partition details (device, mount point, filesystem type, options)
- Filesystem capabilities and features - Filesystem capabilities and features
Configuration: Configuration:
include_pseudo: Include pseudo/virtual filesystems (default: False) include_pseudo: Include pseudo/virtual filesystems (default: False)
exclude_types: List of additional filesystem types to exclude (default: []) exclude_types: List of additional filesystem types to exclude (default: [])
""" """
name = "filesystem_info" name = "filesystem_info"
interval = 0 # InfoPlugin - collect once interval = 0 # InfoPlugin - collect once
def __init__(self, config: Optional[Dict[str, Any]] = None): def __init__(self, config: Optional[Dict[str, Any]] = None):
""" """
Initialize the filesystem info plugin. Initialize the filesystem info plugin.
Args: Args:
config: Optional configuration dict with keys: config: Optional configuration dict with keys:
- include_pseudo: Include pseudo/virtual filesystems (default: False) - include_pseudo: Include pseudo/virtual filesystems (default: False)
@@ -53,25 +53,25 @@ class FilesystemInfoPlugin(InfoPlugin):
# By default, no exclusions since all=False filters most pseudo filesystems # By default, no exclusions since all=False filters most pseudo filesystems
# Users can add specific types to exclude if needed # Users can add specific types to exclude if needed
self.exclude_types = set(self.config.get('exclude_types', [])) self.exclude_types = set(self.config.get('exclude_types', []))
if psutil is None: if psutil is None:
raise ImportError("psutil library is required for filesystem_info plugin") raise ImportError("psutil library is required for filesystem_info plugin")
async def initialize(self): async def initialize(self):
"""Initialize the plugin (check psutil availability).""" """Initialize the plugin (check psutil availability)."""
if psutil is None: if psutil is None:
logger.error("psutil not available - filesystem_info cannot run") logger.error("psutil not available - filesystem_info cannot run")
return False return False
logger.info(f"Filesystem info initialized (pseudo: {self.include_pseudo})") logger.info(f"Filesystem info initialized (pseudo: {self.include_pseudo})")
return True return True
async def collect(self) -> Dict[str, Any]: async def collect(self) -> Dict[str, Any]:
""" """
Collect filesystem information. Collect filesystem information.
Returns only physical mounted filesystems by default. Returns only physical mounted filesystems by default.
Returns: Returns:
Dictionary with filesystem data: Dictionary with filesystem data:
- filesystems: List of filesystem dictionaries: - filesystems: List of filesystem dictionaries:
@@ -87,7 +87,7 @@ class FilesystemInfoPlugin(InfoPlugin):
if psutil is None: if psutil is None:
logger.error("psutil not available") logger.error("psutil not available")
return {} return {}
try: try:
data = await self._collect_info() data = await self._collect_info()
logger.info(f"Collected filesystem info: {len(data.get('filesystems', []))} filesystems") logger.info(f"Collected filesystem info: {len(data.get('filesystems', []))} filesystems")
@@ -95,30 +95,30 @@ class FilesystemInfoPlugin(InfoPlugin):
except Exception as e: except Exception as e:
logger.error(f"Error collecting filesystem info: {e}") logger.error(f"Error collecting filesystem info: {e}")
return {"error": str(e)} return {"error": str(e)}
async def _collect_info(self) -> Dict[str, Any]: async def _collect_info(self) -> Dict[str, Any]:
"""Collect filesystem information from psutil.""" """Collect filesystem information from psutil."""
info = {} info: Dict[str, Any] = {}
filesystems = [] filesystems: list = []
filesystem_types = set() filesystem_types = set()
# Get mounted disk partitions # Get mounted disk partitions
# all=False returns only physical devices (real mounted filesystems) # all=False returns only physical devices (real mounted filesystems)
# all=True would include pseudo filesystems (proc, sysfs, etc.) # all=True would include pseudo filesystems (proc, sysfs, etc.)
partitions = psutil.disk_partitions(all=self.include_pseudo) partitions = psutil.disk_partitions(all=self.include_pseudo)
for partition in partitions: for partition in partitions:
# Additional filtering if exclude_types is specified # Additional filtering if exclude_types is specified
if partition.fstype in self.exclude_types: if partition.fstype in self.exclude_types:
continue continue
fs_info = { fs_info: Dict[str, Any] = {
'device': partition.device, 'device': partition.device,
'mountpoint': partition.mountpoint, 'mountpoint': partition.mountpoint,
'fstype': partition.fstype, 'fstype': partition.fstype,
'opts': partition.opts, 'opts': partition.opts,
} }
# Try to get filesystem capabilities # Try to get filesystem capabilities
try: try:
# Get path configuration for this mount point # Get path configuration for this mount point
@@ -127,28 +127,28 @@ class FilesystemInfoPlugin(InfoPlugin):
try: try:
# Maximum filename length # Maximum filename length
max_name = os.pathconf(partition.mountpoint, 'PC_NAME_MAX') max_name = os.pathconf(partition.mountpoint, 'PC_NAME_MAX')
if max_name: if max_name is not None:
fs_info['maxfile'] = max_name fs_info['maxfile'] = max_name
except (OSError, ValueError): except (OSError, ValueError):
pass pass
try: try:
# Maximum path length # Maximum path length
max_path = os.pathconf(partition.mountpoint, 'PC_PATH_MAX') max_path = os.pathconf(partition.mountpoint, 'PC_PATH_MAX')
if max_path: if max_path is not None:
fs_info['maxpath'] = max_path fs_info['maxpath'] = max_path
except (OSError, ValueError): except (OSError, ValueError):
pass pass
except Exception as e: except Exception as e:
logger.debug(f"Could not get pathconf for {partition.mountpoint}: {e}") logger.debug(f"Could not get pathconf for {partition.mountpoint}: {e}")
filesystems.append(fs_info) filesystems.append(fs_info)
filesystem_types.add(partition.fstype) filesystem_types.add(partition.fstype)
info['filesystems'] = filesystems info['filesystems'] = filesystems
info['filesystem_types'] = sorted(list(filesystem_types)) info['filesystem_types'] = sorted(list(filesystem_types))
info['mount_count'] = len(filesystems) info['mount_count'] = len(filesystems)
# Add some additional filesystem statistics # Add some additional filesystem statistics
try: try:
# Get boot time (useful for determining filesystem mount times) # Get boot time (useful for determining filesystem mount times)
@@ -156,9 +156,9 @@ class FilesystemInfoPlugin(InfoPlugin):
info['boot_time'] = boot_time info['boot_time'] = boot_time
except Exception as e: except Exception as e:
logger.debug(f"Could not get boot time: {e}") logger.debug(f"Could not get boot time: {e}")
return info return info
async def cleanup(self): async def cleanup(self):
"""Cleanup (nothing to do for this plugin).""" """Cleanup (nothing to do for this plugin)."""
logger.info("Filesystem info cleanup") logger.info("Filesystem info cleanup")
+52 -23
View File
@@ -10,35 +10,54 @@ from typing import Dict, Any, Optional
try: try:
import psutil import psutil
except ImportError: except ImportError:
psutil = None psutil = None # type: ignore[assignment]
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__)
class MemoryMonitorPlugin(MonitorPlugin): class MemoryMonitorPlugin(MonitorPlugin):
""" """
Monitor memory and swap usage. Monitor memory and swap usage.
Collects: Collects:
- Physical memory (RAM) usage and statistics - Physical memory (RAM) usage and statistics
- Virtual memory details - Virtual memory details
- Swap memory usage and statistics - Swap memory usage and statistics
- Memory available for applications - Memory available for applications
Configuration: Configuration:
interval: Collection interval in seconds (default: 300) interval: Collection interval in seconds (default: 300)
include_swap: Include swap statistics (default: True) include_swap: Include swap statistics (default: True)
""" """
name = "memory_monitor" name = "memory_monitor"
interval = 300 # Collect every 5 minutes by default interval = 300 # Collect every 5 minutes by default
def __init__(self, config: Optional[Dict[str, Any]] = None): def __init__(self, config: Optional[Dict[str, Any]] = None):
""" """
Initialize the memory monitor plugin. Initialize the memory monitor plugin.
Args: Args:
config: Optional configuration dict with keys: config: Optional configuration dict with keys:
- interval: Collection interval in seconds (default: 300) - interval: Collection interval in seconds (default: 300)
@@ -47,23 +66,23 @@ class MemoryMonitorPlugin(MonitorPlugin):
super().__init__(config) super().__init__(config)
self.include_swap = self.config.get('include_swap', True) self.include_swap = self.config.get('include_swap', True)
self.interval = self.config.get('interval', 300) self.interval = self.config.get('interval', 300)
if psutil is None: if psutil is None:
raise ImportError("psutil library is required for memory_monitor plugin") raise ImportError("psutil library is required for memory_monitor plugin")
async def initialize(self): async def initialize(self):
"""Initialize the plugin (check psutil availability).""" """Initialize the plugin (check psutil availability)."""
if psutil is None: if psutil is None:
logger.error("psutil not available - memory_monitor cannot run") logger.error("psutil not available - memory_monitor cannot run")
return False return False
logger.info(f"Memory monitor initialized (interval: {self.interval}s, swap: {self.include_swap})") logger.info(f"Memory monitor initialized (interval: {self.interval}s, swap: {self.include_swap})")
return True return True
async def collect(self) -> Dict[str, Any]: async def collect(self) -> Dict[str, Any]:
""" """
Collect current memory statistics. Collect current memory statistics.
Returns: Returns:
Dictionary with memory metrics: Dictionary with memory metrics:
- memory_total: Total physical RAM in bytes - memory_total: Total physical RAM in bytes
@@ -86,7 +105,7 @@ class MemoryMonitorPlugin(MonitorPlugin):
if psutil is None: if psutil is None:
logger.error("psutil not available") logger.error("psutil not available")
return {} return {}
try: try:
data = await self._collect_metrics() data = await self._collect_metrics()
logger.debug(f"Collected memory metrics: {len(data)} fields") logger.debug(f"Collected memory metrics: {len(data)} fields")
@@ -94,19 +113,29 @@ class MemoryMonitorPlugin(MonitorPlugin):
except Exception as e: except Exception as e:
logger.error(f"Error collecting memory metrics: {e}") logger.error(f"Error collecting memory metrics: {e}")
return {"error": str(e)} return {"error": str(e)}
async def _collect_metrics(self) -> Dict[str, Any]: async def _collect_metrics(self) -> Dict[str, Any]:
"""Collect memory metrics from psutil.""" """Collect memory metrics from psutil."""
metrics = {} metrics: Dict[str, Any] = {}
# 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'):
metrics['memory_active'] = vmem.active metrics['memory_active'] = vmem.active
@@ -118,7 +147,7 @@ class MemoryMonitorPlugin(MonitorPlugin):
metrics['memory_cached'] = vmem.cached metrics['memory_cached'] = vmem.cached
if hasattr(vmem, 'shared'): if hasattr(vmem, 'shared'):
metrics['memory_shared'] = vmem.shared metrics['memory_shared'] = vmem.shared
# Swap memory statistics # Swap memory statistics
if self.include_swap: if self.include_swap:
try: try:
@@ -127,7 +156,7 @@ class MemoryMonitorPlugin(MonitorPlugin):
metrics['swap_used'] = swap.used metrics['swap_used'] = swap.used
metrics['swap_free'] = swap.free metrics['swap_free'] = swap.free
metrics['swap_percent'] = swap.percent metrics['swap_percent'] = swap.percent
# Swap in/out counters (may not be available on all platforms) # Swap in/out counters (may not be available on all platforms)
if hasattr(swap, 'sin'): if hasattr(swap, 'sin'):
metrics['swap_sin'] = swap.sin metrics['swap_sin'] = swap.sin
@@ -135,9 +164,9 @@ class MemoryMonitorPlugin(MonitorPlugin):
metrics['swap_sout'] = swap.sout metrics['swap_sout'] = swap.sout
except Exception as e: except Exception as e:
logger.warning(f"Could not collect swap statistics: {e}") logger.warning(f"Could not collect swap statistics: {e}")
return metrics return metrics
async def cleanup(self): async def cleanup(self):
"""Cleanup (nothing to do for this plugin).""" """Cleanup (nothing to do for this plugin)."""
logger.info("Memory monitor cleanup") logger.info("Memory monitor cleanup")
+42 -57
View File
@@ -31,25 +31,22 @@ 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",
} }
class NagiosRunnerPlugin(MonitorPlugin): class NagiosRunnerPlugin(MonitorPlugin):
"""Run Nagios-compatible monitoring plugins. """Run Nagios-compatible monitoring plugins.
This plugin executes external Nagios plugins and collects their output, This plugin executes external Nagios plugins and collects their output,
including status codes, messages, and performance data. including status codes, messages, and performance data.
Configuration: Configuration:
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
@@ -65,20 +62,20 @@ class NagiosRunnerPlugin(MonitorPlugin):
- name: check_load - name: check_load
command: /usr/lib/nagios/plugins/check_load -w 5,4,3 -c 10,8,6 command: /usr/lib/nagios/plugins/check_load -w 5,4,3 -c 10,8,6
""" """
name = "nagios_runner" name = "nagios_runner"
version = "1.0.0" version = "1.0.0"
description = "Execute Nagios-compatible monitoring plugins" description = "Execute Nagios-compatible monitoring plugins"
interval = 300 # MonitorPlugin: collect every 5 minutes by default interval = 300 # MonitorPlugin: collect every 5 minutes by default
def __init__(self, config: Optional[Dict[str, Any]] = None): def __init__(self, config: Optional[Dict[str, Any]] = None):
super().__init__(config) super().__init__(config)
# 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.interval = config.get("interval", 300) if config else 300 self.interval = config.get("interval", 300) if config else 300
async def initialize(self) -> bool: async def initialize(self) -> bool:
"""Initialize the Nagios runner plugin. """Initialize the Nagios runner plugin.
@@ -120,62 +117,49 @@ class NagiosRunnerPlugin(MonitorPlugin):
) )
return True return True
async def _collect_metrics(self) -> Dict[str, Any]: async def _collect_metrics(self) -> Dict[str, Any]:
"""Collect metrics from all configured Nagios plugins. """Collect metrics from all configured Nagios plugins.
Returns: Returns:
Dictionary with results from all plugins Dictionary with results from all plugins
""" """
results = {} results: Dict[str, Any] = {}
# 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.info( 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
@@ -197,7 +181,8 @@ class NagiosRunnerPlugin(MonitorPlugin):
self.logger.error(f"Command timed out: {command}") self.logger.error(f"Command timed out: {command}")
return NAGIOS_UNKNOWN, f"Command timed out after {self.timeout}s", {} return NAGIOS_UNKNOWN, f"Command timed out after {self.timeout}s", {}
status_code = proc.returncode # communicate() has finished, so the process has exited and returncode is set
status_code = proc.returncode if proc.returncode is not None else NAGIOS_UNKNOWN
if status_code < 0: if status_code < 0:
return NAGIOS_UNKNOWN, f"Process killed by signal {-status_code}", {} return NAGIOS_UNKNOWN, f"Process killed by signal {-status_code}", {}
@@ -226,33 +211,33 @@ class NagiosRunnerPlugin(MonitorPlugin):
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)}", {}
def _parse_perfdata(self, output: str) -> Dict[str, Any]: def _parse_perfdata(self, output: str) -> Dict[str, Any]:
"""Parse Nagios performance data from plugin output. """Parse Nagios performance data from plugin output.
Nagios performance data format: Nagios performance data format:
'label'=value[UOM];[warn];[crit];[min];[max] 'label'=value[UOM];[warn];[crit];[min];[max]
Multiple metrics separated by spaces. Multiple metrics separated by spaces.
Args: Args:
output: Plugin output string output: Plugin output string
Returns: Returns:
Dictionary of metric_name: value Dictionary of metric_name: value
""" """
perfdata = {} perfdata: Dict[str, Any] = {}
# Performance data comes after the pipe character # Performance data comes after the pipe character
if '|' not in output: if '|' not in output:
return perfdata return perfdata
perf_section = output.split('|', 1)[1].strip() perf_section = output.split('|', 1)[1].strip()
# Regex to match performance data format # Regex to match performance data format
# Matches: 'label'=value or label=value # Matches: 'label'=value or label=value
perf_regex = r"'?([^'=]+)'?=([\d.]+)([a-zA-Z%]*);?([\d.]*);?([\d.]*);?([\d.]*);?([\d.]*)" perf_regex = r"'?([^'=]+)'?=([\d.]+)([a-zA-Z%]*);?([\d.]*);?([\d.]*);?([\d.]*);?([\d.]*)"
for match in re.finditer(perf_regex, perf_section): for match in re.finditer(perf_regex, perf_section):
label = match.group(1).strip() label = match.group(1).strip()
value_str = match.group(2) value_str = match.group(2)
@@ -261,43 +246,43 @@ class NagiosRunnerPlugin(MonitorPlugin):
crit = match.group(5) crit = match.group(5)
min_val = match.group(6) min_val = match.group(6)
max_val = match.group(7) max_val = match.group(7)
# Convert value to float # Convert value to float
try: try:
value = float(value_str) value = float(value_str)
except ValueError: except ValueError:
continue continue
# Store the value # Store the value
perfdata[label] = value perfdata[label] = value
# Optionally store UOM as separate field # Optionally store UOM as separate field
if uom: if uom:
perfdata[f"{label}_uom"] = uom perfdata[f"{label}_uom"] = uom
# Store thresholds if present # Store thresholds if present
if warn: if warn:
try: try:
perfdata[f"{label}_warn"] = float(warn) perfdata[f"{label}_warn"] = float(warn)
except ValueError: except ValueError:
pass pass
if crit: if crit:
try: try:
perfdata[f"{label}_crit"] = float(crit) perfdata[f"{label}_crit"] = float(crit)
except ValueError: except ValueError:
pass pass
if min_val: if min_val:
try: try:
perfdata[f"{label}_min"] = float(min_val) perfdata[f"{label}_min"] = float(min_val)
except ValueError: except ValueError:
pass pass
if max_val: if max_val:
try: try:
perfdata[f"{label}_max"] = float(max_val) perfdata[f"{label}_max"] = float(max_val)
except ValueError: except ValueError:
pass pass
return perfdata return perfdata
+57 -46
View File
@@ -5,14 +5,15 @@ Collects network interface statistics and connection information using psutil.
""" """
import logging import logging
from typing import Dict, Any, Optional, List from typing import Dict, Any, Optional
try: try:
import psutil import psutil
except ImportError: except ImportError:
psutil = None psutil = None # type: ignore[assignment]
from hbd.client.plugin import MonitorPlugin from hbd.client.plugin import MonitorPlugin
from hbd.client.connectivity import get_shared
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -20,27 +21,31 @@ logger = logging.getLogger(__name__)
class NetworkMonitorPlugin(MonitorPlugin): class NetworkMonitorPlugin(MonitorPlugin):
""" """
Monitor network interface statistics and connections. Monitor network interface statistics and connections.
Collects: Collects:
- Network interface I/O counters (bytes sent/received, packets, errors, drops) - Network interface I/O counters (bytes sent/received, packets, errors, drops)
- Per-interface statistics - Per-interface statistics
- Network connection counts by state - Network connection counts by state
- Interface addresses and configuration - Interface addresses and configuration
Configuration: Configuration:
interval: Collection interval in seconds (default: 300) interval: Collection interval in seconds (default: 300)
interfaces: List of interfaces to monitor (default: all) interfaces: List of interfaces to monitor (default: all)
include_connections: Include connection statistics (default: True) include_connections: Include connection statistics (default: True)
include_addresses: Include interface addresses (default: False) include_addresses: Include interface addresses (default: False)
probe_enabled: Run the per-protocol internet presence probe (default: True)
probe_v4: IPv4 address to ping (default: 1.1.1.1)
probe_v6: IPv6 address to ping (default: 2606:4700:4700::1111)
probe_interval: Seconds between ICMP probes while healthy (default: 60)
""" """
name = "network_monitor" name = "network_monitor"
interval = 300 # Collect every 5 minutes by default interval = 300 # Collect every 5 minutes by default
def __init__(self, config: Optional[Dict[str, Any]] = None): def __init__(self, config: Optional[Dict[str, Any]] = None):
""" """
Initialize the network monitor plugin. Initialize the network monitor plugin.
Args: Args:
config: Optional configuration dict with keys: config: Optional configuration dict with keys:
- interval: Collection interval in seconds (default: 300) - interval: Collection interval in seconds (default: 300)
@@ -53,34 +58,34 @@ class NetworkMonitorPlugin(MonitorPlugin):
self.include_connections = self.config.get('include_connections', True) self.include_connections = self.config.get('include_connections', True)
self.include_addresses = self.config.get('include_addresses', False) self.include_addresses = self.config.get('include_addresses', False)
self.interval = self.config.get('interval', 300) self.interval = self.config.get('interval', 300)
if psutil is None: if psutil is None:
raise ImportError("psutil library is required for network_monitor plugin") raise ImportError("psutil library is required for network_monitor plugin")
# Store previous I/O counters for delta calculation # Store previous I/O counters for delta calculation
self._prev_io = {} self._prev_io: Dict[str, Any] = {}
async def initialize(self): async def initialize(self):
"""Initialize the plugin (check psutil availability).""" """Initialize the plugin (check psutil availability)."""
if psutil is None: if psutil is None:
logger.error("psutil not available - network_monitor cannot run") logger.error("psutil not available - network_monitor cannot run")
return False return False
logger.info(f"Network monitor initialized (interval: {self.interval}s, " logger.info(f"Network monitor initialized (interval: {self.interval}s, "
f"connections: {self.include_connections})") f"connections: {self.include_connections})")
# Initialize I/O counters # Initialize I/O counters
try: try:
self._prev_io = psutil.net_io_counters(pernic=True) self._prev_io = psutil.net_io_counters(pernic=True)
except Exception as e: except Exception as e:
logger.warning(f"Could not initialize network I/O counters: {e}") logger.warning(f"Could not initialize network I/O counters: {e}")
return True return True
async def collect(self) -> Dict[str, Any]: async def collect(self) -> Dict[str, Any]:
""" """
Collect current network statistics. Collect current network statistics.
Returns: Returns:
Dictionary with network metrics: Dictionary with network metrics:
- interfaces: Dict of interface statistics, keyed by interface name - interfaces: Dict of interface statistics, keyed by interface name
@@ -103,11 +108,12 @@ class NetworkMonitorPlugin(MonitorPlugin):
- etc. - etc.
- addresses: Interface address information (if include_addresses) - addresses: Interface address information (if include_addresses)
- Dict keyed by interface name with address details - Dict keyed by interface name with address details
- connectivity: per-protocol presence {ipv4, ipv6} -> {present, addr, gateway, rtt}
""" """
if psutil is None: if psutil is None:
logger.error("psutil not available") logger.error("psutil not available")
return {} return {}
try: try:
data = await self._collect_metrics() data = await self._collect_metrics()
logger.debug(f"Collected network metrics: {len(data.get('interfaces', {}))} interfaces") logger.debug(f"Collected network metrics: {len(data.get('interfaces', {}))} interfaces")
@@ -115,21 +121,21 @@ class NetworkMonitorPlugin(MonitorPlugin):
except Exception as e: except Exception as e:
logger.error(f"Error collecting network metrics: {e}") logger.error(f"Error collecting network metrics: {e}")
return {"error": str(e)} return {"error": str(e)}
async def _collect_metrics(self) -> Dict[str, Any]: async def _collect_metrics(self) -> Dict[str, Any]:
"""Collect network metrics from psutil.""" """Collect network metrics from psutil."""
metrics = {} metrics: Dict[str, Any] = {}
# Collect per-interface I/O counters # Collect per-interface I/O counters
try: try:
io_counters = psutil.net_io_counters(pernic=True) io_counters = psutil.net_io_counters(pernic=True)
interfaces_data = {} interfaces_data = {}
for iface_name, counters in io_counters.items(): for iface_name, counters in io_counters.items():
# Skip if we're only monitoring specific interfaces # Skip if we're only monitoring specific interfaces
if self.interfaces and iface_name not in self.interfaces: if self.interfaces and iface_name not in self.interfaces:
continue continue
iface_stats = { iface_stats = {
'bytes_sent': counters.bytes_sent, 'bytes_sent': counters.bytes_sent,
'bytes_recv': counters.bytes_recv, 'bytes_recv': counters.bytes_recv,
@@ -140,7 +146,7 @@ class NetworkMonitorPlugin(MonitorPlugin):
'dropin': counters.dropin, 'dropin': counters.dropin,
'dropout': counters.dropout, 'dropout': counters.dropout,
} }
# Calculate deltas from previous collection # Calculate deltas from previous collection
if iface_name in self._prev_io: if iface_name in self._prev_io:
prev = self._prev_io[iface_name] prev = self._prev_io[iface_name]
@@ -148,46 +154,46 @@ class NetworkMonitorPlugin(MonitorPlugin):
iface_stats['bytes_recv_delta'] = counters.bytes_recv - prev.bytes_recv iface_stats['bytes_recv_delta'] = counters.bytes_recv - prev.bytes_recv
iface_stats['packets_sent_delta'] = counters.packets_sent - prev.packets_sent iface_stats['packets_sent_delta'] = counters.packets_sent - prev.packets_sent
iface_stats['packets_recv_delta'] = counters.packets_recv - prev.packets_recv iface_stats['packets_recv_delta'] = counters.packets_recv - prev.packets_recv
interfaces_data[iface_name] = iface_stats interfaces_data[iface_name] = iface_stats
metrics['interfaces'] = interfaces_data metrics['interfaces'] = interfaces_data
# Store current counters for next delta calculation # Store current counters for next delta calculation
self._prev_io = io_counters self._prev_io = io_counters
except Exception as e: except Exception as e:
logger.warning(f"Could not collect network I/O counters: {e}") logger.warning(f"Could not collect network I/O counters: {e}")
# Collect connection statistics # Collect connection statistics
if self.include_connections: if self.include_connections:
try: try:
connections = psutil.net_connections(kind='inet') connections = psutil.net_connections(kind='inet')
conn_stats = {} conn_stats: Dict[str, int] = {}
# Count connections by state # Count connections by state
for conn in connections: for conn in connections:
state = conn.status state = conn.status
conn_stats[state] = conn_stats.get(state, 0) + 1 conn_stats[state] = conn_stats.get(state, 0) + 1
metrics['connections'] = conn_stats metrics['connections'] = conn_stats
except (PermissionError, psutil.AccessDenied): except (PermissionError, psutil.AccessDenied):
logger.debug("Permission denied for net_connections (requires root/admin)") logger.debug("Permission denied for net_connections (requires root/admin)")
except Exception as e: except Exception as e:
logger.warning(f"Could not collect connection statistics: {e}") logger.warning(f"Could not collect connection statistics: {e}")
# Collect interface addresses # Collect interface addresses
if self.include_addresses: if self.include_addresses:
try: try:
addresses = psutil.net_if_addrs() addresses = psutil.net_if_addrs()
addr_data = {} addr_data = {}
for iface_name, addrs in addresses.items(): for iface_name, addrs in addresses.items():
# Skip if we're only monitoring specific interfaces # Skip if we're only monitoring specific interfaces
if self.interfaces and iface_name not in self.interfaces: if self.interfaces and iface_name not in self.interfaces:
continue continue
iface_addrs = [] iface_addrs = []
for addr in addrs: for addr in addrs:
addr_info = { addr_info = {
@@ -199,38 +205,43 @@ class NetworkMonitorPlugin(MonitorPlugin):
if addr.broadcast: if addr.broadcast:
addr_info['broadcast'] = addr.broadcast addr_info['broadcast'] = addr.broadcast
iface_addrs.append(addr_info) iface_addrs.append(addr_info)
addr_data[iface_name] = iface_addrs addr_data[iface_name] = iface_addrs
metrics['addresses'] = addr_data metrics['addresses'] = addr_data
except Exception as e: except Exception as e:
logger.warning(f"Could not collect interface addresses: {e}") logger.warning(f"Could not collect interface addresses: {e}")
# Add interface stats (up/down status, speed, mtu) # Add interface stats (up/down status, speed, mtu)
try: try:
if_stats = psutil.net_if_stats() if_stats = psutil.net_if_stats()
stats_data = {} stats_data = {}
for iface_name, stats in if_stats.items(): for iface_name, stats in if_stats.items():
# Skip if we're only monitoring specific interfaces # Skip if we're only monitoring specific interfaces
if self.interfaces and iface_name not in self.interfaces: if self.interfaces and iface_name not in self.interfaces:
continue continue
stats_data[iface_name] = { stats_data[iface_name] = {
'isup': stats.isup, 'isup': stats.isup,
'duplex': str(stats.duplex) if hasattr(stats, 'duplex') else None, 'duplex': str(stats.duplex) if hasattr(stats, 'duplex') else None,
'speed': stats.speed, 'speed': stats.speed,
'mtu': stats.mtu, 'mtu': stats.mtu,
} }
metrics['interface_stats'] = stats_data metrics['interface_stats'] = stats_data
except Exception as e: except Exception as e:
logger.warning(f"Could not collect interface stats: {e}") logger.warning(f"Could not collect interface stats: {e}")
# Per-protocol internet presence, address and gateway (from the shared probe)
shared = get_shared()
if shared is not None:
metrics['connectivity'] = shared.as_metric()
return metrics return metrics
async def cleanup(self): async def cleanup(self):
"""Cleanup (nothing to do for this plugin).""" """Cleanup (nothing to do for this plugin)."""
logger.info("Network monitor cleanup") logger.info("Network monitor cleanup")
+20 -18
View File
@@ -5,7 +5,6 @@ kernel, architecture, and distribution details.
""" """
import platform import platform
import sys
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
@@ -15,7 +14,7 @@ from hbd.client.plugin import InfoPlugin
class OSInfoPlugin(InfoPlugin): class OSInfoPlugin(InfoPlugin):
"""Collect operating system information. """Collect operating system information.
This plugin gathers static OS information that rarely changes: This plugin gathers static OS information that rarely changes:
- OS name and version - OS name and version
- Kernel version - Kernel version
@@ -23,27 +22,27 @@ class OSInfoPlugin(InfoPlugin):
- Distribution details (for Linux) - Distribution details (for Linux)
- Python version (used by hbc) - Python version (used by hbc)
""" """
name = "os_info" name = "os_info"
version = "1.0.0" version = "1.0.0"
description = "Operating system and platform information" description = "Operating system and platform information"
interval = 0 # InfoPlugin: collect once at startup interval = 0 # InfoPlugin: collect once at startup
def __init__(self, config: Optional[Dict[str, Any]] = None): def __init__(self, config: Optional[Dict[str, Any]] = None):
super().__init__(config) super().__init__(config)
async def initialize(self) -> bool: async def initialize(self) -> bool:
"""Initialize the OS info plugin. """Initialize the OS info plugin.
Returns: Returns:
True (always succeeds - platform module is stdlib) True (always succeeds - platform module is stdlib)
""" """
self.logger.info(f"Initializing {self.name} plugin") self.logger.info(f"Initializing {self.name} plugin")
return True return True
async def _collect_info(self) -> Dict[str, Any]: async def _collect_info(self) -> Dict[str, Any]:
"""Collect OS information. """Collect OS information.
Returns: Returns:
Dictionary with OS details Dictionary with OS details
""" """
@@ -62,15 +61,18 @@ class OSInfoPlugin(InfoPlugin):
"hbc_version": hbc_version, "hbc_version": hbc_version,
"hbc_type": "full", "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":
data.update(self._get_linux_distro()) data.update(self._get_linux_distro())
# Add macOS-specific info # Add macOS-specific info
elif platform.system() == "Darwin": elif platform.system() == "Darwin":
data["macos_version"] = platform.mac_ver()[0] data["macos_version"] = platform.mac_ver()[0]
# Add Windows-specific info # Add Windows-specific info
elif platform.system() == "Windows": elif platform.system() == "Windows":
win_ver = platform.win32_ver() win_ver = platform.win32_ver()
@@ -78,22 +80,22 @@ class OSInfoPlugin(InfoPlugin):
data["windows_version"] = win_ver[1] data["windows_version"] = win_ver[1]
data["windows_sp"] = win_ver[2] data["windows_sp"] = win_ver[2]
data["windows_type"] = win_ver[3] data["windows_type"] = win_ver[3]
self.logger.debug(f"Collected OS info: {data['system']} {data['release']}") self.logger.debug(f"Collected OS info: {data['system']} {data['release']}")
return data return data
except Exception as e: except Exception as e:
self.logger.error(f"Error collecting OS info: {e}", exc_info=True) self.logger.error(f"Error collecting OS info: {e}", exc_info=True)
return {} return {}
def _get_linux_distro(self) -> Dict[str, str]: def _get_linux_distro(self) -> Dict[str, str]:
"""Get Linux distribution information. """Get Linux distribution information.
Returns: Returns:
Dictionary with distribution details Dictionary with distribution details
""" """
distro_info = {} distro_info = {}
# Try reading /etc/os-release (standard on modern Linux) # Try reading /etc/os-release (standard on modern Linux)
os_release = Path("/etc/os-release") os_release = Path("/etc/os-release")
if os_release.exists(): if os_release.exists():
@@ -118,7 +120,7 @@ class OSInfoPlugin(InfoPlugin):
distro_info["distro_pretty_name"] = value distro_info["distro_pretty_name"] = value
except Exception as e: except Exception as e:
self.logger.warning(f"Could not read /etc/os-release: {e}") self.logger.warning(f"Could not read /etc/os-release: {e}")
# Fallback: try lsb_release (older systems) # Fallback: try lsb_release (older systems)
elif Path("/etc/lsb-release").exists(): elif Path("/etc/lsb-release").exists():
try: try:
@@ -135,5 +137,5 @@ class OSInfoPlugin(InfoPlugin):
distro_info["distro_name"] = value distro_info["distro_name"] = value
except Exception as e: except Exception as e:
self.logger.warning(f"Could not read /etc/lsb-release: {e}") self.logger.warning(f"Could not read /etc/lsb-release: {e}")
return distro_info return distro_info
+6 -47
View File
@@ -13,12 +13,8 @@ plugins:
count: 3 # ICMP packets per ping run (default 3) count: 3 # ICMP packets per ping run (default 3)
timeout: 5 # seconds before a host is considered unreachable (default 5) timeout: 5 # seconds before a host is considered unreachable (default 5)
hosts: hosts:
8.8.8.8: - 8.8.8.8
warning: 20.0 # ms - 192.168.1.1
critical: 100.0 # ms
192.168.1.1:
warning: 5.0
critical: 20.0
``` ```
Reported metrics per host (metric key uses the hostname with dots/colons replaced Reported metrics per host (metric key uses the hostname with dots/colons replaced
@@ -44,9 +40,9 @@ threshold_configs:
import asyncio import asyncio
import re import re
import sys
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from hbd.client.pingutil import PingUnavailable, run_ping
from hbd.client.plugin import MonitorPlugin from hbd.client.plugin import MonitorPlugin
@@ -71,6 +67,7 @@ class PingMonitorPlugin(MonitorPlugin):
self.timeout = int(cfg.get("timeout", 5)) self.timeout = int(cfg.get("timeout", 5))
# hosts: dict of {hostname: {warning: x, critical: y}} or list of hostnames # hosts: dict of {hostname: {warning: x, critical: y}} or list of hostnames
raw_hosts = cfg.get("hosts", {}) raw_hosts = cfg.get("hosts", {})
self.hosts: Dict[str, Any]
if isinstance(raw_hosts, list): if isinstance(raw_hosts, list):
self.hosts = {h: {} for h in raw_hosts} self.hosts = {h: {} for h in raw_hosts}
else: else:
@@ -88,51 +85,13 @@ class PingMonitorPlugin(MonitorPlugin):
async def _ping(self, host: str) -> Dict[str, float]: async def _ping(self, host: str) -> Dict[str, float]:
"""Run a system ping command and return rtt_min/avg/max/loss.""" """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: try:
proc = await asyncio.create_subprocess_exec( return await run_ping(host, self.count, self.timeout)
*cmd, except (asyncio.TimeoutError, PingUnavailable, OSError) as e:
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) self.logger.warning("ping_monitor: ping failed for %s: %s", host, e)
return {"rtt_min": float("inf"), "rtt_avg": float("inf"), return {"rtt_min": float("inf"), "rtt_avg": float("inf"),
"rtt_max": float("inf"), "loss": 100.0} "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]: async def _collect_metrics(self) -> Dict[str, Any]:
data: Dict[str, Any] = {} data: Dict[str, Any] = {}
tasks = {host: asyncio.create_task(self._ping(host)) for host in self.hosts} tasks = {host: asyncio.create_task(self._ping(host)) for host in self.hosts}
+17 -7
View File
@@ -89,14 +89,24 @@ class ZFSMonitorPlugin(MonitorPlugin):
name = parts[0].strip() name = parts[0].strip()
if self._pools_filter and name not in self._pools_filter: if self._pools_filter and name not in self._pools_filter:
continue 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] = { pools[name] = {
"health": parts[1].strip(), "health": health,
"size": _int(parts[2]), "status": status,
"alloc": _int(parts[3]), "size": _int(parts[2]),
"free": _int(parts[4]), "alloc": _int(parts[3]),
"capacity": _float(parts[5]), "free": _int(parts[4]),
"frag": _float(parts[6]), "capacity": _float(parts[5]),
"dedup": _float(parts[7]), "frag": _float(parts[6]),
"dedup": _float(parts[7]),
} }
return pools return pools
+1 -1
View File
@@ -1,3 +1,3 @@
"""Common utilities shared between hbc and hbd.""" """Common utilities shared between hbc and hbd."""
from hbd import __version__ from hbd import __version__ # noqa: F401
+13 -14
View File
@@ -8,17 +8,17 @@ Message Types:
PLG: Plugin data message (client -> server) PLG: Plugin data message (client -> server)
""" """
from typing import Dict, Any, Union from typing import Dict, Any
import json import json
import zlib import zlib
def encode_value(v: Any) -> str: def encode_value(v: Any) -> str:
"""Encode a value for protocol transmission. """Encode a value for protocol transmission.
Args: Args:
v: Value to encode (int, float, str, bool, list, dict, etc.) v: Value to encode (int, float, str, bool, list, dict, etc.)
Returns: Returns:
String representation suitable for protocol String representation suitable for protocol
""" """
@@ -35,23 +35,23 @@ def encode_value(v: Any) -> str:
def decode_value(val: str) -> Any: def decode_value(val: str) -> Any:
"""Decode a value from protocol format. """Decode a value from protocol format.
Args: Args:
val: String value from protocol val: String value from protocol
Returns: Returns:
Decoded Python object Decoded Python object
""" """
if not val: if not val:
return val return val
# Check for JSON-encoded complex types # Check for JSON-encoded complex types
if val.startswith("@"): if val.startswith("@"):
try: try:
return json.loads(val[1:]) return json.loads(val[1:])
except Exception: except Exception:
return val[1:] # Return as string without @ return val[1:] # Return as string without @
# Try numeric conversion (avoid eval to prevent SyntaxWarnings on version strings) # 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:
@@ -63,7 +63,7 @@ def decode_value(val: str) -> Any:
except ValueError: except ValueError:
pass pass
return val return val
return val return val
@@ -92,7 +92,7 @@ def stodict(msg: bytes):
'!' and decodes accordingly. Returns a dict with key 'ID' set to the '!' and decodes accordingly. Returns a dict with key 'ID' set to the
message ID and the parsed key/value pairs. message ID and the parsed key/value pairs.
""" """
d = {} d: Dict[str, Any] = {}
if len(msg) > 0 and chr(msg[0]) == "!": if len(msg) > 0 and chr(msg[0]) == "!":
# message is: b'!ID:' + compressed_payload # message is: b'!ID:' + compressed_payload
# original code used msg[1:4].decode() for ID (3 bytes including colon) # original code used msg[1:4].decode() for ID (3 bytes including colon)
@@ -135,12 +135,12 @@ def oldmtodict(msg: bytes):
def encode_plugin_data(plugin_name: str, data: Dict[str, Any]) -> bytes: def encode_plugin_data(plugin_name: str, data: Dict[str, Any]) -> bytes:
"""Encode plugin data into a PLG message. """Encode plugin data into a PLG message.
Args: Args:
plugin_name: Name of the plugin (e.g., "os_info", "cpu_monitor") plugin_name: Name of the plugin (e.g., "os_info", "cpu_monitor")
data: Plugin data dictionary data: Plugin data dictionary
compress: Whether to compress the payload compress: Whether to compress the payload
Returns: Returns:
Encoded message bytes Encoded message bytes
""" """
@@ -151,12 +151,11 @@ def encode_plugin_data(plugin_name: str, data: Dict[str, Any]) -> bytes:
def decode_plugin_data(msg: bytes) -> Dict[str, Any]: def decode_plugin_data(msg: bytes) -> Dict[str, Any]:
"""Decode a PLG message into plugin data. """Decode a PLG message into plugin data.
Args: Args:
msg: Raw message bytes msg: Raw message bytes
Returns: Returns:
Dictionary with 'ID', 'plugin', and plugin data fields Dictionary with 'ID', 'plugin', and plugin data fields
""" """
return stodict(msg) return stodict(msg)
+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."""
from hbd import __version__ from hbd import __version__ # noqa: F401
+10 -4
View File
@@ -120,7 +120,7 @@ def cmd_passwd(args):
def cmd_notify(args): def cmd_notify(args):
"""Send a test message via a single notification channel.""" """Send a test message via a single notification channel."""
from .config import load_config from .config import load_config
from .notify import Notification, _dispatch_to_channel, setup from .notify import Notification, setup
config = load_config(args.configfile) config = load_config(args.configfile)
setup(config) setup(config)
@@ -193,7 +193,9 @@ def _read_pid(configfile) -> int | None:
def cmd_stop(args): def cmd_stop(args):
import os, signal as _signal, time import os
import signal as _signal
import time
pid = _read_pid(args.configfile) pid = _read_pid(args.configfile)
if pid is None: if pid is None:
sys.exit(1) sys.exit(1)
@@ -212,7 +214,8 @@ def cmd_stop(args):
def cmd_reload(args): def cmd_reload(args):
import os, signal as _signal import os
import signal as _signal
pid = _read_pid(args.configfile) pid = _read_pid(args.configfile)
if pid is None: if pid is None:
sys.exit(1) sys.exit(1)
@@ -222,7 +225,10 @@ def cmd_reload(args):
def cmd_restart(args): def cmd_restart(args):
import os, signal as _signal, time, subprocess import os
import signal as _signal
import time
import subprocess
pid = _read_pid(args.configfile) pid = _read_pid(args.configfile)
if pid is not None: if pid is not None:
print(f"Stopping hbd (pid {pid})...") print(f"Stopping hbd (pid {pid})...")
+79 -66
View File
@@ -7,7 +7,7 @@ import os
try: try:
import yaml import yaml
except Exception: except Exception:
yaml = None yaml = None # type: ignore[assignment]
SERVER_DEFAULTS = { SERVER_DEFAULTS = {
# Network settings # Network settings
@@ -27,41 +27,46 @@ SERVER_DEFAULTS = {
# 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
# Flap detection (0 in either key disables it)
"flap_count": 5, # Warning/critical notifications within flap_interval that trip flapping
"flap_interval": 10, # Minutes: the counting window, and the quiet window after an OK
# User management # User management
"users": {}, # username -> {full_name, avatar, password, admin, notification_channels} "users": {}, # username -> {full_name, avatar, password, admin, notification_channels}
"default_owner": None, # Username that owns hosts with no explicit owner "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": {}, # Unified host definitions "hosts": {}, # Unified host definitions
"dyndnshosts": [], # Hosts with dynamic DNS (legacy) "dyndomains": ["example.org"], # Domains to update via nsupdate when a host with dyndns: true is updated
"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",
"wss_key": "privkey.pem", "wss_key": "privkey.pem",
# Message journal configuration # Message journal configuration
"journal_enabled": True, "journal_enabled": True,
"journal_dir": "/var/log/heartbeat", "journal_dir": "/var/log/heartbeat",
"journal_file": "messages.journal", "journal_file": "messages.journal",
"journal_max_size": 100 * 1024 * 1024, # 100MB "journal_max_size": 100 * 1024 * 1024, # 100MB
"journal_max_backups": 10, "journal_max_backups": 10,
# Runtime flags # Runtime flags
"foreground": False, "foreground": False,
"verbose": False, "verbose": False,
"debug": 0, "debug": 0,
# Plugin/threshold configs (for clients reporting to this server) # Plugin/threshold configs (for clients reporting to this server)
"plugins": {}, "plugins": {},
"thresholds": {}, "thresholds": {},
@@ -71,17 +76,21 @@ THRESHOLD_DEFAULTS = {
'thresholds': { 'thresholds': {
'cpu_monitor': { 'cpu_monitor': {
'cpu_percent': { 'cpu_percent': {
'warning': 80.0, 'warning': 80.0,
'critical': 90.0 'critical': 90.0
} }
}, },
'memory_monitor': { 'memory_monitor': {
'percent': { 'memory_percent': {
'warning': 85.0, 'warning': 85.0,
'critical': 95.0 'critical': 95.0
},
'swap_percent': {
'warning': 40.0,
'critical': 75.0
} }
}, },
'disk_monitor': { 'disk_monitor': {
'partitions': { 'partitions': {
'/': { '/': {
'percent': { 'percent': {
@@ -91,11 +100,35 @@ THRESHOLD_DEFAULTS = {
} }
} }
}, },
'rtt': { 'rtt': {
'warning': 200, 'warning': 200,
'critical': 250.0, 'critical': 250.0,
'count': 3 # Optional: number of consecutive breaches before alerting '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,
}
}
}
},
} }
} }
@@ -104,10 +137,10 @@ 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.
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: ~/.hb.yaml)
Returns: Returns:
Dictionary with configuration Dictionary with configuration
""" """
@@ -132,16 +165,16 @@ def load_config(path=None):
class ReloadableConfig: class ReloadableConfig:
"""Thread-safe/async-safe configuration wrapper that supports runtime reloading. """Thread-safe/async-safe configuration wrapper that supports runtime reloading.
This class wraps the configuration dictionary and provides: This class wraps the configuration dictionary and provides:
- Thread-safe config reloading via SIGHUP - Thread-safe config reloading via SIGHUP
- Backward-compatible dict-like access - Backward-compatible dict-like access
- Async lock to prevent concurrent reloads - Async lock to prevent concurrent reloads
""" """
def __init__(self, initial_config, config_path=None): def __init__(self, initial_config, config_path=None):
"""Initialize with initial configuration. """Initialize with initial configuration.
Args: Args:
initial_config: Initial configuration dictionary initial_config: Initial configuration dictionary
config_path: Path to config file for reloading (optional) config_path: Path to config file for reloading (optional)
@@ -150,65 +183,62 @@ class ReloadableConfig:
self._config_path = config_path self._config_path = config_path
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
self._logger = logging.getLogger(__name__) self._logger = logging.getLogger(__name__)
async def reload(self, config_path=None): async def reload(self, config_path=None):
"""Reload configuration from file. """Reload configuration from file.
Args: Args:
config_path: Path to config file (uses stored path if not provided) config_path: Path to config file (uses stored path if not provided)
Returns: Returns:
New configuration dictionary New configuration dictionary
Raises: Raises:
Exception if reload fails (keeps existing config) Exception if reload fails (keeps existing config)
""" """
path = config_path or self._config_path path = config_path or self._config_path
if not path: if not path:
raise ValueError("No config path specified for reload") raise ValueError("No config path specified for reload")
async with self._lock: async with self._lock:
try: try:
# Load new config # Load new config
new_config = load_config(path) new_config = load_config(path)
# Store old config for rollback if needed
old_config = self._config
# Update config # Update config
self._config = new_config self._config = new_config
self._logger.info(f"Configuration reloaded from {path}") self._logger.info(f"Configuration reloaded from {path}")
return new_config return new_config
except Exception as e: except Exception as e:
self._logger.error(f"Failed to reload config from {path}: {e}", exc_info=True) self._logger.error(f"Failed to reload config from {path}: {e}", exc_info=True)
# Keep existing config on error # Keep existing config on error
raise raise
def get(self, key, default=None): def get(self, key, default=None):
"""Get a config value (dict-compatible).""" """Get a config value (dict-compatible)."""
return self._config.get(key, default) return self._config.get(key, default)
def __getitem__(self, key): def __getitem__(self, key):
"""Get a config value via subscript (dict-compatible).""" """Get a config value via subscript (dict-compatible)."""
return self._config[key] return self._config[key]
def __contains__(self, key): def __contains__(self, key):
"""Check if key exists (dict-compatible).""" """Check if key exists (dict-compatible)."""
return key in self._config return key in self._config
def keys(self): def keys(self):
"""Return config keys (dict-compatible).""" """Return config keys (dict-compatible)."""
return self._config.keys() return self._config.keys()
def items(self): def items(self):
"""Return config items (dict-compatible).""" """Return config items (dict-compatible)."""
return self._config.items() return self._config.items()
def values(self): def values(self):
"""Return config values (dict-compatible).""" """Return config values (dict-compatible)."""
return self._config.values() return self._config.values()
@property @property
def config(self): def config(self):
"""Get the underlying config dict (for components that need full dict).""" """Get the underlying config dict (for components that need full dict)."""
@@ -219,7 +249,7 @@ def get_watchhosts(config):
"""Extract watched hostnames from config (hosts with watch: true). """Extract watched hostnames from config (hosts with watch: true).
Returns: Returns:
List of hostnames to watch # List of hostnames to watch
""" """
watchhosts = [] watchhosts = []
hosts_config = config.get("hosts", {}) hosts_config = config.get("hosts", {})
@@ -231,31 +261,14 @@ def get_watchhosts(config):
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):
@@ -303,7 +316,7 @@ def get_host_access(config, hostname) -> dict:
""" """
host_cfg = get_host_config(config, hostname) host_cfg = get_host_config(config, hostname)
owner = host_cfg.get("owner") or get_default_owner(config) owner = host_cfg.get("owner") # or get_default_owner(config)
managers = host_cfg.get("managers", []) managers = host_cfg.get("managers", [])
if isinstance(managers, str): if isinstance(managers, str):
+168
View File
@@ -0,0 +1,168 @@
"""Ownership rules for config-file entities (hosts, channels, threshold configs).
Rule: an entry whose config dict has a non-empty ``owner`` is private to that
owner — visible, usable, and editable only by the owner (and admins). An
entry with no ``owner`` is global: usable by everyone, editable by admins only.
The scoped-merge helpers implement the non-admin save path for
``POST /api/0/config``: the caller's visible subset of a section is replaced
by the submitted payload; everything else is preserved untouched.
"""
from typing import Any, Dict
class ScopedMergeError(Exception):
"""A non-admin payload violated an ownership rule; message names the entry."""
def is_global(cfg: Any) -> bool:
"""True when *cfg* has no owner (usable by everyone)."""
return not (isinstance(cfg, dict) and cfg.get("owner"))
def user_can_use(cfg: Any, username: str) -> bool:
"""True when *username* may use/see this entry (global or own)."""
return is_global(cfg) or cfg.get("owner") == username
def _as_list(value: Any) -> list:
if value is None:
return []
if isinstance(value, str):
return [value]
return list(value)
def user_hosts(hosts_cfg: Any, username: str) -> Dict[str, Any]:
"""Subset of *hosts_cfg* where *username* is owner or manager."""
result = {}
for name, cfg in (hosts_cfg or {}).items():
if not isinstance(cfg, dict):
continue
if cfg.get("owner") == username or username in _as_list(cfg.get("managers")):
result[name] = cfg
return result
def user_channels(channels_cfg: Any, username: str) -> Dict[str, Any]:
"""Subset of channels usable by *username*: global + own."""
return {
name: cfg for name, cfg in (channels_cfg or {}).items()
if isinstance(cfg, dict) and user_can_use(cfg, username)
}
def user_threshold_configs(threshold_cfgs: Any, username: str) -> Dict[str, Any]:
"""Subset of threshold configs usable by *username*: global + own."""
return {
name: cfg for name, cfg in (threshold_cfgs or {}).items()
if isinstance(cfg, dict) and user_can_use(cfg, username)
}
_HOST_SETTING_KEYS = ("watch", "dyndns", "notification_channels", "threshold_config")
_HOST_ACCESS_KEYS = ("owner", "managers", "monitors")
_HOST_ALLOWED_KEYS = frozenset(_HOST_SETTING_KEYS) | frozenset(_HOST_ACCESS_KEYS)
def _check_added_assignments(host_name: str, old_cfg: Any, new_cfg: dict,
key: str, usable: set) -> None:
"""Every value under *key* not already on the host must be in *usable*."""
old_vals = set(_as_list((old_cfg or {}).get(key)))
for v in _as_list(new_cfg.get(key)):
if v not in old_vals and v not in usable:
raise ScopedMergeError(
f"host {host_name!r}: {key} entry {v!r} is not available to you")
def merge_hosts_scoped(existing: Any, payload: Any, username: str,
channels_cfg: Any, threshold_cfgs: Any) -> Dict[str, Any]:
"""Return a new hosts section with *username*'s visible subset replaced by *payload*.
Hosts the user cannot see are preserved untouched. Visible hosts missing
from the payload are deleted (owners only). Managers may change setting
keys but not access keys and may not delete. New hosts get their owner
forced to *username*. Newly added channel/threshold assignments must be
global or owned by the user. Violations raise ScopedMergeError.
"""
existing = existing or {}
payload = payload or {}
visible = user_hosts(existing, username)
usable_channels = set(user_channels(channels_cfg, username))
usable_tcs = set(user_threshold_configs(threshold_cfgs, username)) | {"default"}
result: Dict[str, Any] = {n: c for n, c in existing.items() if n not in visible}
for name, entry in payload.items():
if not isinstance(entry, dict):
raise ScopedMergeError(f"host {name!r}: invalid entry")
unknown = set(entry) - _HOST_ALLOWED_KEYS
if unknown:
raise ScopedMergeError(
f"host {name!r}: fields not permitted: {', '.join(sorted(unknown))}")
if name in existing and name not in visible:
raise ScopedMergeError(f"host {name!r}: you are not an owner or manager")
old = visible.get(name)
if old is None:
new_cfg = dict(entry)
new_cfg["owner"] = username
else:
new_cfg = dict(old)
for key in _HOST_SETTING_KEYS:
if key in entry:
new_cfg[key] = entry[key]
else:
new_cfg.pop(key, None)
if old.get("owner") == username:
for key in _HOST_ACCESS_KEYS:
if key in entry:
new_cfg[key] = entry[key]
else:
new_cfg.pop(key, None)
else:
if "owner" in entry and (entry.get("owner") or None) != (old.get("owner") or None):
raise ScopedMergeError(
f"host {name!r}: only the owner may change ownership")
for key in ("managers", "monitors"):
if key in entry and set(_as_list(entry[key])) != set(_as_list(old.get(key))):
raise ScopedMergeError(
f"host {name!r}: only the owner may change {key}")
_check_added_assignments(name, old, new_cfg, "notification_channels", usable_channels)
_check_added_assignments(name, old, new_cfg, "threshold_config", usable_tcs)
result[name] = new_cfg
for name, old in visible.items():
if name not in payload and old.get("owner") != username:
raise ScopedMergeError(f"host {name!r}: only the owner may delete a host")
return result
def merge_threshold_configs_scoped(existing: Any, payload: Any,
username: str) -> Dict[str, Any]:
"""Return a new threshold_configs section with the user's own configs
replaced by *payload*.
Global and foreign-owned configs are preserved and may not appear in the
payload ('default' included). Own configs missing from the payload are
deleted. Every payload entry gets its owner forced to *username*.
"""
existing = existing or {}
payload = payload or {}
result: Dict[str, Any] = {
n: c for n, c in existing.items()
if not (isinstance(c, dict) and c.get("owner") == username)
}
for name, entry in payload.items():
if name == "default":
raise ScopedMergeError("threshold config 'default' is global and admin-managed")
old = existing.get(name)
if old is not None and (not isinstance(old, dict) or old.get("owner") != username):
raise ScopedMergeError(f"threshold config {name!r}: not owned by you")
new_cfg = dict(entry) if isinstance(entry, dict) else {}
new_cfg["owner"] = username
result[name] = new_cfg
return result
+138
View File
@@ -0,0 +1,138 @@
"""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",
"flap_count", "flap_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}")
+6 -4
View File
@@ -1,12 +1,14 @@
msgs = [] # in-memory list of recent messages for new websocket clients; also logged to file via notify.eventlog msgs: list = [] # in-memory list of recent messages for new websocket clients; also logged to file via notify.eventlog
class Data: class Data:
def __init__(self, config): def __init__(self, config):
self.config = config self.config = config
self.data = {} self.data = {}
def update(self, new_data): def update(self, new_data):
self.data.update(new_data) self.data.update(new_data)
def get(self, key, default=None): def get(self, key, default=None):
return self.data.get(key, default) return self.data.get(key, default)
+23 -20
View File
@@ -2,8 +2,11 @@
from __future__ import annotations from __future__ import annotations
from subprocess import Popen, PIPE, STDOUT from subprocess import Popen, PIPE, STDOUT
from typing import Optional from typing import Any, Callable, Optional
import asyncio import asyncio
import logging
logger = logging.getLogger(__name__)
def create_nsupdate_payload( def create_nsupdate_payload(
@@ -76,8 +79,8 @@ async def dns_update_worker(
hbdclass, hbdclass,
cfg: dict, cfg: dict,
async_queue=None, async_queue=None,
log: Optional[callable] = None, log: Optional[Callable[..., Any]] = None,
pushmsg: Optional[callable] = None, pushmsg: Optional[Callable[..., Any]] = None,
loop: Optional[asyncio.AbstractEventLoop] = None, loop: Optional[asyncio.AbstractEventLoop] = None,
): ):
"""Pure async DNS worker that processes updates from asyncio.Queue. """Pure async DNS worker that processes updates from asyncio.Queue.
@@ -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,33 +137,34 @@ 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,
cfg: dict, cfg: dict,
log: Optional[callable] = None, log: Optional[Callable[..., Any]] = None,
loop: Optional[asyncio.AbstractEventLoop] = None, loop: Optional[asyncio.AbstractEventLoop] = None,
): ):
"""Start the async DNS worker and return the Task. """Start the async DNS worker and return the Task.
@@ -173,7 +176,7 @@ def start_dns_worker(
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
# Create asyncio.Queue and wrap in a bridge for thread-safe puts # Create asyncio.Queue and wrap in a bridge for thread-safe puts
async_q = asyncio.Queue() async_q: asyncio.Queue[Any] = asyncio.Queue()
class _QueueBridge: class _QueueBridge:
"""Thread-safe wrapper around asyncio.Queue for synchronous callers.""" """Thread-safe wrapper around asyncio.Queue for synchronous callers."""
+130
View File
@@ -0,0 +1,130 @@
"""Flap detection: silence checks that toggle faster than they are useful.
A ``(host, service)`` pair is *flapping* once it exceeds ``flap_count``
WARNING/CRITICAL notifications within ``flap_interval`` minutes. The
notification that trips the threshold is delivered with ``FLAP_MARKER``
appended; every notification for that pair afterwards is dropped. The state
clears silently ``flap_interval`` minutes after a RECOVER, provided no further
WARNING/CRITICAL arrived in the meantime.
Only outbound notifications are affected — ``notify.eventlog`` keeps recording
every event, so the journal and ``/log`` retain the full history of the flap.
State lives here at module level rather than on ``Host`` so it is never
pickled: a restart starts every check with a clean slate.
"""
import logging
import time
logger = logging.getLogger(__name__)
FLAP_MARKER = "Now flapping!! No more messages!"
# Actions returned by observe()
PASS = "pass" # deliver unchanged
TRIP = "trip" # deliver with FLAP_MARKER appended
SUPPRESS = "suppress" # drop
_count = 0 # flap_count: alerts within the window needed to trip
_window = 0.0 # flap_interval in seconds
# {(host, service): {"events": [ts, ...], "flapping": bool, "ok_since": float|None}}
_state: dict = {}
def setup(cfg) -> None:
"""Read flap_count / flap_interval from *cfg* (also called on reload)."""
global _count, _window
_count = int(cfg.get("flap_count", 0) or 0)
_window = float(cfg.get("flap_interval", 0) or 0) * 60.0
def _enabled() -> bool:
return _count > 0 and _window > 0
def _label(key) -> str:
host, service = key
return f"{host}/{service}" if service else host
def _sweep(st: dict, now: float) -> bool:
"""Clear the flapping flag once the post-RECOVER quiet window has elapsed."""
if st["flapping"] and st["ok_since"] is not None and now - st["ok_since"] >= _window:
st["flapping"] = False
st["ok_since"] = None
st["events"].clear()
return True
return False
def observe(host: str, service: str, level: str) -> str:
"""Record a notification for *host*/*service* and return what to do with it.
Returns PASS, TRIP or SUPPRESS.
"""
if not _enabled():
return PASS
now = time.time()
key = (host or "", service or "")
level = (level or "").upper()
st = _state.get(key)
if st is not None and _sweep(st, now):
logger.info("flapping cleared for %s", _label(key))
if level in ("WARNING", "CRITICAL"):
if st is None:
st = _state[key] = {"events": [], "flapping": False, "ok_since": None}
# An alert inside the quiet window means it never settled.
st["ok_since"] = None
st["events"] = [t for t in st["events"] if now - t < _window]
st["events"].append(now)
if st["flapping"]:
return SUPPRESS
if len(st["events"]) > _count:
st["flapping"] = True
logger.info(
"flapping detected for %s (%d alerts in %.0f min)",
_label(key), len(st["events"]), _window / 60,
)
return TRIP
return PASS
if st is None or not st["flapping"]:
return PASS
if level == "RECOVER":
# Starts the quiet window; the state ends silently when it elapses.
st["ok_since"] = now
return SUPPRESS
def clear_host(host: str) -> None:
"""Discard all flap state for *host* (called when a host is dropped).
A dropped host may be mid-flap with no RECOVER ever received, in which
case ``ok_since`` stays ``None`` and ``_sweep`` can never clear it on its
own — the state would otherwise persist forever.
"""
for key in [k for k in _state if k[0] == host]:
del _state[key]
def flapping_services(host: str) -> list:
"""Return the services of *host* that are currently flapping.
An empty string in the result means the host itself (connectivity, boot,
shutdown) rather than a named service.
"""
if not _enabled():
return []
now = time.time()
flapping = []
for (h, service), st in _state.items():
if h != host:
continue
_sweep(st, now)
if st["flapping"]:
flapping.append(service)
return sorted(flapping)
+135 -40
View File
@@ -8,6 +8,7 @@ import time
import json import json
import copy import copy
import queue import queue
from typing import Any, Dict
num = 0 num = 0
@@ -24,11 +25,12 @@ def log(host, m):
class Connection: class Connection:
# map of addrs to names # map of addrs to names
htab = {} htab: Dict[str, Any] = {}
UNKNOWN = "unknown" UNKNOWN = "unknown"
UP = "up" UP = "up"
DOWN = "down" DOWN = "down"
OVERDUE = "overdue" OVERDUE = "overdue"
ABSENT = "absent"
def __init__(self, host, cid, addr, afam): def __init__(self, host, cid, addr, afam):
self.host = host self.host = host
@@ -42,7 +44,7 @@ class Connection:
self.statetime = self.lastbeat self.statetime = self.lastbeat
self.deltastatetime = "computed" self.deltastatetime = "computed"
self.state = Connection.UNKNOWN self.state = Connection.UNKNOWN
# Timer-based reachability monitoring # Timer-based reachability monitoring
self.overdue_timer = None self.overdue_timer = None
self.overdue_callback = None self.overdue_callback = None
@@ -94,7 +96,9 @@ class Connection:
now = time.time() now = time.time()
if not Null: if not Null:
d["addr"] = self.addr d["addr"] = self.addr
if self.rtts[-1]: if self.state == Connection.ABSENT:
d["rtt"] = ""
elif self.rtts[-1]:
d["rtt"] = "%d" % round(self.rtts[-1]) d["rtt"] = "%d" % round(self.rtts[-1])
elif self.state == Connection.UNKNOWN: elif self.state == Connection.UNKNOWN:
d["rtt"] = "" d["rtt"] = ""
@@ -193,39 +197,39 @@ class Connection:
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
def reset_overdue_timer(self, timeout_seconds, callback): def reset_overdue_timer(self, timeout_seconds, callback):
"""Reset the overdue timer for this connection. """Reset the overdue timer for this connection.
Cancels any existing timer and sets a new one that will mark Cancels any existing timer and sets a new one that will mark
the connection as overdue if no heartbeat arrives before timeout. the connection as overdue if no heartbeat arrives before timeout.
Args: Args:
timeout_seconds: Seconds before marking as overdue timeout_seconds: Seconds before marking as overdue
callback: Async function to call when timer expires callback: Async function to call when timer expires
""" """
import asyncio import asyncio
# Cancel existing timer if any # Cancel existing timer if any
if self.overdue_timer and not self.overdue_timer.cancelled(): if self.overdue_timer and not self.overdue_timer.cancelled():
self.overdue_timer.cancel() self.overdue_timer.cancel()
# Store parameters for later reference # Store parameters for later reference
self.timeout_duration = timeout_seconds self.timeout_duration = timeout_seconds
self.overdue_callback = callback self.overdue_callback = callback
# Create new timer # Create new timer
async def timer_expired(): async def timer_expired():
await callback(self) await callback(self)
try: try:
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
self.overdue_timer = loop.call_later(timeout_seconds, self.overdue_timer = loop.call_later(timeout_seconds,
lambda: asyncio.create_task(timer_expired())) lambda: asyncio.create_task(timer_expired()))
except RuntimeError: except RuntimeError:
# No event loop running yet # No event loop running yet
pass pass
def cancel_overdue_timer(self): def cancel_overdue_timer(self):
"""Cancel the overdue timer if it exists and clear all timer references.""" """Cancel the overdue timer if it exists and clear all timer references."""
if self.overdue_timer: if self.overdue_timer:
@@ -238,45 +242,45 @@ class Connection:
self.overdue_timer = None self.overdue_timer = None
self.overdue_callback = None self.overdue_callback = None
self.timeout_duration = None self.timeout_duration = None
def get_avg_rtt(self): def get_avg_rtt(self):
"""Get average RTT from recent samples.""" """Get average RTT from recent samples."""
valid_rtts = [r for r in self.rtts if r > 0] valid_rtts = [r for r in self.rtts if r > 0]
if valid_rtts: if valid_rtts:
return sum(valid_rtts) / len(valid_rtts) return sum(valid_rtts) / len(valid_rtts)
return 0 return 0
def get_current_rtt(self): def get_current_rtt(self):
"""Get most recent RTT value.""" """Get most recent RTT value."""
return self.rtts[-1] if self.rtts else 0 return self.rtts[-1] if self.rtts else 0
def check_rtt_threshold(self, warning_threshold=None, critical_threshold=None): def check_rtt_threshold(self, warning_threshold=None, critical_threshold=None):
"""Check if RTT exceeds thresholds. """Check if RTT exceeds thresholds.
Args: Args:
warning_threshold: RTT in ms for warning level warning_threshold: RTT in ms for warning level
critical_threshold: RTT in ms for critical level critical_threshold: RTT in ms for critical level
Returns: Returns:
Tuple of (level, rtt_value) where level is None, 'WARNING', or 'CRITICAL' Tuple of (level, rtt_value) where level is None, 'WARNING', or 'CRITICAL'
""" """
rtt = self.get_current_rtt() rtt = self.get_current_rtt()
if rtt <= 0: if rtt <= 0:
return (None, rtt) return (None, rtt)
if critical_threshold and rtt > critical_threshold: if critical_threshold and rtt > critical_threshold:
return ('CRITICAL', rtt) return ('CRITICAL', rtt)
elif warning_threshold and rtt > warning_threshold: elif warning_threshold and rtt > warning_threshold:
return ('WARNING', rtt) return ('WARNING', rtt)
return (None, rtt) return (None, rtt)
# #
class Host: class Host:
# Table of Hosts # Table of Hosts
hosts = {} hosts: Dict[str, "Host"] = {}
dnsQ = queue.Queue() dnsQ: "queue.Queue[Any]" = queue.Queue()
def __init__(self, name): def __init__(self, name):
global num global num
@@ -286,7 +290,7 @@ class Host:
Host.hosts[name] = self Host.hosts[name] = self
self.num = num self.num = num
self.dyn = False self.dyn = False
self.watched = True self.watched = False
self.upcount = 0 self.upcount = 0
self.interval = 0 self.interval = 0
self.doesack = -1 self.doesack = -1
@@ -297,11 +301,23 @@ 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 = {}
# Stale-data timers: {plugin_name: asyncio.TimerHandle}
self.plugin_timers = {}
# Client-reported protocol presence: {afam: {"present": bool, "ts": float}}
self.presence: dict = {}
# User access control # User access control
self.owner: str | None = None # username of owner self.owner: str | None = None # username of owner
self.managers: list = [] # usernames with manager role self.managers: list = [] # usernames with manager role
self.monitors: list = [] # usernames with monitor role self.monitors: list = [] # usernames with monitor role
def __getstate__(self):
"""Prepare Host for pickling by excluding non-serializable timer objects."""
state = self.__dict__.copy()
# asyncio TimerHandles (and their lambda callbacks) can't be pickled.
# They're recreated when the next PLG arrives after unpickling.
state['plugin_timers'] = {}
return state
def statedict(self): def statedict(self):
d = {} d = {}
d["raw_name"] = self.name d["raw_name"] = self.name
@@ -312,7 +328,8 @@ class Host:
d["name"] = "<b>%s</b>" % d["name"] d["name"] = "<b>%s</b>" % d["name"]
d["dyn"] = str(self.dyn) d["dyn"] = str(self.dyn)
d["num"] = self.num d["num"] = self.num
d["status"] = self.status()
# Add alert counts (split by acknowledged status) # Add alert counts (split by acknowledged status)
warning_unacked = 0 warning_unacked = 0
warning_acked = 0 warning_acked = 0
@@ -331,12 +348,12 @@ class Host:
critical_acked += 1 critical_acked += 1
else: else:
critical_unacked += 1 critical_unacked += 1
d["alert_warning_unacked"] = warning_unacked d["alert_warning_unacked"] = warning_unacked
d["alert_warning_acked"] = warning_acked d["alert_warning_acked"] = warning_acked
d["alert_critical_unacked"] = critical_unacked d["alert_critical_unacked"] = critical_unacked
d["alert_critical_acked"] = critical_acked d["alert_critical_acked"] = critical_acked
for c in ["IPv4", "IPv6"]: for c in ["IPv4", "IPv6"]:
if c in self.connections: if c in self.connections:
cs = self.connections[c].statedict() cs = self.connections[c].statedict()
@@ -365,7 +382,7 @@ class Host:
def stateinfo(self): def stateinfo(self):
ddict = {} ddict = {}
for d in self.__dict__: for d in self.__dict__:
if d in ["alert_states", "plugin_data"]: if d in ["alert_states", "plugin_data", "plugin_timers"]:
continue continue
if d == "connections": if d == "connections":
cl = [] cl = []
@@ -393,7 +410,7 @@ class Host:
ddict[d] = cl ddict[d] = cl
else: else:
ddict[d] = self.__dict__[d] ddict[d] = self.__dict__[d]
# Add alert counts (computed from alert_states) # Add alert counts (computed from alert_states)
warning_unacked = 0 warning_unacked = 0
warning_acked = 0 warning_acked = 0
@@ -412,12 +429,16 @@ class Host:
critical_acked += 1 critical_acked += 1
else: else:
critical_unacked += 1 critical_unacked += 1
ddict["alert_warning_unacked"] = warning_unacked ddict["alert_warning_unacked"] = warning_unacked
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
# Flap detection state (module-level in flap.py, never pickled)
from . import flap
ddict["flapping"] = flap.flapping_services(self.name)
# User access # User access
ddict["owner"] = getattr(self, "owner", None) ddict["owner"] = getattr(self, "owner", None)
ddict["managers"] = list(getattr(self, "managers", [])) ddict["managers"] = list(getattr(self, "managers", []))
@@ -430,6 +451,7 @@ class Host:
_, os_data = latest_os _, os_data = latest_os
hbc_version = os_data.get("hbc_version") hbc_version = os_data.get("hbc_version")
ddict["hbc_version"] = hbc_version ddict["hbc_version"] = hbc_version
ddict["status"] = self.status()
return ddict return ddict
@@ -445,6 +467,47 @@ class Host:
else: else:
return addr.find(".") > 0 return addr.find(".") > 0
def set_presence(self, afam, present, now):
"""Record the client's report of whether it has internet connectivity on afam."""
self.presence[afam] = {"present": bool(present), "ts": now}
def flag_present(self, afam):
"""Client-reported presence; True when the client never reported (legacy clients)."""
entry = getattr(self, "presence", {}).get(afam)
return True if entry is None else entry["present"]
def family_present(self, afam):
"""True if afam should be monitored: the client says it is present, or heartbeats
are demonstrably arriving on it (real traffic overrides a false "absent")."""
conn = self.connections.get(afam)
return self.flag_present(afam) or (conn is not None and conn.state == Connection.UP)
def status(self):
"""Host-level status: down, overdue, simplex, up or unknown.
overdue: every present family is overdue. simplex: both present, one up, one overdue
(same severity as up). Families the client reports absent are not considered,
unless none is present: then every connection not parked as ABSENT is considered.
"""
conns = self.connections
if any(c.state == Connection.DOWN for c in conns.values()):
return "down"
present = [c for afam, c in conns.items() if self.family_present(afam)]
if not present:
# The client reports no protocol present (e.g. its internet probe fails on every
# family) yet may still be heard: monitor whatever is not parked as absent, so a
# host that then dies is "overdue", not silently "unknown".
present = [c for c in conns.values() if c.state != Connection.ABSENT]
up = sum(c.state == Connection.UP for c in present)
overdue = sum(c.state == Connection.OVERDUE for c in present)
if up and overdue:
return "simplex"
if up:
return "up"
if overdue:
return "overdue"
return "unknown"
def conndata(self, cid, addr, rtt, now): def conndata(self, cid, addr, rtt, now):
if addr[0:7] == "::ffff:": if addr[0:7] == "::ffff:":
addr = addr[7:] addr = addr[7:]
@@ -483,12 +546,16 @@ class Host:
self.managers = [] self.managers = []
if not hasattr(self, "monitors"): if not hasattr(self, "monitors"):
self.monitors = [] self.monitors = []
if not hasattr(self, "plugin_timers"):
self.plugin_timers = {}
if not hasattr(self, "presence"):
self.presence = {}
pass pass
def add_plugin_data(self, plugin_name, data, timestamp=None): def add_plugin_data(self, plugin_name, data, timestamp=None):
"""Store plugin data with timestamp. """Store plugin data with timestamp.
Args: Args:
plugin_name: Name of the plugin (e.g., "cpu_monitor") plugin_name: Name of the plugin (e.g., "cpu_monitor")
data: Dict of plugin data data: Dict of plugin data
@@ -496,24 +563,24 @@ class Host:
""" """
if timestamp is None: if timestamp is None:
timestamp = time.time() timestamp = time.time()
if plugin_name not in self.plugin_data: if plugin_name not in self.plugin_data:
self.plugin_data[plugin_name] = [] self.plugin_data[plugin_name] = []
# Add new data # Add new data
self.plugin_data[plugin_name].append((timestamp, data)) self.plugin_data[plugin_name].append((timestamp, data))
# Enforce retention limit (keep last N samples) # Enforce retention limit (keep last N samples)
if len(self.plugin_data[plugin_name]) > self.plugin_retention: if len(self.plugin_data[plugin_name]) > self.plugin_retention:
self.plugin_data[plugin_name] = self.plugin_data[plugin_name][-self.plugin_retention:] self.plugin_data[plugin_name] = self.plugin_data[plugin_name][-self.plugin_retention:]
def get_plugin_data(self, plugin_name, limit=None): def get_plugin_data(self, plugin_name, limit=None):
"""Retrieve plugin data for a specific plugin. """Retrieve plugin data for a specific plugin.
Args: Args:
plugin_name: Name of the plugin plugin_name: Name of the plugin
limit: Optional limit on number of recent samples to return limit: Optional limit on number of recent samples to return
Returns: Returns:
List of (timestamp, data) tuples, most recent last List of (timestamp, data) tuples, most recent last
""" """
@@ -521,19 +588,19 @@ class Host:
if limit and len(data) > limit: if limit and len(data) > limit:
return data[-limit:] return data[-limit:]
return data return data
def get_latest_plugin_data(self, plugin_name): def get_latest_plugin_data(self, plugin_name):
"""Get the most recent plugin data for a plugin. """Get the most recent plugin data for a plugin.
Args: Args:
plugin_name: Name of the plugin plugin_name: Name of the plugin
Returns: Returns:
(timestamp, data) tuple or None if no data (timestamp, data) tuple or None if no data
""" """
data = self.plugin_data.get(plugin_name, []) data = self.plugin_data.get(plugin_name, [])
return data[-1] if data else None return data[-1] if data else None
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.
@@ -542,6 +609,34 @@ class Host:
""" """
return self.plugin_data return self.plugin_data
def reset_plugin_timer(self, plugin_name, timeout_seconds, callback):
"""Reset the stale-data timer for a plugin.
If no new PLG data arrives within timeout_seconds, callback(host, plugin_name)
is called so the caller can clear history and alerts.
"""
import asyncio
existing = self.plugin_timers.get(plugin_name)
if existing and not existing.cancelled():
existing.cancel()
async def _fire():
await callback(self, plugin_name)
try:
loop = asyncio.get_event_loop()
self.plugin_timers[plugin_name] = loop.call_later(
timeout_seconds, lambda: asyncio.create_task(_fire())
)
except RuntimeError:
pass
def cancel_plugin_timer(self, plugin_name):
"""Cancel the stale timer for a plugin, if any."""
handle = self.plugin_timers.pop(plugin_name, None)
if handle and not handle.cancelled():
handle.cancel()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# User-role helpers # User-role helpers
# ------------------------------------------------------------------ # ------------------------------------------------------------------
+980 -36
View File
File diff suppressed because it is too large Load Diff
+189 -57
View File
@@ -11,7 +11,7 @@ import os
import asyncio import asyncio
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Dict, Any, Optional from typing import Dict, Any, Optional, Callable, Iterable, List, TextIO, Tuple, Union
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -19,14 +19,14 @@ logger = logging.getLogger(__name__)
class MessageJournal: class MessageJournal:
""" """
Journal logger for heartbeat messages with size-based rotation. Journal logger for heartbeat messages with size-based rotation.
Features: Features:
- Logs all received messages in JSON format - Logs all received messages in JSON format
- Automatic rotation when file size exceeds threshold - Automatic rotation when file size exceeds threshold
- Keeps configurable number of rotated logs - Keeps configurable number of rotated logs
- Thread-safe and async-safe operation - Thread-safe and async-safe operation
- Configurable log directory and file naming - Configurable log directory and file naming
Configuration: Configuration:
journal_dir: Directory for journal files (default: /var/log/heartbeat/) journal_dir: Directory for journal files (default: /var/log/heartbeat/)
journal_file: Base filename (default: messages.journal) journal_file: Base filename (default: messages.journal)
@@ -34,69 +34,69 @@ class MessageJournal:
max_backups: Number of backup files to keep (default: 10) max_backups: Number of backup files to keep (default: 10)
enabled: Enable/disable journaling (default: True) enabled: Enable/disable journaling (default: True)
""" """
def __init__(self, config: Optional[Dict[str, Any]] = None): def __init__(self, config: Optional[Dict[str, Any]] = None):
""" """
Initialize the message journal. Initialize the message journal.
Args: Args:
config: Configuration dictionary with journal settings config: Configuration dictionary with journal settings
""" """
self.config = config or {} self.config = config or {}
# Configuration options # Configuration options
self.journal_dir = Path(self.config.get('journal_dir', '/var/log/heartbeat')) self.journal_dir = Path(self.config.get('journal_dir', '/var/log/heartbeat'))
self.journal_file = self.config.get('journal_file', 'messages.journal') self.journal_file = self.config.get('journal_file', 'messages.journal')
self.max_size = self.config.get('journal_max_size', 100 * 1024 * 1024) # 100MB default self.max_size = self.config.get('journal_max_size', 100 * 1024 * 1024) # 100MB default
self.max_backups = self.config.get('journal_max_backups', 10) self.max_backups = self.config.get('journal_max_backups', 10)
self.enabled = self.config.get('journal_enabled', True) self.enabled = self.config.get('journal_enabled', True)
# Runtime state # Runtime state
self._file_handle = None self._file_handle: Optional[TextIO] = None
self._current_size = 0 self._current_size = 0
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
self._initialized = False self._initialized = False
# Full path to current journal file # Full path to current journal file
self.journal_path = self.journal_dir / self.journal_file self.journal_path = self.journal_dir / self.journal_file
async def initialize(self) -> bool: async def initialize(self) -> bool:
""" """
Initialize the journal. Initialize the journal.
Creates journal directory if needed and opens the journal file. Creates journal directory if needed and opens the journal file.
Returns: Returns:
True if initialization successful, False otherwise True if initialization successful, False otherwise
""" """
if not self.enabled: if not self.enabled:
logger.info("Message journal disabled in configuration") logger.info("Message journal disabled in configuration")
return True return True
try: try:
# Create journal directory if it doesn't exist # Create journal directory if it doesn't exist
self.journal_dir.mkdir(parents=True, exist_ok=True) self.journal_dir.mkdir(parents=True, exist_ok=True)
# Open journal file in append mode # Open journal file in append mode
self._file_handle = open(self.journal_path, 'a', encoding='utf-8') self._file_handle = open(self.journal_path, 'a', encoding='utf-8')
# Get current file size # Get current file size
try: try:
self._current_size = os.path.getsize(self.journal_path) self._current_size = os.path.getsize(self.journal_path)
except OSError: except OSError:
self._current_size = 0 self._current_size = 0
self._initialized = True self._initialized = True
logger.info(f"Message journal initialized: {self.journal_path} " logger.info(f"Message journal initialized: {self.journal_path} "
f"(current size: {self._current_size:,} bytes, " f"(current size: {self._current_size:,} bytes, "
f"max: {self.max_size:,} bytes)") f"max: {self.max_size:,} bytes)")
return True return True
except Exception as e: except Exception as e:
logger.error(f"Failed to initialize message journal: {e}") logger.error(f"Failed to initialize message journal: {e}")
self.enabled = False self.enabled = False
return False return False
async def log_message( async def log_message(
self, self,
msg: Dict[str, Any], msg: Dict[str, Any],
@@ -105,7 +105,7 @@ class MessageJournal:
): ):
""" """
Log a received message to the journal. Log a received message to the journal.
Args: Args:
msg: Parsed message dictionary msg: Parsed message dictionary
addr: Source address (ip, port) tuple addr: Source address (ip, port) tuple
@@ -113,19 +113,19 @@ class MessageJournal:
""" """
if not self.enabled or not self._initialized: if not self.enabled or not self._initialized:
return return
# Skip HTB (heartbeat) messages - too verbose # Skip HTB (heartbeat) messages - too verbose
msg_id = msg.get('ID', '') msg_id = msg.get('ID', '')
if msg_id == 'HTB': if msg_id == 'HTB':
return return
async with self._lock: async with self._lock:
try: try:
# Prepare journal entry # Prepare journal entry
if timestamp is None: if timestamp is None:
import time import time
timestamp = time.time() timestamp = time.time()
entry = { entry = {
'timestamp': timestamp, 'timestamp': timestamp,
'datetime': datetime.fromtimestamp(timestamp).isoformat(), 'datetime': datetime.fromtimestamp(timestamp).isoformat(),
@@ -133,30 +133,55 @@ class MessageJournal:
'source_port': addr[1] if isinstance(addr, (tuple, list)) and len(addr) > 1 else None, 'source_port': addr[1] if isinstance(addr, (tuple, list)) and len(addr) > 1 else None,
'message': msg 'message': msg
} }
# Serialize to JSON (one line per entry) # Serialize to JSON (one line per entry)
json_line = json.dumps(entry, separators=(',', ':')) + '\n' json_line = json.dumps(entry, separators=(',', ':')) + '\n'
json_bytes = json_line.encode('utf-8') json_bytes = json_line.encode('utf-8')
# Check if rotation is needed # Check if rotation is needed
if self._current_size + len(json_bytes) > self.max_size: if self._current_size + len(json_bytes) > self.max_size:
await self._rotate() await self._rotate()
# Write to journal # Write to journal
if self._file_handle: if self._file_handle:
self._file_handle.write(json_line) self._file_handle.write(json_line)
self._file_handle.flush() # Ensure data is written self._file_handle.flush() # Ensure data is written
self._current_size += len(json_bytes) self._current_size += len(json_bytes)
logger.debug(f"Logged message from {addr[0]}: {msg.get('ID', 'UNKNOWN')}") logger.debug(f"Logged message from {addr[0]}: {msg.get('ID', 'UNKNOWN')}")
except Exception as e: except Exception as e:
logger.error(f"Error writing to journal: {e}") logger.error(f"Error writing to journal: {e}")
async def log_event(self, event: Dict[str, Any]):
"""Write a caller-provided dict verbatim as one JSONL line (with rotation)."""
if not self.enabled or not self._initialized:
return
async with self._lock:
try:
line = json.dumps(event, separators=(',', ':')) + '\n'
nbytes = len(line.encode('utf-8'))
if self._current_size + nbytes > self.max_size:
await self._rotate()
if self._file_handle:
self._file_handle.write(line)
self._file_handle.flush()
self._current_size += nbytes
except Exception as e:
logger.error(f"Error writing event to journal: {e}")
async def backfill(self, events: Iterable[Dict[str, Any]]):
"""One-time seed: write *events* only if the journal file is currently empty."""
if not self.enabled or not self._initialized or self._current_size > 0:
return
for ev in events:
if isinstance(ev, dict):
await self.log_event(ev)
async def _rotate(self): async def _rotate(self):
""" """
Rotate the journal file. Rotate the journal file.
Renames current file with timestamp, opens new file, and removes Renames current file with timestamp, opens new file, and removes
old backups exceeding max_backups limit. old backups exceeding max_backups limit.
""" """
@@ -165,25 +190,25 @@ class MessageJournal:
if self._file_handle: if self._file_handle:
self._file_handle.close() self._file_handle.close()
self._file_handle = None self._file_handle = None
# Generate backup filename with timestamp # Generate backup filename with timestamp
timestamp_str = datetime.now().strftime('%Y%m%d-%H%M%S') timestamp_str = datetime.now().strftime('%Y%m%d-%H%M%S')
backup_name = f"{self.journal_file}.{timestamp_str}" backup_name = f"{self.journal_file}.{timestamp_str}"
backup_path = self.journal_dir / backup_name backup_path = self.journal_dir / backup_name
# Rename current file to backup # Rename current file to backup
if self.journal_path.exists(): if self.journal_path.exists():
self.journal_path.rename(backup_path) self.journal_path.rename(backup_path)
logger.info(f"Rotated journal: {backup_path} " logger.info(f"Rotated journal: {backup_path} "
f"(size: {self._current_size:,} bytes)") f"(size: {self._current_size:,} bytes)")
# Open new journal file # Open new journal file
self._file_handle = open(self.journal_path, 'a', encoding='utf-8') self._file_handle = open(self.journal_path, 'a', encoding='utf-8')
self._current_size = 0 self._current_size = 0
# Clean up old backups # Clean up old backups
await self._cleanup_old_backups() await self._cleanup_old_backups()
except Exception as e: except Exception as e:
logger.error(f"Error rotating journal: {e}") logger.error(f"Error rotating journal: {e}")
# Try to reopen the file even if rotation failed # Try to reopen the file even if rotation failed
@@ -192,18 +217,18 @@ class MessageJournal:
except Exception as e2: except Exception as e2:
logger.error(f"Failed to reopen journal after rotation error: {e2}") logger.error(f"Failed to reopen journal after rotation error: {e2}")
self.enabled = False self.enabled = False
async def _cleanup_old_backups(self): async def _cleanup_old_backups(self):
""" """
Remove old backup files exceeding max_backups limit. Remove old backup files exceeding max_backups limit.
Keeps only the most recent backups based on filename (which includes timestamp). Keeps only the most recent backups based on filename (which includes timestamp).
""" """
try: try:
# Find all backup files # Find all backup files
backup_pattern = f"{self.journal_file}.*" backup_pattern = f"{self.journal_file}.*"
backup_files = sorted(self.journal_dir.glob(backup_pattern)) backup_files = sorted(self.journal_dir.glob(backup_pattern))
# Remove oldest backups if we have too many # Remove oldest backups if we have too many
if len(backup_files) > self.max_backups: if len(backup_files) > self.max_backups:
files_to_remove = backup_files[:len(backup_files) - self.max_backups] files_to_remove = backup_files[:len(backup_files) - self.max_backups]
@@ -213,10 +238,10 @@ class MessageJournal:
logger.info(f"Removed old backup: {backup_file.name}") logger.info(f"Removed old backup: {backup_file.name}")
except Exception as e: except Exception as e:
logger.warning(f"Failed to remove old backup {backup_file}: {e}") logger.warning(f"Failed to remove old backup {backup_file}: {e}")
except Exception as e: except Exception as e:
logger.error(f"Error cleaning up old backups: {e}") logger.error(f"Error cleaning up old backups: {e}")
async def log_threshold_event( async def log_threshold_event(
self, self,
host_name: str, host_name: str,
@@ -228,7 +253,7 @@ class MessageJournal:
): ):
""" """
Log a threshold state change event. Log a threshold state change event.
Args: Args:
host_name: Name of the host host_name: Name of the host
metric_path: Full metric path (e.g., "cpu_monitor.cpu_percent") metric_path: Full metric path (e.g., "cpu_monitor.cpu_percent")
@@ -239,11 +264,11 @@ class MessageJournal:
""" """
if not self.enabled or not self._initialized: if not self.enabled or not self._initialized:
return return
try: try:
if timestamp is None: if timestamp is None:
timestamp = __import__('time').time() timestamp = __import__('time').time()
event = { event = {
'timestamp': timestamp, 'timestamp': timestamp,
'iso_time': datetime.fromtimestamp(timestamp).isoformat(), 'iso_time': datetime.fromtimestamp(timestamp).isoformat(),
@@ -254,30 +279,30 @@ class MessageJournal:
'new_level': new_level, 'new_level': new_level,
'value': value, 'value': value,
} }
async with self._lock: async with self._lock:
if not self._file_handle: if not self._file_handle:
return return
# Check if rotation is needed # Check if rotation is needed
if self._current_size >= self.max_size: if self._current_size >= self.max_size:
await self._rotate() await self._rotate()
# Write event # Write event
line = json.dumps(event) + '\n' line = json.dumps(event) + '\n'
self._file_handle.write(line) self._file_handle.write(line)
self._file_handle.flush() self._file_handle.flush()
# Update size # Update size
self._current_size += len(line.encode('utf-8')) self._current_size += len(line.encode('utf-8'))
except Exception as e: except Exception as e:
logger.error(f"Error logging threshold event: {e}") logger.error(f"Error logging threshold event: {e}")
async def close(self): async def close(self):
""" """
Close the journal and release resources. Close the journal and release resources.
Should be called during shutdown. Should be called during shutdown.
""" """
async with self._lock: async with self._lock:
@@ -290,11 +315,11 @@ class MessageJournal:
finally: finally:
self._file_handle = None self._file_handle = None
self._initialized = False self._initialized = False
def get_stats(self) -> Dict[str, Any]: def get_stats(self) -> Dict[str, Any]:
""" """
Get journal statistics. Get journal statistics.
Returns: Returns:
Dictionary with journal stats Dictionary with journal stats
""" """
@@ -309,6 +334,88 @@ class MessageJournal:
} }
def _iter_journal_events(journal_dir: Union[str, Path], journal_file: str) -> Iterable[Dict[str, Any]]:
"""Yield event dicts from the journal files, newest event first.
Reads the current file, then rotated backups newest-to-oldest (backup
names embed rotation timestamps, so filename sort is chronological).
"""
dirp = Path(journal_dir)
files = [dirp / journal_file]
files.extend(sorted(dirp.glob(journal_file + '.*'), reverse=True))
for f in files:
if not f.is_file():
continue
try:
lines = f.read_text(encoding='utf-8', errors='replace').splitlines()
except OSError as e:
logger.warning(f"Cannot read journal file {f}: {e}")
continue
for line in reversed(lines):
try:
ev = json.loads(line)
except ValueError:
continue
if isinstance(ev, dict):
yield ev
def filter_events(
events: Iterable[Dict[str, Any]],
limit: int = 100,
before: Optional[float] = None,
host: Optional[str] = None,
level: Optional[str] = None,
q: Optional[str] = None,
predicate: Optional[Callable[[Dict[str, Any]], bool]] = None,
) -> Tuple[List[Dict[str, Any]], bool]:
"""Filter an iterable of event dicts already ordered newest-first.
Returns (events, more): up to *limit* matching events, and whether at
least one further matching event exists beyond the limit.
"""
host_l = host.lower() if host else None
level_l = level.lower() if level else None
q_l = q.lower() if q else None
out: List[Dict[str, Any]] = []
for ev in events:
if not isinstance(ev, dict):
continue
ts = ev.get('ts')
if before is not None and (not isinstance(ts, (int, float)) or ts >= before):
continue
if host_l and host_l not in str(ev.get('host') or '').lower():
continue
if level_l and str(ev.get('level') or '').lower() != level_l:
continue
if q_l and q_l not in str(ev.get('message') or '').lower():
continue
if predicate is not None and not predicate(ev):
continue
if len(out) >= limit:
return out, True
out.append(ev)
return out, False
def read_events(
journal_dir: Union[str, Path],
journal_file: str = 'events.journal',
*,
limit: int = 100,
before: Optional[float] = None,
host: Optional[str] = None,
level: Optional[str] = None,
q: Optional[str] = None,
predicate: Optional[Callable[[Dict[str, Any]], bool]] = None,
) -> Tuple[List[Dict[str, Any]], bool]:
"""Read filtered events newest-first from the events journal files."""
return filter_events(
_iter_journal_events(journal_dir, journal_file),
limit=limit, before=before, host=host, level=level, q=q, predicate=predicate,
)
# Global journal instance # Global journal instance
_journal_instance: Optional[MessageJournal] = None _journal_instance: Optional[MessageJournal] = None
@@ -316,10 +423,10 @@ _journal_instance: Optional[MessageJournal] = None
def get_journal(config: Optional[Dict[str, Any]] = None) -> MessageJournal: def get_journal(config: Optional[Dict[str, Any]] = None) -> MessageJournal:
""" """
Get or create the global journal instance. Get or create the global journal instance.
Args: Args:
config: Configuration dictionary (only used on first call) config: Configuration dictionary (only used on first call)
Returns: Returns:
MessageJournal instance MessageJournal instance
""" """
@@ -332,7 +439,7 @@ def get_journal(config: Optional[Dict[str, Any]] = None) -> MessageJournal:
async def log_message(msg: Dict[str, Any], addr: tuple, timestamp: Optional[float] = None): async def log_message(msg: Dict[str, Any], addr: tuple, timestamp: Optional[float] = None):
""" """
Convenience function to log a message using the global journal. Convenience function to log a message using the global journal.
Args: Args:
msg: Parsed message dictionary msg: Parsed message dictionary
addr: Source address (ip, port) tuple addr: Source address (ip, port) tuple
@@ -340,3 +447,28 @@ async def log_message(msg: Dict[str, Any], addr: tuple, timestamp: Optional[floa
""" """
journal = get_journal() journal = get_journal()
await journal.log_message(msg, addr, timestamp) await journal.log_message(msg, addr, timestamp)
# Global events journal instance (human-readable event log, written by notify.eventlog)
_events_journal_instance: Optional[MessageJournal] = None
def get_events_journal(config: Optional[Dict[str, Any]] = None) -> MessageJournal:
"""Get or create the global events journal instance.
Uses the events_journal_* config keys; shares journal_dir and
journal_enabled with the raw-datagram journal.
"""
global _events_journal_instance
if _events_journal_instance is None:
cfg = config or {}
_events_journal_instance = MessageJournal(
{
'journal_dir': cfg.get('journal_dir', '/var/log/heartbeat'),
'journal_file': cfg.get('events_journal_file', 'events.journal'),
'journal_max_size': cfg.get('events_journal_max_size', 10 * 1024 * 1024),
'journal_max_backups': cfg.get('events_journal_max_backups', 10),
'journal_enabled': cfg.get('journal_enabled', True),
}
)
return _events_journal_instance
+57 -33
View File
@@ -5,8 +5,6 @@ import logging
import socket import socket
import time import time
import signal import signal
import sys
import ssl
from . import __version__ from . import __version__
from . import udp from . import udp
@@ -23,6 +21,7 @@ eventlog = notify_mod.eventlog
# shared runtime collections and helpers # shared runtime collections and helpers
def save_state(config, hbdclass): def save_state(config, hbdclass):
"""Save current state to pickle file. Safe to call at any time.""" """Save current state to pickle file. Safe to call at any time."""
import pickle import pickle
@@ -68,23 +67,21 @@ def cleanup_function(config, hbdclass):
async def reload_configuration(config_obj, config_path, components): async def reload_configuration(config_obj, config_path, components):
"""Reload configuration and update all components. """Reload configuration and update all components.
Args: Args:
config_obj: ReloadableConfig instance config_obj: ReloadableConfig instance
config_path: Path to config file config_path: Path to config file
components: Dict with threshold_checker and other components components: Dict with threshold_checker and other components
Returns: Returns:
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)
@@ -105,25 +102,23 @@ async def reload_configuration(config_obj, config_path, components):
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) 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)
# - SSL certificates (already loaded) # - SSL certificates (already loaded)
# - pickfile (already opened) # - pickfile (already opened)
# - journal settings (journal already initialized) # - journal settings (journal already initialized)
# 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:
logger.error("=" * 60) logger.error("=" * 60)
logger.error(f"Failed to reload configuration: {e}", exc_info=True) logger.error(f"Failed to reload configuration: {e}", exc_info=True)
@@ -146,7 +141,7 @@ async def _run_async(config, config_path=None):
sig_name = signal.Signals(signum).name if hasattr(signal, "Signals") else signum sig_name = signal.Signals(signum).name if hasattr(signal, "Signals") else signum
logger.info(f"Received {sig_name}, initiating shutdown...") logger.info(f"Received {sig_name}, initiating shutdown...")
loop.call_soon_threadsafe(shutdown_event.set) loop.call_soon_threadsafe(shutdown_event.set)
def reload_handler(signum, frame): def reload_handler(signum, frame):
sig_name = signal.Signals(signum).name if hasattr(signal, "Signals") else signum sig_name = signal.Signals(signum).name if hasattr(signal, "Signals") else signum
logger.info(f"Received {sig_name}, initiating config reload...") logger.info(f"Received {sig_name}, initiating config reload...")
@@ -164,11 +159,20 @@ async def _run_async(config, config_path=None):
from . import threshold as threshold_mod from . import threshold as threshold_mod
notify_mod.setup(config, loop=loop) 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)
await msg_journal.initialize() await msg_journal.initialize()
# Initialize events journal (human-readable event log for the /log page)
events_journal = journal_mod.get_events_journal(config)
await events_journal.initialize()
if data.msgs:
# One-time seed on upgrade: only writes when the journal file is empty
await events_journal.backfill(data.msgs)
notify_mod.eventlog(None, "INFO", f"hbd version {__version__} starting up")
# Initialize threshold checker # Initialize threshold checker
threshold_checker = threshold_mod.ThresholdChecker( threshold_checker = threshold_mod.ThresholdChecker(
config=config, config=config,
@@ -176,7 +180,7 @@ async def _run_async(config, config_path=None):
journal=msg_journal, journal=msg_journal,
) )
logger.info("Threshold checker initialized") logger.info("Threshold checker initialized")
# Components dict for reload orchestration # Components dict for reload orchestration
components = { components = {
'threshold_checker': threshold_checker, 'threshold_checker': threshold_checker,
@@ -246,6 +250,9 @@ async def _run_async(config, config_path=None):
# upgrade or config change between runs). # upgrade or config change between runs).
threshold_checker.purge_stale_alerts(hbdclass) 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:
http_task = asyncio.create_task( http_task = asyncio.create_task(
@@ -259,6 +266,7 @@ async def _run_async(config, config_path=None):
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(
@@ -315,7 +323,7 @@ async def _run_async(config, config_path=None):
], ],
return_when=asyncio.FIRST_COMPLETED return_when=asyncio.FIRST_COMPLETED
) )
# Check which event was triggered # Check which event was triggered
if shutdown_event.is_set(): if shutdown_event.is_set():
logger.info("Shutdown signal received, stopping services...") logger.info("Shutdown signal received, stopping services...")
@@ -323,24 +331,24 @@ async def _run_async(config, config_path=None):
for task in pending: for task in pending:
task.cancel() task.cancel()
break break
if reload_event.is_set(): if reload_event.is_set():
# Clear the event for next reload # Clear the event for next reload
reload_event.clear() reload_event.clear()
# Cancel pending wait tasks # Cancel pending wait tasks
for task in pending: for task in pending:
task.cancel() task.cancel()
# Perform reload if config_path is available # Perform reload if config_path is available
if config_path: if config_path:
await reload_configuration(config, config_path, components) await reload_configuration(config, config_path, components)
else: else:
logger.warning("Cannot reload: no config path available") logger.warning("Cannot reload: no config path available")
# Continue main loop # Continue main loop
continue continue
except Exception as e: except Exception as e:
logger.exception("Error in main loop: %s", e) logger.exception("Error in main loop: %s", e)
finally: finally:
@@ -372,13 +380,33 @@ async def _run_async(config, config_path=None):
logger.warning("Timeout waiting for tasks to cancel") logger.warning("Timeout waiting for tasks to cancel")
except Exception as e: except Exception as e:
logger.debug("Exception during task cancellation: %s", e) logger.debug("Exception during task cancellation: %s", e)
# Close message journal # Close message journal
try: try:
await msg_journal.close() await msg_journal.close()
except Exception as e: except Exception as e:
logger.warning("Error closing message journal: %s", e) logger.warning("Error closing message journal: %s", e)
# Journal the shutdown event now; run()'s shutdown eventlog fires after this journal closes
try:
await events_journal.log_event(
{
"ts": time.time(),
"host": None,
"level": "INFO",
"service": None,
"message": f"hbd version {__version__} shutdown",
}
)
except Exception as e:
logger.debug("Could not journal shutdown event: %s", e)
# Close events journal
try:
await events_journal.close()
except Exception as e:
logger.warning("Error closing events journal: %s", e)
# Signal DNS worker to exit and await it # Signal DNS worker to exit and await it
try: try:
if "dns_task" in locals() and dns_task: if "dns_task" in locals() and dns_task:
@@ -422,7 +450,6 @@ def load_pickled_hosts(config, hbdclass):
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)
@@ -448,9 +475,6 @@ def load_pickled_hosts(config, hbdclass):
hbdclass.Host.hosts[h].apply_access( hbdclass.Host.hosts[h].apply_access(
access["owner"], access["managers"], access["monitors"] access["owner"], access["managers"], access["monitors"]
) )
for h in drophosts:
if h in hbdclass.Host.hosts:
del hbdclass.Host.hosts[h]
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:
@@ -462,7 +486,7 @@ def run(config, config_path=None):
"""Start the hbd service (blocking). """Start the hbd service (blocking).
Manually manages the event loop to ensure clean shutdown. Manually manages the event loop to ensure clean shutdown.
Args: Args:
config: Configuration dictionary config: Configuration dictionary
config_path: Path to config file (for reload support) config_path: Path to config file (for reload support)
@@ -475,6 +499,8 @@ def run(config, config_path=None):
if config.get("debug", 0) > 0: if config.get("debug", 0) > 0:
log_level = logging.DEBUG log_level = logging.DEBUG
logging.basicConfig(level=log_level) 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"))
@@ -489,8 +515,6 @@ def run(config, config_path=None):
except Exception as e: except Exception as e:
logger.warning("Failed to write pidfile %s: %s", pidfile, e) logger.warning("Failed to write pidfile %s: %s", pidfile, e)
eventlog(None, "INFO", f"hbd version {__version__} starting up")
if config_path: if config_path:
logger.info(f"Config file: {config_path} (reload with SIGHUP)") logger.info(f"Config file: {config_path} (reload with SIGHUP)")
else: else:
+2 -5
View File
@@ -1,15 +1,13 @@
"""Monitor helper for heartbeat daemon. """Monitor helper for heartbeat daemon.
This module provides monitoring tasks for the heartbeat daemon. This module provides monitoring tasks for the heartbeat daemon.
The primary reachability monitoring is now event-driven (timers set/reset The primary reachability monitoring is now event-driven (timers set/reset
on HTB arrival in udp.py) rather than periodic polling. on HTB arrival in udp.py) rather than periodic polling.
This module can be extended for additional monitoring tasks. This module can be extended for additional monitoring tasks.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
import time
from . import notify as notify_mod from . import notify as notify_mod
DROPOVERDUE = 7 * 24 * 3600 DROPOVERDUE = 7 * 24 * 3600
@@ -18,11 +16,10 @@ eventlog = notify_mod.eventlog
async def cleanup_connections(hbdclass): async def cleanup_connections(hbdclass):
"""Clean up connection timers on shutdown. """Clean up connection timers on shutdown.
Cancels all active overdue timers to prevent callbacks after shutdown. Cancels all active overdue timers to prevent callbacks after shutdown.
""" """
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'):
conn.cancel_overdue_timer() conn.cancel_overdue_timer()
+61 -13
View File
@@ -21,10 +21,11 @@ import smtplib
import subprocess import subprocess
import time import time
import sys import sys
from dataclasses import dataclass, field from dataclasses import dataclass
from typing import Optional from typing import Optional
from . import data from . import data
from . import flap as flap_mod
from . import ws as ws_mod from . import ws as ws_mod
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -33,6 +34,7 @@ msg_to_websockets = ws_mod.broadcast
# Module-level state set via setup() # Module-level state set via setup()
_config: dict = {} _config: dict = {}
_loop: Optional[asyncio.AbstractEventLoop] = None
# Tracks which channels fired a WARNING/CRITICAL per host. # Tracks which channels fired a WARNING/CRITICAL per host.
# {host_name: set of channel_names} — used to route RECOVER to the same channels. # {host_name: set of channel_names} — used to route RECOVER to the same channels.
@@ -47,6 +49,7 @@ logf = None
_LEVEL_ORDER = {"RECOVER": 0, "INFO": 0, "WARNING": 1, "CRITICAL": 2} _LEVEL_ORDER = {"RECOVER": 0, "INFO": 0, "WARNING": 1, "CRITICAL": 2}
def _level_value(level: str) -> int: def _level_value(level: str) -> int:
return _LEVEL_ORDER.get(level.upper(), 0) return _LEVEL_ORDER.get(level.upper(), 0)
@@ -62,6 +65,7 @@ class Notification:
body: str # detail message body: str # detail message
level: str # RECOVER | WARNING | CRITICAL | INFO level: str # RECOVER | WARNING | CRITICAL | INFO
url: str = "" # link to plugin metrics page url: str = "" # link to plugin metrics page
service: str = "" # flap-detection key within the host ("" = the host itself)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -70,14 +74,18 @@ class Notification:
def setup(cfg: dict, loop: Optional[asyncio.AbstractEventLoop] = None): def setup(cfg: dict, loop: Optional[asyncio.AbstractEventLoop] = None):
"""Initialize notifier from configuration dict.""" """Initialize notifier from configuration dict."""
global _config global _config, _loop
_config = dict(cfg) _config = dict(cfg)
flap_mod.setup(_config)
if loop is not None:
_loop = loop
def reload_config(cfg: dict): def reload_config(cfg: dict):
"""Reload notification configuration on SIGHUP.""" """Reload notification configuration on SIGHUP."""
global _config global _config
_config = dict(cfg) _config = dict(cfg)
flap_mod.setup(_config)
logger.info("Notification configuration reloaded") logger.info("Notification configuration reloaded")
@@ -96,7 +104,6 @@ def initlog(logfile):
def closelog(): def closelog():
global logf
if logf and logf != sys.stderr: if logf and logf != sys.stderr:
try: try:
logf.close() logf.close()
@@ -106,11 +113,23 @@ def closelog():
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)
# Cap the in-memory buffer so it doesn't grow without bound; this list is
# replayed to every dashboard client on connect and persisted in the pickle.
cap = _config.get("msg_buffer_size", 500)
if cap and len(data.msgs) > cap:
del data.msgs[:-cap]
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:
@@ -118,7 +137,22 @@ 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)
_journal_event(msg)
def _journal_event(msg: dict):
"""Schedule an async write of *msg* to the events journal (no-op without a loop)."""
if _loop is None or not _loop.is_running():
return
from . import journal as journal_mod
ej = journal_mod._events_journal_instance
if ej is None or not ej.enabled:
return
try:
asyncio.run_coroutine_threadsafe(ej.log_event(msg), _loop)
except Exception as e:
logger.warning("failed to schedule events journal write: %s", e)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -133,10 +167,14 @@ def _send_pushover(channel_cfg: dict, notif: Notification) -> bool:
if not token or not user: if not token or not user:
logger.warning("pushover: missing token or user") logger.warning("pushover: missing token or user")
return False return False
params: dict = {"token": token, "user": user, "title": notif.title, "message": notif.body} body = "%s: %s" % (notif.title, notif.body)
title = ""
params: dict = {"token": token, "user": user, "title": title, "message": body}
if channel_cfg.get("sound"):
params["sound"] = channel_cfg["sound"]
if notif.url: if notif.url:
params["url"] = notif.url params["url"] = notif.url
params["url_title"] = "Plugin metrics" params["url_title"] = "Heartbeat"
conn = http.client.HTTPSConnection("api.pushover.net:443") conn = http.client.HTTPSConnection("api.pushover.net:443")
try: try:
conn.request( conn.request(
@@ -209,7 +247,7 @@ def _send_mattermost(channel_cfg: dict, notif: Notification) -> bool:
return False return False
text = f"**{notif.title}**\n{notif.body}" text = f"**{notif.title}**\n{notif.body}"
if notif.url: if notif.url:
text += f"\n[Plugin metrics]({notif.url})" text += f"\n[Plugin metrics] {notif.url}"
ses = {"url": host, "scheme": "http", "basepath": "/api/v4", "port": 8065} ses = {"url": host, "scheme": "http", "basepath": "/api/v4", "port": 8065}
mm = Driver(ses) mm = Driver(ses)
payload: dict = {"text": text, "channel": channel, "username": channel_cfg.get("username", "hbd")} payload: dict = {"text": text, "channel": channel, "username": channel_cfg.get("username", "hbd")}
@@ -293,8 +331,6 @@ async def _send_sms_voipms_async(channel_cfg: dict, notif: Notification) -> bool
return False return False
async def _send_matrix_async(channel_cfg: dict, notif: Notification) -> bool: async def _send_matrix_async(channel_cfg: dict, notif: Notification) -> bool:
"""Send a Matrix message using matrix-nio.""" """Send a Matrix message using matrix-nio."""
try: try:
@@ -303,7 +339,6 @@ async def _send_matrix_async(channel_cfg: dict, notif: Notification) -> bool:
logger.error("matrix-nio not installed; pip install matrix-nio") logger.error("matrix-nio not installed; pip install matrix-nio")
return False return False
from nio import AsyncClient
homeserver = channel_cfg.get("homeserver", "") homeserver = channel_cfg.get("homeserver", "")
access_token = channel_cfg.get("access_token", "") access_token = channel_cfg.get("access_token", "")
room_id = channel_cfg.get("room_id", "") room_id = channel_cfg.get("room_id", "")
@@ -321,7 +356,6 @@ async def _send_matrix_async(channel_cfg: dict, notif: Notification) -> bool:
client = AsyncClient(homeserver) client = AsyncClient(homeserver)
client.access_token = access_token client.access_token = access_token
try: try:
from nio import RoomSendResponse
content = { content = {
"msgtype": "m.text", "msgtype": "m.text",
"body": text, "body": text,
@@ -357,6 +391,9 @@ _TIMEOUT = 15 # seconds per channel send
async def _dispatch_to_channel(channel_name: str, channel_cfg: dict, notif: Notification) -> bool: async def _dispatch_to_channel(channel_name: str, channel_cfg: dict, notif: Notification) -> bool:
"""Send *notif* to a single named channel, honouring min_level.""" """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() level = notif.level.upper()
if level != "RECOVER": if level != "RECOVER":
min_level = channel_cfg.get("min_level", "WARNING").upper() min_level = channel_cfg.get("min_level", "WARNING").upper()
@@ -392,7 +429,7 @@ def _build_url(host_name: str) -> str:
base_url = _config.get("base_url", "").rstrip("/") base_url = _config.get("base_url", "").rstrip("/")
if not base_url: if not base_url:
return "" return ""
return f"{base_url}/plugins#{host_name}" return f"{base_url}/alerts?filter={host_name}"
async def send_notification(host_name: str, notif: Notification) -> dict: async def send_notification(host_name: str, notif: Notification) -> dict:
@@ -402,11 +439,22 @@ async def send_notification(host_name: str, notif: Notification) -> dict:
notification_channels, and dispatches. Silently does nothing if notification_channels, and dispatches. Silently does nothing if
no users are configured. no users are configured.
Flap detection runs first: once *host_name*/*notif.service* is flapping the
notification is dropped, and the one that trips the state carries
``flap.FLAP_MARKER``.
Returns a dict of {channel_name: bool} results. Returns a dict of {channel_name: bool} results.
""" """
from . import users as users_mod from . import users as users_mod
from . import hbdclass from . import hbdclass
action = flap_mod.observe(host_name, notif.service, notif.level)
if action == flap_mod.SUPPRESS:
logger.debug("flapping: suppressed %s notification for %s", notif.level, host_name)
return {}
if action == flap_mod.TRIP:
notif.body = f"{notif.body} {flap_mod.FLAP_MARKER}"
if not users_mod.users_enabled(): if not users_mod.users_enabled():
return {} return {}
+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: list = []
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}")
+210 -40
View File
@@ -21,19 +21,73 @@ editable bool Reserved for future use — currently always False
sensitive bool True when the raw value must never be shown sensitive bool True when the raw value must never be shown
""" """
from . import config_access
# Credential field names that should always be masked. # Credential field names that should always be masked.
_SECRET_KEYS = frozenset({ _SECRET_KEYS = frozenset({
"password", "token", "user_key", "api_key", "secret", "password", "token", "user_key", "api_key", "secret",
"smtp_password", "smtp_user", "api_password", "access_token", "smtp_password", "smtp_user", "api_password", "access_token",
}) })
_CHANNEL_TYPE_LABELS = { CHANNEL_TYPE_SCHEMAS = {
"pushover": "Pushover", "pushover": {
"email": "E-mail", "label": "Pushover",
"signal": "Signal", "fields": [
"mattermost": "Mattermost", {"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): def _mask(value):
"""Return a masked placeholder for sensitive values.""" """Return a masked placeholder for sensitive values."""
@@ -88,9 +142,14 @@ def _sanitize_channel(name, cfg):
# Public API # Public API
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def get_settings_sections(config: dict, threshold_checker=None) -> list: def get_settings_sections(config: dict, threshold_checker=None, user=None) -> list:
"""Return ordered list of setting sections for the settings page. """Return ordered list of setting sections for the settings page.
*user* is an object with ``username``/``admin`` attributes, or None for
the unauthenticated (admin-equivalent) view. Non-admins get only the
channels/hosts/thresholds sections, filtered to global items plus what
they own or manage.
Each section: Each section:
{ {
"title": str, "title": str,
@@ -110,6 +169,9 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
"sensitive": bool, "sensitive": bool,
} }
""" """
is_admin = user is None or getattr(user, "admin", False)
username: str = getattr(user, "username", "") or ""
def field(key, label, ftype, description="", editable=False, sensitive=False): def field(key, label, ftype, description="", editable=False, sensitive=False):
raw = config.get(key) raw = config.get(key)
if sensitive: if sensitive:
@@ -143,14 +205,17 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
} }
# ---- Notification channels (complex, built separately) ---------------- # ---- Notification channels (complex, built separately) ----------------
_METADATA_KEYS = {"type", "owner", "private", "min_level"}
notif_channels = [] notif_channels = []
for ch_name, ch_cfg in (config.get("notification_channels") or {}).items(): for ch_name, ch_cfg in sorted((config.get("notification_channels") or {}).items()):
if not isinstance(ch_cfg, dict): if not isinstance(ch_cfg, dict):
continue continue
if not is_admin and not config_access.user_can_use(ch_cfg, username):
continue
ch_type = ch_cfg.get("type", "") ch_type = ch_cfg.get("type", "")
fields = [] fields = []
for k, v in ch_cfg.items(): for k, v in ch_cfg.items():
if k == "type": if k in _METADATA_KEYS:
continue continue
sensitive = k in _SECRET_KEYS sensitive = k in _SECRET_KEYS
fields.append({ fields.append({
@@ -165,16 +230,19 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
"name": ch_name, "name": ch_name,
"type": ch_type, "type": ch_type,
"type_label": _CHANNEL_TYPE_LABELS.get(ch_type, ch_type.title()), "type_label": _CHANNEL_TYPE_LABELS.get(ch_type, ch_type.title()),
"owner": ch_cfg.get("owner"),
"editable": is_admin or ch_cfg.get("owner") == username,
"min_level": ch_cfg.get("min_level", "WARNING"),
"fields": fields, "fields": fields,
}) })
# ---- Users (show metadata only, never password hashes) ---------------- # ---- Users (show metadata only, never password hashes) ----------------
users_list = [] users_list = []
for username, attrs in (config.get("users") or {}).items(): for uname, attrs in (config.get("users") or {}).items():
if not isinstance(attrs, dict): if not isinstance(attrs, dict):
continue continue
users_list.append({ users_list.append({
"username": username, "username": uname,
"full_name": attrs.get("full_name", ""), "full_name": attrs.get("full_name", ""),
"admin": bool(attrs.get("admin", False)), "admin": bool(attrs.get("admin", False)),
"avatar": attrs.get("avatar", ""), "avatar": attrs.get("avatar", ""),
@@ -191,12 +259,19 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
"hysteresis": tc.hysteresis, "hysteresis": tc.hysteresis,
"count": tc.count, "count": tc.count,
"enabled": tc.enabled, "enabled": tc.enabled,
"display": tc.display or "",
"grace": tc.grace,
} }
threshold_config_list = [] threshold_config_list = []
raw_threshold_cfgs = config.get("threshold_configs") or {}
if threshold_checker is not None: if threshold_checker is not None:
if threshold_checker.threshold_configs: if threshold_checker.threshold_configs:
for cfg_name, cfg_metrics in sorted(threshold_checker.threshold_configs.items()): for cfg_name, cfg_metrics in sorted(threshold_checker.threshold_configs.items()):
raw_cfg = raw_threshold_cfgs.get(cfg_name)
tc_owner = raw_cfg.get("owner") if isinstance(raw_cfg, dict) else None
if not is_admin and tc_owner and tc_owner != username:
continue
# For the default config use the merged effective set; # For the default config use the merged effective set;
# for named overrides use only the explicitly defined metrics # for named overrides use only the explicitly defined metrics
# (threshold_raw_configs) so inherited defaults are not repeated. # (threshold_raw_configs) so inherited defaults are not repeated.
@@ -208,52 +283,87 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
[_tc_to_row(tc) for tc in display_metrics.values()], [_tc_to_row(tc) for tc in display_metrics.values()],
key=lambda m: m["metric"], key=lambda m: m["metric"],
) )
threshold_config_list.append({"name": cfg_name, "metrics": metrics}) threshold_config_list.append({
"name": cfg_name,
"metrics": metrics,
"owner": tc_owner,
"editable": is_admin or (tc_owner is not None and tc_owner == username),
})
elif threshold_checker.thresholds: elif threshold_checker.thresholds:
metrics = sorted( metrics = sorted(
[_tc_to_row(tc) for tc in threshold_checker.thresholds.values()], [_tc_to_row(tc) for tc in threshold_checker.thresholds.values()],
key=lambda m: m["metric"], key=lambda m: m["metric"],
) )
threshold_config_list.append({"name": "default", "metrics": metrics}) threshold_config_list.append({"name": "default", "metrics": metrics,
"owner": None, "editable": is_admin})
# ---- Hosts summary ---------------------------------------------------- # ---- Hosts summary ----------------------------------------------------
hosts_list = [] hosts_list = []
for hname, hcfg in (config.get("hosts") or {}).items(): for hname, hcfg in sorted((config.get("hosts") or {}).items()):
if not isinstance(hcfg, dict): if not isinstance(hcfg, dict):
continue continue
managers = hcfg.get("managers", [])
if isinstance(managers, str):
managers = [managers]
if not is_admin and hcfg.get("owner") != username and username not in managers:
continue
hosts_list.append({ hosts_list.append({
"name": hname, "name": hname,
"watch": bool(hcfg.get("watch", True)), "watch": bool(hcfg.get("watch", True)),
"dyndns": bool(hcfg.get("dyndns", False)), "dyndns": bool(hcfg.get("dyndns", False)),
"owner": hcfg.get("owner", ""), "owner": hcfg.get("owner", ""),
"managers": hcfg.get("managers", []), "is_owner": is_admin or hcfg.get("owner") == username,
"managers": managers,
"monitors": hcfg.get("monitors", []), "monitors": hcfg.get("monitors", []),
"threshold_config": hcfg.get("threshold_config", ""), "threshold_configs": (
list(v) if isinstance(v := hcfg.get("threshold_config"), list)
else ([v] if v else [])
),
"notification_channels": hcfg.get("notification_channels", []), "notification_channels": hcfg.get("notification_channels", []),
}) })
return [ # ---- 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", ""),
})
sections: list = [
{ {
"id": "network", "id": "network",
"title": "Network", "title": "Network",
"description": "Ports and bind addresses for all server sockets.", "description": "Ports and bind addresses for all server sockets.",
"section_mode": "form",
"api_section": "server",
"fields": [ "fields": [
field("hb_port", "Heartbeat UDP port", "port", field("hb_port", "Heartbeat UDP port", "port",
"UDP port the server listens on for heartbeat datagrams."), "UDP port the server listens on for heartbeat datagrams.", editable=True),
field("hbd_host", "HTTP bind address", "text", field("hbd_host", "HTTP bind address", "text",
"Interface to bind the HTTP server to. Empty = all interfaces."), "Interface to bind the HTTP server to. Empty = all interfaces.", editable=True),
field("hbd_port", "HTTP API port", "port", field("hbd_port", "HTTP API port", "port",
"TCP port for the HTTP API and web UI."), "TCP port for the HTTP API and web UI.", editable=True),
field("ws_port", "WebSocket port", "port", field("ws_port", "WebSocket port", "port",
"TCP port for the plain WebSocket server."), "TCP port for the plain WebSocket server.", editable=True),
field("wss_port", "Secure WebSocket port", "port", field("wss_port", "Secure WebSocket port", "port",
"TCP port for WSS (TLS WebSocket). Leave empty to disable."), "TCP port for WSS (TLS WebSocket). Leave empty to disable.", editable=True),
], ],
}, },
{ {
"id": "tls", "id": "tls",
"title": "TLS / WebSocket Security", "title": "TLS / WebSocket Security",
"description": "Certificate paths used when wss_port is set.", "description": "Certificate paths used when wss_port is set.",
"section_mode": "form",
"api_section": None,
"fields": [ "fields": [
field("cert_path", "Certificate directory", "path", field("cert_path", "Certificate directory", "path",
"Directory containing the TLS certificate and key files."), "Directory containing the TLS certificate and key files."),
@@ -267,73 +377,103 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
"id": "monitoring", "id": "monitoring",
"title": "Monitoring", "title": "Monitoring",
"description": "Heartbeat timing and alert re-notification behaviour.", "description": "Heartbeat timing and alert re-notification behaviour.",
"section_mode": "form",
"api_section": "server",
"fields": [ "fields": [
field("interval", "Heartbeat interval", "duration", field("interval", "Heartbeat interval", "duration",
"Expected time between heartbeat messages from each client."), "Expected time between heartbeat messages from each client.", editable=True),
field("grace", "Grace multiplier", "number", field("grace", "Grace period", "number",
"A host is marked overdue after interval × grace seconds of silence."), "Extra seconds to wait after a missed heartbeat before sending notifications.", editable=True),
field("threshold_renotify_interval", "Re-notify interval", "duration", field("threshold_renotify_interval", "Re-notify interval", "duration",
"How often to re-send notifications for ongoing threshold alerts."), "How often to re-send notifications for ongoing threshold alerts.", editable=True),
field("flap_count", "Flap count", "number",
"Warning/critical notifications within the flap interval that mark a "
"service as flapping and silence it. 0 disables flap detection.", editable=True),
field("flap_interval", "Flap interval", "number",
"Minutes: the window flap count is measured over, and how long a service "
"must stay OK before flapping ends.", editable=True),
field("autosave_interval", "Autosave interval", "duration", field("autosave_interval", "Autosave interval", "duration",
"How often the server saves its state to disk."), "How often the server saves its state to disk."),
field("base_url", "Base URL", "text",
"Base URL for notification links.", editable=True),
], ],
}, },
{ {
"id": "persistence", "id": "persistence",
"title": "Persistence & Logging", "title": "Persistence & Logging",
"description": "State file and event log settings.", "description": "State file and event log settings.",
"section_mode": "form",
"api_section": "server",
"fields": [ "fields": [
field("pickfile", "State file", "path", field("pickfile", "State file", "path",
"Path to the pickle file used to persist host state across restarts."), "Path to the pickle file used to persist host state across restarts.", editable=True),
field("logfile", "Event log", "path", field("logfile", "Event log", "path",
"Path to the event log file."), "Path to the event log file.", editable=True),
], ],
}, },
{ {
"id": "journal", "id": "journal",
"title": "Message Journal", "title": "Message Journal",
"description": "All received heartbeat and plugin messages are journalled here.", "description": "All received heartbeat and plugin messages are journalled here.",
"section_mode": "form",
"api_section": "server",
"fields": [ "fields": [
field("journal_enabled", "Enabled", "boolean", field("journal_enabled", "Enabled", "boolean",
"Turn journalling on or off."), "Turn journalling on or off.", editable=True),
field("journal_dir", "Journal directory","path", field("journal_dir", "Journal directory", "path",
"Directory where journal files are written."), "Directory where journal files are written.", editable=True),
field("journal_file", "Journal filename", "text", field("journal_file", "Journal filename", "text",
"Base filename for the journal (rotated copies get a numeric suffix)."), "Base filename for the journal (rotated copies get a numeric suffix)."),
field("journal_max_size", "Max file size", "size", field("journal_max_size", "Max file size", "size",
"Rotate the journal when it exceeds this size."), "Rotate the journal when it exceeds this size.", editable=True),
field("journal_max_backups", "Backup count", "number", field("journal_max_backups", "Backup count", "number",
"Number of rotated journal files to keep."), "Number of rotated journal files to keep.", editable=True),
], ],
}, },
{ {
"id": "dns", "id": "dns",
"title": "Dynamic DNS", "title": "Dynamic DNS",
"description": "nsupdate-based DNS registration for dynamic hosts.", "description": "nsupdate-based DNS registration via nsupdate(8).",
"section_mode": "form",
"api_section": "dns",
"fields": [ "fields": [
field("nsupdate_bin", "nsupdate binary", "path", field("nsupdate_bin", "nsupdate binary", "path",
"Full path to the nsupdate executable."), "Path to the nsupdate binary.", editable=True),
field("dyndomains", "Dynamic domains", "list", field("rndc_key", "RNDC key file", "path",
"DNS zones managed by nsupdate for dynamic hosts."), "Path to the rndc key file used to authenticate DNS updates.", editable=True),
field("drophosts", "Drop hosts", "list", field("dyndomains", "Dynamic domains", "list",
"Hostnames to silently ignore — no state, no alerts."), "Domains updated via nsupdate when a host with dyndns: true reports in.",
editable=True),
], ],
}, },
{ {
"id": "users", "id": "users",
"title": "Users", "title": "Users",
"description": "Accounts defined in the config file. Password hashes are never shown.", "description": "Accounts defined in the config file. Password hashes are never shown.",
"section_mode": "form",
"api_section": "users",
"users": users_list, "users": users_list,
"fields": [ "fields": [
field("default_owner", "Default owner", "text", field("default_owner", "Default owner", "text",
"Username that owns hosts with no explicit owner. " "Username that owns hosts with no explicit owner. "
"Falls back to the first admin user."), "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", "id": "channels",
"title": "Notification Channels", "title": "Notification Channels",
"description": "Named notification providers. Credentials are masked.", "description": "Named notification providers. Credentials are masked.",
"section_mode": "channels",
"api_section": "notification_channels",
"channels": notif_channels, "channels": notif_channels,
"fields": [ "fields": [
field("default_notification_channels", "Default channels", "list", field("default_notification_channels", "Default channels", "list",
@@ -344,6 +484,8 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
"id": "hosts", "id": "hosts",
"title": "Hosts", "title": "Hosts",
"description": "Host definitions loaded from the config file.", "description": "Host definitions loaded from the config file.",
"section_mode": "hosts",
"api_section": "hosts",
"hosts": hosts_list, "hosts": hosts_list,
"fields": [], "fields": [],
}, },
@@ -351,16 +493,20 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
"id": "thresholds", "id": "thresholds",
"title": "Threshold Configurations", "title": "Threshold Configurations",
"description": "Named alert threshold sets. Each defines warning/critical levels per metric.", "description": "Named alert threshold sets. Each defines warning/critical levels per metric.",
"section_mode": "thresholds",
"api_section": "thresholds",
"threshold_configs": threshold_config_list, "threshold_configs": threshold_config_list,
"fields": [ "fields": [
field("default_threshold_config", "Default config", "text", field("default_threshold_config", "Default config", "text",
"Threshold config used for hosts with no explicit mapping."), "Threshold config used for hosts with no explicit mapping.", editable=True),
], ],
}, },
{ {
"id": "runtime", "id": "runtime",
"title": "Runtime", "title": "Runtime",
"description": "Flags set at startup (require restart to change).", "description": "Flags set at startup (require restart to change).",
"section_mode": "form",
"api_section": None,
"fields": [ "fields": [
field("foreground", "Foreground mode", "boolean", field("foreground", "Foreground mode", "boolean",
"Run in the foreground instead of daemonising."), "Run in the foreground instead of daemonising."),
@@ -371,3 +517,27 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
], ],
}, },
] ]
if not is_admin:
sections = [s for s in sections if s["id"] in ("channels", "hosts", "thresholds")]
for s in sections:
s["fields"] = []
return sections
def get_settings_data(config: dict, threshold_checker=None, user=None) -> dict:
"""Return sections list + auxiliary data for the settings template."""
sections = get_settings_sections(config, threshold_checker=threshold_checker, user=user)
is_admin = user is None or getattr(user, "admin", False)
username: str = getattr(user, "username", "") or ""
channels = config.get("notification_channels") or {}
threshold_cfgs = config.get("threshold_configs") or {}
if not is_admin:
channels = config_access.user_channels(channels, username)
threshold_cfgs = config_access.user_threshold_configs(threshold_cfgs, username)
return {
"sections": sections,
"all_channel_names": sorted(channels.keys()),
"all_usernames": sorted((config.get("users") or {}).keys()),
"all_threshold_configs": sorted(threshold_cfgs.keys()),
}
+155
View File
@@ -0,0 +1,155 @@
/* hbd-ui.css — shared design system for hbd pages.
*
* Green-biased neutrals, teal accent, mono identifiers. Pages link this
* after static/style.css and build on the tokens + components below.
* Introduced with the settings-page redesign; see settings.html for the
* fullest use of the row/editor idiom.
*/
:root {
--st-paper: #f4f6f4;
--st-surface: #ffffff;
--st-surface-2: #eef1ee;
--st-ink: #1d2420;
--st-muted: #5f6a63;
--st-faint: #8a948d;
--st-line: #dde3de;
--st-line-soft: #e8ece8;
--st-accent: #0e7280;
--st-accent-ink: #ffffff;
--st-accent-soft: #e0eef0;
--st-ok: #3f9c5a;
--st-ok-soft: #e2f2e7;
--st-warn: #d9982a;
--st-warn-soft: #f9efdc;
--st-crit: #d64545;
--st-crit-soft: #f9e3e3;
--st-badge-owner: #ede7f6; --st-badge-owner-ink: #5e35b1;
--st-badge-mgr: #e3f2fd; --st-badge-mgr-ink: #1565c0;
--st-mono: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace;
}
html[data-theme="dark"] {
--st-paper: #131816;
--st-surface: #1b211e;
--st-surface-2: #222925;
--st-ink: #e4eae6;
--st-muted: #9aa69e;
--st-faint: #6e7a72;
--st-line: #2c342f;
--st-line-soft: #252c28;
--st-accent: #4cc4d4;
--st-accent-ink: #0d2326;
--st-accent-soft: #143b40;
--st-ok: #5cba77;
--st-ok-soft: #1c2f22;
--st-warn: #e0ad4e;
--st-warn-soft: #33290f;
--st-crit: #e06c6c;
--st-crit-soft: #3a1a1a;
--st-badge-owner: #33294a; --st-badge-owner-ink: #b39ddb;
--st-badge-mgr: #1b3350; --st-badge-mgr-ink: #90caf9;
}
/* ── sticky toolbar under the site nav ── */
.st-toolbar {
position: sticky; top: var(--nav-h, 48px); z-index: 90;
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
padding: 9px 20px;
background: var(--st-surface); border-bottom: 1px solid var(--st-line);
}
.st-toolbar .brand { font-family: var(--st-mono); font-size: 15px; font-weight: 700; letter-spacing: -.02em; color: var(--st-ink); }
.st-toolbar .brand .tld { color: var(--st-accent); }
.st-toolbar .hintline { font-size: 12px; color: var(--st-faint); }
.st-toolbar .spacer { flex: 1; }
/* ── ECG pulse divider ── */
.pulse { display: block; width: 100%; height: 14px; }
.pulse polyline { fill: none; stroke: var(--st-accent); stroke-width: 1.5; opacity: .55; }
/* ── buttons ── */
.btn {
font: 600 13px/1 inherit; border: 1px solid var(--st-line); border-radius: 6px;
background: var(--st-surface); color: var(--st-ink); padding: 7px 14px; cursor: pointer;
}
.btn:focus-visible, a:focus-visible { outline: 2px solid var(--st-accent); outline-offset: 2px; }
.btn.primary { background: var(--st-accent); border-color: var(--st-accent); color: var(--st-accent-ink); }
.btn.quiet { border-color: transparent; background: transparent; color: var(--st-muted); }
.btn.small { padding: 4px 10px; font-size: 12px; }
.btn.danger { color: var(--st-crit); }
.btn:disabled { opacity: .6; cursor: default; }
/* ── section headers as config keys ── */
section.st { margin-top: 26px; scroll-margin-top: calc(var(--bars-h, 94px) + 12px); }
.sec-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 8px; flex-wrap: wrap; }
.sec-head h2 { margin: 0; font-family: var(--st-mono); font-size: 15px; font-weight: 700; color: var(--st-ink); }
.sec-head h2 .colon { color: var(--st-accent); }
.sec-head .count { font-family: var(--st-mono); font-size: 11.5px; color: var(--st-faint); }
.sec-head .sub { font-size: 12px; color: var(--st-muted); }
.sec-head .add { margin-left: auto; }
/* ── record list + row ── */
.list { background: var(--st-surface); border: 1px solid var(--st-line); border-radius: 8px; overflow: hidden; }
.row {
display: grid; grid-template-columns: 190px minmax(0,1fr) auto;
gap: 4px 14px; align-items: center;
padding: 8px 14px; border-bottom: 1px solid var(--st-line-soft);
transition: background .12s ease;
}
@media (prefers-reduced-motion: reduce) { .row { transition: none; } }
.row:last-child { border-bottom: none; }
.row:hover { background: var(--st-surface-2); }
.row .id { font-family: var(--st-mono); font-size: 13px; font-weight: 600; color: var(--st-ink); display: flex; align-items: center; gap: 8px; min-width: 0; }
.row .id .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.row .act { display: flex; gap: 4px; justify-content: flex-end; }
.dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
.dot.ok { background: var(--st-ok); }
.dot.warn { background: var(--st-warn); }
.dot.crit { background: var(--st-crit); }
.dot.off { background: transparent; border: 1.5px solid var(--st-faint); }
.facts { display: flex; flex-wrap: wrap; gap: 4px 6px; align-items: center; min-width: 0; }
.chip {
display: inline-flex; align-items: center; gap: 4px;
font-family: var(--st-mono); font-size: 11px; line-height: 1.3;
padding: 3px 7px; border-radius: 4px;
background: var(--st-surface-2); color: var(--st-muted);
}
.chip.owner { background: var(--st-badge-owner); color: var(--st-badge-owner-ink); }
.chip.mgr { background: var(--st-badge-mgr); color: var(--st-badge-mgr-ink); }
.chip.level { background: transparent; border: 1px solid var(--st-line); }
.chip.private { background: transparent; border: 1px dashed var(--st-badge-owner-ink); color: var(--st-badge-owner-ink); }
.chip.ok { background: var(--st-ok-soft); color: var(--st-ok); }
.chip.warn { background: var(--st-warn-soft); color: var(--st-warn); }
.chip.crit { background: var(--st-crit-soft); color: var(--st-crit); }
.chip .k { color: var(--st-faint); }
.chip .w { color: var(--st-warn); font-weight: 700; }
.chip .c { color: var(--st-crit); font-weight: 700; }
/* ── key/value rows (server groups, about page) ── */
.kv .row { grid-template-columns: 230px minmax(0,1fr); padding: 6px 14px; }
.group {
display: flex; align-items: center; gap: 10px;
padding: 7px 14px 5px; font-family: var(--st-mono); font-size: 11px; color: var(--st-faint);
background: var(--st-surface); border-bottom: 1px solid var(--st-line-soft);
}
.group .gbtn { margin-left: auto; }
.kv .val { font-family: var(--st-mono); font-size: 12.5px; color: var(--st-muted); min-width: 0; }
.kv .val a { color: var(--st-accent); text-decoration: none; }
.kv .val a:hover { text-decoration: underline; }
.kv .desc { display: block; font-size: 11px; color: var(--st-faint); margin-top: 2px; }
.val-masked { color: var(--st-faint); letter-spacing: 2px; }
.note { margin: 8px 2px 0; font-size: 12px; color: var(--st-faint); }
/* ── handheld defaults for rows ── */
@media (max-width: 760px) {
.st-toolbar { padding: 8px 10px; }
.st-toolbar .hintline { display: none; }
.row { grid-template-columns: minmax(0,1fr) auto; padding: 9px 12px; }
.row .id { grid-row: 1; grid-column: 1; }
.row .act { grid-row: 1; grid-column: 2; }
.row .facts { grid-column: 1 / -1; }
.kv .row { grid-template-columns: 1fr; }
section.st { scroll-margin-top: calc(var(--bars-h, 94px) + 50px); }
}
+16
View File
@@ -0,0 +1,16 @@
/* hbd-ui.js — shared helpers for pages using hbd-ui.css.
*
* Keeps sticky toolbars stuck just below the fixed site nav, whose height
* varies with viewport width and wrapping.
*/
function _setStickyOffsets() {
const nav = document.querySelector('.nav');
const tb = document.querySelector('.st-toolbar');
const navH = nav ? nav.offsetHeight : 48;
const barsH = navH + (tb ? tb.offsetHeight : 46);
document.documentElement.style.setProperty('--nav-h', navH + 'px');
document.documentElement.style.setProperty('--bars-h', barsH + 'px');
}
window.addEventListener('resize', _setStickyOffsets);
document.addEventListener('DOMContentLoaded', _setStickyOffsets);
+1 -1
View File
@@ -185,7 +185,7 @@
/* Slightly larger tap targets in tables */ /* Slightly larger tap targets in tables */
#ntable td, #ntable th { #ntable td, #ntable th {
padding: 4px 6px !important; padding: 4px 6px !important;
font-size: 0.82em !important; font-size: 1.00em !important;
} }
/* Cards on plugin/alerts pages */ /* Cards on plugin/alerts pages */
+96 -160
View File
@@ -1,177 +1,113 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
{% include 'head.html' %} {% include 'head.html' %}
<link rel="stylesheet" href="/static/hbd-ui.css">
<script src="/static/hbd-ui.js"></script>
<style> <style>
html, body { overflow: visible; } html, body { height: auto; overflow: visible; }
body { background: var(--st-paper); padding: 60px 0 0; }
.container { .about-main { max-width: 700px; margin: 0 auto; padding: 0 20px 60px; }
max-width: 700px;
margin: 0 auto; .hero {
display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap;
padding: 26px 2px 0;
} }
.hero .hb-logo {
h1 { font-family: var(--st-mono); font-size: 30px; font-weight: 700;
color: #333; letter-spacing: -.03em; color: var(--st-ink);
margin-bottom: 4px;
font-size: 1.5em;
} }
.hero .hb-logo .tld { color: var(--st-accent); }
.subtitle { .hero .version { font-family: var(--st-mono); font-size: 13px; color: var(--st-accent);
color: #666; background: var(--st-accent-soft); border-radius: 999px; padding: 3px 12px; }
margin-bottom: 24px; .hero .tagline { flex-basis: 100%; font-size: 13px; color: var(--st-muted); }
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: 0.85em;
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; }
</style> </style>
<body> <body>
{% include 'nav.html' %} {% include 'nav.html' %}
<div class="container"> <div class="st-toolbar">
<h1>{{ header }}</h1> <span class="brand">hbd<span class="tld">·about</span></span>
<p class="subtitle">Heartbeat monitoring system</p> <span class="hintline">heartbeat monitoring system</span>
<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@wrede.ca">aew@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> </div>
<svg class="pulse" viewBox="0 0 1200 14" preserveAspectRatio="none" aria-hidden="true">
<polyline points="0,10 340,10 352,10 358,3 364,13 370,1 378,12 384,10 560,10 572,10 578,3 584,13 590,1 598,12 604,10 1200,10"/>
</svg>
<div class="about-main">
<div class="hero">
<span class="hb-logo">heart<span class="tld">beat</span></span>
<span class="version">v{{ hbd_version }}</span>
<span class="tagline">Lightweight host monitoring over UDP</span>
</div>
<section class="st">
<div class="sec-head">
<h2>version<span class="colon">:</span></h2>
</div>
<div class="list kv">
<div class="row">
<span class="id"><span class="name">server</span></span>
<span class="val">{{ hbd_version }}</span>
</div>
<div class="row">
<span class="id"><span class="name">python</span></span>
<span class="val">{{ python_version }}</span>
</div>
<div class="row">
<span class="id"><span class="name">license</span></span>
<span class="val">MIT</span>
</div>
</div>
</section>
<section class="st">
<div class="sec-head">
<h2>runtime<span class="colon">:</span></h2>
</div>
<div class="list kv">
<div class="row">
<span class="id"><span class="name">host</span></span>
<span class="val">{{ server_hostname }}</span>
</div>
<div class="row">
<span class="id"><span class="name">started</span></span>
<span class="val">{{ start_time_str }}</span>
</div>
<div class="row">
<span class="id"><span class="name">uptime</span></span>
<span class="val" id="uptime-value">{{ uptime_str }}</span>
</div>
<div class="row">
<span class="id"><span class="name">hosts_monitored</span></span>
<span class="val">{{ host_count }}</span>
</div>
</div>
</section>
<section class="st">
<div class="sec-head">
<h2>contact<span class="colon">:</span></h2>
</div>
<div class="list kv">
<div class="row">
<span class="id"><span class="name">author</span></span>
<span class="val">Andreas Wrede</span>
</div>
<div class="row">
<span class="id"><span class="name">email</span></span>
<span class="val"><a href="mailto:aew.hbd@wrede.ca">aew.hbd@wrede.ca</a></span>
</div>
<div class="row">
<span class="id"><span class="name">repository</span></span>
<span class="val"><a href="https://git.wrede.ca/andreas/heartbeat" target="_blank" rel="noopener">git.wrede.ca/andreas/heartbeat</a></span>
</div>
</div>
</section>
</div>
{% include 'foot.html' %}
<script> <script>
(function() { (function() {
+173 -441
View File
@@ -1,528 +1,236 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
{% include 'head.html' %} {% include 'head.html' %}
<link rel="stylesheet" href="/static/hbd-ui.css">
<script src="/static/hbd-ui.js"></script>
<style> <style>
html, body { height: auto; overflow: visible; }
body { background: var(--st-paper); padding: 60px 0 0; }
body { .alerts-main { max-width: 1100px; margin: 0 auto; padding: 0 20px 60px; }
height: auto;
overflow-y: auto;
}
.container { /* summary tiles */
max-width: 1400px; .stats { display: flex; gap: 10px; flex-wrap: wrap; padding-top: 18px; }
margin: 0 auto; .stat {
flex: 1 1 140px; display: flex; align-items: baseline; gap: 10px;
background: var(--st-surface); border: 1px solid var(--st-line); border-radius: 8px;
padding: 10px 14px;
} }
.stat .num { font-family: var(--st-mono); font-size: 24px; font-weight: 700; font-variant-numeric: tabular-nums; }
.stat .lbl { font-family: var(--st-mono); font-size: 11.5px; color: var(--st-faint); }
.stat.crit .num { color: var(--st-crit); }
.stat.warn .num { color: var(--st-warn); }
.stat.ok .num { color: var(--st-ok); }
h1 { color: #333; margin-bottom: 5px; margin-top: 15px; font-size: 1.5em; } /* filter bar */
.filterbar { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; margin: 14px 0 0; }
.filterbar .fbtn {
font-family: var(--st-mono); font-size: 12px; cursor: pointer;
color: var(--st-muted); background: var(--st-surface); border: 1px solid var(--st-line);
border-radius: 999px; padding: 5px 12px;
}
.filterbar .fbtn.active { background: var(--st-accent-soft); border-color: var(--st-accent); color: var(--st-accent); font-weight: 600; }
.filterbar input {
font: 12px var(--st-mono); color: var(--st-ink);
background: var(--st-surface); border: 1px solid var(--st-line); border-radius: 999px;
padding: 5px 12px; min-width: 170px;
}
.filterbar input.invalid { border-color: var(--st-crit); }
.filterbar .upd { margin-left: auto; font-size: 11.5px; color: var(--st-faint); }
.subtitle { /* alert row specifics */
color: #666; .row.alert-crit { border-left: 3px solid var(--st-crit); padding-left: 11px; }
margin-bottom: 30px; .row.alert-warn { border-left: 3px solid var(--st-warn); padding-left: 11px; }
} .row.acked { opacity: .55; border-left-style: dashed; }
.row .id a { color: inherit; text-decoration: none; }
.row .id a:hover { color: var(--st-accent); }
.acked-chip { color: var(--st-ok); font-size: 12px; white-space: nowrap; }
.summary-cards { .empty {
display: flex; padding: 36px 14px; text-align: center; color: var(--st-faint); font-size: 13px;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 16px;
} }
.empty .big { font-size: 26px; color: var(--st-ok); display: block; margin-bottom: 6px; }
.summary-card { .errorbox {
background: white; background: var(--st-crit-soft); color: var(--st-crit);
border-radius: 6px; border-radius: 8px; padding: 14px 16px; font-size: 13px;
padding: 6px 14px;
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
display: flex;
align-items: center;
gap: 8px;
border-left: 4px solid #ddd;
}
.summary-card.critical { border-left-color: #ea1e0f; }
.summary-card.warning { border-left-color: #ff9800; }
.summary-card.ok { border-left-color: #4caf50; }
.summary-number {
font-size: 1.4em;
font-weight: bold;
line-height: 1;
}
.summary-number.critical { color: #ea1e0f; }
.summary-number.warning { color: #ff9800; }
.summary-number.ok { color: #4caf50; }
.summary-label {
color: #666;
font-size: 0.85em;
}
.filters {
background: white;
border-radius: 8px;
padding: 15px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
display: flex;
gap: 15px;
align-items: center;
}
.filter-label {
font-weight: bold;
color: #555;
}
.filter-button {
padding: 8px 16px;
border: 2px solid #ddd;
background: white;
border-radius: 20px;
cursor: pointer;
transition: all 0.2s;
font-size: 0.9em;
}
.filter-button:hover {
border-color: #2196f3;
}
.filter-button.active {
background: #2196f3;
color: white;
border-color: #2196f3;
}
.alerts-container {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.alert-item {
border-left: 5px solid #ddd;
padding: 15px;
margin-bottom: 15px;
background: #fafafa;
border-radius: 4px;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.2s;
}
.alert-item.acknowledged {
opacity: 0.8;
background: #f0f0f0;
}
.alert-item:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
transform: translateX(5px);
}
.alert-item.critical {
border-left-color: #f44336;
background: #ffebee;
}
.alert-item.warning {
border-left-color: #ff9800;
background: #fff3e0;
}
.alert-item.unknown {
border-left-color: #9e9e9e;
background: #f5f5f5;
}
.alert-main {
flex: 1;
}
.alert-header {
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 8px;
}
.alert-level {
padding: 4px 12px;
border-radius: 12px;
font-size: 0.75em;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.alert-level.critical {
background: #f44336;
color: white;
}
.alert-level.warning {
background: #ff9800;
color: white;
}
.alert-level.unknown {
background: #9e9e9e;
color: white;
}
.alert-hostname {
font-weight: bold;
color: #333;
font-size: 1.1em;
}
.alert-metric {
color: #666;
font-family: 'Courier New', monospace;
font-size: 0.9em;
}
.alert-details {
display: flex;
gap: 20px;
color: #666;
font-size: 0.9em;
}
.alert-value {
font-weight: bold;
color: #333;
}
.alert-duration {
color: #999;
font-size: 0.85em;
}
.alert-actions {
display: flex;
flex-direction: column;
gap: 8px;
margin-left: 15px;
}
.acknowledge-btn {
padding: 8px 16px;
background: #2196f3;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.85em;
transition: all 0.2s;
white-space: nowrap;
}
.acknowledge-btn:hover {
background: #1976d2;
transform: scale(1.05);
}
.acknowledge-btn:disabled {
background: #ccc;
cursor: not-allowed;
transform: none;
}
.acknowledged-badge {
padding: 4px 8px;
background: #4caf50;
color: white;
border-radius: 4px;
font-size: 0.75em;
text-align: center;
white-space: nowrap;
}
.no-alerts {
text-align: center;
padding: 60px 20px;
color: #999;
}
.no-alerts-icon {
font-size: 4em;
margin-bottom: 20px;
}
.loading {
text-align: center;
padding: 40px;
color: #666;
}
.error {
background: #ffebee;
border-left: 4px solid #f44336;
padding: 20px;
margin: 20px 0;
border-radius: 4px;
color: #c62828;
}
.refresh-info {
text-align: center;
color: #999;
font-size: 0.85em;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #e0e0e0;
}
.last-update {
color: #666;
font-size: 0.9em;
text-align: right;
margin-bottom: 15px;
} }
</style> </style>
<body> <body>
{% include 'nav.html' %} {% include 'nav.html' %}
<div class="container"> <div class="st-toolbar">
<h1>{{ header }}</h1> <span class="brand">hbd<span class="tld">·alerts</span></span>
<p class="subtitle">Real-time monitoring alerts and threshold violations</p> <span class="hintline">threshold violations and reachability — refreshes every 15 s</span>
</div>
<svg class="pulse" viewBox="0 0 1200 14" preserveAspectRatio="none" aria-hidden="true">
<polyline points="0,10 340,10 352,10 358,3 364,13 370,1 378,12 384,10 560,10 572,10 578,3 584,13 590,1 598,12 604,10 1200,10"/>
</svg>
<div class="summary-cards" id="summary-cards"> <div class="alerts-main">
<div class="summary-card critical"> <div class="stats">
<div class="summary-label">Critical</div> <div class="stat crit"><span class="num" id="critical-count">–</span><span class="lbl">critical</span></div>
<div class="summary-number critical" id="critical-count">-</div> <div class="stat warn"><span class="num" id="warning-count">–</span><span class="lbl">warning</span></div>
</div> <div class="stat ok"><span class="num" id="host-count">–</span><span class="lbl">hosts</span></div>
<div class="summary-card warning">
<div class="summary-label">Warning</div>
<div class="summary-number warning" id="warning-count">-</div>
</div>
<div class="summary-card ok">
<div class="summary-label">Total Hosts</div>
<div class="summary-number ok" id="host-count">-</div>
</div>
</div> </div>
<div class="filters"> <div class="filterbar">
<span class="filter-label">Show:</span> <button class="fbtn active" onclick="filterAlerts('all', this)">all</button>
<button class="filter-button active" onclick="filterAlerts('all')">All</button> <button class="fbtn" onclick="filterAlerts('critical', this)">critical</button>
<button class="filter-button" onclick="filterAlerts('critical')">Critical Only</button> <button class="fbtn" onclick="filterAlerts('warning', this)">warning</button>
<button class="filter-button" onclick="filterAlerts('warning')">Warning Only</button> <input id="host-filter" type="text" placeholder="host filter (regex)" oninput="onHostFilterInput(this)">
<span class="upd">updated <span id="last-update-time">never</span></span>
</div> </div>
<div class="alerts-container"> <section class="st" id="alerts">
<div class="last-update">Last updated: <span id="last-update-time">Never</span></div> <div class="sec-head">
<div id="alerts-list"> <h2>alerts<span class="colon">:</span></h2>
<div class="loading">Loading alerts...</div> <span class="count" id="alert-count"></span>
</div> </div>
<div class="refresh-info"> <div class="list" id="alerts-list">
Auto-refreshing every 15 seconds <div class="empty">Loading alerts…</div>
</div> </div>
</div> </section>
</div> </div>
<script> <script>
let currentFilter = 'all'; let currentFilter = 'all';
let allAlerts = []; let allAlerts = [];
let hostFilterRe = null;
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
async function loadAlerts() { async function loadAlerts() {
try { try {
const response = await fetch('/api/0/alerts'); const response = await fetch('/api/0/alerts');
if (!response.ok) { if (!response.ok) throw new Error(`HTTP ${response.status}`);
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json(); const data = await response.json();
allAlerts = data.alerts; allAlerts = data.alerts;
// Update summary cards
document.getElementById('critical-count').textContent = data.summary.critical || 0; document.getElementById('critical-count').textContent = data.summary.critical || 0;
document.getElementById('warning-count').textContent = data.summary.warning || 0; document.getElementById('warning-count').textContent = data.summary.warning || 0;
document.getElementById('host-count').textContent = data.host_count || 0; document.getElementById('host-count').textContent = data.host_count || 0;
// Update last update time
document.getElementById('last-update-time').textContent = new Date().toLocaleTimeString(); document.getElementById('last-update-time').textContent = new Date().toLocaleTimeString();
// Render alerts
renderAlerts(allAlerts); renderAlerts(allAlerts);
} catch (error) { } catch (error) {
document.getElementById('alerts-list').innerHTML = document.getElementById('alerts-list').innerHTML =
`<div class="error">Failed to load alerts: ${error.message}</div>`; `<div class="errorbox">Failed to load alerts: ${escHtml(error.message)}. Retrying automatically.</div>`;
} }
} }
function renderAlerts(alerts) { function renderAlerts(alerts) {
const container = document.getElementById('alerts-list'); const container = document.getElementById('alerts-list');
// Filter alerts based on current filter let filtered = alerts;
let filteredAlerts = alerts;
if (currentFilter !== 'all') { if (currentFilter !== 'all') {
filteredAlerts = alerts.filter(alert => filtered = filtered.filter(a => a.level.toLowerCase() === currentFilter);
alert.level.toLowerCase() === currentFilter
);
} }
if (hostFilterRe) {
if (filteredAlerts.length === 0) { filtered = filtered.filter(a => hostFilterRe.test(a.hostname));
if (currentFilter === 'all' && alerts.length === 0) { }
container.innerHTML = `
<div class="no-alerts"> document.getElementById('alert-count').textContent =
<div class="no-alerts-icon">✓</div> filtered.length === alerts.length ? alerts.length : `${filtered.length} of ${alerts.length}`;
<h2>All Systems Normal</h2>
<p>No active alerts at this time</p> if (filtered.length === 0) {
</div> container.innerHTML = (currentFilter === 'all' && !hostFilterRe && alerts.length === 0)
`; ? `<div class="empty"><span class="big">✓</span>All systems normal — no active alerts.</div>`
} else { : `<div class="empty">No matching alerts.</div>`;
container.innerHTML = `
<div class="no-alerts">
<p>No ${currentFilter} alerts</p>
</div>
`;
}
return; return;
} }
let html = ''; container.innerHTML = filtered.map(renderAlert).join('');
for (const alert of filteredAlerts) {
html += renderAlert(alert);
}
container.innerHTML = html;
} }
function renderAlert(alert) { function renderAlert(alert) {
const level = alert.level.toLowerCase(); const level = alert.level.toLowerCase();
const cls = level === 'critical' ? 'crit' : (level === 'warning' ? 'warn' : 'level');
const duration = getDuration(alert.since); const duration = getDuration(alert.since);
const acknowledged = alert.acknowledged || false; const acked = alert.acknowledged || false;
// Use formatted message if available, otherwise build from individual fields const metric = (alert.metric_path.includes('.')
let valueText = `Value: <span class="alert-value">${formatValue(alert.last_value)}</span>`; ? alert.metric_path.slice(alert.metric_path.indexOf('.') + 1)
: alert.metric_path).replace(/_status_code$/, '');
let chips = `<span class="chip ${cls}">${escHtml(alert.level)}</span>`;
chips += `<span class="chip"><span class="k">metric</span> ${escHtml(metric)}</span>`;
if (alert.formatted_message) { if (alert.formatted_message) {
valueText += ` <span class="threshold-info">${alert.formatted_message}</span>`; chips += `<span class="chip">${escHtml(alert.formatted_message)}</span>`;
} 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>`;
}
// Build actions section
let actionsHtml = '';
if (acknowledged) {
actionsHtml = `
<div class="alert-actions">
<div class="acknowledged-badge">✓ Acknowledged</div>
</div>
`;
} else { } else {
actionsHtml = ` chips += `<span class="chip"><span class="k">value</span> ${escHtml(formatValue(alert.last_value))}</span>`;
<div class="alert-actions"> if (alert.threshold_value !== undefined && alert.threshold_value !== null && alert.operator) {
<button class="acknowledge-btn" onclick="acknowledgeAlert('${alert.hostname}', '${alert.metric_path}', event)"> chips += `<span class="chip level">${escHtml(alert.operator)} ${escHtml(formatValue(alert.threshold_value))}</span>`;
Acknowledge }
</button>
</div>
`;
} }
if (alert.recovery_threshold !== undefined && alert.recovery_threshold !== null) {
const recOp = (alert.operator === '>' || alert.operator === '>=') ? '<' : '>';
chips += `<span class="chip level">recovers ${recOp} ${escHtml(formatValue(alert.recovery_threshold))}</span>`;
}
chips += `<span class="chip"><span class="k">for</span> ${duration}</span>`;
const act = acked
? `<span class="acked-chip">✓ acknowledged</span>`
: `<button class="btn small" onclick="acknowledgeAlert('${escHtml(alert.hostname)}', '${escHtml(alert.metric_path)}', event)">Acknowledge</button>`;
return ` return `
<div class="alert-item ${level} ${acknowledged ? 'acknowledged' : ''}"> <div class="row alert-${cls === 'level' ? 'warn' : cls}${acked ? ' acked' : ''}">
<div class="alert-main"> <span class="id">
<div class="alert-header"> <span class="dot ${cls === 'crit' ? 'crit' : 'warn'}"></span>
<span class="alert-level ${level}">${alert.level}</span> <a class="name" href="/plugins#${encodeURIComponent(alert.hostname)}">${escHtml(alert.hostname)}</a>
<span class="alert-hostname">${alert.hostname}</span> </span>
</div> <span class="facts">${chips}</span>
<div class="alert-metric">${alert.metric_path}</div> <span class="act">${act}</span>
<div class="alert-details"> </div>`;
<span>${valueText}</span>
<span class="alert-duration">Active for ${duration}</span>
</div>
</div>
${actionsHtml}
</div>
`;
} }
function formatValue(value) { function formatValue(value) {
if (typeof value === 'number') { if (typeof value === 'number') {
if (value > 1000) { if (value > 1000) return value.toLocaleString();
return value.toLocaleString();
}
return value.toFixed(2); return value.toFixed(2);
} }
return value; return value;
} }
function getDuration(timestamp) { function getDuration(timestamp) {
const now = Date.now() / 1000; const seconds = Math.floor(Date.now() / 1000 - timestamp);
const seconds = Math.floor(now - timestamp); if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
if (seconds < 60) { if (seconds < 86400) {
return `${seconds}s`; return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
} else if (seconds < 3600) {
return `${Math.floor(seconds / 60)}m`;
} else if (seconds < 86400) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}h ${minutes}m`;
} else {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
return `${days}d ${hours}h`;
} }
return `${Math.floor(seconds / 86400)}d ${Math.floor((seconds % 86400) / 3600)}h`;
} }
function filterAlerts(filter) { function filterAlerts(filter, btn) {
currentFilter = filter; currentFilter = filter;
document.querySelectorAll('.filterbar .fbtn').forEach(b => b.classList.remove('active'));
// Update active button btn.classList.add('active');
document.querySelectorAll('.filter-button').forEach(btn => {
btn.classList.remove('active');
});
event.target.classList.add('active');
// Re-render with new filter
renderAlerts(allAlerts); renderAlerts(allAlerts);
} }
async function acknowledgeAlert(hostname, metricPath, event) { async function acknowledgeAlert(hostname, metricPath, event) {
// Prevent event bubbling if (event) event.stopPropagation();
if (event) {
event.stopPropagation();
}
// Disable the button
const button = event.target; const button = event.target;
button.disabled = true; button.disabled = true;
button.textContent = 'Acknowledging...'; button.textContent = 'Acknowledging…';
try { try {
const response = await fetch('/api/0/alerts/acknowledge', { const response = await fetch('/api/0/alerts/acknowledge', {
method: 'POST', method: 'POST',
headers: { headers: {'Content-Type': 'application/json'},
'Content-Type': 'application/json', body: JSON.stringify({hostname: hostname, metric_path: metricPath}),
},
body: JSON.stringify({
hostname: hostname,
metric_path: metricPath,
}),
}); });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const result = await response.json(); const result = await response.json();
const a = allAlerts.find(x => x.hostname === hostname && x.metric_path === metricPath);
// Update the alert in our local data if (a) {
const alert = allAlerts.find(a => a.hostname === hostname && a.metric_path === metricPath); a.acknowledged = true;
if (alert) { a.acknowledged_at = result.acknowledged_at;
alert.acknowledged = true;
alert.acknowledged_at = result.acknowledged_at;
} }
// Re-render alerts
renderAlerts(allAlerts); renderAlerts(allAlerts);
} catch (error) { } catch (error) {
alert(`Failed to acknowledge alert: ${error.message}`); alert(`Failed to acknowledge alert: ${error.message}`);
button.disabled = false; button.disabled = false;
@@ -530,10 +238,34 @@
} }
} }
// Auto-refresh every 15 seconds 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);
}
setInterval(loadAlerts, 15000); setInterval(loadAlerts, 15000);
// Initial load (function () {
const param = new URLSearchParams(window.location.search).get('filter');
if (param) {
const input = document.getElementById('host-filter');
input.value = param;
onHostFilterInput(input);
}
})();
loadAlerts(); loadAlerts();
</script> </script>
</body> </body>
+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>
+112 -13
View File
@@ -5,7 +5,68 @@
<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>
{% if extra_scripts %}<script src="{{ extra_scripts }}"></script>{% endif %} {% 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> <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 ── */ /* ── Reset / shared baseline ── */
*, *::before, *::after { box-sizing: border-box; } *, *::before, *::after { box-sizing: border-box; }
html { html {
@@ -16,10 +77,11 @@
margin: 0; margin: 0;
padding: 10px; padding: 10px;
padding-top: 60px; padding-top: 60px;
background: #f5f5f5; background: var(--bg);
color: var(--text);
} }
h1 { font-size: 1.5em; color: #333; margin: 0 0 5px; } h1 { font-size: 1.5em; color: var(--text-2); margin: 0 0 5px; }
h2 { font-size: 1.1em; color: #333; margin: 0 0 8px; } h2 { font-size: 1.1em; color: var(--text-2); margin: 0 0 8px; }
p { margin: 0; } p { margin: 0; }
/* Navigation bar — shared across all pages */ /* Navigation bar — shared across all pages */
@@ -29,9 +91,9 @@
left: 0; left: 0;
right: 0; right: 0;
z-index: 200; z-index: 200;
background: #fff; background: var(--nav-bg);
padding: 6px 12px; padding: 6px 12px;
box-shadow: 0 2px 4px rgba(0,0,0,.1); box-shadow: 0 2px 4px var(--shadow-nav);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@@ -42,25 +104,25 @@
.nav a { .nav a {
margin-right: 20px; margin-right: 20px;
text-decoration: none; text-decoration: none;
color: #0066cc; color: var(--link);
font-weight: 500; font-weight: 500;
font-size: 0.9em; font-size: 0.9em;
} }
.nav a:hover { text-decoration: underline; } .nav a:hover { text-decoration: underline; }
.nav a.active { color: #333; font-weight: bold; } .nav a.active { color: var(--text-2); font-weight: bold; }
.nav-user { .nav-user {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
text-decoration: none; text-decoration: none;
color: #333; color: var(--text-2);
font-size: 0.9em; font-size: 0.9em;
font-weight: 500; font-weight: 500;
padding: 4px 8px; padding: 4px 8px;
border-radius: 20px; border-radius: 20px;
transition: background 0.15s; transition: background 0.15s;
} }
.nav-user:hover { background: #f0f4ff; text-decoration: none; } .nav-user:hover { background: var(--surface-2); text-decoration: none; }
.nav-username { .nav-username {
max-width: 0; max-width: 0;
overflow: hidden; overflow: hidden;
@@ -81,7 +143,7 @@
.nav-initials { .nav-initials {
width: 28px; height: 28px; width: 28px; height: 28px;
border-radius: 50%; border-radius: 50%;
background: #0066cc; background: var(--link);
color: #fff; color: #fff;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -106,7 +168,7 @@
.nav-hamburger span { .nav-hamburger span {
display: block; display: block;
height: 3px; height: 3px;
background: #555; background: var(--text-muted);
border-radius: 2px; border-radius: 2px;
} }
@@ -118,13 +180,39 @@
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
padding-top: 8px; padding-top: 8px;
border-top: 1px solid #eee; border-top: 1px solid var(--border-2);
order: 3; order: 3;
} }
.nav-links.nav-open { display: flex; } .nav-links.nav-open { display: flex; }
.nav-links a { margin-right: 0; padding: 6px 0; font-size: 1em; } .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 */ /* Swiss railway clock — nav */
.nav-pie { .nav-pie {
flex-shrink: 0; flex-shrink: 0;
@@ -214,7 +302,7 @@
ctx.restore(); ctx.restore();
} }
hand((m + s / 60) / 60 * Math.PI * 2 - Math.PI / 2, hand((sFrac >= 58.5 ? m + 1 : m) / 60 * Math.PI * 2 - Math.PI / 2,
R * 0.88, -R * 0.12, SIZE * 0.027, '#222'); /* minute */ R * 0.88, -R * 0.12, SIZE * 0.027, '#222'); /* minute */
hand((h + m / 60) / 12 * Math.PI * 2 - Math.PI / 2, hand((h + m / 60) / 12 * Math.PI * 2 - Math.PI / 2,
R * 0.58, -R * 0.12, SIZE * 0.039, '#222'); /* hour */ R * 0.58, -R * 0.12, SIZE * 0.039, '#222'); /* hour */
@@ -262,6 +350,17 @@
setTimeout(clockTick, delay); 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() { document.addEventListener('DOMContentLoaded', function() {
/* Start the shared tick loop */ /* Start the shared tick loop */
clockTick(); clockTick();
+382 -541
View File
@@ -1,570 +1,411 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
{% include 'head.html' %} {% include 'head.html' %}
<link rel="stylesheet" href="/static/hbd-ui.css">
<script src="/static/hbd-ui.js"></script>
<style> <style>
body { html, body { height: auto; overflow: visible; }
display: flex; body { background: var(--st-paper); padding: 60px 0 0; }
flex-direction: column;
height: 100vh;
overflow: hidden;
}
@media (max-width: 640px) { .live-main { max-width: 1180px; margin: 0 auto; padding: 0 20px 70px; }
body {
height: auto;
min-height: 100vh;
overflow: auto;
flex-direction: column;
}
.container {
max-height: none;
overflow: visible;
}
.table-section {
max-height: 55vh;
}
.log-section {
flex: none;
max-height: 40vh;
}
}
.container { .st-toolbar input {
flex: 1; font: 12px var(--st-mono); color: var(--st-ink);
min-height: 0; background: var(--st-surface); border: 1px solid var(--st-line); border-radius: 999px;
max-width: 1600px; padding: 5px 12px; min-width: 160px;
width: 100%;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 15px;
overflow: hidden;
} }
.live-dot { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; color: var(--st-ok); }
.live-dot::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--st-ok); }
.live-dot.reconnecting { color: var(--st-crit); }
.live-dot.reconnecting::before { background: var(--st-crit); }
h1 { /* ── host rows: 6 columns ── */
color: #333; .hrow, .hhead {
margin-bottom: 5px; display: grid;
margin-top: 15px; grid-template-columns: 175px 105px 70px 125px 85px minmax(0,1fr);
font-size: 1.5em; gap: 4px 14px; align-items: center;
padding: 6px 14px; border-bottom: 1px solid var(--st-line-soft);
} }
.hrow:last-child { border-bottom: none; }
.hrow:hover { background: var(--st-surface-2); }
.hhead {
font-family: var(--st-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: .05em;
color: var(--st-faint); background: var(--st-surface-2); border-radius: 8px 8px 0 0;
}
.hhead .num, .hrow .num { text-align: right; }
h2 { .hrow.sev-warn { border-left: 3px solid var(--st-warn); padding-left: 11px; }
color: #333; .hrow.sev-crit { border-left: 3px solid var(--st-crit); padding-left: 11px; }
margin-bottom: 10px;
font-size: 1.2em;
padding: 10px 15px;
background: white;
border-radius: 6px;
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
}
.subtitle { .host { font-family: var(--st-mono); font-size: 13px; font-weight: 400; display: flex; align-items: center; gap: 7px; min-width: 0; }
color: #666; .host.watched { font-weight: 700; }
margin-bottom: 15px; .host a { color: inherit; text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
font-size: 0.9em; .host a:hover { color: var(--st-accent); }
} .dot.down { background: var(--st-crit); box-shadow: 0 0 0 3px var(--st-crit-soft); }
.dot.overdue { background: transparent; border: 2px solid var(--st-warn); }
.stale { color: var(--st-faint); font-size: 10px; border: 1px solid var(--st-line); border-radius: 4px; padding: 1px 4px; font-family: var(--st-mono); white-space: nowrap; }
.flap { color: var(--st-warn); font-size: 10px; border: 1px solid var(--st-warn); border-radius: 4px; padding: 1px 4px; font-family: var(--st-mono); white-space: nowrap; }
.chip.outline { background: transparent; border: 1px solid var(--st-line); }
.content { .lat { font-family: var(--st-mono); font-size: 12px; color: var(--st-muted); font-variant-numeric: tabular-nums; }
display: flex; .since { font-family: var(--st-mono); font-size: 11.5px; color: var(--st-faint); font-variant-numeric: tabular-nums; }
flex-direction: column;
gap: 15px;
}
.table-section { .lastalert { font-family: var(--st-mono); font-size: 11.5px; color: var(--st-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
background: white; .lastalert .lvl { font-weight: 700; }
border-radius: 6px; .lastalert .lvl.crit { color: var(--st-crit); }
padding: 15px; .lastalert .lvl.warn { color: var(--st-warn); }
box-shadow: 0 1px 4px rgba(0,0,0,0.1); .lastalert .ago { color: var(--st-faint); }
overflow-x: auto; .lastalert.quiet { color: var(--st-faint); }
overflow-y: auto;
max-height: 60vh;
}
.log-section { /* ── hover card on the status chip ── */
flex: 1; .tip { position: relative; display: inline-flex; }
min-height: 0; .tipbox {
background: white; display: none; position: absolute; z-index: 40; top: calc(100% + 6px); left: 0;
border-radius: 6px; width: 340px; background: var(--st-surface); border: 1px solid var(--st-line); border-radius: 8px;
padding: 15px; box-shadow: 0 8px 28px var(--shadow); padding: 10px 12px;
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
overflow-y: auto;
} }
.tip:hover .tipbox, .tip:focus-within .tipbox { display: block; }
.tipbox table { border-collapse: collapse; width: 100%; font-family: var(--st-mono); font-size: 11.5px; }
.tipbox th { text-align: left; font-size: 10px; text-transform: uppercase; letter-spacing: .05em; color: var(--st-faint); padding: 2px 6px 4px; border-bottom: 1px solid var(--st-line-soft); }
.tipbox td { padding: 3px 6px; color: var(--st-muted); }
.tipbox td:first-child { color: var(--st-faint); }
.tipbox .up { color: var(--st-ok); font-weight: 700; }
.tipbox .dn { color: var(--st-crit); font-weight: 700; }
.tipbox .ov { color: var(--st-warn); font-weight: 700; }
.tipbox .ab { color: var(--st-faint); font-weight: 700; }
.tipbox .meta { margin-top: 6px; padding-top: 6px; border-top: 1px solid var(--st-line-soft); font-size: 11px; color: var(--st-faint); }
#ntable { @media (max-width: 760px) {
border-collapse: collapse; .hhead { display: none; }
width: 100%; .hrow { grid-template-columns: minmax(0,1fr) auto auto; padding: 8px 12px; }
font-size: 0.9em; .hrow .num { text-align: left; }
.hrow .since { display: none; }
.lastalert { grid-column: 1 / -1; }
.tipbox { left: auto; right: 0; }
} }
#ntable td,
#ntable th {
border: 1px solid #e0e0e0;
text-align: left;
padding: 2px 4px;
white-space: nowrap;
}
#ntable tr:nth-child(even) {
background-color: #fafafa;
}
#ntable tr:hover {
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 {
padding: 6px 8px;
background-color: #2196f3;
color: white;
font-weight: 600;
position: sticky;
top: 0;
z-index: 10;
}
#ntable
th:not(.sorttable_sorted):not(.sorttable_sorted_reverse):not(.sorttable_nosort):after {
content: " ⇅";
opacity: 0.5;
}
/* Alert count column styling */
#ntable td.alert-warning {
color: #ff9800;
font-weight: bold;
text-align: center;
}
#ntable td.alert-critical {
color: #f44336;
font-weight: bold;
text-align: center;
}
/* Scrollbar styling */
.log-section::-webkit-scrollbar {
width: 8px;
}
.log-section::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
.log-section::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
.log-section::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Message styling */
#messages {
font-size: 0.85em;
line-height: 1.0;
}
#messages div {
padding: 5px 0;
border-bottom: 1px solid #f0f0f0;
}
/* Modal for connection status messages */
.connection-modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
}
.connection-modal.show {
display: flex;
justify-content: center;
align-items: center;
}
.connection-modal-content {
background-color: white;
padding: 30px 40px;
border-radius: 8px;
text-align: center;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
min-width: 300px;
}
.connection-modal-content p {
margin: 0;
font-size: 16px;
color: #333;
}
/* State indicators */
.state-up {
color: #4caf50;
font-weight: 600;
}
.state-down {
color: #f44336;
font-weight: 700;
}
.state-overdue {
color: #ff9800;
font-weight: 700;
}
#ntable a.host-link { color: inherit; text-decoration: none; }
#ntable a.host-link:hover { text-decoration: underline; }
</style> </style>
<script type="text/javascript">
var cnt = 0;
var nTable = document;
var name_idx = {};
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() {
name_idx = {};
nTable = document.getElementById("ntable");
for (var i = 0, row; (row = nTable.rows[i]); i++) {
if (i == 0) continue;
var cell = nTable.rows[i].cells[0];
var name = cell.dataset.name || cell.innerText.replace(/\s*🥀\s*$/, '').trim();
name_idx[name] = nTable.rows[i];
}
}
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');
}
}
function createRow(data) {
var row = document.createElement("tr");
var c_name = document.createElement("td");
var c_warning = document.createElement("td");
c_warning.style.textAlign = "center";
c_warning.style.color = "#ff9800";
c_warning.style.fontWeight = "bold";
var c_critical = document.createElement("td");
c_critical.style.textAlign = "center";
c_critical.style.color = "#f44336";
c_critical.style.fontWeight = "bold";
var c_ipv4addr = document.createElement("td");
var c_ipv4state = document.createElement("td");
var c_ipv4latency = document.createElement("td");
c_ipv4latency.style.textAlign = "right";
var c_ipv4statets = document.createElement("td");
c_ipv4statets.style.textAlign = "right";
var c_ipv6addr = document.createElement("td");
var c_ipv6state = document.createElement("td");
var c_ipv6latency = document.createElement("td");
c_ipv6latency.style.textAlign = "right";
var c_ipv6statets = document.createElement("td");
c_ipv6statets.style.textAlign = "right";
row.appendChild(c_name);
row.appendChild(c_warning);
row.appendChild(c_critical);
row.appendChild(c_ipv4addr);
row.appendChild(c_ipv4state);
row.appendChild(c_ipv4latency);
row.appendChild(c_ipv4statets);
row.appendChild(c_ipv6addr);
row.appendChild(c_ipv6state);
row.appendChild(c_ipv6latency);
row.appendChild(c_ipv6statets);
c_name.dataset.name = data.name;
c_name.innerHTML = hostNameHtml(data);
// Set alert counts in "x/y" format (unacked/acked)
var warningUnacked = data.alert_warning_unacked || 0;
var warningAcked = data.alert_warning_acked || 0;
var criticalUnacked = data.alert_critical_unacked || 0;
var criticalAcked = data.alert_critical_acked || 0;
if (warningUnacked > 0 || warningAcked > 0) {
c_warning.innerHTML = warningAcked > 0 ? warningUnacked + "/" + warningAcked : warningUnacked;
} else {
c_warning.innerHTML = "";
}
if (criticalUnacked > 0 || criticalAcked > 0) {
c_critical.innerHTML = criticalAcked > 0 ? criticalUnacked + "/" + criticalAcked : criticalUnacked;
} else {
c_critical.innerHTML = "";
}
c_ipv4addr.innerHTML = data.connections[0].addr;
c_ipv4state.innerHTML = data.connections[0].state;
if (data.connections.length > 1) {
c_ipv6addr.innerHTML = data.connections[1].addr;
c_ipv6state.innerHTML = data.connections[1].state;
}
var table = document.getElementById("ntablebody"); // find table to append to
table.appendChild(row); // append row to table
name_idx[c_name] = row;
updateRowAlert(row, data);
}
function formatTS(ts) {
const now = new Date();
const d = new Date(ts * 1000);
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) {
if (!(data.name in name_idx)) {
createRow(data);
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)
var warningUnacked = data.alert_warning_unacked || 0;
var warningAcked = data.alert_warning_acked || 0;
var criticalUnacked = data.alert_critical_unacked || 0;
var criticalAcked = data.alert_critical_acked || 0;
if (warningUnacked > 0 || warningAcked > 0) {
name_idx[data.name].cells[1].innerHTML = warningAcked > 0 ? warningUnacked + "/" + warningAcked : warningUnacked;
} else {
name_idx[data.name].cells[1].innerHTML = "";
}
if (criticalUnacked > 0 || criticalAcked > 0) {
name_idx[data.name].cells[2].innerHTML = criticalAcked > 0 ? criticalUnacked + "/" + criticalAcked : criticalUnacked;
} else {
name_idx[data.name].cells[2].innerHTML = "";
}
for (var i = 0; i < data.connections.length; i++) {
// Offset by 2 for the warning/critical count columns
name_idx[data.name].cells[3 + i * 4].innerHTML = data.connections[i].addr;
name_idx[data.name].cells[6 + i * 4].innerHTML = formatTS(
data.connections[i].statetime
);
if (data.connections[i].state == "up") {
state = '<span class="state-up">up</span>';
latency = String(Math.round(Number.parseFloat(data.connections[i].rtts[0])));
} else {
if (data.connections[i].state == "unknown") {
state = "";
latency = "";
name_idx[data.name].cells[3 + i * 4].innerHTML = "";
name_idx[data.name].cells[6 + i * 4].innerHTML = "";
} else if (data.connections[i].state == "down") {
state = '<span class="state-down">down</span>';
latency = "-";
} else if (data.connections[i].state == "overdue") {
state = '<span class="state-overdue">overdue</span>';
latency = "-";
} else {
state = "<b>" + data.connections[i].state + "</b>";
latency = "-";
}
}
name_idx[data.name].cells[4 + i * 4].innerHTML = state;
name_idx[data.name].cells[5 + i * 4].innerHTML = latency;
}
updateRowAlert(name_idx[data.name], data);
}
function WS_Connect() {
if ("WebSocket" in window) {
//N.B: subprotocol field causes chrome to error 1006
var ws_hbd = new WebSocket("{{heartbeat_ws_url}}", /* "hdb" */ );
ws_hbd.onopen = function () {
// Web Socket is connected, send data using send()
console.log("ws connect {{heartbeat_ws_url}}");
// Hide modal window if visible
var modal = document.getElementById("connectionModal");
if (modal) {
modal.classList.remove("show");
}
ws_hbd.send("heartbeat_web");
};
ws_hbd.onerror = function (event) {
console.log(event);
};
ws_hbd.onmessage = function (event) {
/* console.log(event.data); */
var state = JSON.parse(event.data);
/* console.log("State: " + state.type); */
if (state.type == "host") {
update_table(state.data);
} else if (state.type == "message") {
var msgs = document.getElementById("messages");
msgs.insertAdjacentHTML("afterbegin", "<div>" + state.data + "</div>");
}
cnt++;
};
ws_hbd.onclose = function (event) {
/* console.log(event); */
console.log("Connection is closed, reopening");
// Show modal window
var modal = document.getElementById("connectionModal");
if (modal) {
modal.classList.add("show");
}
setTimeout(function () {
WS_Connect();
}, 3000);
};
} else {
// The browser doesn't support WebSocket
console.log("WebSocket NOT supported by your Browser!");
}
}
WS_Connect();
</script>
<body> <body>
{% include 'nav.html' %} {% include 'nav.html' %}
{% include 'menu.html' %} <div class="st-toolbar">
<span class="brand">hbd<span class="tld">·live</span></span>
<span class="hintline">every host, one line — details on hover, history in the overview</span>
<span class="spacer"></span>
<input type="text" id="host-filter" placeholder="host filter (regex)">
<span class="live-dot reconnecting" id="ws-status">connecting…</span>
</div>
<svg class="pulse" viewBox="0 0 1200 14" preserveAspectRatio="none" aria-hidden="true">
<polyline points="0,10 340,10 352,10 358,3 364,13 370,1 378,12 384,10 560,10 572,10 578,3 584,13 590,1 598,12 604,10 1200,10"/>
</svg>
<div class="container"> <div class="live-main">
<div> <section class="st">
<h1>{{ header }}</h1> <div class="sec-head">
<p class="subtitle">Real-time host monitoring and event log</p> <h2>hosts<span class="colon">:</span></h2>
</div> <span class="count" id="host-counts"></span>
<span class="sub">sorted by severity, then name</span>
<div class="table-section"> </div>
<table id="ntable" class="sortable"> <div class="list">
<thead> <div class="hhead">
<tr> <span>host</span><span>status</span><span class="num">latency</span>
<th>Name</th> <span>alerts</span><span>since</span><span>last alert</span>
<th style="text-align: center" title="Warning Alerts">⚠️</th> </div>
<th style="text-align: center" title="Critical Alerts">🔴</th> <div id="host-rows"></div>
<th>IPv4 Addr</th> </div>
<th>State</th> </section>
<th style="text-align: right">Latency</th>
<th style="text-align: right">Last State</th>
<th>IPv6 Addr</th>
<th>State</th>
<th style="text-align: right">Latency</th>
<th style="text-align: right">Last State</th>
</tr>
</thead>
<tbody id="ntablebody">
{% for host in hosts %}
<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 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;">
{%- set warning_unacked = host.alert_warning_unacked -%}
{%- set warning_acked = host.alert_warning_acked -%}
{%- if warning_unacked > 0 or warning_acked > 0 -%}
{{ warning_unacked }}{% if warning_acked > 0 %}/{{ warning_acked }}{% endif %}
{%- endif -%}
</td>
<td style="text-align: center; color: #f44336; font-weight: bold;">
{%- set critical_unacked = host.alert_critical_unacked -%}
{%- set critical_acked = host.alert_critical_acked -%}
{%- if critical_unacked > 0 or critical_acked > 0 -%}
{{ critical_unacked }}{% if critical_acked > 0 %}/{{ critical_acked }}{% endif %}
{%- endif -%}
</td>
{% for conn in host.connections %}
<td>{{ conn.addr if conn.addr else '' }}</td>
<td>{{ conn.state if conn.state else '' }}</td>
<td style="text-align: right">{{ conn.latency if conn.latency else '' }}</td>
<td style="text-align: right">{{ conn.last_state_ts if conn.last_state_ts else '' }}</td>
{% endfor %}
{% if host.connections|length == 0 %}
<td></td><td></td><td></td><td></td>
<td></td><td></td><td></td><td></td>
{% elif host.connections|length == 1 %}
<td></td><td></td><td></td><td></td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="log-section">
<h2>Log of Events</h2>
<div id="messages"></div>
</div>
</div> </div>
{% include 'foot.html' %}
<!-- Connection status modal -->
<div id="connectionModal" class="connection-modal">
<div class="connection-modal-content">
<p>⚠️ Connection is closed, reopening...</p>
</div>
</div>
<script> <script>
setup(); var HBD_VERSION = "{{ hbd_version }}";
var INITIAL_HOSTS = {{ hosts | tojson }};
var hostData = {}; // name -> latest stateinfo payload
var lastAlert = {}; // name -> {level, message, ts}
var hostFilterRe = null;
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
// ---- derived per-host facts ----------------------------------------
var STATE_RANK = { down: 3, overdue: 2, up: 1 };
function worstConnection(data) {
var worst = null;
(data.connections || []).forEach(function (c) {
if (!c.state || c.state === 'unknown' || c.state === 'absent') return;
if (!worst || (STATE_RANK[c.state] || 0) > (STATE_RANK[worst.state] || 0)) worst = c;
});
return worst;
}
function severity(data) {
var st = data.status;
if ((data.alert_critical_unacked || 0) > 0 || (data.alert_critical_acked || 0) > 0) return 'crit';
if (st === 'down') return 'crit';
if ((data.alert_warning_unacked || 0) > 0 || (data.alert_warning_acked || 0) > 0) return 'warn';
if (st === 'overdue') return 'warn';
return 'ok';
}
var SEV_ORDER = { crit: 0, warn: 1, ok: 2 };
function relTime(ts) {
if (!ts) return '—';
var s = Math.max(0, Math.floor(Date.now() / 1000 - ts));
if (s < 60) return s + 's';
if (s < 3600) return Math.floor(s / 60) + 'm';
if (s < 86400) return Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm';
if (s < 86400 * 30) return Math.floor(s / 86400) + 'd ' + Math.floor((s % 86400) / 3600) + 'h';
return Math.floor(s / 86400) + 'd';
}
function fmtClock(ts) {
var d = new Date(ts * 1000);
function p(n) { return n < 10 ? '0' + n : '' + n; }
var t = p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
return (d.toDateString() === new Date().toDateString())
? t
: d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + t;
}
// ---- host row rendering ---------------------------------------------
function statusChip(data) {
var st = data.status;
if (!st || st === 'unknown') return '<span class="chip outline">—</span>';
var cls = (st === 'up' || st === 'simplex') ? 'ok' : (st === 'down' ? 'crit' : 'warn');
return '<span class="chip ' + cls + '">' + escHtml(st) + '</span>';
}
function tipCard(data) {
var rows = '';
(data.connections || []).forEach(function (c) {
if (!c.state || c.state === 'unknown') return;
var cls = c.state === 'up' ? 'up' : (c.state === 'down' ? 'dn' : (c.state === 'absent' ? 'ab' : 'ov'));
var fam = (c.afam || '').replace('IPv', 'v');
var rtt = (c.state === 'up' && c.rtts && c.rtts[0]) ? Math.round(c.rtts[0]) + ' ms' : '—';
rows += '<tr><td>' + escHtml(fam) + '</td><td>' + escHtml(c.addr || '—') + '</td>'
+ '<td class="' + cls + '">' + escHtml(c.state) + '</td>'
+ '<td>' + rtt + '</td><td>' + (c.statetime ? fmtClock(c.statetime) : '—') + '</td></tr>';
});
if (!rows) rows = '<tr><td colspan="5">no connections yet</td></tr>';
var meta = [];
meta.push('agent ' + (data.hbc_version ? 'v' + escHtml(String(data.hbc_version)) : 'unknown'));
if (data.dyn) meta.push('dyndns');
if (data.watched) meta.push('watched');
if (data.interval) meta.push('interval ' + escHtml(String(data.interval)) + ' s');
return '<span class="tipbox" role="tooltip">'
+ '<table><thead><tr><th></th><th>address</th><th>state</th><th>rtt</th><th>since</th></tr></thead>'
+ '<tbody>' + rows + '</tbody></table>'
+ '<div class="meta">' + meta.join(' · ') + '</div></span>';
}
function alertChips(data) {
var cu = data.alert_critical_unacked || 0, ca = data.alert_critical_acked || 0;
var wu = data.alert_warning_unacked || 0, wa = data.alert_warning_acked || 0;
var out = '';
if (cu) out += '<span class="chip crit"><b>' + cu + '</b> crit</span> ';
if (wu) out += '<span class="chip warn"><b>' + wu + '</b> warn</span> ';
var acked = ca + wa;
if (acked) out += '<span class="chip outline">' + acked + ' ack’d</span>';
return out;
}
function lastAlertHtml(name) {
var a = lastAlert[name];
if (!a) return '<span class="lastalert quiet">—</span>';
var lvl = (a.level || '').toUpperCase();
var quiet = (lvl !== 'WARNING' && lvl !== 'CRITICAL');
var lvlCls = lvl === 'CRITICAL' ? 'crit' : (lvl === 'WARNING' ? 'warn' : '');
var short = lvl === 'CRITICAL' ? 'CRIT' : (lvl === 'WARNING' ? 'WARN' : escHtml(lvl));
return '<span class="lastalert' + (quiet ? ' quiet' : '') + '" title="' + escHtml(a.message) + '">'
+ '<span class="lvl ' + lvlCls + '">' + short + '</span> '
+ escHtml(a.message)
+ (a.ts ? ' <span class="ago">· ' + relTime(a.ts) + ' ago</span>' : '')
+ '</span>';
}
function renderRow(data) {
var name = data.name;
var sev = severity(data);
var row = document.querySelector('.hrow[data-host="' + CSS.escape(name) + '"]');
var isNew = false;
if (!row) {
row = document.createElement('div');
row.dataset.host = name;
isNew = true;
}
row.className = 'hrow' + (sev !== 'ok' ? ' sev-' + sev : '');
row.dataset.sev = sev;
var w = worstConnection(data);
var up = (data.connections || []).find(function (c) { return c.state === 'up' && c.rtts && c.rtts.length; });
var lat = up ? Math.round(up.rtts[0]) + ' ms' : '—';
var since = w ? relTime(w.statetime) : '—';
var staleTag = '';
if (!data.hbc_version) staleTag = '<span class="stale" title="agent version unknown">agent?</span>';
else if (data.hbc_version !== HBD_VERSION) {
staleTag = '<span class="stale" title="agent v' + escHtml(String(data.hbc_version)) + ' ≠ server v' + HBD_VERSION + '">v' + escHtml(String(data.hbc_version)) + '</span>';
}
var st = data.status;
var dotCls = (!st || st === 'unknown') ? 'off'
: ((st === 'up' || st === 'simplex') ? sev : (st === 'down' ? 'down' : 'overdue'));
var flapTag = '';
var flapping = data.flapping || [];
if (flapping.length) {
var svcs = flapping.map(function (s) { return s || 'host'; }).join(', ');
flapTag = '<span class="flap" title="notifications suppressed: ' + escHtml(svcs) + '">flapping</span>';
}
row.innerHTML =
'<span class="host' + (data.watched ? ' watched' : '') + '">'
+ '<span class="dot ' + dotCls + '"></span>'
+ '<a href="/plugins#' + encodeURIComponent(name) + '">' + escHtml(name) + '</a>' + staleTag + flapTag + '</span>'
+ '<span class="tip" tabindex="0">' + statusChip(data) + tipCard(data) + '</span>'
+ '<span class="lat num">' + lat + '</span>'
+ '<span>' + alertChips(data) + '</span>'
+ '<span class="since">' + since + '</span>'
+ lastAlertHtml(name);
if (isNew) {
document.getElementById('host-rows').appendChild(row);
sortRows();
}
applyHostFilter(row);
}
function sortRows() {
var container = document.getElementById('host-rows');
var rows = [...container.children];
rows.sort(function (a, b) {
var d = (SEV_ORDER[a.dataset.sev] ?? 2) - (SEV_ORDER[b.dataset.sev] ?? 2);
return d !== 0 ? d : a.dataset.host.localeCompare(b.dataset.host);
});
rows.forEach(function (r) { container.appendChild(r); });
}
function updateCounts() {
var total = 0, up = 0, overdue = 0, down = 0;
Object.values(hostData).forEach(function (d) {
total++;
var st = d.status;
if (!st || st === 'unknown') return;
if (st === 'up' || st === 'simplex') up++;
else if (st === 'overdue') overdue++;
else if (st === 'down') down++;
});
var parts = [total + ' hosts', up + ' up'];
if (overdue) parts.push(overdue + ' overdue');
if (down) parts.push(down + ' down');
document.getElementById('host-counts').textContent = parts.join(' · ');
}
function updateHost(data) {
var prev = hostData[data.name];
var prevSev = prev ? severity(prev) : null;
hostData[data.name] = data;
renderRow(data);
if (prevSev !== severity(data)) sortRows();
updateCounts();
}
// ---- host filter -----------------------------------------------------
function applyHostFilter(row) {
var rows = row ? [row] : [...document.querySelectorAll('.hrow')];
rows.forEach(function (r) {
r.style.display = (!hostFilterRe || hostFilterRe.test(r.dataset.host)) ? '' : 'none';
});
}
document.getElementById('host-filter').addEventListener('input', function () {
var v = this.value.trim();
try {
hostFilterRe = v ? new RegExp(v, 'i') : null;
this.style.borderColor = '';
} catch (_) {
hostFilterRe = null;
this.style.borderColor = 'var(--st-crit)';
}
applyHostFilter();
});
// ---- last alert tracking ---------------------------------------------
var ALERT_LEVELS = { WARNING: 1, CRITICAL: 1, RECOVER: 1 };
function noteAlert(host, level, message, ts) {
if (!host || !ALERT_LEVELS[(level || '').toUpperCase()]) return;
var cur = lastAlert[host];
if (cur && cur.ts && ts && ts < cur.ts) return; // keep the newest
lastAlert[host] = { level: level, message: message, ts: ts };
if (hostData[host]) renderRow(hostData[host]);
}
async function seedActiveAlerts() {
try {
var r = await fetch('/api/0/alerts');
if (!r.ok) return;
var data = await r.json();
(data.alerts || []).forEach(function (a) {
var metric = (a.metric_path.includes('.')
? a.metric_path.slice(a.metric_path.indexOf('.') + 1)
: a.metric_path).replace(/_status_code$/, '');
var msg = metric + ' = ' + a.last_value
+ (a.formatted_message ? ' ' + a.formatted_message
: (a.operator && a.threshold_value != null ? ' (' + a.operator + ' ' + a.threshold_value + ')' : ''));
noteAlert(a.hostname, a.level, msg, a.since);
});
} catch (_) { /* dashboard still works without the seed */ }
}
// ---- websocket -----------------------------------------------------------
function setWsStatus(ok) {
var el = document.getElementById('ws-status');
el.classList.toggle('reconnecting', !ok);
el.textContent = ok ? 'live' : 'reconnecting…';
}
function WS_Connect() {
if (!("WebSocket" in window)) {
console.log("WebSocket NOT supported by your Browser!");
return;
}
var ws_hbd = new WebSocket("{{ heartbeat_ws_url }}");
ws_hbd.onopen = function () {
setWsStatus(true);
ws_hbd.send("heartbeat_web");
};
ws_hbd.onerror = function (event) { console.log(event); };
ws_hbd.onmessage = function (event) {
var state = JSON.parse(event.data);
if (state.type === "host") {
updateHost(state.data);
} else if (state.type === "message") {
var msg = state.data;
noteAlert(msg.host, msg.level, msg.message, msg.ts);
}
};
ws_hbd.onclose = function () {
setWsStatus(false);
setTimeout(WS_Connect, 3000);
};
}
// ---- boot -----------------------------------------------------------------
INITIAL_HOSTS.forEach(function (h) { hostData[h.name] = h; });
Object.values(hostData).forEach(renderRow);
sortRows();
updateCounts();
seedActiveAlerts();
WS_Connect();
// refresh relative times once a minute
setInterval(function () {
Object.values(hostData).forEach(renderRow);
}, 60000);
</script> </script>
</body> </body>
</html> </html>
+228
View File
@@ -0,0 +1,228 @@
<!DOCTYPE html>
<html>
{% include 'head.html' %}
<link rel="stylesheet" href="/static/hbd-ui.css">
<script src="/static/hbd-ui.js"></script>
<style>
html, body { height: auto; overflow: visible; }
body { background: var(--st-paper); padding: 60px 0 0; }
.log-main { max-width: 1100px; margin: 0 auto; padding: 0 20px 60px; }
.st-toolbar input, .st-toolbar select {
font: 12px var(--st-mono); color: var(--st-ink);
background: var(--st-surface); border: 1px solid var(--st-line); border-radius: 999px;
padding: 5px 12px;
}
.live-dot { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; color: var(--st-ok); }
.live-dot::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--st-ok); }
.live-dot.reconnecting { color: var(--st-crit); }
.live-dot.reconnecting::before { background: var(--st-crit); }
.logrow {
display: grid; grid-template-columns: 130px 74px 150px minmax(0,1fr);
gap: 4px 14px; align-items: baseline;
padding: 4px 14px; border-bottom: 1px solid var(--st-line-soft);
font-family: var(--st-mono); font-size: 11.5px;
}
.logrow:last-child { border-bottom: none; }
.logrow .t { color: var(--st-faint); font-variant-numeric: tabular-nums; white-space: nowrap; }
.logrow .h { color: var(--st-ink); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.logrow .m { color: var(--st-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.empty { padding: 24px 14px; text-align: center; color: var(--st-faint); font-size: 12.5px; }
.loadmore {
display: block; margin: 14px auto 0; cursor: pointer;
font-family: var(--st-mono); font-size: 12px; color: var(--st-muted);
background: var(--st-surface); border: 1px solid var(--st-line); border-radius: 999px;
padding: 6px 16px;
}
.loadmore:hover { color: var(--st-accent); border-color: var(--st-accent); }
.loadmore:disabled { opacity: .5; cursor: default; }
@media (max-width: 760px) {
.logrow { grid-template-columns: 66px minmax(0,1fr); }
.logrow .lvlc, .logrow .h { display: none; }
}
</style>
<body>
{% include 'nav.html' %}
<div class="st-toolbar">
<span class="brand">hbd<span class="tld">·log</span></span>
<span class="hintline">connectivity and alert events — full journal history</span>
<span class="spacer"></span>
<input type="text" id="filter-host" placeholder="host">
<select id="filter-level">
<option value="">level</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">
<span class="live-dot reconnecting" id="ws-status">connecting…</span>
</div>
<svg class="pulse" viewBox="0 0 1200 14" preserveAspectRatio="none" aria-hidden="true">
<polyline points="0,10 340,10 352,10 358,3 364,13 370,1 378,12 384,10 560,10 572,10 578,3 584,13 590,1 598,12 604,10 1200,10"/>
</svg>
<div class="log-main">
<section class="st">
<div class="sec-head">
<h2>log<span class="colon">:</span></h2>
<span class="count" id="log-count"></span>
</div>
<div class="list" id="messages">
<div class="empty">Loading events…</div>
</div>
<button class="loadmore" id="load-older" style="display:none">load older</button>
</section>
</div>
<script>
var PAGE_SIZE = 200;
var oldestTs = null; // ts of the oldest loaded row (pagination cursor)
var seq = 0; // guards against out-of-order fetch responses
var newestTs = null; // ts of the newest loaded row (dedup guard for the WS tail)
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
function fmtClock(ts) {
var d = new Date(ts * 1000);
function p(n) { return n < 10 ? '0' + n : '' + n; }
var t = p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
return (d.toDateString() === new Date().toDateString())
? t
: d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + t;
}
function logRowHtml(msg) {
var lvl = (msg.level || 'INFO');
var lc = lvl.toLowerCase();
var chipCls = lc === 'critical' ? 'crit' : (lc === 'warning' ? 'warn' : (lc === 'recover' ? 'ok' : ''));
var short = lc === 'critical' ? 'CRIT' : (lc === 'warning' ? 'WARN' : escHtml(lvl));
return '<div class="logrow">'
+ '<span class="t">' + fmtClock(msg.ts) + '</span>'
+ '<span class="lvlc"><span class="chip ' + chipCls + '">' + short + '</span></span>'
+ '<span class="h">' + escHtml(msg.host || '') + (msg.service ? ' · ' + escHtml(msg.service) : '') + '</span>'
+ '<span class="m">' + escHtml(msg.message || '') + '</span>'
+ '</div>';
}
function currentFilters() {
return {
host: document.getElementById('filter-host').value.trim(),
level: document.getElementById('filter-level').value,
q: document.getElementById('filter-msg').value.trim()
};
}
function matchesFilters(msg) {
var f = currentFilters();
if (f.host && !(msg.host || '').toLowerCase().includes(f.host.toLowerCase())) return false;
if (f.level && (msg.level || '').toLowerCase() !== f.level) return false;
if (f.q && !(msg.message || '').toLowerCase().includes(f.q.toLowerCase())) return false;
return true;
}
function updateCount() {
var n = document.querySelectorAll('#messages .logrow').length;
document.getElementById('log-count').textContent = n ? n + ' events loaded' : '';
}
async function fetchLog(reset) {
var mySeq = ++seq;
var box = document.getElementById('messages');
var btn = document.getElementById('load-older');
btn.disabled = true;
var f = currentFilters();
var params = new URLSearchParams();
params.set('limit', String(PAGE_SIZE));
if (f.host) params.set('host', f.host);
if (f.level) params.set('level', f.level);
if (f.q) params.set('q', f.q);
if (!reset && oldestTs != null) params.set('before', String(oldestTs));
var data;
try {
var r = await fetch('/api/0/log?' + params.toString());
if (!r.ok) throw new Error('HTTP ' + r.status);
data = await r.json();
} catch (e) {
if (mySeq === seq && reset) {
box.innerHTML = '<div class="empty">Failed to load events: ' + escHtml(e.message) + '</div>';
}
btn.disabled = false;
return;
}
if (mySeq !== seq) return; // a newer request superseded this one
if (reset) { box.innerHTML = ''; oldestTs = null; }
var html = '';
data.events.forEach(function (ev) {
html += logRowHtml(ev);
if (ev.ts != null && (oldestTs == null || ev.ts < oldestTs)) oldestTs = ev.ts;
if (ev.ts != null && (newestTs == null || ev.ts > newestTs)) newestTs = ev.ts;
});
box.insertAdjacentHTML('beforeend', html);
if (!box.children.length) box.innerHTML = '<div class="empty">No events found.</div>';
btn.style.display = data.more ? '' : 'none';
btn.disabled = false;
updateCount();
}
function onFilterChange() { fetchLog(true); }
function debounce(fn, ms) {
var t;
return function () { clearTimeout(t); t = setTimeout(fn, ms); };
}
document.getElementById('filter-host').addEventListener('input', debounce(onFilterChange, 300));
document.getElementById('filter-msg').addEventListener('input', debounce(onFilterChange, 300));
document.getElementById('filter-level').addEventListener('change', onFilterChange);
document.getElementById('load-older').addEventListener('click', function () { fetchLog(false); });
// ---- live tail over the shared websocket --------------------------------
function setWsStatus(ok) {
var el = document.getElementById('ws-status');
el.classList.toggle('reconnecting', !ok);
el.textContent = ok ? 'live' : 'reconnecting…';
}
function WS_Connect() {
if (!("WebSocket" in window)) return;
var ws_hbd = new WebSocket("{{ heartbeat_ws_url }}");
ws_hbd.onopen = function () {
setWsStatus(true);
ws_hbd.send("heartbeat_web");
};
ws_hbd.onmessage = function (event) {
var state = JSON.parse(event.data);
// history replays are covered by the API seed; only tail new events
if (state.type !== "message" || state.history) return;
var msg = state.data;
if (!matchesFilters(msg)) return;
if (msg.ts != null && newestTs != null && msg.ts <= newestTs) return; // already loaded via the API
if (msg.ts != null && (newestTs == null || msg.ts > newestTs)) newestTs = msg.ts;
var box = document.getElementById('messages');
var placeholder = box.querySelector('.empty');
if (placeholder) placeholder.remove();
box.insertAdjacentHTML('afterbegin', logRowHtml(msg));
updateCount();
};
ws_hbd.onclose = function () {
setWsStatus(false);
setTimeout(WS_Connect, 3000);
};
}
// ---- boot ----------------------------------------------------------------
fetchLog(true);
WS_Connect();
</script>
</body>
</html>
-2
View File
@@ -1,2 +0,0 @@
<!-- <label for="drawer-toggle" id="drawer-toggle-label"></label>
s<header>{{ header }}</header> -->
+40 -1
View File
@@ -6,11 +6,15 @@
<a href="/live"{% if active_page == "live" %} class="active"{% endif %}>Live Dashboard</a> <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="/plugins"{% if active_page == "plugins" %} class="active"{% endif %}>Host Overview</a>
<a href="/alerts"{% if active_page == "alerts" %} class="active"{% endif %}>Alerts</a> <a href="/alerts"{% if active_page == "alerts" %} class="active"{% endif %}>Alerts</a>
{% if current_user and current_user.admin %} <a href="/log"{% if active_page == "log" %} class="active"{% endif %}>Log</a>
{% if current_user %}
<a href="/settings"{% if active_page == "settings" %} class="active"{% endif %}>Settings</a> <a href="/settings"{% if active_page == "settings" %} class="active"{% endif %}>Settings</a>
{% endif %} {% endif %}
<a href="/about"{% if active_page == "about" %} class="active"{% endif %}>About</a> <a href="/about"{% if active_page == "about" %} class="active"{% endif %}>About</a>
</div> </div>
{% if current_user %}
<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"> <div class="nav-pie" title="Host alert status">
<canvas id="alert-pie" width="44" height="44"></canvas> <canvas id="alert-pie" width="44" height="44"></canvas>
</div> </div>
@@ -92,5 +96,40 @@
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
updateAlertPie(); updateAlertPie();
setInterval(updateAlertPie, 30000); 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> </script>
File diff suppressed because it is too large Load Diff
+512 -10
View File
@@ -96,7 +96,7 @@
border-radius: 4px; border-radius: 4px;
background: #f44336; background: #f44336;
color: #fff; color: #fff;
font-size: 0.85em; font-size: 1.00em;
font-weight: 500; font-weight: 500;
text-decoration: none; text-decoration: none;
transition: background 0.15s; transition: background 0.15s;
@@ -157,7 +157,7 @@
gap: 6px; gap: 6px;
padding: 4px 12px; padding: 4px 12px;
border-radius: 16px; border-radius: 16px;
font-size: 0.85em; font-size: 1.00em;
font-weight: 500; font-weight: 500;
text-decoration: none; text-decoration: none;
} }
@@ -204,6 +204,119 @@
} }
.channel-name { color: #333; } .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-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> </style>
<body> <body>
@@ -266,19 +379,158 @@
</div> </div>
</div> </div>
<!-- Notification channels --> {% 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"> <div class="section">
<h2>Notification Channels</h2> <h2>Notification Channels</h2>
{% if notification_channels %} {% if current_user %}
{% for ch in notification_channels %} <p style="font-size:.82em;color:#888;margin:0 0 12px">Click a channel to add or remove it from your alert list.</p>
<div class="channel-row"> {% if all_channels %}
<span class="channel-type">{{ ch.type }}</span> <div class="ch-picker">
<span class="channel-name">{{ ch.name }}</span> <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> </div>
{% endfor %}
{% else %} {% else %}
<span class="no-hosts">No personal notification channels configured.</span> <p style="font-size:.83em;color:#bbb;font-style:italic">No notification channels available. You can create your own below.</p>
{% endif %} {% 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 are private to you. Global channels are managed by administrators.</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>
<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 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> </div>
<!-- Host access --> <!-- Host access -->
@@ -326,5 +578,255 @@
</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';
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';
(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 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 };
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> </body>
</html> </html>
File diff suppressed because it is too large Load Diff
+520 -238
View File
File diff suppressed because it is too large Load Diff
+236 -42
View File
@@ -5,6 +5,7 @@ import socket
import struct import struct
import time import time
import zlib import zlib
from typing import Optional
import logging import logging
from platform import system as platform_system from platform import system as platform_system
@@ -16,16 +17,22 @@ from . import notify as notify_mod
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
eventlog = notify_mod.eventlog eventlog = notify_mod.eventlog
# Plugin data is declared stale after this many collection intervals without a
# fresh sample. >= 2 so a single missed sample never purges live data (the user
# directive: wait at least two full intervals after recovery before going stale).
_STALE_INTERVAL_MULTIPLIER = 3
# SO_TIMESTAMP: kernel attaches a struct timeval to each received datagram. # SO_TIMESTAMP: kernel attaches a struct timeval to each received datagram.
# Supported on Linux, FreeBSD, and macOS. The constant is not exposed by # Supported on Linux, FreeBSD, and macOS. The constant is not exposed by
# Python's socket module on all platforms # Python's socket module on all platforms
platform = platform_system() platform = platform_system()
_SO_TIMESTAMP: Optional[int]
if platform == "Darwin": if platform == "Darwin":
_SO_TIMESTAMP = 1024 # SO_TIMESTAMP on macOS (not in Python's socket module) _SO_TIMESTAMP = 1024 # SO_TIMESTAMP on macOS (not in Python's socket module)
elif platform == "Linux": elif platform == "Linux":
_SO_TIMESTAMP = 29 # Linux value (not in older Python versions) _SO_TIMESTAMP = 29 # Linux value (not in older Python versions)
elif platform == "FreeBSD": elif platform == "FreeBSD":
_SO_TIMESTAMP = 32 # FreeBSD value (not in older Python versions) _SO_TIMESTAMP = 32 # FreeBSD value (not in older Python versions)
else: else:
logger.warning("SO_TIMESTAMP may not be supported on this platform (%s)", platform) logger.warning("SO_TIMESTAMP may not be supported on this platform (%s)", platform)
_SO_TIMESTAMP = None _SO_TIMESTAMP = None
@@ -40,6 +47,8 @@ def enable_kernel_timestamps(sock) -> bool:
Returns True if the kernel will supply receive timestamps, False otherwise Returns True if the kernel will supply receive timestamps, False otherwise
(unsupported platform, older kernel, or insufficient permissions). (unsupported platform, older kernel, or insufficient permissions).
""" """
if _SO_TIMESTAMP is None:
return False
try: try:
sock.setsockopt(socket.SOL_SOCKET, _SO_TIMESTAMP, 1) sock.setsockopt(socket.SOL_SOCKET, _SO_TIMESTAMP, 1)
return True return True
@@ -66,6 +75,7 @@ class RecvmsgTransport:
Exposes the same sendto() / close() interface as asyncio's DatagramTransport 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. so the rest of the code does not need to know which path is in use.
""" """
def __init__(self, loop, sock): def __init__(self, loop, sock):
self._loop = loop self._loop = loop
self._sock = sock self._sock = sock
@@ -189,6 +199,40 @@ def _set_connectivity_alert(host, afam, level_name):
state.update(level, level_name) state.update(level, level_name)
_PRESENCE_KEYS = {"p4": "IPv4", "p6": "IPv6"}
def _apply_presence(host, msg, now, uname, ctx, skip_conn):
"""Record client-reported protocol presence and settle the affected connections.
A family reported absent whose connection is already overdue/unknown is parked as
ABSENT (timer cancelled, connectivity alert cleared). One still UP is left alone; its
timer expiry parks it. A family reported present again while ABSENT restarts a grace
timer. The connection that carried this very heartbeat (skip_conn) is never touched.
"""
cfg = ctx.get("config", {})
for key, afam in _PRESENCE_KEYS.items():
if key not in msg:
continue
present = bool(msg[key])
host.set_presence(afam, present, now)
conn = host.connections.get(afam)
if conn is None or conn is skip_conn:
continue
cls = conn.__class__
if not present:
if conn.getstate() in (cls.OVERDUE, cls.UNKNOWN):
conn.cancel_overdue_timer()
conn.newstate(cls.ABSENT, now)
_set_connectivity_alert(host, afam, "OK")
elif conn.getstate() == cls.ABSENT:
conn.newstate(cls.UNKNOWN, now)
interval = int(msg.get("interval", 0) or 0) or host.interval
if interval > 0:
on_overdue, _ = _make_timer_callbacks(uname, host, ctx)
conn.reset_overdue_timer(interval + cfg.get("grace", 2), on_overdue)
def _make_timer_callbacks(uname, host, ctx): def _make_timer_callbacks(uname, host, ctx):
"""Return (on_overdue, on_unknown) async callbacks for connection timer logic. """Return (on_overdue, on_unknown) async callbacks for connection timer logic.
@@ -205,26 +249,52 @@ def _make_timer_callbacks(uname, host, ctx):
msg_to_websockets("host", host.stateinfo()) msg_to_websockets("host", host.stateinfo())
async def on_overdue(connection): async def on_overdue(connection):
if connection.getstate() != connection.__class__.UP: cls = connection.__class__
if connection.getstate() not in (cls.UP, cls.UNKNOWN):
return return
now = time.time() now = time.time()
connection.newstate(connection.__class__.OVERDUE, now, cfg.get("grace", 2)) afam = connection.afam
msg = f"{connection.afam} overdue" # Park only while some other family is still present: the last monitored family
eventlog(uname, "CRITICAL", msg) # must never be parked silently, or a host whose client reports no internet on any
if host.watched: # protocol would die without an alert.
asyncio.create_task(notify_mod.send_notification( if not host.flag_present(afam) and any(
uname, af != afam and host.family_present(af) for af in host.connections):
notify_mod.Notification(title=f"[CRITICAL] {uname}", body=msg, level="CRITICAL"), # The client says this protocol has no connectivity: not an outage.
)) connection.newstate(cls.ABSENT, now)
# Track in alert_states so the Alerts Dashboard shows this connection.cancel_overdue_timer()
_set_connectivity_alert(host, connection.afam, "CRITICAL") _set_connectivity_alert(host, afam, "OK")
if threshold_checker: if msg_to_websockets:
threshold_checker.check_value( msg_to_websockets("host", host.stateinfo())
host_name=uname, return
metric_path="rtt",
value=float("inf"), # If any connection is DOWN (clean shutdown reported) Host.status() is "down", so a
alert_states=host.alert_states, # later overdue of the other family raises no separate event/alert; the shutdown did.
) prev = host.status()
connection.newstate(cls.OVERDUE, now, cfg.get("grace", 2))
new = host.status()
if new == "overdue" and prev != "overdue":
names = ", ".join(sorted(af for af, c in host.connections.items()
if c.getstate() == cls.OVERDUE))
msg = f"{names} 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
for af, c in host.connections.items():
if c.getstate() == cls.OVERDUE:
_set_connectivity_alert(host, af, "CRITICAL")
if threshold_checker:
threshold_checker.check_value(
host_name=uname,
metric_path="rtt",
value=float("inf"),
alert_states=host.alert_states,
)
elif new == "simplex":
eventlog(uname, "INFO", f"{afam} overdue (simplex)")
if msg_to_websockets: if msg_to_websockets:
msg_to_websockets("host", host.stateinfo()) msg_to_websockets("host", host.stateinfo())
connection.reset_overdue_timer(DROPOVERDUE, on_unknown) connection.reset_overdue_timer(DROPOVERDUE, on_unknown)
@@ -232,6 +302,23 @@ def _make_timer_callbacks(uname, host, ctx):
return on_overdue, on_unknown return on_overdue, on_unknown
def _make_plugin_stale_callback(uname, ctx):
"""Return an async callback that clears stale plugin data and its alerts."""
msg_to_websockets = ctx.get("msg_to_websockets")
async def on_plugin_stale(host, plugin_name):
host.plugin_data.pop(plugin_name, None)
stale_keys = [k for k in host.alert_states if k.startswith(f"{plugin_name}.")]
for k in stale_keys:
del host.alert_states[k]
eventlog(uname, "INFO", f"plugin data stale: {plugin_name}")
if msg_to_websockets:
msg_to_websockets("plugin_stale", {"host": uname, "plugin": plugin_name})
msg_to_websockets("host", host.stateinfo())
return on_plugin_stale
def restore_connection_timers(hbdclass, ctx): def restore_connection_timers(hbdclass, ctx):
"""Restore overdue timers for all loaded connections after a pickle restore. """Restore overdue timers for all loaded connections after a pickle restore.
@@ -248,11 +335,18 @@ def restore_connection_timers(hbdclass, ctx):
interval = host.interval interval = host.interval
for afam, conn in list(host.connections.items()): for afam, conn in list(host.connections.items()):
state = conn.getstate() state = conn.getstate()
if state == hbdclass.Connection.ABSENT:
continue
if state == hbdclass.Connection.DOWN: if state == hbdclass.Connection.DOWN:
_set_connectivity_alert(host, afam, "CRITICAL")
continue continue
on_overdue, on_unknown = _make_timer_callbacks(uname, host, ctx) on_overdue, on_unknown = _make_timer_callbacks(uname, host, ctx)
if state == hbdclass.Connection.UNKNOWN:
_set_connectivity_alert(host, afam, "CRITICAL")
continue
if state == hbdclass.Connection.UP and interval > 0: if state == hbdclass.Connection.UP and interval > 0:
elapsed = now - conn.lastbeat elapsed = now - conn.lastbeat
# Give hosts one full (interval + grace) of extra time on startup # Give hosts one full (interval + grace) of extra time on startup
@@ -268,6 +362,7 @@ def restore_connection_timers(hbdclass, ctx):
restored += 1 restored += 1
elif state == hbdclass.Connection.OVERDUE: elif state == hbdclass.Connection.OVERDUE:
host_overdue = host.status() == "overdue"
elapsed_overdue = now - conn.statetime elapsed_overdue = now - conn.statetime
remaining = DROPOVERDUE - elapsed_overdue remaining = DROPOVERDUE - elapsed_overdue
if remaining <= 1: if remaining <= 1:
@@ -277,17 +372,35 @@ def restore_connection_timers(hbdclass, ctx):
"Marking %s/%s UNKNOWN (overdue %.1f days)", "Marking %s/%s UNKNOWN (overdue %.1f days)",
uname, afam, elapsed_overdue / 86400, uname, afam, elapsed_overdue / 86400,
) )
# A connection dropped to UNKNOWN keeps its alert, like the
# UNKNOWN branch above and on_unknown do.
host_overdue = True
else: else:
conn.reset_overdue_timer(remaining, on_unknown) conn.reset_overdue_timer(remaining, on_unknown)
logger.debug( logger.debug(
"Restored OVERDUE timer %s/%s: %.0fs remaining", "Restored OVERDUE timer %s/%s: %.0fs remaining",
uname, afam, remaining, uname, afam, remaining,
) )
# Ensure the connectivity alert is set — it may be missing if
# hbd was shut down before the on_overdue callback had a chance
# to record it.
if host_overdue:
_set_connectivity_alert(host, afam, "CRITICAL")
restored += 1 restored += 1
logger.info("Restored timers for %d connection(s)", restored) logger.info("Restored timers for %d connection(s)", restored)
def _is_rtt_key(plugin_name: str) -> bool:
"""True for synthetic RTT-history keys (rtt_ipv4/rtt_ipv6), not real plugin data."""
return plugin_name.startswith("rtt_")
def _has_real_plugin_data(plugin_data: dict) -> bool:
"""True if plugin_data holds any real (non-RTT) client-collected plugin data."""
return any(not _is_rtt_key(k) for k in plugin_data)
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.
@@ -302,7 +415,7 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
if not msg: if not msg:
return return
now = ctx.get("recv_ts") or 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")
if msg_journal: if msg_journal:
@@ -313,16 +426,21 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
loop.create_task(msg_journal.log_message(msg, addr, now)) loop.create_task(msg_journal.log_message(msg, addr, now))
except Exception as e: except Exception as e:
logger.debug(f"Failed to log message to journal: {e}") logger.debug(f"Failed to log message to journal: {e}")
# Only named HTB/PLG datagrams come from clients; anything else (e.g. an internet scanner's
# SIP probe) is journaled above but must not sign on a host.
if msg.get("ID") not in ("HTB", "PLG") or not msg.get("name"):
logger.debug("Ignoring non-client datagram from %s", addr)
return
cfg = ctx.get("config", {}) cfg = ctx.get("config", {})
hbdcls = ctx.get("hbdclass") hbdcls = ctx["hbdclass"]
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)
# normalize addr (ip, port) # normalize addr (ip, port)
ip = addr[0] if isinstance(addr, (list, tuple)) else addr ip = addr[0] if isinstance(addr, (list, tuple)) else addr
name = msg.get("name", "unknown") name = msg["name"]
from ..common.utils import shortname from ..common.utils import shortname
from . import config as config_mod from . import config as config_mod
@@ -333,26 +451,27 @@ 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
watchhosts = config_mod.get_watchhosts(cfg)
host.watched = uname in watchhosts
# Apply user-access settings from config # Apply user-access settings from config
access = config_mod.get_host_access(cfg, uname) access = config_mod.get_host_access(cfg, uname)
host.apply_access(access["owner"], access["managers"], access["monitors"]) host.apply_access(access["owner"], access["managers"], access["monitors"])
if verbose: logger.info("New host signed on: %s (dyn=%s, access=%s)", uname, host.dyn, access)
print(("XX: New host, num now %s" % (len(hbdcls.Host.hosts))))
newh = True newh = True
else: else:
host = hbdcls.Host.hosts[uname] host = hbdcls.Host.hosts[uname]
newh = False newh = False
cid = msg.get("id", 0) cid = msg.get("id", 0)
try: rtt_raw = msg.get("rtt")
rtt = float(msg.get("rtt")) rtt = float(rtt_raw) if rtt_raw is not None else None
except TypeError:
rtt = None
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": time.time()} rmsg = {"time": time.time()}
if not _has_real_plugin_data(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)
@@ -365,13 +484,55 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
plugin_name = msg.get("plugin") plugin_name = msg.get("plugin")
if plugin_name: if plugin_name:
# Extract plugin fields, dropping protocol metadata fields # Extract plugin fields, dropping protocol metadata fields
# (_interval is the client-declared collection interval, not a metric).
plugin_data = {k: v for k, v in msg.items() plugin_data = {k: v for k, v in msg.items()
if k not in ("ID", "plugin", "id", "name")} if k not in ("ID", "plugin", "id", "name", "_interval")}
# 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)
# Set the stale timer. Prefer the interval the client declares: it is
# the true collection cadence and is correct from the very first
# post-recovery sample, so we never purge data too early after an
# outage. interval == 0 means a collect-once InfoPlugin that never
# goes stale. Legacy clients omit _interval, so fall back to inferring
# the cadence from the gap between the last two received samples.
history = host.plugin_data.get(plugin_name, [])
declared = msg.get("_interval")
if declared is not None:
if declared > 0:
host.reset_plugin_timer(plugin_name, declared * _STALE_INTERVAL_MULTIPLIER,
_make_plugin_stale_callback(uname, ctx))
else:
host.cancel_plugin_timer(plugin_name)
elif len(history) >= 2:
plugin_interval = max(history[-1][0] - history[-2][0], 1)
host.reset_plugin_timer(plugin_name, plugin_interval * _STALE_INTERVAL_MULTIPLIER,
_make_plugin_stale_callback(uname, ctx))
else:
host.cancel_plugin_timer(plugin_name)
# Remove alert states for metrics present in the previous sample but
# absent now (e.g. a nagios check removed from configuration).
if len(history) >= 2:
prev_keys = set(history[-2][1].keys())
curr_keys = set(plugin_data.keys())
for metric_name in prev_keys - curr_keys:
metric_path = f"{plugin_name}.{metric_name}"
if host.alert_states.pop(metric_path, None) is not None:
eventlog(uname, "INFO", f"stale check removed: {metric_path}")
if (prev_keys - curr_keys) and msg_to_websockets:
msg_to_websockets("host", host.stateinfo())
# 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 = config_owner or plugin_data.get("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}")
# Check thresholds if checker is available # Check thresholds if checker is available
threshold_checker = ctx.get("threshold_checker") threshold_checker = ctx.get("threshold_checker")
if threshold_checker: if threshold_checker:
@@ -386,7 +547,7 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
print(f"Threshold state changes for {uname}: {state_changes}") print(f"Threshold state changes for {uname}: {state_changes}")
except Exception as e: except Exception as e:
logger.error(f"Error checking thresholds for {uname}.{plugin_name}: {e}") logger.error(f"Error checking thresholds for {uname}.{plugin_name}: {e}")
# Notify websockets of plugin update # Notify websockets of plugin update
if msg_to_websockets: if msg_to_websockets:
try: try:
@@ -421,6 +582,7 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
boot = msg.get("boot", 0) boot = msg.get("boot", 0)
if boot: if boot:
# hbc was stared with a -b flag
eventlog(uname, "INFO", "booted") eventlog(uname, "INFO", "booted")
if host.watched: if host.watched:
asyncio.create_task(notify_mod.send_notification( asyncio.create_task(notify_mod.send_notification(
@@ -428,13 +590,38 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
notify_mod.Notification(title=f"[INFO] {uname}", body=f"{host.name} booted", level="INFO"), 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", message, service=service)
_apply_presence(host, msg, now, uname, ctx, conn)
prev_status = host.status()
if conn.getstate() != hbdcls.Connection.UP: if conn.getstate() != hbdcls.Connection.UP:
# Transition to UP and log/notify if appropriate
lasts = conn.state lasts = conn.state
d = conn.newstate(hbdcls.Connection.UP, now) d = conn.newstate(hbdcls.Connection.UP, now)
# Clear connectivity alert now that the host is back up if boot:
# On reboot, pre-boot plugin data and derived alerts are stale.
# Cancel all plugin timers and wipe plugin state so timers restart
# cleanly from the first two post-boot samples. An ordinary
# reconnect (no boot flag) doesn't invalidate the client's
# already-collected data, so it's left alone — this keeps chart
# history intact across a transient network blip.
for pname in list(host.plugin_timers):
host.cancel_plugin_timer(pname)
for pname in [k for k in host.plugin_data if not _is_rtt_key(k)]:
del host.plugin_data[pname]
stale_plugin_keys = [
k for k in host.alert_states
if k not in ("rtt",) and not k.startswith("connectivity.")
]
for k in stale_plugin_keys:
del host.alert_states[k]
# Clear connectivity alert now that the host is back up; once the host is no longer
# overdue no family may keep a connectivity alert (e.g. the simplex case).
_set_connectivity_alert(host, conn.afam, "OK") _set_connectivity_alert(host, conn.afam, "OK")
if host.status() in ("up", "simplex"):
for af in host.connections:
_set_connectivity_alert(host, af, "OK")
# Don't log/notify RECOVER for a brand-new host seen for the first time — # Don't log/notify RECOVER for a brand-new host seen for the first time —
# it was never down, it just hasn't been seen before. # it was never down, it just hasn't been seen before.
if not newh: if not newh:
@@ -446,8 +633,11 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
else: else:
m = "%s back after being %s for %s" % (conn.afam, lasts, dur(d)) m = "%s back after being %s for %s" % (conn.afam, lasts, dur(d))
if m: if m:
eventlog(uname, "RECOVER", m) # RECOVER (and a notification) only if the host itself was overdue/down;
if host.watched: # a single family coming back while another stayed up is informational.
level = "RECOVER" if prev_status in ("overdue", "down", "unknown") else "INFO"
eventlog(uname, level, m)
if host.watched and level == "RECOVER":
asyncio.create_task(notify_mod.send_notification( asyncio.create_task(notify_mod.send_notification(
uname, uname,
notify_mod.Notification(title=f"[RECOVER] {uname}", body=m, level="RECOVER"), notify_mod.Notification(title=f"[RECOVER] {uname}", body=m, level="RECOVER"),
@@ -472,6 +662,10 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
if interval > 0: if interval > 0:
host.interval = interval host.interval = interval
# Record RTT history for charting
if rtt is not None:
host.add_plugin_data(f"rtt_{conn.afam.lower()}", {"rtt": rtt}, timestamp=now)
# 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:
@@ -479,13 +673,13 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
timeout_seconds = interval + grace timeout_seconds = interval + grace
on_overdue, _ = _make_timer_callbacks(uname, host, ctx) on_overdue, _ = _make_timer_callbacks(uname, host, ctx)
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
threshold_checker = ctx.get("threshold_checker") threshold_checker = ctx.get("threshold_checker")
if threshold_checker and rtt and rtt > 0: if threshold_checker and rtt and rtt > 0:
# Metric path for RTT is simply "rtt" # Metric path for RTT is simply "rtt"
metric_path = "rtt" metric_path = "rtt"
# Check against configured thresholds (handles alerts, notifications, etc.) # Check against configured thresholds (handles alerts, notifications, etc.)
threshold_checker.check_value( threshold_checker.check_value(
host_name=uname, host_name=uname,
+29
View File
@@ -146,9 +146,14 @@ def load_users(config: dict) -> dict:
Returns the new ``users`` dict. Returns the new ``users`` dict.
""" """
global users global users
old_users = dict(users) # snapshot before rebuild
users_cfg = config.get("users", {}) users_cfg = config.get("users", {})
if not isinstance(users_cfg, dict): if not isinstance(users_cfg, dict):
users = {} 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 return users
result: dict = {} result: dict = {}
@@ -166,6 +171,10 @@ def load_users(config: dict) -> dict:
) )
users = result 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)) logger.info("Loaded %d user(s) from config", len(users))
return users return users
@@ -187,6 +196,26 @@ def authenticate(username: str, password: str) -> "User | None":
return None return None
def provision_oauth_user(username: str, full_name: str, avatar: str) -> "User":
"""Create or update a user sourced from an OAuth2 provider.
New users are inserted with no password_hash — they can only authenticate
via OAuth. Existing users (e.g. defined in config with a password) have
their display name and avatar refreshed; all other attributes are preserved.
"""
user = users.get(username)
if user is None:
user = User(username=username, full_name=full_name, avatar=avatar)
users[username] = user
logger.info("Provisioned OAuth user %r", username)
else:
if full_name:
user.full_name = full_name
if avatar:
user.avatar = avatar
return user
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Session management # Session management
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+9 -3
View File
@@ -85,11 +85,15 @@ async def handler(request):
except Exception as e: except Exception as e:
logger.error("Error sending initial hosts: %s", e) logger.error("Error sending initial hosts: %s", e)
# Send recent messages # 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: if data.msgs:
try: try:
for m in data.msgs: for m in reversed(data.msgs[-30:]):
await ws.send_str(json.dumps({"type": "message", "data": m})) 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: except Exception as e:
logger.error("Error sending initial messages: %s", e) logger.error("Error sending initial messages: %s", e)
@@ -128,6 +132,8 @@ def broadcast(typ: str, payload) -> bool:
host_name: Optional[str] = None host_name: Optional[str] = None
if typ in ("host", "plugin"): if typ in ("host", "plugin"):
host_name = payload.get("raw_name") or payload.get("host") or payload.get("name") host_name = payload.get("raw_name") or payload.get("host") or payload.get("name")
elif typ == "message" and isinstance(payload, dict):
host_name = payload.get("host")
jmsg = json.dumps({"type": typ, "data": payload}) jmsg = json.dumps({"type": typ, "data": payload})
+34 -10
View File
@@ -4,20 +4,32 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "hbd" name = "hbd"
version = "5.1.18" version = "5.4.7"
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)
@@ -32,6 +44,7 @@ server = [
"aiohttp>=3.11", "aiohttp>=3.11",
"Jinja2>=3.1.6", "Jinja2>=3.1.6",
"matrix-nio>=0.24", "matrix-nio>=0.24",
"ruamel.yaml>=0.18",
] ]
# Minimal client — hbc_mini only, no external dependencies # Minimal client — hbc_mini only, no external dependencies
@@ -45,9 +58,12 @@ all = [
# Development dependencies # Development dependencies
dev = [ dev = [
"pytest>=7.0", "pytest>=7.0",
"pytest-asyncio>=0.23",
"pytest-cov>=4.0", "pytest-cov>=4.0",
"flake8>=5.0", "flake8>=5.0",
"mypy>=1.10", "mypy>=1.10",
"types-PyYAML",
"types-psutil",
"black>=23.0", "black>=23.0",
"isort>=5.0", "isort>=5.0",
"tox>=4.0", "tox>=4.0",
@@ -70,10 +86,18 @@ include = ["hbd*"]
[tool.black] [tool.black]
line-length = 111 line-length = 165
[tool.flake8] [tool.flake8]
max-line-length = 111 max-line-length = 165
[tool.pylint.format] [tool.pylint.format]
max-line-length = 111 max-line-length = 111
[[tool.mypy.overrides]]
# optional notification libraries that ship neither stubs nor py.typed
module = ["nio", "nio.*", "mattermostdriver", "mattermostdriver.*"]
ignore_missing_imports = true
[tool.pytest.ini_options]
testpaths = ["tests"]
-4
View File
@@ -1,4 +0,0 @@
key "rndc-key" {
algorithm hmac-md5;
secret "qlGa+AYKtyOgWNuozqECMw==";
};
+18 -1
View File
@@ -5,9 +5,25 @@ 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 sed -i".bak" "s/__version__ = \"[0-9.]*\"\(.*\)$/__version__ = \"$VER\"\1/" scripts/hbc_mini.py
sed -i".bak" "s/\*\*Package:\*\* \`hbd\` v[0-9.]*/\*\*Package:\*\* \`hbd\` v$VER/" README.md
# Update CHANGELOG.md with commits since last tag
LASTTAG=$(git describe --tags --abbrev=0 2>/dev/null || true)
# Matches both unscoped ("feat: ...") and scoped ("feat(server): ...") conventional-commit
# subjects; the scope is dropped from the changelog line.
ADDED=$(git log "${LASTTAG:+$LASTTAG..}HEAD" --pretty="%s" | grep -E "^feat(\([^)]*\))?:" | sed -E 's/^feat(\([^)]*\))?: /- /')
FIXED=$(git log "${LASTTAG:+$LASTTAG..}HEAD" --pretty="%s" | grep -E "^fix(\([^)]*\))?:" | sed -E 's/^fix(\([^)]*\))?: /- /')
{
printf "## [%s]\n" "$VER"
[ -n "$ADDED" ] && printf "\n### Added\n%s\n" "$ADDED"
[ -n "$FIXED" ] && printf "\n### Fixed\n%s\n" "$FIXED"
printf "\n---\n\n"
} > /tmp/changelog_entry.txt
sed -i".bak" "4r /tmp/changelog_entry.txt" CHANGELOG.md
rm /tmp/changelog_entry.txt CHANGELOG.md.bak
# commit pyproject.toml # commit pyproject.toml
git commit -m "version $VER" pyproject.toml hbd/__init__.py scripts/hbc_mini.py git commit -m "version $VER" pyproject.toml hbd/__init__.py scripts/hbc_mini.py README.md CHANGELOG.md
git push git push
# tag version # tag version
git tag -a v$VER -m "Version $VER" git tag -a v$VER -m "Version $VER"
@@ -15,3 +31,4 @@ git push --tags
rm hbd/__init__.py.bak rm hbd/__init__.py.bak
rm scripts/hbc_mini.py.bak rm scripts/hbc_mini.py.bak
rm README.md.bak
+3
View File
@@ -0,0 +1,3 @@
hbc_mini
hbc_mini_dbg
test_probe
+29
View File
@@ -0,0 +1,29 @@
CC ?= cc
CFLAGS = -O2 -Wall -Wextra -std=c11
LDFLAGS = -lz -lpthread -lm
# res_search() lives in libresolv on older glibc; in libc on newer glibc, musl and the BSDs
LDFLAGS += $(shell echo 'int main(void){return 0;}' | $(CC) -x c - -lresolv -o /dev/null 2>/dev/null && echo -lresolv)
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 test
all: $(TARGET)
$(TARGET): $(SRC)
$(CC) $(CFLAGS) -o $@ $< $(LDFLAGS)
debug: $(SRC)
$(CC) -g -fsanitize=address,undefined -o $(TARGET)_dbg $< $(LDFLAGS)
test: test_probe
./test_probe
test_probe: test_probe.c $(SRC)
$(CC) $(CFLAGS) -o $@ test_probe.c $(LDFLAGS)
clean:
rm -f $(TARGET) $(TARGET)_dbg test_probe
+2035
View File
File diff suppressed because it is too large Load Diff
+294
View File
@@ -0,0 +1,294 @@
/* Unit tests for the connectivity-probe helpers in hbc_mini.c.
* Build and run: make test (live check of this host: ./test_probe --live)
* The source file is included directly so its static functions can be exercised; its main()
* is renamed out of the way.
*/
#define main hbc_mini_main
#include "hbc_mini.c"
#undef main
static int g_run = 0, g_fail = 0;
#define CHECK(cond) do { g_run++; if (!(cond)) { g_fail++; \
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); } } while (0)
#define CHECK_STR(a, b) do { g_run++; if (strcmp((a), (b)) != 0) { g_fail++; \
fprintf(stderr, "FAIL %s:%d: \"%s\" != \"%s\"\n", __FILE__, __LINE__, (a), (b)); } } while (0)
static const char PROC_V4[] =
"Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n"
"wlan0\t00000000\t0100A8C0\t0003\t0\t0\t600\t00000000\t0\t0\t0\n"
"eth0\t00000000\t0101A8C0\t0003\t0\t0\t100\t00000000\t0\t0\t0\n"
"wlan0\t0000A8C0\t00000000\t0001\t0\t0\t600\t00FFFFFF\t0\t0\t0\n";
static const char PROC_V6[] =
"00000000000000000000000000000000 00 00000000000000000000000000000000 00 "
"fe800000000000000000000000000001 00000400 00000001 00000000 00000003 wlan0\n"
"00000000000000000000000000000000 00 00000000000000000000000000000000 00 "
"00000000000000000000000000000000 ffffffff 00000001 00000000 00200200 lo\n"
"fe800000000000000000000000000000 40 00000000000000000000000000000000 00 "
"00000000000000000000000000000000 00000100 00000001 00000000 00000001 wlan0\n";
static const char ROUTE_GET[] =
" route to: 0.0.0.0\ndestination: 0.0.0.0\n mask: 0.0.0.0\n"
" gateway: 192.0.2.1\n fib: 0\n interface: vtnet0\n";
static const char LINUX_PING[] =
"PING 1.1.1.1 (1.1.1.1) 56(84) bytes of data.\n"
"64 bytes from 1.1.1.1: icmp_seq=1 ttl=57 time=11.2 ms\n\n"
"--- 1.1.1.1 ping statistics ---\n"
"1 packets transmitted, 1 received, 0% packet loss, time 0ms\n"
"rtt min/avg/max/mdev = 11.234/11.234/11.234/0.000 ms\n";
static const char FREEBSD_PING[] =
"PING 1.1.1.1 (1.1.1.1): 56 data bytes\n"
"64 bytes from 1.1.1.1: icmp_seq=0 ttl=59 time=4.006 ms\n\n"
"--- 1.1.1.1 ping statistics ---\n"
"1 packets transmitted, 1 packets received, 0.0% packet loss, 1 packets out of wait time\n"
"round-trip min/avg/max/stddev = 4.006/4.006/4.006/0.000 ms\n";
static void test_gateway_parsers(void) {
char gw[64];
gw_parse_proc_v4(PROC_V4, gw, sizeof(gw));
CHECK_STR(gw, "192.168.1.1"); /* eth0, metric 100 beats wlan0, 600 */
gw_parse_proc_v4("Iface\tDestination\n", gw, sizeof(gw));
CHECK_STR(gw, "");
gw_parse_proc_v6(PROC_V6, gw, sizeof(gw));
CHECK_STR(gw, "fe80::1"); /* reject and non-default lines skipped */
gw_parse_route_get(ROUTE_GET, gw, sizeof(gw));
CHECK_STR(gw, "192.0.2.1");
gw_parse_route_get("route: writing to routing socket: not in table\n", gw, sizeof(gw));
CHECK_STR(gw, "");
}
static void test_ping_parser(void) {
CHECK(ping_parse_rtt(LINUX_PING) > 11.23 && ping_parse_rtt(LINUX_PING) < 11.24);
CHECK(ping_parse_rtt(FREEBSD_PING) > 4.0 && ping_parse_rtt(FREEBSD_PING) < 4.01);
CHECK(ping_parse_rtt("1 packets transmitted, 0 received, 100% packet loss\n") == -1.0);
CHECK(ping_parse_rtt("sh: 1: ping: not found\n") == -2.0);
CHECK(ping_parse_rtt("ping: command not found\n") == -2.0);
}
/* ---- scriptable network for probe_step ------------------------------------------------ */
static struct {
char addr[2][INET6_ADDRSTRLEN]; /* "" = no route */
char gw[2][INET6_ADDRSTRLEN];
double rtt[2]; /* -1 no reply, -2 ping unavailable */
int unsupported[2];
int npings[2];
} net;
static int fake_local(int af, const char *t, char *out, size_t n) {
int f = af == AF_INET6; (void)t;
if (net.unsupported[f]) return -1;
if (!net.addr[f][0]) return 0;
snprintf(out, n, "%s", net.addr[f]);
return 1;
}
static double fake_ping(int af, const char *t) { int f = af == AF_INET6; (void)t; net.npings[f]++; return net.rtt[f]; }
static void fake_gw(int af, char *out, size_t n) { snprintf(out, n, "%s", net.gw[af == AF_INET6]); }
static const probe_ops_t FAKE = { fake_local, fake_ping, fake_gw };
static void net_reset(void) {
memset(&net, 0, sizeof(net));
snprintf(net.addr[0], INET6_ADDRSTRLEN, "192.0.2.5");
snprintf(net.addr[1], INET6_ADDRSTRLEN, "2001:db8::5");
snprintf(net.gw[0], INET6_ADDRSTRLEN, "192.0.2.1");
snprintf(net.gw[1], INET6_ADDRSTRLEN, "fe80::1");
net.rtt[0] = 10.0; net.rtt[1] = 20.0;
}
static void probe_reset(probe_t *p) {
memset(p, 0, sizeof(*p));
snprintf(p->target[0], sizeof(p->target[0]), "1.1.1.1");
snprintf(p->target[1], sizeof(p->target[1]), "2606:4700:4700::1111");
p->probe_interval = 60; p->retry_interval = 10; p->cheap_gateway = true;
p->fam[0].present = p->fam[1].present = PROBE_UNKNOWN;
p->fam[0].rtt = p->fam[1].rtt = -1;
pthread_mutex_init(&p->mu, NULL);
}
static void step_both(probe_t *p, double now) {
probe_step(p, &p->fam[0], 0, &FAKE, now);
probe_step(p, &p->fam[1], 1, &FAKE, now);
}
static void test_present_with_details(void) {
net_reset(); probe_t p; probe_reset(&p);
step_both(&p, 1000);
CHECK(p.fam[0].present == 1 && p.fam[1].present == 1);
CHECK_STR(p.fam[0].addr, "192.0.2.5");
CHECK_STR(p.fam[0].gateway, "192.0.2.1");
CHECK(p.fam[0].rtt == 10.0);
kvdict_t d; kv_clear(&d);
probe_flags(&p, &d);
CHECK_STR(kv_get(&d, "p4") ? kv_get(&d, "p4") : "(null)", "1");
CHECK_STR(kv_get(&d, "p6") ? kv_get(&d, "p6") : "(null)", "1");
char js[512];
CHECK(probe_metric_json(&p, js, sizeof(js)) > 0);
CHECK(strstr(js, "\"ipv6\":{\"present\":true,\"addr\":\"2001:db8::5\",\"gateway\":\"fe80::1\"") != NULL);
}
static void test_no_route_is_absent_and_wastes_no_ping(void) {
net_reset(); net.addr[1][0] = '\0'; probe_t p; probe_reset(&p);
step_both(&p, 1000);
CHECK(p.fam[1].present == 0 && net.npings[1] == 0 && net.npings[0] == 1);
kvdict_t d; kv_clear(&d);
probe_flags(&p, &d);
CHECK_STR(kv_get(&d, "p6") ? kv_get(&d, "p6") : "(null)", "0");
char js[512];
probe_metric_json(&p, js, sizeof(js));
CHECK(strstr(js, "\"ipv6\":{\"present\":false,\"addr\":null,\"gateway\":null,\"rtt\":null}") != NULL);
}
static void test_debounce_and_rate_limit(void) {
net_reset(); probe_t p; probe_reset(&p);
step_both(&p, 1000);
step_both(&p, 1000); /* cached: no second ping */
CHECK(net.npings[0] == 1 && net.npings[1] == 1);
net.rtt[1] = -1;
step_both(&p, 1060); /* first failure: still present */
CHECK(p.fam[1].present == 1);
step_both(&p, 1070); /* retry interval elapsed: flips */
CHECK(p.fam[1].present == 0);
net.rtt[1] = 25;
step_both(&p, 1080); /* first success recovers */
CHECK(p.fam[1].present == 1);
}
static void test_first_failure_is_unknown_and_omitted(void) {
net_reset(); net.rtt[0] = -1; probe_t p; probe_reset(&p);
step_both(&p, 1000);
CHECK(p.fam[0].present == PROBE_UNKNOWN);
kvdict_t d; kv_clear(&d);
probe_flags(&p, &d);
CHECK(kv_get(&d, "p4") == NULL && kv_get(&d, "p6") != NULL);
char js[512];
probe_metric_json(&p, js, sizeof(js));
CHECK(strstr(js, "\"ipv4\":{\"present\":null") != NULL);
}
static void test_address_or_gateway_change_repings(void) {
net_reset(); probe_t p; probe_reset(&p);
step_both(&p, 1000);
snprintf(net.addr[0], INET6_ADDRSTRLEN, "198.51.100.7");
step_both(&p, 1000);
CHECK(net.npings[0] == 2);
snprintf(net.gw[0], INET6_ADDRSTRLEN, "192.0.2.254");
step_both(&p, 1000);
CHECK(net.npings[0] == 3);
}
static void test_route_loss_then_return_pings_immediately(void) {
net_reset(); probe_t p; probe_reset(&p);
step_both(&p, 1000);
net.addr[1][0] = '\0';
step_both(&p, 1000);
CHECK(p.fam[1].present == 0);
snprintf(net.addr[1], INET6_ADDRSTRLEN, "2001:db8::5");
step_both(&p, 1000);
CHECK(net.npings[1] == 2 && p.fam[1].present == 1);
}
static void test_missing_ping_stays_unknown_even_after_route_loss(void) {
net_reset(); net.rtt[0] = -2; probe_t p; probe_reset(&p);
step_both(&p, 1000);
step_both(&p, 1600);
CHECK(p.fam[0].present == PROBE_UNKNOWN && net.npings[0] == 1);
net.addr[0][0] = '\0';
step_both(&p, 1700);
CHECK(p.fam[0].present == 0); /* no route is definitive */
snprintf(net.addr[0], INET6_ADDRSTRLEN, "192.0.2.5");
step_both(&p, 1800);
CHECK(p.fam[0].present == PROBE_UNKNOWN && net.npings[0] == 1);
}
static void test_unsupported_family_is_unknown(void) {
net_reset(); net.unsupported[1] = 1; probe_t p; probe_reset(&p);
step_both(&p, 1000);
CHECK(p.fam[1].present == PROBE_UNKNOWN && p.fam[0].present == 1);
}
static int live_mode(void) {
static const probe_ops_t sys_ops = { sys_local_address, sys_ping, sys_gateway };
probe_t p; probe_reset(&p);
p.cheap_gateway = false;
snprintf(p.target[0], sizeof(p.target[0]), "1.1.1.1");
for (int round = 0; round < 3; round++) {
double now = mono_ts();
for (int f = 0; f < 2; f++) probe_step(&p, &p.fam[f], f, &sys_ops, now);
sleep(round < 2 ? 11 : 0);
}
char js[512];
probe_metric_json(&p, js, sizeof(js));
kvdict_t d; kv_clear(&d);
probe_flags(&p, &d);
printf("%s\np4=%s p6=%s\n", js, kv_get(&d, "p4") ? kv_get(&d, "p4") : "-",
kv_get(&d, "p6") ? kv_get(&d, "p6") : "-");
return 0;
}
/* ---- server address re-resolution (dns_apply) ---- */
static void dns_conn(int i, int af, const char *addr) {
memset(&g_conns[i], 0, sizeof(conn_t));
g_conns[i].conn_id = i + 1; g_conns[i].af = af; g_conns[i].port = 5;
snprintf(g_conns[i].addr, sizeof(g_conns[i].addr), "%s", addr);
conn_open(&g_conns[i]);
}
static void dns_push(int n, const dns_addr_t *a) {
memcpy(g_dns.addrs, a, n * sizeof(*a)); g_dns.n = n; g_dns.ready = true;
}
static void test_dns_apply_follows_changes(void) {
int id = 3;
dns_conn(0, AF_INET, "1.1.1.1"); dns_conn(1, AF_INET6, "2001:db8::1"); g_nconns = 2;
dns_addr_t moved[2] = {{AF_INET, "9.9.9.9"}, {AF_INET6, "2001:db8::9"}};
dns_push(2, moved); dns_apply(&id, "me", 5);
CHECK(g_nconns == 2);
CHECK_STR(g_conns[0].addr, "9.9.9.9");
CHECK_STR(g_conns[1].addr, "2001:db8::9");
/* the first connection's address vanishes and nothing replaces it: it survives, the other goes */
dns_addr_t v6only[1] = {{AF_INET6, "2001:db8::9"}};
dns_push(1, v6only); dns_apply(&id, "me", 5);
CHECK(g_nconns == 1);
CHECK_STR(g_conns[0].addr, "2001:db8::9");
CHECK(g_conns[0].af == AF_INET6);
dns_addr_t grown[2] = {{AF_INET6, "2001:db8::9"}, {AF_INET, "5.5.5.5"}};
dns_push(2, grown); dns_apply(&id, "me", 5);
CHECK(g_nconns == 2);
CHECK_STR(g_conns[1].addr, "5.5.5.5");
CHECK(g_conns[1].conn_id == 3);
dns_push(2, grown); dns_apply(&id, "me", 5); /* unchanged: nothing happens */
CHECK(g_nconns == 2 && id == 4);
for (int i = 0; i < g_nconns; i++) conn_close(&g_conns[i]);
g_nconns = 0;
}
static void test_dns_ttl_unknown_for_ip_literals(void) {
CHECK(dns_host_ttl("192.0.2.1", 0) == -1);
CHECK(dns_host_ttl("2001:db8::1", 0) == -1);
const char *h[] = {"192.0.2.1"}; dns_ctx_t x = {h, 1, 5, 0};
CHECK(dns_refresh_delay(&x) == DNS_DEFAULT_TTL);
}
int main(int argc, char **argv) {
if (argc > 1 && !strcmp(argv[1], "--live")) return live_mode();
test_gateway_parsers();
test_ping_parser();
test_present_with_details();
test_no_route_is_absent_and_wastes_no_ping();
test_debounce_and_rate_limit();
test_first_failure_is_unknown_and_omitted();
test_address_or_gateway_change_repings();
test_route_loss_then_return_pings_immediately();
test_missing_ping_stays_unknown_even_after_route_loss();
test_unsupported_family_is_unknown();
test_dns_apply_follows_changes();
test_dns_ttl_unknown_for_ip_literals();
printf("%d checks, %d failed\n", g_run, g_fail);
return g_fail ? 1 : 0;
}
+664 -56
View File
@@ -10,18 +10,25 @@ Built-in plugins (always available):
os_info — OS name, kernel, distro, Python version os_info — OS name, kernel, distro, Python version
ping_monitor — ICMP ping RTT to configured hosts ping_monitor — ICMP ping RTT to configured hosts
nagios_runner — run Nagios-compatible check scripts nagios_runner — run Nagios-compatible check scripts
network_monitor — per-interface TX/RX bytes/s (Linux); per-protocol connectivity (all platforms)
Linux-only plugins (via /proc, no extra tools needed): Linux-only plugins (via /proc, no extra tools needed):
cpu_monitor — CPU %, load average, core count cpu_monitor — CPU %, load average, core count
memory_monitor — RAM and swap usage memory_monitor — RAM and swap usage
network_monitor — per-interface TX/RX bytes/s
Cross-platform (via df subprocess): Cross-platform (via df subprocess):
disk_monitor — per-mount usage and capacity disk_monitor — per-mount usage and capacity
Presence probe (built in): reports per protocol (IPv4/IPv6) whether the host has internet
connectivity, as p4/p6 flags on every heartbeat, so hbd stops treating a missing IPv6 (or
IPv4) path as an outage. Config, in the network_monitor section of ~/.hbc.json:
probe_enabled (default true), probe_v4 (default 1.1.1.1), probe_v6 (default
2606:4700:4700::1111; IP literals only), probe_interval (default 60 s)
""" """
import argparse import argparse
import asyncio import asyncio
import ipaddress
import json import json
import logging import logging
import os import os
@@ -30,18 +37,20 @@ import re
import shutil import shutil
import signal import signal
import socket import socket
import struct
import subprocess import subprocess
import sys import sys
import time import time
import zlib import zlib
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections import defaultdict from collections import defaultdict
from dataclasses import asdict, dataclass
from logging.handlers import SysLogHandler from logging.handlers import SysLogHandler
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Awaitable, Callable, Dict, List, Optional, Set, Tuple
# updated by scripts/bumpminor.sh # updated by scripts/bumpminor.sh
__version__ = "5.1.18" __version__ = "5.4.7"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Protocol (mirrors hbd/common/proto.py) # Protocol (mirrors hbd/common/proto.py)
@@ -114,9 +123,15 @@ def _stodict(data: bytes) -> Dict[str, Any]:
_DEFAULTS: Dict[str, Any] = { _DEFAULTS: Dict[str, Any] = {
"hb_port": 50003, "hb_port": 50003,
"interval": 10, "interval": 10,
"owner": None,
"allow_remote_command": False, # Execute shell commands received in CMD packets
"plugins": {}, "plugins": {},
} }
# Set from config in main(). Off by default: a CMD packet is an unauthenticated
# UDP datagram, so executing one must be opted into per host.
_allow_remote_command = False
def _load_config(path: Optional[str] = None) -> Dict[str, Any]: def _load_config(path: Optional[str] = None) -> Dict[str, Any]:
cfg = dict(_DEFAULTS) cfg = dict(_DEFAULTS)
@@ -239,6 +254,8 @@ class OSInfoPlugin(InfoPlugin):
"hbc_version": __version__, "hbc_version": __version__,
"hbc_type": "mini", "hbc_type": "mini",
} }
if self.config.get("owner"):
data["owner"] = self.config["owner"]
if platform.system() == "Linux": if platform.system() == "Linux":
data.update(_linux_distro()) data.update(_linux_distro())
elif platform.system() == "Darwin": elif platform.system() == "Darwin":
@@ -388,7 +405,6 @@ class NagiosRunnerPlugin(MonitorPlugin):
async def _collect_metrics(self) -> Dict[str, Any]: async def _collect_metrics(self) -> Dict[str, Any]:
results: Dict[str, Any] = {} results: Dict[str, Any] = {}
worst = 0
for cmd_cfg in self.commands: for cmd_cfg in self.commands:
name = cmd_cfg.get("name") name = cmd_cfg.get("name")
command = cmd_cfg.get("command") command = cmd_cfg.get("command")
@@ -399,10 +415,6 @@ class NagiosRunnerPlugin(MonitorPlugin):
results[f"{name}_status_code"] = rc results[f"{name}_status_code"] = rc
results[f"{name}_output"] = msg results[f"{name}_output"] = msg
results.update({f"{name}_{k}": v for k, v in perf.items()}) results.update({f"{name}_{k}": v for k, v in perf.items()})
worst = max(worst, rc)
results["overall_status"] = _NAGIOS_STATUS.get(worst, "UNKNOWN")
results["overall_status_code"] = worst
results["plugin_count"] = len(self.commands)
return results return results
@@ -487,6 +499,12 @@ class CPUMonitorPlugin(MonitorPlugin):
except Exception: except Exception:
pass pass
try:
with open("/proc/uptime") as fh:
data["uptime_seconds"] = int(float(fh.read().split()[0]))
except Exception:
pass
return data return data
@@ -535,6 +553,20 @@ class MemoryMonitorPlugin(MonitorPlugin):
total = mi.get("MemTotal", 0) total = mi.get("MemTotal", 0)
avail = mi.get("MemAvailable", mi.get("MemFree", 0)) avail = mi.get("MemAvailable", mi.get("MemFree", 0))
free = mi.get("MemFree", 0) free = mi.get("MemFree", 0)
# ZFS ARC is reclaimable but not included in MemAvailable; add it.
arc_kb = 0
try:
with open("/proc/spl/kstat/zfs/arcstats") as _f:
for _line in _f:
_p = _line.split()
if len(_p) >= 3 and _p[0] == "size":
arc_kb = int(_p[2]) // 1024
break
except (OSError, ValueError):
pass
avail = min(avail + arc_kb, total)
used = total - avail used = total - avail
data: Dict[str, Any] = { data: Dict[str, Any] = {
"memory_total": total * 1024, "memory_total": total * 1024,
@@ -614,6 +646,315 @@ class DiskMonitorPlugin(MonitorPlugin):
return {"partitions": partitions} if partitions else {} return {"partitions": partitions} if partitions else {}
# ---------------------------------------------------------------------------
# Connectivity probe — per-protocol internet presence (IPv4 / IPv6)
#
# Tier 1 (every tier1_interval s, sends no packets): a UDP connect() to the probe address
# shows whether a route exists and which source address would be used. Tier 2 (every
# probe_interval s, sooner after a failure or a change of address or gateway): one ICMP echo,
# only when tier 1 found a route. A family is absent after no route at all or fail_limit
# consecutive failed echoes, present after one success, and unknown (flag omitted) when it
# cannot be probed. The result rides on every HTB as p4/p6 and, when network_monitor runs, as
# its `connectivity` metric. Mirrors hbd/client/connectivity.py.
# ---------------------------------------------------------------------------
_FAMILIES = ("ipv4", "ipv6")
_FLAG_KEYS = {"ipv4": "p4", "ipv6": "p6"}
_DEFAULT_PROBES = {"ipv4": "1.1.1.1", "ipv6": "2606:4700:4700::1111"}
_BSD_PLATFORMS = ("darwin", "freebsd", "netbsd", "openbsd", "dragonfly")
class _ProbeUnavailable(Exception):
"""The address family cannot be probed on this host (e.g. no IPv6 support)."""
class _PingUnavailable(Exception):
"""The system ping binary could not be executed."""
def _parse_proc_route_v4(text: str) -> Optional[str]:
"""Default gateway from /proc/net/route (lowest metric wins)."""
best: Optional[Tuple[int, str]] = None
for line in text.splitlines()[1:]:
f = line.split()
if len(f) < 8 or f[1] != "00000000" or not int(f[3], 16) & 0x2:
continue
metric = int(f[6])
if best is None or metric < best[0]:
# the kernel prints the raw network-order u32 in host order: use the host byte order
best = (metric, socket.inet_ntoa(int(f[2], 16).to_bytes(4, sys.byteorder)))
return best[1] if best else None
def _parse_proc_route_v6(text: str) -> Optional[str]:
"""Default gateway from /proc/net/ipv6_route (lowest metric wins)."""
best: Optional[Tuple[int, str]] = None
for line in text.splitlines():
f = line.split()
if len(f) < 10:
continue
dest, plen, nexthop, metric, flags, iface = f[0], f[1], f[4], f[5], f[8], f[9]
if dest != "0" * 32 or plen != "00" or nexthop == "0" * 32 or iface == "lo":
continue
fl = int(flags, 16)
if not fl & 0x2 or fl & 0x200: # need RTF_GATEWAY, skip RTF_REJECT
continue
m = int(metric, 16)
if best is None or m < best[0]:
best = (m, socket.inet_ntop(socket.AF_INET6, bytes.fromhex(nexthop)))
return best[1] if best else None
def _parse_route_get(text: str) -> Optional[str]:
"""Gateway from `route -n get [-inet6] default` (macOS and the BSDs)."""
m = re.search(r"^\s*gateway:\s*(\S+)", text, re.MULTILINE)
return m.group(1) if m else None
def _parse_windows_route(text: str, family: str) -> Optional[str]:
"""Default gateway from `route print -4` / `route print -6` (lowest metric wins)."""
best: Optional[Tuple[int, str]] = None
for line in text.splitlines():
if family == "ipv4":
f = line.split()
if not (len(f) >= 5 and f[0] == "0.0.0.0" and f[1] == "0.0.0.0" and f[2] != "On-link"):
continue
cand = (int(f[4]), f[2])
else:
m = re.match(r"^\s*\d+\s+(\d+)\s+::/0\s+(\S+)\s*$", line)
if not m or m.group(2) == "On-link":
continue
cand = (int(m.group(1)), m.group(2))
if best is None or cand[0] < best[0]:
best = cand
return best[1] if best else None
async def _run_cmd(cmd: List[str], timeout: float = 5.0) -> str:
proc = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL)
try:
out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
raise
return out.decode(errors="replace")
async def _default_gateway(family: str) -> Optional[str]:
"""Default gateway for "ipv4"/"ipv6", or None on any failure (informational only)."""
v4 = family == "ipv4"
try:
if sys.platform.startswith("linux"):
with open("/proc/net/route" if v4 else "/proc/net/ipv6_route") as fh:
text = fh.read()
return _parse_proc_route_v4(text) if v4 else _parse_proc_route_v6(text)
if sys.platform.startswith(_BSD_PLATFORMS):
cmd = ["route", "-n", "get"] + ([] if v4 else ["-inet6"]) + ["default"]
return _parse_route_get(await _run_cmd(cmd))
if sys.platform == "win32":
text = await _run_cmd(["route", "print", "-4" if v4 else "-6"])
return _parse_windows_route(text, family)
except Exception:
return None
return None
def _probe_ping_command(host: str, family: str) -> List[str]:
"""Command for ONE echo request with a ~2 s reply wait."""
v4 = family == "ipv4"
if sys.platform == "win32":
return ["ping", "-n", "1", "-w", "2000", "-4" if v4 else "-6", host]
if sys.platform.startswith(_BSD_PLATFORMS):
# BSD/macOS -W is in milliseconds, and IPv6 is reached through ping6.
return ["ping", "-c", "1", "-W", "2000", host] if v4 else ["ping6", "-c", "1", host]
return ["ping", "-c", "1", "-W", "2", "-4" if v4 else "-6", host]
def _parse_ping_rtt(output: str) -> Optional[float]:
"""Average RTT in ms from ping output, or None when there was no reply."""
m = re.search(r"(?:rtt|round-trip)\s+min/avg/max/\S+\s*=\s*([\d.]+)/([\d.]+)/([\d.]+)", output)
if m:
return float(m.group(2))
if "TTL=" in output: # Windows reply line (also when localized)
m = re.search(r"[=<]\s*(\d+)\s*ms", output)
return float(m.group(1)) if m else 0.0
return None
async def _ping_rtt(target: str, family: str) -> Optional[float]:
"""One ICMP echo; RTT in ms, or None if no reply. Raises _PingUnavailable without ping."""
try:
proc = await asyncio.create_subprocess_exec(
*_probe_ping_command(target, family),
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
except FileNotFoundError as e:
raise _PingUnavailable(str(e))
try:
out, _ = await asyncio.wait_for(proc.communicate(), timeout=6)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
return None
return _parse_ping_rtt(out.decode(errors="replace"))
def _local_address(family: str, target: str) -> Optional[str]:
"""Source address the kernel would use to reach target, or None if there is no route.
A UDP connect() sends nothing. Raises _ProbeUnavailable if the family is unsupported.
"""
af = socket.AF_INET if family == "ipv4" else socket.AF_INET6
try:
sock = socket.socket(af, socket.SOCK_DGRAM)
except OSError as e:
raise _ProbeUnavailable(str(e))
try:
sock.connect((target, 9))
return str(sock.getsockname()[0])
except OSError:
return None
finally:
sock.close()
@dataclass
class _FamilyState:
present: Optional[bool] = None # None = unknown (probe cannot run yet/at all)
addr: Optional[str] = None
gateway: Optional[str] = None
rtt: Optional[float] = None
class _Connectivity:
def __init__(
self,
probes: Optional[Dict[str, str]] = None,
probe_interval: float = 60.0,
retry_interval: float = 10.0,
tier1_interval: float = 5.0,
fail_limit: int = 2,
local_address_fn: Callable[[str, str], Optional[str]] = _local_address,
ping_fn: Callable[[str, str], Awaitable[Optional[float]]] = _ping_rtt,
gateway_fn: Callable[[str], Awaitable[Optional[str]]] = _default_gateway,
clock: Callable[[], float] = time.monotonic,
):
self.probes = dict(_DEFAULT_PROBES)
self.probes.update(probes or {})
self.probe_interval = probe_interval
self.retry_interval = retry_interval
self.tier1_interval = tier1_interval
self.fail_limit = fail_limit
self._local_address = local_address_fn
self._ping = ping_fn
self._gateway_fn = gateway_fn
self._clock = clock
# Reading the gateway is a cheap /proc read on Linux but a subprocess elsewhere, so
# elsewhere it is refreshed only when a tier-2 probe is due.
self._cheap_gateway = sys.platform.startswith("linux")
self.states: Dict[str, _FamilyState] = {f: _FamilyState() for f in _FAMILIES}
self._fails: Dict[str, int] = {f: 0 for f in _FAMILIES}
self._next_ping: Dict[str, float] = {f: 0.0 for f in _FAMILIES}
self._last_key: Dict[str, Optional[Tuple[Optional[str], Optional[str]]]] = {
f: None for f in _FAMILIES}
self._ping_disabled: Set[str] = set()
@classmethod
def from_config(cls, cfg: Optional[Dict[str, Any]]) -> "_Connectivity":
cfg = cfg or {}
probes = {}
if cfg.get("probe_v4"):
probes["ipv4"] = str(cfg["probe_v4"])
if cfg.get("probe_v6"):
probes["ipv6"] = str(cfg["probe_v6"])
return cls(probes=probes, probe_interval=float(cfg.get("probe_interval", 60)))
# ---- readers (cheap; safe to call on every heartbeat) ---------------------------
def snapshot(self) -> Dict[str, _FamilyState]:
return {f: _FamilyState(**asdict(s)) for f, s in self.states.items()}
def flags(self) -> Dict[str, bool]:
"""HTB flags for families whose presence is known."""
return {_FLAG_KEYS[f]: s.present for f, s in self.states.items() if s.present is not None}
def as_metric(self) -> Dict[str, Dict[str, Any]]:
return {f: asdict(s) for f, s in self.states.items()}
# ---- probing ----------------------------------------------------------------------
async def refresh(self) -> None:
for fam in _FAMILIES:
try:
await self._refresh_family(fam)
except Exception:
logging.getLogger("hbc.probe").exception("connectivity probe failed for %s", fam)
async def run(self) -> None:
while _running:
await self.refresh()
await _sleep(self.tier1_interval)
async def _gateway(self, fam: str) -> Optional[str]:
try:
return await self._gateway_fn(fam)
except Exception:
return None
async def _refresh_family(self, fam: str) -> None:
st = self.states[fam]
try:
addr = self._local_address(fam, self.probes[fam])
except _ProbeUnavailable:
st.present = None
return
if addr is None: # tier 1: no route => absent immediately
self._fails[fam] = 0
self._next_ping[fam] = 0.0
self._last_key[fam] = None
st.present, st.addr, st.gateway, st.rtt = False, None, None, None
return
now = self._clock()
gw = await self._gateway(fam) if self._cheap_gateway else st.gateway
key = (addr, gw)
due = key != self._last_key[fam] or now >= self._next_ping[fam]
if due and not self._cheap_gateway:
gw = await self._gateway(fam)
key = (addr, gw)
st.addr, st.gateway = addr, gw
self._last_key[fam] = key
if fam in self._ping_disabled:
st.present = None # cannot probe: unknown, never absent, while a route exists
elif due:
await self._tier2(fam, st, now)
async def _tier2(self, fam: str, st: _FamilyState, now: float) -> None:
try:
rtt = await self._ping(self.probes[fam], fam)
except _PingUnavailable:
logging.getLogger("hbc.probe").warning("ping unavailable; %s presence stays unknown", fam)
self._ping_disabled.add(fam)
st.present = None
return
except (asyncio.TimeoutError, OSError):
rtt = None
if rtt is None:
self._fails[fam] += 1
self._next_ping[fam] = now + self.retry_interval
if self._fails[fam] >= self.fail_limit:
st.present, st.rtt = False, None
else:
self._fails[fam] = 0
self._next_ping[fam] = now + self.probe_interval
st.present, st.rtt = True, rtt
# The running probe (None when probe_enabled is false); shared by the heartbeat sender and
# the network_monitor plugin. Set in _async_main.
_probe: Optional[_Connectivity] = None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Plugin: network_monitor (Linux /proc/net/dev) # Plugin: network_monitor (Linux /proc/net/dev)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -635,7 +976,7 @@ def _read_net_dev() -> Dict[str, Tuple[int, int]]:
class NetworkMonitorPlugin(MonitorPlugin): class NetworkMonitorPlugin(MonitorPlugin):
name = "network_monitor" name = "network_monitor"
description = "Network I/O rates via /proc/net/dev (Linux)" description = "Network I/O rates via /proc/net/dev (Linux) and per-protocol internet connectivity"
interval = 300 interval = 300
def __init__(self, config: Optional[Dict[str, Any]] = None): def __init__(self, config: Optional[Dict[str, Any]] = None):
@@ -644,19 +985,25 @@ class NetworkMonitorPlugin(MonitorPlugin):
self.interval = cfg.get("interval", 300) self.interval = cfg.get("interval", 300)
self.skip_ifaces: set = set(cfg.get("skip_interfaces", ["lo"])) self.skip_ifaces: set = set(cfg.get("skip_interfaces", ["lo"]))
self._prev: Optional[Tuple[float, Dict[str, Tuple[int, int]]]] = None self._prev: Optional[Tuple[float, Dict[str, Tuple[int, int]]]] = None
self._have_dev = False
async def initialize(self) -> bool: async def initialize(self) -> bool:
if platform.system() != "Linux": dev = _read_net_dev() if platform.system() == "Linux" else {}
self.skip_reason = "Linux only (/proc/net/dev not available)" self._have_dev = bool(dev)
if not self._have_dev and _probe is None:
self.skip_reason = "no /proc/net/dev (Linux only) and the connectivity probe is disabled"
return False return False
dev = _read_net_dev() if self._have_dev:
if not dev: self._prev = (time.time(), dev) # prime first delta
self.skip_reason = "/proc/net/dev not readable"
return False
self._prev = (time.time(), dev) # prime first delta
return True return True
async def _collect_metrics(self) -> Dict[str, Any]: async def _collect_metrics(self) -> Dict[str, Any]:
data: Dict[str, Any] = await self._collect_interfaces() if self._have_dev else {}
if _probe is not None:
data["connectivity"] = _probe.as_metric()
return data
async def _collect_interfaces(self) -> Dict[str, Any]:
now = time.time() now = time.time()
curr = _read_net_dev() curr = _read_net_dev()
if not curr or self._prev is None: if not curr or self._prev is None:
@@ -701,7 +1048,9 @@ async def _load_plugins(cfg: Dict[str, Any]) -> List[Plugin]:
plugins_cfg: Dict[str, Any] = cfg.get("plugins", {}) plugins_cfg: Dict[str, Any] = cfg.get("plugins", {})
loaded: List[Plugin] = [] loaded: List[Plugin] = []
for cls in _ALL_PLUGIN_CLASSES: for cls in _ALL_PLUGIN_CLASSES:
plugin_cfg = plugins_cfg.get(cls.name) or cfg.get(cls.name, {}) plugin_cfg = dict(plugins_cfg.get(cls.name) or cfg.get(cls.name) or {})
if "owner" in cfg and "owner" not in plugin_cfg:
plugin_cfg["owner"] = cfg["owner"]
plugin: Plugin = cls(config=plugin_cfg) plugin: Plugin = cls(config=plugin_cfg)
try: try:
ok = await plugin.initialize() ok = await plugin.initialize()
@@ -771,7 +1120,7 @@ class _HeartbeatProtocol(asyncio.DatagramProtocol):
msg_id = msg.get("ID") msg_id = msg.get("ID")
now = time.time() now = time.time()
if msg_id == "ACK": if msg_id == "ACK":
self._conn._handle_ack(now) self._conn._handle_ack(msg, now)
elif msg_id == "CMD": elif msg_id == "CMD":
asyncio.create_task(_handle_command(self._conn, msg)) asyncio.create_task(_handle_command(self._conn, msg))
elif msg_id == "UPD": elif msg_id == "UPD":
@@ -782,8 +1131,7 @@ class _HeartbeatProtocol(asyncio.DatagramProtocol):
self._log.error("datagram error: %s", e) self._log.error("datagram error: %s", e)
def error_received(self, exc): def error_received(self, exc):
self._log.warning("protocol error on %s: %s — dropping connection", self._conn.addr, exc) self._log.warning("protocol error on %s: %s — will retry", self._conn.addr, exc)
self._conn._dead = True
self._conn.close() self._conn.close()
@@ -799,6 +1147,7 @@ class AsyncConnection:
self.rtts: List[float] = [0.0] self.rtts: List[float] = [0.0]
self._transport: Optional[asyncio.DatagramTransport] = None self._transport: Optional[asyncio.DatagramTransport] = None
self._dead = False self._dead = False
self._request_info: asyncio.Event = asyncio.Event()
self._log = logging.getLogger(f"hbc.conn.{addr}") self._log = logging.getLogger(f"hbc.conn.{addr}")
async def open(self) -> bool: async def open(self) -> bool:
@@ -817,12 +1166,22 @@ class AsyncConnection:
self._transport.close() self._transport.close()
self._transport = None self._transport = None
def _handle_ack(self, now: float): def set_target(self, addr: str, af: int):
"""Re-point at a new server address; the socket is reopened on the next send if the family changed."""
if af != self.af:
self.close()
self.af = af
self.addr = addr
self._log = logging.getLogger(f"hbc.conn.{addr}")
def _handle_ack(self, msg: Dict[str, Any], now: float):
rtt = (now - self.lastsend) * 1000.0 rtt = (now - self.lastsend) * 1000.0
self.rtts.append(rtt) self.rtts.append(rtt)
if len(self.rtts) > 10: if len(self.rtts) > 10:
self.rtts.pop(0) self.rtts.pop(0)
self.ackcount += 1 self.ackcount += 1
if msg.get("request_update"):
self._request_info.set()
async def sendto(self, msg: Dict[str, Any], msg_id: str = "HTB"): async def sendto(self, msg: Dict[str, Any], msg_id: str = "HTB"):
if self._dead: if self._dead:
@@ -839,6 +1198,207 @@ class AsyncConnection:
self.lastsend = time.time() self.lastsend = time.time()
# ---------------------------------------------------------------------------
# Re-resolution of the server hosts at the TTL of their A/AAAA records
# getaddrinfo() hides the TTL, so a minimal stub-resolver query is made only to learn it; the
# addresses still come from getaddrinfo(). TTL unknown (IP literal, /etc/hosts name, no
# resolv.conf, timeout) -> _DNS_DEFAULT_TTL.
# ---------------------------------------------------------------------------
log = logging.getLogger("hbc.dns")
_DNS_DEFAULT_TTL = 300 # used when the real TTL is unknown
_DNS_MIN_TTL = 30 # a TTL of 0 must not turn into a resolution loop
_DNS_FAILED_RETRY = 60 # re-try delay after a failed resolution
_DNS_QUERY_TIMEOUT = 2.0
_DNS_TYPE_A, _DNS_TYPE_CNAME, _DNS_TYPE_AAAA = 1, 5, 28
def _dns_parse_resolv_conf(path: str = "/etc/resolv.conf") -> Tuple[List[str], List[str]]:
"""Return (nameservers, search domains)."""
servers: List[str] = []
search: List[str] = []
try:
with open(path) as f:
for line in f:
parts = line.split()
if len(parts) < 2:
continue
if parts[0] == "nameserver":
servers.append(parts[1].split("%")[0])
elif parts[0] in ("search", "domain"):
search = parts[1:]
except OSError:
pass
return servers, search
def _dns_build_query(name: str, qtype: int, qid: int) -> bytes:
out = struct.pack("!HHHHHH", qid, 0x0100, 1, 0, 0, 0)
for label in name.rstrip(".").split("."):
out += bytes([len(label)]) + label.encode("idna")
return out + b"\x00" + struct.pack("!HH", qtype, 1)
def _dns_skip_name(buf: bytes, off: int) -> int:
while True:
n = buf[off]
if n == 0:
return off + 1
if n & 0xC0:
return off + 2
off += n + 1
def _dns_min_answer_ttl(resp: bytes, qid: int, qtype: int) -> Optional[int]:
"""Smallest TTL among the answer RRs (CNAMEs included), or None if there is no qtype answer."""
try:
rid, flags, qd, an = struct.unpack("!HHHH", resp[:8])
if rid != qid or flags & 0x000F:
return None
off = 12
for _ in range(qd):
off = _dns_skip_name(resp, off) + 4
ttls: List[int] = []
found = False
for _ in range(an):
off = _dns_skip_name(resp, off)
rtype, _cls, ttl, rdlen = struct.unpack("!HHIH", resp[off:off + 10])
off += 10 + rdlen
if rtype in (qtype, _DNS_TYPE_CNAME):
ttls.append(ttl)
found = found or rtype == qtype
return min(ttls) if found else None
except (struct.error, IndexError):
return None
def _dns_query_ttl(server: str, name: str, qtype: int) -> Optional[int]:
qid = struct.unpack("!H", os.urandom(2))[0]
try:
af = socket.AF_INET6 if ":" in server else socket.AF_INET
with socket.socket(af, socket.SOCK_DGRAM) as s:
s.settimeout(_DNS_QUERY_TIMEOUT)
s.sendto(_dns_build_query(name, qtype, qid), (server, 53))
return _dns_min_answer_ttl(s.recv(4096), qid, qtype)
except (OSError, UnicodeError):
return None
def _dns_lookup_ttl(host: str, af_filter: int = 0) -> Optional[int]:
"""Blocking. Smallest A/AAAA TTL for host, or None when it cannot be determined."""
try:
ipaddress.ip_address(host)
return None
except ValueError:
pass
servers, search = _dns_parse_resolv_conf()
if host.endswith("."):
names = [host]
elif "." in host:
names = [host] + [f"{host}.{d}" for d in search]
else:
names = [f"{host}.{d}" for d in search] + [host]
qtypes = [t for t, af in ((_DNS_TYPE_A, socket.AF_INET), (_DNS_TYPE_AAAA, socket.AF_INET6))
if af_filter in (0, af)]
for name in names:
ttls = []
for qtype in qtypes:
for server in servers:
ttl = _dns_query_ttl(server, name, qtype)
if ttl is not None:
ttls.append(ttl)
break
if ttls:
return min(ttls)
return None
def _dns_refresh_delay(ttls: List[Optional[int]]) -> int:
"""Seconds until the next resolution: the smallest known TTL, floored at _DNS_MIN_TTL."""
known = [t for t in ttls if t is not None]
return max(min(known), _DNS_MIN_TTL) if known else _DNS_DEFAULT_TTL
def _dns_resolve(hosts: List[str], port: int, af_filter: int) -> Optional[List[Tuple[int, str]]]:
"""Blocking. Every (family, address) the hosts resolve to, deduplicated, or None if any lookup fails."""
found: List[Tuple[int, str]] = []
for host in hosts:
try:
infos = socket.getaddrinfo(host, port, af_filter, 0, socket.SOL_UDP)
except socket.gaierror as e:
log.warning("cannot resolve %s: %s", host, e)
return None
for info in infos:
entry = (info[0], str(info[4][0]))
if entry not in found:
found.append(entry)
return found or None
def _dns_retarget(connections: list, resolved: List[Tuple[int, str]]) -> Tuple[List[Tuple[int, str]], list]:
"""Point the connections at the resolved addresses, in place.
A connection whose address is gone is re-pointed at an address that is new, in list order, so
the first connection (the one plugin data uses) survives as long as anything resolves.
Returns (new addresses still needing a connection, connections to drop).
"""
current = {c.addr for c in connections}
wanted = {addr for _, addr in resolved}
new = [(af, addr) for af, addr in resolved if addr not in current]
gone = [c for c in connections if c.addr not in wanted]
paired = min(len(gone), len(new))
for conn, (af, addr) in zip(gone, new):
log.info("server address %s -> %s", conn.addr, addr)
conn.set_target(addr, af)
drop = gone[paired:]
if connections and connections[0] in drop:
# plugin data goes out on the first connection: hand it a surviving address and drop that one instead
keep = next(c for c in connections if c not in gone)
connections[0].set_target(keep.addr, keep.af)
drop.remove(connections[0])
drop.append(keep)
return new[paired:], drop
async def _dns_refresher(connections: List["AsyncConnection"], hosts: List[str], port: int, af_filter: int,
name: str, interval: int, probe: Optional[_Connectivity], next_id: int):
"""Re-resolve the server hosts every TTL seconds and follow address changes.
A failed lookup leaves the connections untouched. Addresses that disappear are re-pointed at
new ones where possible; the rest are retired, and surplus new ones get a connection.
"""
loop = asyncio.get_running_loop()
def ttl_delay() -> int:
return _dns_refresh_delay([_dns_lookup_ttl(h, af_filter) for h in hosts])
delay = await loop.run_in_executor(None, ttl_delay)
while _running:
await _sleep(delay)
if not _running:
break
resolved = await loop.run_in_executor(None, _dns_resolve, hosts, port, af_filter)
if resolved is None:
delay = _DNS_FAILED_RETRY
continue
new, gone = _dns_retarget(connections, resolved)
for conn in gone:
if len(connections) > 1:
log.info("dropping server address %s", conn.addr)
conn._dead = True
conn.close()
connections.remove(conn)
for af, addr in new:
conn = AsyncConnection(next_id, addr, port, af, name)
next_id += 1
log.info("adding server address %s", addr)
connections.append(conn)
_active_tasks.append(asyncio.create_task(_heartbeat_sender(conn, interval, probe)))
delay = await loop.run_in_executor(None, ttl_delay)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Server command handlers # Server command handlers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -848,6 +1408,10 @@ async def _handle_command(conn: AsyncConnection, msg: Dict[str, Any]):
if not cmd: if not cmd:
return return
log = logging.getLogger("hbc.cmd") log = logging.getLogger("hbc.cmd")
if not _allow_remote_command:
log.warning("refused command (allow_remote_command is false): %s", cmd)
await conn.sendto({"service": "command", "msg": "Refused: allow_remote_command is false"})
return
log.info("exec: %s", cmd) log.info("exec: %s", cmd)
try: try:
out = subprocess.check_output( out = subprocess.check_output(
@@ -909,15 +1473,21 @@ async def _handle_update(conn: AsyncConnection):
# Heartbeat sender # Heartbeat sender
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def _heartbeat_sender(conn: AsyncConnection, interval: int): def _build_heartbeat_msg(conn: AsyncConnection, interval: int,
probe: Optional[_Connectivity] = None) -> Dict[str, Any]:
"""HTB payload; carries p4/p6 presence flags when the probe knows them."""
msg: Dict[str, Any] = {"acks": conn.ackcount, "rtt": conn.rtts[-1], "interval": interval}
if probe is not None:
msg.update(probe.flags())
return msg
async def _heartbeat_sender(conn: AsyncConnection, interval: int,
probe: Optional[_Connectivity] = None):
log = logging.getLogger("hbc.hb") log = logging.getLogger("hbc.hb")
while _running: while _running:
try: try:
await conn.sendto({ await conn.sendto(_build_heartbeat_msg(conn, interval, probe))
"acks": conn.ackcount,
"rtt": conn.rtts[-1],
"interval": interval,
})
except Exception as e: except Exception as e:
log.error("send: %s", e) log.error("send: %s", e)
await _sleep(interval) await _sleep(interval)
@@ -933,7 +1503,7 @@ async def _run_info_plugins(conn: AsyncConnection, plugins: List[Plugin]):
try: try:
data = await plugin.collect() data = await plugin.collect()
if data: if data:
await conn.sendto({"plugin": plugin.name, **data}, "PLG") await conn.sendto({"plugin": plugin.name, **data, "_interval": plugin.interval}, "PLG")
log.info("sent %s", plugin.name) log.info("sent %s", plugin.name)
except Exception as e: except Exception as e:
log.error("%s collect: %s", plugin.name, e) log.error("%s collect: %s", plugin.name, e)
@@ -946,7 +1516,7 @@ async def _run_monitor_group(conn: AsyncConnection, plugins: List[Plugin], inter
try: try:
data = await plugin.collect() data = await plugin.collect()
if data: if data:
await conn.sendto({"plugin": plugin.name, **data}, "PLG") await conn.sendto({"plugin": plugin.name, **data, "_interval": plugin.interval}, "PLG")
log.debug("sent %s", plugin.name) log.debug("sent %s", plugin.name)
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
@@ -955,6 +1525,19 @@ async def _run_monitor_group(conn: AsyncConnection, plugins: List[Plugin], inter
await _sleep(interval) await _sleep(interval)
async def _info_refresh_loop(conn: AsyncConnection, info: List[Plugin]):
log = logging.getLogger("hbc.plugins")
while _running:
await conn._request_info.wait()
if not _running:
break
conn._request_info.clear()
log.info("refreshing InfoPlugins on server request")
for plugin in info:
plugin._cache = None
await _run_info_plugins(conn, info)
async def _plugin_collector(conn: AsyncConnection, plugins: List[Plugin]): async def _plugin_collector(conn: AsyncConnection, plugins: List[Plugin]):
info = [p for p in plugins if isinstance(p, InfoPlugin)] info = [p for p in plugins if isinstance(p, InfoPlugin)]
monitor = [p for p in plugins if isinstance(p, MonitorPlugin)] monitor = [p for p in plugins if isinstance(p, MonitorPlugin)]
@@ -965,12 +1548,10 @@ async def _plugin_collector(conn: AsyncConnection, plugins: List[Plugin]):
for p in monitor: for p in monitor:
by_interval[p.interval].append(p) by_interval[p.interval].append(p)
if by_interval: tasks = [asyncio.create_task(_info_refresh_loop(conn, info))]
await asyncio.gather( tasks += [asyncio.create_task(_run_monitor_group(conn, grp, iv))
*[asyncio.create_task(_run_monitor_group(conn, grp, iv)) for iv, grp in by_interval.items()]
for iv, grp in by_interval.items()], await asyncio.gather(*tasks, return_exceptions=True)
return_exceptions=True,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1014,7 +1595,7 @@ def _reconfigure_syslog(level: int):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def _async_main(args, cfg: Dict[str, Any]) -> int: async def _async_main(args, cfg: Dict[str, Any]) -> int:
global _running, _shutdown_event, _active_tasks global _running, _shutdown_event, _active_tasks, send_shutdown, _probe
_running = True _running = True
_shutdown_event = asyncio.Event() _shutdown_event = asyncio.Event()
_active_tasks = [] _active_tasks = []
@@ -1024,43 +1605,57 @@ async def _async_main(args, cfg: Dict[str, Any]) -> int:
port = cfg.get("hb_port", PORT) port = cfg.get("hb_port", PORT)
interval = cfg.get("interval", INTERVAL) interval = cfg.get("interval", INTERVAL)
log.info("starting: %s -> %s port=%d interval=%ds", iam, args.hosts, port, interval) log.info("hbc_mini %s on %s -> %s port=%d interval=%ds",__version__, iam, args.hosts, port, interval)
af_filter = (socket.AF_INET if getattr(args, "ipv4_only", False)
else socket.AF_INET6 if getattr(args, "ipv6_only", False)
else 0)
connections: List[AsyncConnection] = [] connections: List[AsyncConnection] = []
conn_id = 1 conn_id = 1
for host in args.hosts: _retry_delay = 5
try: while _running and not connections:
addrs = socket.getaddrinfo(host, port, 0, 0, socket.SOL_UDP) for host in args.hosts:
except socket.gaierror as e: try:
log.error("cannot resolve %s: %s", host, e) addrs = socket.getaddrinfo(host, port, af_filter, 0, socket.SOL_UDP)
continue except socket.gaierror as e:
for ai in addrs: log.warning("cannot resolve %s: %s — retrying in %ds", host, e, _retry_delay)
conn = AsyncConnection(conn_id, ai[4][0], port, ai[0], iam) continue
if await conn.open(): for ai in addrs:
connections.append(conn) conn = AsyncConnection(conn_id, ai[4][0], port, ai[0], iam)
conn_id += 1 if await conn.open():
connections.append(conn)
conn_id += 1
if not connections:
await _sleep(_retry_delay)
_retry_delay = min(_retry_delay * 2, 60)
if not connections: if not connections:
log.error("no connections established")
return 1 return 1
# Boot / one-shot message # Boot / one-shot message
send_shutdown = False
if args.boot or args.message: if args.boot or args.message:
bmsg: Dict[str, Any] = {"acks": 0} bmsg: Dict[str, Any] = {"acks": 0}
if args.boot: if args.boot:
bmsg["boot"] = 1 bmsg["boot"] = 1
args.boot = False # don't repeat on restart
send_shutdown = True
if args.message: if args.message:
bmsg["service"] = "service" bmsg["service"] = "service"
bmsg["msg"] = args.message bmsg["msg"] = args.message
for c in connections: target = next((c for c in connections if c._transport), connections[0])
await c.sendto(bmsg) await target.sendto(bmsg)
break
if args.message and not args.daemon: if args.message and not args.daemon:
await asyncio.sleep(0.3) await asyncio.sleep(0.3)
for c in connections: for c in connections:
c.close() c.close()
return 0 return 0
# Per-protocol internet presence probe (shared with the network_monitor plugin)
net_cfg = dict((cfg.get("plugins") or {}).get("network_monitor") or cfg.get("network_monitor") or {})
_probe = _Connectivity.from_config(net_cfg) if net_cfg.get("probe_enabled", True) else None
plugins = await _load_plugins(cfg) plugins = await _load_plugins(cfg)
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
@@ -1074,8 +1669,13 @@ async def _async_main(args, cfg: Dict[str, Any]) -> int:
loop.add_signal_handler(signal.SIGHUP, _sighup) loop.add_signal_handler(signal.SIGHUP, _sighup)
if _probe is not None:
_active_tasks.append(asyncio.create_task(_probe.run()))
for conn in connections: for conn in connections:
_active_tasks.append(asyncio.create_task(_heartbeat_sender(conn, interval))) _active_tasks.append(asyncio.create_task(_heartbeat_sender(conn, interval, _probe)))
_active_tasks.append(asyncio.create_task(
_dns_refresher(connections, args.hosts, port, af_filter, iam, interval, _probe, conn_id)))
if plugins and connections: if plugins and connections:
_active_tasks.append(asyncio.create_task(_plugin_collector(connections[0], plugins))) _active_tasks.append(asyncio.create_task(_plugin_collector(connections[0], plugins)))
@@ -1086,12 +1686,12 @@ async def _async_main(args, cfg: Dict[str, Any]) -> int:
pass pass
log.info("shutting down") log.info("shutting down")
for conn in connections: target = next((c for c in connections if c._transport), connections[0] if connections else None)
if target and send_shutdown:
try: try:
await conn.sendto({"shutdown": 1, "acks": conn.ackcount}) await target.sendto({"shutdown": 1, "acks": target.ackcount})
except Exception: except Exception:
pass pass
break
for conn in connections: for conn in connections:
conn.close() conn.close()
await asyncio.sleep(0.3) await asyncio.sleep(0.3)
@@ -1118,6 +1718,9 @@ def main(argv=None):
parser.add_argument("-d", "--daemon", action="store_true", help="Run as daemon") parser.add_argument("-d", "--daemon", action="store_true", help="Run as daemon")
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("-x", "--debug", action="count", default=0, help="Debug level") parser.add_argument("-x", "--debug", action="count", default=0, help="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("hosts", nargs="+", help="HBD server(s)") parser.add_argument("hosts", nargs="+", help="HBD server(s)")
args = parser.parse_args(argv) args = parser.parse_args(argv)
@@ -1134,6 +1737,11 @@ def main(argv=None):
cfg = _load_config(args.configfile) cfg = _load_config(args.configfile)
global _allow_remote_command
_allow_remote_command = bool(cfg.get("allow_remote_command", False))
if _allow_remote_command:
logging.warning("allow_remote_command is true — CMD packets from the server will be executed")
if args.daemon: if args.daemon:
_daemonize() _daemonize()
_reconfigure_syslog(level) _reconfigure_syslog(level)
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
# PyInstaller spec for hbc_windows.exe
# Build with: pyinstaller hbc_windows.spec
#
# Requirements (on Windows):
# pip install pyinstaller
block_cipher = None
a = Analysis(
['hbc_windows.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=['tkinter', 'unittest', 'email', 'html', 'http', 'urllib', 'xml'],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zlib_archive, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='hbc_windows',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=None,
version=None,
)
+126
View File
@@ -0,0 +1,126 @@
#Requires -RunAsAdministrator
<#
.SYNOPSIS
Install hbc_windows.exe as a Windows Service using NSSM.
.DESCRIPTION
Installs the HeartBeat Client as a Windows Service that starts automatically.
Requires NSSM (Non-Sucking Service Manager) in PATH or alongside this script.
Requires hbc_windows.exe built via: pyinstaller hbc_windows.spec
.PARAMETER Server
HBD server hostname or IP address (required).
.PARAMETER ExePath
Path to hbc_windows.exe. Defaults to the directory containing this script.
.PARAMETER ServiceName
Windows service name. Default: heartbeat-client
.PARAMETER ConfigFile
Path to hbc.json config file. Optional.
.PARAMETER LogFile
Path to log file. Default: C:\ProgramData\heartbeat\hbc.log
.PARAMETER Interval
Heartbeat interval in seconds. Default: 10
.EXAMPLE
.\install_hbc_windows.ps1 -Server hbd.example.com
.\install_hbc_windows.ps1 -Server hbd.example.com -ConfigFile C:\ProgramData\heartbeat\hbc.json
#>
param(
[Parameter(Mandatory = $true)]
[string]$Server,
[string]$ExePath = "",
[string]$ServiceName = "heartbeat-client",
[string]$ConfigFile = "",
[string]$LogFile = "C:\ProgramData\heartbeat\hbc.log",
[int]$Interval = 10
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
# Locate hbc_windows.exe
if ($ExePath -eq "") {
$ExePath = Join-Path $PSScriptRoot "hbc_windows.exe"
}
if (-not (Test-Path $ExePath)) {
Write-Error "hbc_windows.exe not found at: $ExePath`nBuild it first with: pyinstaller hbc_windows.spec"
exit 1
}
# Locate NSSM
$nssm = Get-Command nssm -ErrorAction SilentlyContinue
if (-not $nssm) {
$nssmLocal = Join-Path $PSScriptRoot "nssm.exe"
if (Test-Path $nssmLocal) {
$nssm = $nssmLocal
} else {
Write-Error "nssm.exe not found in PATH or alongside this script.`nDownload from https://nssm.cc/download"
exit 1
}
} else {
$nssm = $nssm.Source
}
# Build argument list
$args_list = "--daemon $Server"
if ($ConfigFile -ne "") {
$args_list = "--daemon -c `"$ConfigFile`" $Server"
}
if ($LogFile -ne "") {
$args_list = "$args_list --log-file `"$LogFile`""
}
# Create data directory
$dataDir = "C:\ProgramData\heartbeat"
if (-not (Test-Path $dataDir)) {
New-Item -ItemType Directory -Path $dataDir | Out-Null
Write-Host "Created $dataDir"
}
# Remove existing service if present
$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($existing) {
Write-Host "Removing existing service '$ServiceName'..."
& $nssm stop $ServiceName 2>$null
& $nssm remove $ServiceName confirm
}
# Install service
Write-Host "Installing service '$ServiceName'..."
& $nssm install $ServiceName $ExePath $args_list
if ($LASTEXITCODE -ne 0) {
Write-Error "nssm install failed (exit $LASTEXITCODE)"
exit 1
}
# Configure service
& $nssm set $ServiceName DisplayName "HeartBeat Client"
& $nssm set $ServiceName Description "Sends heartbeat and plugin metrics to the HBD monitoring server."
& $nssm set $ServiceName Start SERVICE_AUTO_START
& $nssm set $ServiceName AppStdout (Join-Path $dataDir "nssm_stdout.log")
& $nssm set $ServiceName AppStderr (Join-Path $dataDir "nssm_stderr.log")
& $nssm set $ServiceName AppRotateFiles 1
& $nssm set $ServiceName AppRotateBytes 5242880
# Start service
Write-Host "Starting service '$ServiceName'..."
& $nssm start $ServiceName
if ($LASTEXITCODE -ne 0) {
Write-Warning "Service installed but failed to start — check logs in $dataDir"
} else {
Write-Host "Service '$ServiceName' started successfully."
Write-Host "Log file: $LogFile"
Write-Host ""
Write-Host "Useful commands:"
Write-Host " nssm status $ServiceName"
Write-Host " nssm stop $ServiceName"
Write-Host " nssm restart $ServiceName"
Write-Host " nssm remove $ServiceName confirm"
}
+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:")
+103
View File
@@ -0,0 +1,103 @@
"""Tests for the allow_remote_command gate on CMD packets in hbc."""
import asyncio
import subprocess
import pytest
from hbd.client import main as client_main
from hbd.client.config import CLIENT_DEFAULTS, load_config
class FakeConn:
"""Stand-in for AsyncConnection that records what the client sends back."""
def __init__(self):
self.sent = []
async def sendto(self, msg, msg_id="HTB"):
self.sent.append(msg)
@pytest.fixture
def conn(monkeypatch):
"""A fake connection with remote commands disabled and subprocess booby-trapped."""
monkeypatch.setattr(client_main, "allow_remote_command", False)
def explode(*a, **kw):
raise AssertionError("subprocess must not run when the command is refused")
monkeypatch.setattr(subprocess, "check_output", explode)
return FakeConn()
def test_default_is_false():
assert CLIENT_DEFAULTS["allow_remote_command"] is False
assert load_config("/nonexistent/hbc.yaml")["allow_remote_command"] is False
assert client_main.allow_remote_command is False
def test_refused_when_disabled(conn):
asyncio.run(client_main.handle_command(conn, {"cmd": "id"}))
assert conn.sent == [{
"service": "command",
"msg": "Refused: allow_remote_command is false",
}]
def test_refusal_reported_for_every_attempt(conn):
for _ in range(3):
asyncio.run(client_main.handle_command(conn, {"cmd": "rm -rf /"}))
assert len(conn.sent) == 3
def test_empty_command_is_ignored_either_way(conn, monkeypatch):
asyncio.run(client_main.handle_command(conn, {}))
monkeypatch.setattr(client_main, "allow_remote_command", True)
asyncio.run(client_main.handle_command(conn, {"cmd": ""}))
assert conn.sent == []
def test_executed_when_enabled(conn, monkeypatch):
monkeypatch.setattr(client_main, "allow_remote_command", True)
monkeypatch.setattr(subprocess, "check_output", lambda *a, **kw: b"uid=0(root)")
asyncio.run(client_main.handle_command(conn, {"cmd": "id"}))
assert conn.sent == [{"service": "command", "msg": "OK uid=0(root)"}]
def test_failure_reported_when_enabled(conn, monkeypatch):
monkeypatch.setattr(client_main, "allow_remote_command", True)
def fail(*a, **kw):
raise subprocess.CalledProcessError(1, "false")
monkeypatch.setattr(subprocess, "check_output", fail)
asyncio.run(client_main.handle_command(conn, {"cmd": "false"}))
assert len(conn.sent) == 1
assert conn.sent[0]["msg"].startswith("CalledProcessError ")
def test_config_file_can_opt_in(tmp_path):
cfg_file = tmp_path / "hbc.yaml"
cfg_file.write_text("allow_remote_command: true\n", encoding="utf-8")
assert load_config(str(cfg_file))["allow_remote_command"] is True
@pytest.mark.parametrize("configured, expected", [(True, True), (False, False), (None, False)])
def test_async_main_sets_the_flag_from_config(monkeypatch, configured, expected):
"""async_main must apply the config value before any CMD packet can arrive."""
monkeypatch.setattr(client_main, "allow_remote_command", not expected)
class Bail(Exception):
pass
# gethostname() is the first call after the flag is assigned — bail there so the
# test never opens a socket.
def bail():
raise Bail
monkeypatch.setattr(client_main.socket, "gethostname", bail)
cfg = {} if configured is None else {"allow_remote_command": configured}
with pytest.raises(Bail):
asyncio.run(client_main.async_main(object(), cfg))
assert client_main.allow_remote_command is expected
+119
View File
@@ -0,0 +1,119 @@
"""TTL-driven re-resolution of the server hostnames in the full client."""
import socket
import struct
from hbd.client import dnsttl
def _rr(rtype, ttl, rdata):
return b"\xc0\x0c" + struct.pack("!HHIH", rtype, 1, ttl, len(rdata)) + rdata
def _response(qid, qtype, answers, rcode=0):
q = dnsttl.build_query("hbd.example.com", qtype, qid)[12:]
return struct.pack("!HHHHHH", qid, 0x8180 | rcode, 1, len(answers), 0, 0) + q + b"".join(answers)
def test_min_ttl_covers_cname_and_address():
resp = _response(7, dnsttl.TYPE_A, [_rr(dnsttl.TYPE_CNAME, 60, b"\x01x\x00"),
_rr(dnsttl.TYPE_A, 300, b"\x01\x02\x03\x04")])
assert dnsttl.min_answer_ttl(resp, 7, dnsttl.TYPE_A) == 60
def test_no_answer_of_requested_type_is_unknown():
assert dnsttl.min_answer_ttl(_response(7, dnsttl.TYPE_AAAA, []), 7, dnsttl.TYPE_AAAA) is None
def test_error_rcode_and_foreign_id_are_unknown():
ans = [_rr(dnsttl.TYPE_A, 300, b"\x01\x02\x03\x04")]
assert dnsttl.min_answer_ttl(_response(7, 1, ans, rcode=3), 7, 1) is None
assert dnsttl.min_answer_ttl(_response(7, 1, ans), 8, 1) is None
def test_truncated_response_is_unknown():
assert dnsttl.min_answer_ttl(b"\x00\x07\x81", 7, 1) is None
def test_refresh_delay():
assert dnsttl.refresh_delay([300, 120, None]) == 120
assert dnsttl.refresh_delay([0]) == dnsttl.MIN_TTL
assert dnsttl.refresh_delay([None]) == dnsttl.DEFAULT_TTL
def test_ip_literal_has_no_ttl():
assert dnsttl.lookup_ttl("192.0.2.1") is None
assert dnsttl.lookup_ttl("2001:db8::1") is None
class _C:
def __init__(self, addr, af=socket.AF_INET):
self.addr, self.af = addr, af
def set_target(self, addr, af):
self.addr, self.af = addr, af
V4, V6 = socket.AF_INET, socket.AF_INET6
def test_retarget_follows_address_change_in_place():
first, second = _C("1.1.1.1"), _C("2001:db8::1", V6)
new, gone = dnsttl.retarget([first, second], [(V4, "9.9.9.9"), (V6, "2001:db8::9")])
assert (new, gone) == ([], [])
assert (first.addr, second.addr) == ("9.9.9.9", "2001:db8::9")
def test_retarget_unchanged_is_a_noop():
conns = [_C("1.1.1.1")]
assert dnsttl.retarget(conns, [(V4, "1.1.1.1")]) == ([], [])
def test_retarget_reports_surplus_new_and_gone():
a, b = _C("1.1.1.1"), _C("2.2.2.2")
new, gone = dnsttl.retarget([a, b], [(V4, "3.3.3.3")])
assert new == [] and gone == [b] and a.addr == "3.3.3.3"
new, gone = dnsttl.retarget([a], [(V4, "3.3.3.3"), (V4, "4.4.4.4")])
assert new == [(V4, "4.4.4.4")] and gone == []
def test_retarget_never_drops_the_first_connection():
a, b = _C("1.1.1.1"), _C("2.2.2.2")
new, gone = dnsttl.retarget([a, b], [(V4, "2.2.2.2")])
assert new == [] and gone == [b] and a.addr == "2.2.2.2"
def test_resolve_failure_returns_none(monkeypatch):
def boom(*a):
raise socket.gaierror("nope")
monkeypatch.setattr(socket, "getaddrinfo", boom)
assert dnsttl.resolve(["x.invalid"], 1, 0) is None
def test_refresher_follows_a_change_and_adds_a_family(monkeypatch):
import asyncio
from hbd.client import main as hbc
answers = [[(V4, "9.9.9.9"), (V6, "2001:db8::9")], None, [(V4, "9.9.9.9")]]
monkeypatch.setattr(dnsttl, "resolve", lambda *a: answers.pop(0) if len(answers) > 1 else answers[0])
monkeypatch.setattr(dnsttl, "lookup_ttl", lambda *a: None)
monkeypatch.setattr(dnsttl, "DEFAULT_TTL", 0.01)
monkeypatch.setattr(dnsttl, "FAILED_RETRY", 0.01)
async def idle(*a):
await asyncio.sleep(10)
monkeypatch.setattr(hbc, "heartbeat_sender", idle)
async def go():
hbc.shutdown_event = asyncio.Event()
hbc.active_tasks = []
conns = [hbc.AsyncConnection(1, "1.1.1.1", 5, V4, "me")]
task = asyncio.create_task(hbc.dns_refresher(conns, ["h"], 5, 0, "me", 10, None, 2))
await asyncio.sleep(0.2)
hbc.shutdown_event.set()
await task
for t in hbc.active_tasks:
t.cancel()
return conns
conns = asyncio.run(go())
assert [(c.conn_id, c.addr) for c in conns] == [(1, "9.9.9.9")]
+28
View File
@@ -0,0 +1,28 @@
from hbd.client.main import build_heartbeat_msg
class _Conn:
ackcount = 3
rtts = [0, 12.5]
class _Conn2:
def __init__(self, flags):
self._flags = flags
def flags(self):
return self._flags
def test_heartbeat_without_connectivity_is_unchanged():
assert build_heartbeat_msg(_Conn(), 60) == {"acks": 3, "rtt": 12.5, "interval": 60}
def test_heartbeat_carries_presence_flags():
msg = build_heartbeat_msg(_Conn(), 60, _Conn2({"p4": True, "p6": False}))
assert msg == {"acks": 3, "rtt": 12.5, "interval": 60, "p4": True, "p6": False}
def test_heartbeat_omits_unknown_flags():
msg = build_heartbeat_msg(_Conn(), 60, _Conn2({}))
assert "p4" not in msg and "p6" not in msg
+247
View File
@@ -0,0 +1,247 @@
"""Tests for ownership rules and scoped config merges (hbd.server.config_access)."""
import pytest
from hbd.server import config_access as ca
# ---------------------------------------------------------------------------
# Visibility helpers
# ---------------------------------------------------------------------------
def test_is_global_when_no_owner():
assert ca.is_global({"type": "pushover"})
assert ca.is_global({"type": "email", "owner": ""})
assert ca.is_global(None) # non-dict is treated as global
assert not ca.is_global({"type": "email", "owner": "alice"})
def test_user_can_use_global_or_own():
assert ca.user_can_use({"type": "pushover"}, "alice")
assert ca.user_can_use({"owner": "alice"}, "alice")
assert not ca.user_can_use({"owner": "bob"}, "alice")
HOSTS = {
"web1": {"owner": "alice", "watch": True},
"web2": {"owner": "bob", "managers": ["alice"]},
"web3": {"owner": "bob", "managers": "carol"}, # string manager form
"web4": {"watch": True}, # unowned
}
def test_user_hosts_owner_and_manager():
assert set(ca.user_hosts(HOSTS, "alice")) == {"web1", "web2"}
assert set(ca.user_hosts(HOSTS, "carol")) == {"web3"}
assert ca.user_hosts(HOSTS, "dave") == {}
assert ca.user_hosts(None, "alice") == {}
CHANNELS = {
"global_ch": {"type": "pushover"},
"alice_ch": {"type": "email", "owner": "alice"},
"bob_ch": {"type": "signal", "owner": "bob"},
}
def test_user_channels_global_plus_own():
assert set(ca.user_channels(CHANNELS, "alice")) == {"global_ch", "alice_ch"}
assert set(ca.user_channels(CHANNELS, "dave")) == {"global_ch"}
TCS = {
"default": {"thresholds": {}},
"alice_tc": {"owner": "alice", "thresholds": {}},
}
def test_user_threshold_configs_global_plus_own():
assert set(ca.user_threshold_configs(TCS, "alice")) == {"default", "alice_tc"}
assert set(ca.user_threshold_configs(TCS, "bob")) == {"default"}
# ---------------------------------------------------------------------------
# merge_hosts_scoped
# ---------------------------------------------------------------------------
MERGE_HOSTS = {
"mine": {"owner": "alice", "watch": True, "notification_channels": ["global_ch"]},
"managed": {"owner": "bob", "managers": ["alice"], "watch": True,
"notification_channels": ["bob_ch"]},
"foreign": {"owner": "bob", "watch": False},
}
MERGE_CHANNELS = {
"global_ch": {"type": "pushover"},
"alice_ch": {"type": "email", "owner": "alice"},
"bob_ch": {"type": "signal", "owner": "bob"},
}
MERGE_TCS = {"alice_tc": {"owner": "alice"}, "bob_tc": {"owner": "bob"}}
def _merge_hosts(payload, existing=None):
return ca.merge_hosts_scoped(
existing if existing is not None else dict(MERGE_HOSTS),
payload, "alice", MERGE_CHANNELS, MERGE_TCS,
)
def _full_payload(**overrides):
"""Payload covering alice's full visible subset, with per-host overrides."""
p = {
"mine": {"owner": "alice", "watch": True, "notification_channels": ["global_ch"]},
"managed": {"watch": True, "notification_channels": ["bob_ch"]},
}
p.update(overrides)
return p
def test_merge_hosts_preserves_foreign_hosts():
result = _merge_hosts(_full_payload())
assert result["foreign"] == {"owner": "bob", "watch": False}
def test_merge_hosts_manager_edits_settings():
result = _merge_hosts(_full_payload(managed={"watch": False, "dyndns": True,
"notification_channels": ["bob_ch"]}))
assert result["managed"]["watch"] is False
assert result["managed"]["dyndns"] is True
# access fields carried over untouched
assert result["managed"]["owner"] == "bob"
assert result["managed"]["managers"] == ["alice"]
def test_merge_hosts_manager_cannot_change_owner():
with pytest.raises(ca.ScopedMergeError, match="managed"):
_merge_hosts(_full_payload(managed={"watch": True, "owner": "alice"}))
def test_merge_hosts_manager_cannot_change_managers():
with pytest.raises(ca.ScopedMergeError, match="managers"):
_merge_hosts(_full_payload(managed={"watch": True, "managers": ["alice", "carol"]}))
def test_merge_hosts_manager_same_access_values_ok():
# Submitting unchanged access fields is not a violation.
result = _merge_hosts(_full_payload(managed={"watch": True, "owner": "bob",
"managers": ["alice"],
"notification_channels": ["bob_ch"]}))
assert result["managed"]["owner"] == "bob"
def test_merge_hosts_manager_cannot_delete():
payload = {"mine": {"owner": "alice", "watch": True}} # 'managed' missing
with pytest.raises(ca.ScopedMergeError, match="managed"):
_merge_hosts(payload)
def test_merge_hosts_owner_can_delete():
payload = _full_payload()
del payload["mine"]
result = _merge_hosts(payload)
assert "mine" not in result
assert "managed" in result and "foreign" in result
def test_merge_hosts_owner_can_transfer_ownership():
result = _merge_hosts(_full_payload(mine={"owner": "bob", "watch": True}))
assert result["mine"]["owner"] == "bob"
def test_merge_hosts_new_host_owner_forced():
result = _merge_hosts(_full_payload(newhost={"watch": True, "owner": "bob"}))
assert result["newhost"]["owner"] == "alice"
def test_merge_hosts_cannot_touch_foreign_host():
with pytest.raises(ca.ScopedMergeError, match="foreign"):
_merge_hosts(_full_payload(foreign={"watch": True}))
def test_merge_hosts_cannot_add_foreign_private_channel():
with pytest.raises(ca.ScopedMergeError, match="bob_ch"):
_merge_hosts(_full_payload(mine={"owner": "alice", "watch": True,
"notification_channels": ["bob_ch"]}))
def test_merge_hosts_keeps_preexisting_foreign_assignment():
# 'managed' already has bob_ch; keeping it is fine.
result = _merge_hosts(_full_payload())
assert result["managed"]["notification_channels"] == ["bob_ch"]
def test_merge_hosts_can_add_global_and_own_channel():
result = _merge_hosts(_full_payload(mine={"owner": "alice", "watch": True,
"notification_channels": ["global_ch", "alice_ch"]}))
assert result["mine"]["notification_channels"] == ["global_ch", "alice_ch"]
def test_merge_hosts_threshold_assignment_validated():
with pytest.raises(ca.ScopedMergeError, match="bob_tc"):
_merge_hosts(_full_payload(mine={"owner": "alice", "watch": True,
"threshold_config": ["bob_tc"]}))
result = _merge_hosts(_full_payload(mine={"owner": "alice", "watch": True,
"threshold_config": ["default", "alice_tc"]}))
assert result["mine"]["threshold_config"] == ["default", "alice_tc"]
def test_merge_hosts_rejects_unknown_fields():
with pytest.raises(ca.ScopedMergeError, match="sneaky"):
_merge_hosts(_full_payload(mine={"owner": "alice", "watch": True, "sneaky": 1}))
def test_merge_hosts_owner_clearing_list_removes_key():
result = _merge_hosts(_full_payload(mine={"owner": "alice", "watch": True}))
assert "notification_channels" not in result["mine"]
# ---------------------------------------------------------------------------
# merge_threshold_configs_scoped
# ---------------------------------------------------------------------------
MERGE_EXISTING_TCS = {
"default": {"thresholds": {"cpu": {"load": {"warning": 2}}}},
"alice_tc": {"owner": "alice", "thresholds": {"cpu": {"load": {"warning": 3}}}},
"bob_tc": {"owner": "bob", "thresholds": {}},
"global_tc": {"thresholds": {}},
}
def _merge_tcs(payload):
return ca.merge_threshold_configs_scoped(dict(MERGE_EXISTING_TCS), payload, "alice")
def test_merge_tcs_new_config_owner_forced():
result = _merge_tcs({"alice_tc": {"thresholds": {}},
"new_tc": {"thresholds": {}, "owner": "bob"}})
assert result["new_tc"]["owner"] == "alice"
def test_merge_tcs_edit_own():
result = _merge_tcs({"alice_tc": {"thresholds": {"mem": {"used": {"warning": 90}}}}})
assert result["alice_tc"]["thresholds"] == {"mem": {"used": {"warning": 90}}}
assert result["alice_tc"]["owner"] == "alice"
def test_merge_tcs_delete_own_when_missing():
result = _merge_tcs({})
assert "alice_tc" not in result
def test_merge_tcs_preserves_global_and_foreign():
result = _merge_tcs({"alice_tc": {"thresholds": {}}})
assert result["default"] == MERGE_EXISTING_TCS["default"]
assert result["bob_tc"] == MERGE_EXISTING_TCS["bob_tc"]
assert result["global_tc"] == MERGE_EXISTING_TCS["global_tc"]
def test_merge_tcs_rejects_default():
with pytest.raises(ca.ScopedMergeError, match="default"):
_merge_tcs({"alice_tc": {"thresholds": {}}, "default": {"thresholds": {}}})
def test_merge_tcs_rejects_global_name_collision():
with pytest.raises(ca.ScopedMergeError, match="global_tc"):
_merge_tcs({"alice_tc": {"thresholds": {}}, "global_tc": {"thresholds": {}}})
def test_merge_tcs_rejects_foreign_owned():
with pytest.raises(ca.ScopedMergeError, match="bob_tc"):
_merge_tcs({"alice_tc": {"thresholds": {}}, "bob_tc": {"thresholds": {}}})
+160
View File
@@ -0,0 +1,160 @@
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"))
+173
View File
@@ -0,0 +1,173 @@
import asyncio
from hbd.client.connectivity import Connectivity, ProbeUnavailable
from hbd.client.pingutil import PingUnavailable
class Net:
"""Scriptable network: addr None = no route, 'unavailable' = family unsupported."""
def __init__(self):
self.addr = {"ipv4": "192.0.2.5", "ipv6": "2001:db8::5"}
self.gw = {"ipv4": "192.0.2.1", "ipv6": "fe80::1"}
self.rtt = {"ipv4": 10.0, "ipv6": 20.0} # None = ping fails, 'unavailable' = no ping binary
self.pings = []
self.now = 1000.0
def local(self, fam, target):
a = self.addr[fam]
if a == "unavailable":
raise ProbeUnavailable("no such family")
return a
async def ping(self, target, fam):
self.pings.append(fam)
r = self.rtt[fam]
if r == "unavailable":
raise PingUnavailable("no ping")
return r
async def gateway(self, fam):
return self.gw[fam]
def clock(self):
return self.now
def make(net, **kw):
c = Connectivity(local_address_fn=net.local, ping_fn=net.ping, gateway_fn=net.gateway,
clock=net.clock, **kw)
c._cheap_gateway = True
return c
def refresh(c):
asyncio.run(c.refresh())
def test_present_with_addr_gateway_rtt():
net = Net()
c = make(net)
refresh(c)
s = c.snapshot()["ipv4"]
assert (s.present, s.addr, s.gateway, s.rtt) == (True, "192.0.2.5", "192.0.2.1", 10.0)
assert c.flags() == {"p4": True, "p6": True}
assert c.as_metric()["ipv6"]["gateway"] == "fe80::1"
def test_no_route_is_immediately_absent():
net = Net()
net.addr["ipv6"] = None
c = make(net)
refresh(c)
assert c.flags() == {"p4": True, "p6": False}
assert net.pings == ["ipv4"] # no ping wasted on a family without a route
assert c.snapshot()["ipv6"].addr is None
def test_ping_is_debounced_and_rate_limited():
net = Net()
c = make(net, probe_interval=60.0, retry_interval=10.0)
refresh(c)
refresh(c) # same instant: cached, no second ping
assert net.pings == ["ipv4", "ipv6"]
net.now += 60
net.rtt["ipv6"] = None # first failure: still present (debounce)
refresh(c)
assert c.flags()["p6"] is True
net.now += 10 # retry_interval elapsed: second failure flips it
refresh(c)
assert c.flags()["p6"] is False
def test_first_probe_failing_once_is_unknown():
net = Net()
net.rtt["ipv4"] = None
c = make(net)
refresh(c)
assert "p4" not in c.flags() and c.flags()["p6"] is True
def test_recovers_on_first_success():
net = Net()
net.rtt["ipv6"] = None
c = make(net, retry_interval=10.0)
refresh(c)
net.now += 10
refresh(c)
assert c.flags()["p6"] is False
net.rtt["ipv6"] = 25.0
net.now += 10
refresh(c)
assert c.flags()["p6"] is True
def test_address_change_invalidates_cache():
net = Net()
c = make(net)
refresh(c)
net.addr["ipv4"] = "198.51.100.7" # moved networks, no time has passed
refresh(c)
assert net.pings.count("ipv4") == 2
assert c.snapshot()["ipv4"].addr == "198.51.100.7"
def test_gateway_change_invalidates_cache():
net = Net()
c = make(net)
refresh(c)
net.gw["ipv4"] = "192.0.2.254"
refresh(c)
assert net.pings.count("ipv4") == 2
def test_route_loss_then_return_pings_immediately():
net = Net()
c = make(net)
refresh(c)
net.addr["ipv6"] = None
refresh(c)
net.addr["ipv6"] = "2001:db8::5"
refresh(c)
assert net.pings.count("ipv6") == 2 and c.flags()["p6"] is True
def test_missing_ping_binary_leaves_presence_unknown_and_stops_retrying():
net = Net()
net.rtt["ipv4"] = "unavailable"
c = make(net)
refresh(c)
net.now += 600
refresh(c)
assert "p4" not in c.flags()
assert net.pings.count("ipv4") == 1
def test_unsupported_family_is_unknown():
net = Net()
net.addr["ipv6"] = "unavailable"
c = make(net)
refresh(c)
assert "p6" not in c.flags() and c.flags()["p4"] is True
def test_from_config():
c = Connectivity.from_config({"probe_v4": "9.9.9.9", "probe_interval": 30})
assert c.probes["ipv4"] == "9.9.9.9"
assert c.probes["ipv6"] == "2606:4700:4700::1111"
assert c.probe_interval == 30.0
def test_ping_less_family_stays_unknown_after_route_returns():
net = Net()
net.rtt["ipv4"] = "unavailable"
c = make(net)
refresh(c)
assert "p4" not in c.flags()
net.addr["ipv4"] = None
refresh(c)
assert c.flags()["p4"] is False # no route is definitive
net.addr["ipv4"] = "192.0.2.5"
refresh(c)
assert "p4" not in c.flags()
assert net.pings.count("ipv4") == 1 # missing binary is not retried
+37 -82
View File
@@ -1,9 +1,8 @@
import time import asyncio
import queue
import unittest import unittest
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
import hbd.dns as dns import hbd.server.dns as dns
class TestDNS(unittest.TestCase): class TestDNS(unittest.TestCase):
@@ -17,7 +16,7 @@ class TestDNS(unittest.TestCase):
p = dns.create_nsupdate_payload("host", "2001:db8::1", "example") p = dns.create_nsupdate_payload("host", "2001:db8::1", "example")
self.assertIn("update add host.dy.example 5 AAAA 2001:db8::1", p) self.assertIn("update add host.dy.example 5 AAAA 2001:db8::1", p)
@patch("hbd.dns.Popen") @patch("hbd.server.dns.Popen")
def test_nsupdate_success(self, mock_popen): def test_nsupdate_success(self, mock_popen):
proc = MagicMock() proc = MagicMock()
proc.communicate.return_value = (b"status: NOERROR", None) proc.communicate.return_value = (b"status: NOERROR", None)
@@ -41,7 +40,7 @@ class TestDNS(unittest.TestCase):
stderr=dns.STDOUT, stderr=dns.STDOUT,
) )
@patch("hbd.dns.Popen") @patch("hbd.server.dns.Popen")
def test_nsupdate_failure(self, mock_popen): def test_nsupdate_failure(self, mock_popen):
proc = MagicMock() proc = MagicMock()
proc.communicate.return_value = (b"some error", None) proc.communicate.return_value = (b"some error", None)
@@ -57,83 +56,7 @@ class TestDNS(unittest.TestCase):
self.assertIsNotNone(err) self.assertIsNotNone(err)
self.assertIn("some error", err) self.assertIn("some error", err)
def test_dnsupdatethread_processes_queue(self): @patch("hbd.server.dns.Popen")
# patch nsupdate to succeed
with patch("hbd.dns.nsupdate", return_value=None):
logs = []
def log(h, m):
logs.append((h, m))
emails = []
def email(s, m):
emails.append((s, m))
class FakeHost:
dnsQ = queue.Queue()
class FakeHbd:
Host = FakeHost
# start the thread (daemon) that processes the queue
t = dns.start_dns_thread(
FakeHbd, {"dyndomains": ["example"]}, log=log, email=email
)
self.assertTrue(t.is_alive())
# enqueue one item and wait for it to be processed (polling with timeout)
FakeHbd.Host.dnsQ.put(("testhost", "1.2.3.4"))
for _ in range(30):
if logs:
break
time.sleep(0.1)
self.assertTrue(logs, "dnsupdatethread did not call log")
self.assertTrue(
any("changed address" in m or "DNS updated" in m for (_h, m) in logs)
)
def test_dnsupdatethread_calls_email_on_failure(self):
# patch nsupdate to fail with an error message
with patch("hbd.dns.nsupdate", return_value="error: failed"):
logs = []
def log(h, m):
logs.append((h, m))
emails = []
def email(s, m):
emails.append((s, m))
class FakeHost:
dnsQ = queue.Queue()
class FakeHbd:
Host = FakeHost
dns.start_dns_thread(
FakeHbd, {"dyndomains": ["example"]}, log=log, email=email
)
# enqueue and wait for the email to be sent
FakeHbd.Host.dnsQ.put(("testhost", "1.2.3.4"))
for _ in range(30):
if emails:
break
time.sleep(0.1)
self.assertTrue(emails, "dnsupdatethread did not call email on failure")
self.assertTrue(
any(
"nsupdate failed" in s or "nsupdate failed" in m or "error" in m
for (s, m) in emails
)
)
@patch("hbd.dns.Popen")
def test_nsupdate_raises_oserror(self, mock_popen): def test_nsupdate_raises_oserror(self, mock_popen):
mock_popen.side_effect = OSError("noexec") mock_popen.side_effect = OSError("noexec")
err = dns.nsupdate( err = dns.nsupdate(
@@ -147,5 +70,37 @@ class TestDNS(unittest.TestCase):
self.assertIn("execution failed", err) self.assertIn("execution failed", err)
class TestDNSWorker(unittest.IsolatedAsyncioTestCase):
async def _run_worker(self, nsupdate_result):
"""Feed one update through dns_update_worker; returns the (name, level, message) log calls."""
logs = []
queue = asyncio.Queue()
await queue.put(("testhost", "1.2.3.4"))
await queue.put(None)
with patch("hbd.server.dns.nsupdate", return_value=nsupdate_result):
await dns.dns_update_worker(
None, {"dyndomains": ["example"]}, async_queue=queue,
log=lambda name, level, msg: logs.append((name, level, msg)),
)
return logs
async def test_worker_logs_successful_update(self):
logs = await self._run_worker(None)
self.assertEqual(len(logs), 1)
name, level, msg = logs[0]
self.assertEqual((name, level), ("testhost", "INFO"))
self.assertIn("DNS updated testhost.dy.example", msg)
self.assertIn("1.2.3.4", msg)
async def test_worker_logs_error_on_failed_update(self):
logs = await self._run_worker("error: failed")
self.assertEqual(len(logs), 1)
name, level, msg = logs[0]
self.assertEqual((name, level), ("testhost", "ERROR"))
self.assertIn("DNS update failed", msg)
self.assertIn("error: failed", msg)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

Some files were not shown because too many files have changed in this diff Show More