Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13f9916693 | ||
|
|
80b830fb07 | ||
|
|
6790ff53a8 | ||
|
|
445ff6b474 | ||
|
|
748349a7b9 | ||
|
|
85c6a4eebe | ||
|
|
952adb68f8 |
@@ -2,6 +2,15 @@
|
|||||||
|
|
||||||
All notable changes to this project are documented here, organized by release.
|
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]
|
## [5.4.6]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ 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.
|
Tests live in `tests/` (pytest, imported as a package). A second directory `test/` contains only TLS fixtures (`.pem` files), not test code.
|
||||||
|
|
||||||
Line length: 111 characters (`black`, `flake8`, both configured).
|
Line length: `black` and `flake8` both use 165 characters (set in `pyproject.toml`, `tox.ini` and `.flake8`).
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ A lightweight UDP-based host monitoring system. Monitored hosts run a client (`h
|
|||||||
└────────────────────┘ └────────────────────────────┘
|
└────────────────────┘ └────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
**Package:** `hbd` v5.4.6
|
**Package:** `hbd` v5.4.7
|
||||||
**Python:** 3.11+
|
**Python:** 3.11+
|
||||||
|
|
||||||
### Subpackages
|
### Subpackages
|
||||||
|
|||||||
+1
-1
@@ -14,4 +14,4 @@ Install options:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
__all__ = ["__version__"]
|
__all__ = ["__version__"]
|
||||||
__version__ = "5.4.6"
|
__version__ = "5.4.7"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -2,16 +2,16 @@
|
|||||||
|
|
||||||
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
|
||||||
|
|||||||
+3
-3
@@ -16,7 +16,7 @@ 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
|
||||||
@@ -36,6 +36,7 @@ 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] = []
|
||||||
|
|
||||||
@@ -572,7 +573,7 @@ async def cleanup(connections: List[AsyncConnection]):
|
|||||||
|
|
||||||
async def async_main(args, config):
|
async def async_main(args, config):
|
||||||
"""Async main function."""
|
"""Async main function."""
|
||||||
global running, shutdown_event, active_tasks, send_shutdown, allow_remote_command
|
global shutdown_event, active_tasks, send_shutdown, allow_remote_command
|
||||||
|
|
||||||
# Create shutdown event
|
# Create shutdown event
|
||||||
shutdown_event = asyncio.Event()
|
shutdown_event = asyncio.Event()
|
||||||
@@ -838,7 +839,6 @@ 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)
|
||||||
|
|||||||
@@ -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):
|
||||||
@@ -256,7 +256,7 @@ class PluginRegistry:
|
|||||||
"""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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -30,7 +28,7 @@ class CPUMonitorPlugin(MonitorPlugin):
|
|||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ class DiskMonitorPlugin(MonitorPlugin):
|
|||||||
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)."""
|
||||||
@@ -114,7 +114,7 @@ class DiskMonitorPlugin(MonitorPlugin):
|
|||||||
|
|
||||||
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 = {}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -98,8 +98,8 @@ class FilesystemInfoPlugin(InfoPlugin):
|
|||||||
|
|
||||||
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
|
||||||
@@ -112,7 +112,7 @@ class FilesystemInfoPlugin(InfoPlugin):
|
|||||||
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,
|
||||||
|
|||||||
@@ -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 MonitorPlugin
|
from hbd.client.plugin import MonitorPlugin
|
||||||
|
|
||||||
@@ -32,6 +32,7 @@ def _zfs_arc_bytes() -> int:
|
|||||||
pass
|
pass
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -115,7 +116,7 @@ class MemoryMonitorPlugin(MonitorPlugin):
|
|||||||
|
|
||||||
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()
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ class NagiosRunnerPlugin(MonitorPlugin):
|
|||||||
Returns:
|
Returns:
|
||||||
Dictionary with results from all plugins
|
Dictionary with results from all plugins
|
||||||
"""
|
"""
|
||||||
results = {}
|
results: Dict[str, Any] = {}
|
||||||
|
|
||||||
for cmd_config in self.commands:
|
for cmd_config in self.commands:
|
||||||
name = cmd_config.get("name")
|
name = cmd_config.get("name")
|
||||||
@@ -181,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}", {}
|
||||||
@@ -225,7 +226,7 @@ class NagiosRunnerPlugin(MonitorPlugin):
|
|||||||
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:
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ 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
|
from hbd.client.connectivity import get_shared
|
||||||
@@ -63,7 +63,7 @@ class NetworkMonitorPlugin(MonitorPlugin):
|
|||||||
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)."""
|
||||||
@@ -72,7 +72,7 @@ class NetworkMonitorPlugin(MonitorPlugin):
|
|||||||
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:
|
||||||
@@ -124,7 +124,7 @@ class NetworkMonitorPlugin(MonitorPlugin):
|
|||||||
|
|
||||||
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:
|
||||||
@@ -169,7 +169,7 @@ class NetworkMonitorPlugin(MonitorPlugin):
|
|||||||
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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -67,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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+2
-3
@@ -8,7 +8,7 @@ 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
|
||||||
|
|
||||||
@@ -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)
|
||||||
@@ -159,4 +159,3 @@ def decode_plugin_data(msg: bytes) -> Dict[str, Any]:
|
|||||||
Dictionary with 'ID', 'plugin', and plugin data fields
|
Dictionary with 'ID', 'plugin', and plugin data fields
|
||||||
"""
|
"""
|
||||||
return stodict(msg)
|
return stodict(msg)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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})...")
|
||||||
|
|||||||
+9
-12
@@ -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
|
||||||
@@ -43,10 +43,10 @@ SERVER_DEFAULTS = {
|
|||||||
|
|
||||||
# Host management
|
# Host management
|
||||||
"hosts": {}, # Unified host definitions
|
"hosts": {}, # Unified host definitions
|
||||||
"dyndomains": ["example.org"], # Domains to update via nsupdate when a host with dyndns: true is updated
|
"dyndomains": ["example.org"], # Domains to update via nsupdate when a host with dyndns: true is updated
|
||||||
|
|
||||||
# DNS updates
|
# DNS updates
|
||||||
"nsupdate_bin": "/usr/bin/nsupdate", # Path to nsupdate binary
|
"nsupdate_bin": "/usr/bin/nsupdate", # Path to nsupdate binary
|
||||||
|
|
||||||
# WebSocket settings
|
# WebSocket settings
|
||||||
"ws_port": 50005,
|
"ws_port": 50005,
|
||||||
@@ -80,7 +80,7 @@ THRESHOLD_DEFAULTS = {
|
|||||||
'critical': 90.0
|
'critical': 90.0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'memory_monitor': {
|
'memory_monitor': {
|
||||||
'memory_percent': {
|
'memory_percent': {
|
||||||
'warning': 85.0,
|
'warning': 85.0,
|
||||||
'critical': 95.0
|
'critical': 95.0
|
||||||
@@ -90,7 +90,7 @@ THRESHOLD_DEFAULTS = {
|
|||||||
'critical': 75.0
|
'critical': 75.0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'disk_monitor': {
|
'disk_monitor': {
|
||||||
'partitions': {
|
'partitions': {
|
||||||
'/': {
|
'/': {
|
||||||
'percent': {
|
'percent': {
|
||||||
@@ -100,18 +100,18 @@ 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': {
|
'nagios_runner': {
|
||||||
'status_code': {
|
'status_code': {
|
||||||
'display': '{check_name} {output}',
|
'display': '{check_name} {output}',
|
||||||
'operator': "nagios"
|
'operator': "nagios"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'zfs_monitor': {
|
'zfs_monitor': {
|
||||||
'pools': {
|
'pools': {
|
||||||
'*': {
|
'*': {
|
||||||
'status': {
|
'status': {
|
||||||
@@ -205,9 +205,6 @@ class ReloadableConfig:
|
|||||||
# 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}")
|
||||||
@@ -319,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):
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ def _make_yaml() -> YAML:
|
|||||||
y.preserve_quotes = True
|
y.preserve_quotes = True
|
||||||
return y
|
return y
|
||||||
|
|
||||||
|
|
||||||
# Top-level keys managed by the 'server' logical section
|
# Top-level keys managed by the 'server' logical section
|
||||||
_SERVER_KEYS = [
|
_SERVER_KEYS = [
|
||||||
"hbd_port", "hbd_host", "ws_port", "wss_port", "hb_port",
|
"hbd_port", "hbd_host", "ws_port", "wss_port", "hb_port",
|
||||||
|
|||||||
+3
-1
@@ -1,4 +1,6 @@
|
|||||||
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):
|
||||||
|
|||||||
+5
-5
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
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
|
import logging
|
||||||
|
|
||||||
@@ -79,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.
|
||||||
@@ -164,7 +164,7 @@ async def dns_update_worker(
|
|||||||
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.
|
||||||
@@ -176,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."""
|
||||||
|
|||||||
@@ -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,7 +25,7 @@ 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"
|
||||||
@@ -224,7 +225,7 @@ class Connection:
|
|||||||
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
|
||||||
@@ -278,8 +279,8 @@ class Connection:
|
|||||||
#
|
#
|
||||||
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
|
||||||
|
|||||||
+1
-1
@@ -109,6 +109,7 @@ def _insert_threshold_metric(thresholds: dict, metric_path: str, values: dict) -
|
|||||||
else:
|
else:
|
||||||
thresholds[plugin].setdefault(intermediate, {})[leaf] = cfg
|
thresholds[plugin].setdefault(intermediate, {})[leaf] = cfg
|
||||||
|
|
||||||
|
|
||||||
def _render_template(html_str: str, **context) -> str:
|
def _render_template(html_str: str, **context) -> str:
|
||||||
tmpl = jinja2.Template(html_str)
|
tmpl = jinja2.Template(html_str)
|
||||||
return tmpl.render(**context)
|
return tmpl.render(**context)
|
||||||
@@ -1132,7 +1133,6 @@ async def start(
|
|||||||
elif is_mon:
|
elif is_mon:
|
||||||
monitored.append(hostname)
|
monitored.append(hostname)
|
||||||
|
|
||||||
|
|
||||||
# Resolve notification channel configs for display
|
# Resolve notification channel configs for display
|
||||||
notif_channels = []
|
notif_channels = []
|
||||||
if current_user:
|
if current_user:
|
||||||
|
|||||||
@@ -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, Callable, Iterable, List, Tuple, Union
|
from typing import Dict, Any, Optional, Callable, Iterable, List, TextIO, Tuple, Union
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ class MessageJournal:
|
|||||||
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
|
||||||
@@ -88,8 +88,8 @@ class MessageJournal:
|
|||||||
|
|
||||||
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:
|
||||||
@@ -200,7 +200,7 @@ class MessageJournal:
|
|||||||
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')
|
||||||
|
|||||||
+1
-2
@@ -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
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ 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
|
||||||
@@ -25,4 +23,3 @@ async def cleanup_connections(hbdclass):
|
|||||||
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()
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ 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
|
||||||
@@ -49,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)
|
||||||
|
|
||||||
@@ -103,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()
|
||||||
@@ -331,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:
|
||||||
@@ -341,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", "")
|
||||||
@@ -359,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,
|
||||||
|
|||||||
+1
-1
@@ -128,7 +128,7 @@ def get_providers(config: dict) -> list[ResolvedProvider]:
|
|||||||
Entries with missing required fields or unknown types are skipped with
|
Entries with missing required fields or unknown types are skipped with
|
||||||
a warning log. Order follows config declaration order.
|
a warning log. Order follows config declaration order.
|
||||||
"""
|
"""
|
||||||
result = []
|
result: list = []
|
||||||
oauth_cfg = config.get("oauth", {})
|
oauth_cfg = config.get("oauth", {})
|
||||||
if not isinstance(oauth_cfg, dict):
|
if not isinstance(oauth_cfg, dict):
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -420,7 +420,7 @@ def get_settings_sections(config: dict, threshold_checker=None, user=None) -> li
|
|||||||
"fields": [
|
"fields": [
|
||||||
field("journal_enabled", "Enabled", "boolean",
|
field("journal_enabled", "Enabled", "boolean",
|
||||||
"Turn journalling on or off.", editable=True),
|
"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.", editable=True),
|
"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)."),
|
||||||
|
|||||||
+14
-11
@@ -13,13 +13,14 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Dict, List, Any, Optional, Tuple, Callable
|
from typing import Dict, List, Any, Optional, Tuple
|
||||||
from . import notify as notify_mod
|
from . import notify as notify_mod
|
||||||
from .config import THRESHOLD_DEFAULTS
|
from .config import THRESHOLD_DEFAULTS
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
eventlog = notify_mod.eventlog
|
eventlog = notify_mod.eventlog
|
||||||
|
|
||||||
|
|
||||||
class AlertLevel(Enum):
|
class AlertLevel(Enum):
|
||||||
"""Alert severity levels."""
|
"""Alert severity levels."""
|
||||||
OK = 0
|
OK = 0
|
||||||
@@ -52,16 +53,16 @@ class AlertState:
|
|||||||
self.metric_path = metric_path
|
self.metric_path = metric_path
|
||||||
self.level = AlertLevel.OK
|
self.level = AlertLevel.OK
|
||||||
self.since = time.time()
|
self.since = time.time()
|
||||||
self.last_value = None
|
self.last_value: Any = None
|
||||||
self.last_check = time.time()
|
self.last_check = time.time()
|
||||||
self.notification_count = 0
|
self.notification_count = 0
|
||||||
self.last_notification = None
|
self.last_notification: Optional[float] = None
|
||||||
self.threshold_value = None # The threshold value that triggered alert
|
self.threshold_value: Optional[float] = None # The threshold value that triggered alert
|
||||||
self.operator = None # The comparison operator (>, <, >=, etc.)
|
self.operator: Optional[str] = None # The comparison operator (>, <, >=, etc.)
|
||||||
self.hysteresis: Optional[float] = None # Hysteresis fraction used for recovery
|
self.hysteresis: Optional[float] = None # Hysteresis fraction used for recovery
|
||||||
self.formatted_message = None # Formatted display message for UI
|
self.formatted_message: Optional[str] = None # Formatted display message for UI
|
||||||
self.acknowledged = False # Whether alert has been acknowledged
|
self.acknowledged = False # Whether alert has been acknowledged
|
||||||
self.acknowledged_at = None # Timestamp when acknowledged
|
self.acknowledged_at: Optional[float] = None # Timestamp when acknowledged
|
||||||
self.consecutive_count = 0 # Consecutive exceedances while still OK (for count gating)
|
self.consecutive_count = 0 # Consecutive exceedances while still OK (for count gating)
|
||||||
self.pending_since: Optional[float] = None # non-None while waiting out grace period before notifying
|
self.pending_since: Optional[float] = None # non-None while waiting out grace period before notifying
|
||||||
|
|
||||||
@@ -182,6 +183,7 @@ class AlertState:
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.to_dict().__str__()
|
return self.to_dict().__str__()
|
||||||
|
|
||||||
|
|
||||||
class ThresholdConfig:
|
class ThresholdConfig:
|
||||||
"""Configuration for a single threshold check."""
|
"""Configuration for a single threshold check."""
|
||||||
|
|
||||||
@@ -360,13 +362,13 @@ class ThresholdChecker:
|
|||||||
journal: Optional MessageJournal instance for logging threshold events
|
journal: Optional MessageJournal instance for logging threshold events
|
||||||
"""
|
"""
|
||||||
# Named threshold configurations (pre-merged: defaults + overrides): {config_name: {metric_path: ThresholdConfig}}
|
# Named threshold configurations (pre-merged: defaults + overrides): {config_name: {metric_path: ThresholdConfig}}
|
||||||
self.threshold_configs = {}
|
self.threshold_configs: Dict[str, Dict[str, ThresholdConfig]] = {}
|
||||||
|
|
||||||
# Raw overrides only for each named config (no defaults baked in): {config_name: {metric_path: ThresholdConfig}}
|
# Raw overrides only for each named config (no defaults baked in): {config_name: {metric_path: ThresholdConfig}}
|
||||||
self.threshold_raw_configs: Dict[str, Dict[str, ThresholdConfig]] = {}
|
self.threshold_raw_configs: Dict[str, Dict[str, ThresholdConfig]] = {}
|
||||||
|
|
||||||
# Single threshold set for backward compatibility: {metric_path: ThresholdConfig}
|
# Single threshold set for backward compatibility: {metric_path: ThresholdConfig}
|
||||||
self.thresholds = {}
|
self.thresholds: Dict[str, ThresholdConfig] = {}
|
||||||
|
|
||||||
# Host to ordered list of config names: {host_name: [config_name, ...]}
|
# Host to ordered list of config names: {host_name: [config_name, ...]}
|
||||||
self.host_config_mapping: Dict[str, List[str]] = {}
|
self.host_config_mapping: Dict[str, List[str]] = {}
|
||||||
@@ -827,7 +829,7 @@ class ThresholdChecker:
|
|||||||
Dictionary of thresholds for this host
|
Dictionary of thresholds for this host
|
||||||
"""
|
"""
|
||||||
# Legacy mode: single threshold set for all hosts
|
# Legacy mode: single threshold set for all hosts
|
||||||
if self.thresholds and not self.threshold_configs:
|
if self.thresholds:
|
||||||
return self.thresholds
|
return self.thresholds
|
||||||
|
|
||||||
if not self.threshold_configs:
|
if not self.threshold_configs:
|
||||||
@@ -946,6 +948,7 @@ class ThresholdChecker:
|
|||||||
self._check_pending_or_renotify(host_name, alert_state, metric_path, value, threshold, None)
|
self._check_pending_or_renotify(host_name, alert_state, metric_path, value, threshold, None)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _find_threshold(
|
def _find_threshold(
|
||||||
self, thresholds: Dict[str, "ThresholdConfig"], metric_path: str
|
self, thresholds: Dict[str, "ThresholdConfig"], metric_path: str
|
||||||
) -> Tuple[Optional["ThresholdConfig"], Optional[str]]:
|
) -> Tuple[Optional["ThresholdConfig"], Optional[str]]:
|
||||||
@@ -1273,7 +1276,7 @@ class ThresholdChecker:
|
|||||||
|
|
||||||
def _format_display(
|
def _format_display(
|
||||||
self,
|
self,
|
||||||
display_format: str,
|
display_format: Optional[str],
|
||||||
value: Any,
|
value: Any,
|
||||||
threshold_value: Optional[float],
|
threshold_value: Optional[float],
|
||||||
op_symbol: str,
|
op_symbol: str,
|
||||||
|
|||||||
+17
-9
@@ -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
|
||||||
@@ -25,12 +26,13 @@ _STALE_INTERVAL_MULTIPLIER = 3
|
|||||||
# 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
|
||||||
@@ -45,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
|
||||||
@@ -71,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
|
||||||
@@ -422,15 +427,20 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
|||||||
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
|
||||||
|
|
||||||
@@ -453,10 +463,8 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
|||||||
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)
|
||||||
@@ -517,7 +525,7 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
|||||||
|
|
||||||
# If os_info reports an owner and none is configured server-side, apply it
|
# If os_info reports an owner and none is configured server-side, apply it
|
||||||
if plugin_name == "os_info":
|
if plugin_name == "os_info":
|
||||||
config_owner = config_mod.get_host_access(cfg, uname).get("owner")
|
config_owner = config_mod.get_host_access(cfg, uname).get("owner")
|
||||||
default_owner = config_mod.get_default_owner(cfg)
|
default_owner = config_mod.get_default_owner(cfg)
|
||||||
inferred_owner = config_owner or plugin_data.get("owner") or default_owner
|
inferred_owner = config_owner or plugin_data.get("owner") or default_owner
|
||||||
host.owner = inferred_owner
|
host.owner = inferred_owner
|
||||||
|
|||||||
+14
-3
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "hbd"
|
name = "hbd"
|
||||||
version = "5.4.6"
|
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"
|
||||||
@@ -58,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",
|
||||||
@@ -83,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"]
|
||||||
|
|||||||
+1
-1
@@ -50,7 +50,7 @@ from pathlib import Path
|
|||||||
from typing import Any, Awaitable, Callable, Dict, List, Optional, Set, Tuple
|
from typing import Any, Awaitable, Callable, Dict, List, Optional, Set, Tuple
|
||||||
|
|
||||||
# updated by scripts/bumpminor.sh
|
# updated by scripts/bumpminor.sh
|
||||||
__version__ = "5.4.6"
|
__version__ = "5.4.7"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Protocol (mirrors hbd/common/proto.py)
|
# Protocol (mirrors hbd/common/proto.py)
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import glob
|
|
||||||
import os
|
|
||||||
import pytest
|
import pytest
|
||||||
from hbd.server import configio
|
from hbd.server import configio
|
||||||
|
|
||||||
|
|||||||
+37
-82
@@ -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()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from hbd.udp import handle_datagram, parse_message
|
from hbd.server.udp import handle_datagram, parse_message
|
||||||
from hbd.proto import dicttos
|
from hbd.common.proto import dicttos, stodict
|
||||||
|
|
||||||
|
|
||||||
class FakeTransport:
|
class FakeTransport:
|
||||||
@@ -17,7 +17,7 @@ def dummy_noop(*a, **k):
|
|||||||
def test_handle_cmd_sends_command():
|
def test_handle_cmd_sends_command():
|
||||||
ftr = FakeTransport()
|
ftr = FakeTransport()
|
||||||
# prepare ctx linking to hbdclass
|
# prepare ctx linking to hbdclass
|
||||||
import hbdclass
|
from hbd.server import hbdclass
|
||||||
|
|
||||||
ctx = {
|
ctx = {
|
||||||
"config": {"watchhosts": []},
|
"config": {"watchhosts": []},
|
||||||
@@ -34,7 +34,7 @@ def test_handle_cmd_sends_command():
|
|||||||
# create host by sending initial heartbeat
|
# create host by sending initial heartbeat
|
||||||
msg = parse_message(dicttos("HTB", {"name": "cmdhost", "interval": 10}))
|
msg = parse_message(dicttos("HTB", {"name": "cmdhost", "interval": 10}))
|
||||||
handle_datagram(msg, ("127.0.0.1", 50000), ftr, ctx)
|
handle_datagram(msg, ("127.0.0.1", 50000), ftr, ctx)
|
||||||
assert ftr.sent[0][0] == b"ACK"
|
assert ftr.sent[0][0].startswith(b"!ACK:")
|
||||||
|
|
||||||
# queue a CMD for the host and send another heartbeat; expect command sent
|
# queue a CMD for the host and send another heartbeat; expect command sent
|
||||||
h = hbdclass.Host.hosts["cmdhost"]
|
h = hbdclass.Host.hosts["cmdhost"]
|
||||||
@@ -42,8 +42,6 @@ def test_handle_cmd_sends_command():
|
|||||||
ftr.sent.clear()
|
ftr.sent.clear()
|
||||||
msg2 = parse_message(dicttos("HTB", {"name": "cmdhost", "interval": 10}))
|
msg2 = parse_message(dicttos("HTB", {"name": "cmdhost", "interval": 10}))
|
||||||
handle_datagram(msg2, ("127.0.0.1", 50000), ftr, ctx)
|
handle_datagram(msg2, ("127.0.0.1", 50000), ftr, ctx)
|
||||||
# should have sent ACK and the command; last send should be non-empty
|
# the ACK and the queued command are both sent; the command is a compressed CMD packet
|
||||||
assert len(ftr.sent) >= 1
|
decoded = [stodict(data) for data, _addr in ftr.sent]
|
||||||
# the command for ??0 will be sent as raw cmd string
|
assert any(d["ID"] == "CMD" and d["cmd"] == "doit" for d in decoded)
|
||||||
# so at least one send contains b'doit' or similar
|
|
||||||
assert any(b"doit" in s[0] for s in ftr.sent)
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
"""Tests for the config read/write API helpers in http.py."""
|
"""Tests for the config read/write API helpers in http.py."""
|
||||||
import pytest
|
|
||||||
from hbd.server import http
|
from hbd.server import http
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
"""Tests for PUT /api/0/users/me logic."""
|
"""Tests for PUT /api/0/users/me logic."""
|
||||||
import pytest
|
|
||||||
from hbd.server import users as users_mod
|
from hbd.server import users as users_mod
|
||||||
|
|
||||||
|
|
||||||
@@ -87,7 +86,6 @@ def test_put_users_me_notification_channels(tmp_path):
|
|||||||
|
|
||||||
def test_visible_channels_excludes_private_from_others():
|
def test_visible_channels_excludes_private_from_others():
|
||||||
"""Private channels owned by another user must not appear in the visible set."""
|
"""Private channels owned by another user must not appear in the visible set."""
|
||||||
from hbd.server import settings as settings_mod
|
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"notification_channels": {
|
"notification_channels": {
|
||||||
@@ -105,7 +103,7 @@ def test_visible_channels_excludes_private_from_others():
|
|||||||
self.admin = admin
|
self.admin = admin
|
||||||
|
|
||||||
alice = FakeUser("alice")
|
alice = FakeUser("alice")
|
||||||
bob = FakeUser("bob")
|
bob = FakeUser("bob")
|
||||||
admin = FakeUser("admin", admin=True)
|
admin = FakeUser("admin", admin=True)
|
||||||
|
|
||||||
# Simulate _visible_channels_for_user logic (mirrors http.py implementation)
|
# Simulate _visible_channels_for_user logic (mirrors http.py implementation)
|
||||||
@@ -119,5 +117,5 @@ def test_visible_channels_excludes_private_from_others():
|
|||||||
}
|
}
|
||||||
|
|
||||||
assert visible(alice) == {"public_ch", "alice_priv"}
|
assert visible(alice) == {"public_ch", "alice_priv"}
|
||||||
assert visible(bob) == {"public_ch", "bob_priv"}
|
assert visible(bob) == {"public_ch", "bob_priv"}
|
||||||
assert visible(admin) == {"public_ch", "alice_priv", "bob_priv"}
|
assert visible(admin) == {"public_ch", "alice_priv", "bob_priv"}
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import stat
|
import stat
|
||||||
|
|
||||||
from hbd.client.plugins.nagios_runner import (
|
from hbd.client.plugins.nagios_runner import NagiosRunnerPlugin, NAGIOS_UNKNOWN
|
||||||
NagiosRunnerPlugin,
|
|
||||||
NAGIOS_OK,
|
# Nagios plugin exit codes (the module itself only needs UNKNOWN)
|
||||||
NAGIOS_WARNING,
|
NAGIOS_OK, NAGIOS_WARNING, NAGIOS_CRITICAL = 0, 1, 2
|
||||||
NAGIOS_CRITICAL,
|
|
||||||
NAGIOS_UNKNOWN,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_commands_sets_skip_reason():
|
def test_no_commands_sets_skip_reason():
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
"""Tests for notification channel CRUD via configio helpers and visibility logic."""
|
"""Tests for notification channel CRUD via configio helpers and visibility logic."""
|
||||||
import pytest
|
|
||||||
from hbd.server import configio, settings as settings_mod
|
from hbd.server import configio, settings as settings_mod
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ def reset_users_dict():
|
|||||||
users_mod.users = original
|
users_mod.users = original
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def test_make_state_returns_unique_tokens():
|
def test_make_state_returns_unique_tokens():
|
||||||
s1 = oauth.make_state()
|
s1 = oauth.make_state()
|
||||||
s2 = oauth.make_state()
|
s2 = oauth.make_state()
|
||||||
@@ -124,7 +123,6 @@ def test_provision_oauth_user_survives_config_reload():
|
|||||||
assert "oauthonly" in users_mod.users
|
assert "oauthonly" in users_mod.users
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Integration-style tests: callback logic chain
|
# Integration-style tests: callback logic chain
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+8
-8
@@ -1,19 +1,19 @@
|
|||||||
from hbd.proto import dicttos, stodict, oldmtodict
|
from hbd.common.proto import dicttos, stodict, oldmtodict
|
||||||
|
|
||||||
|
|
||||||
def test_dicttos_and_stodict_uncompressed():
|
def test_dicttos_and_stodict_roundtrip():
|
||||||
msg = dicttos("HTB", {"name": "host.example", "interval": 10})
|
msg = dicttos("HTB", {"name": "host.example", "interval": 10})
|
||||||
|
assert msg.startswith(b"!HTB:")
|
||||||
d = stodict(msg)
|
d = stodict(msg)
|
||||||
assert d["ID"].startswith("HTB")
|
assert d["ID"] == "HTB"
|
||||||
assert d["name"] == "host.example"
|
assert d["name"] == "host.example"
|
||||||
assert d["interval"] == 10
|
assert d["interval"] == 10
|
||||||
|
|
||||||
|
|
||||||
def test_dicttos_and_stodict_compressed():
|
def test_dicttos_and_stodict_ack():
|
||||||
msg = dicttos("ACK", {"time": 12345}, compress=True)
|
d = stodict(dicttos("ACK", {"time": 12345}))
|
||||||
d = stodict(msg)
|
assert d["ID"] == "ACK"
|
||||||
# for compressed the original code included the colon in ID slice; ensure no crash
|
assert d["time"] == 12345
|
||||||
assert "ID" in d
|
|
||||||
|
|
||||||
|
|
||||||
def test_oldmtodict():
|
def test_oldmtodict():
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import pytest
|
|
||||||
from hbd.server import settings as settings_mod
|
from hbd.server import settings as settings_mod
|
||||||
|
|
||||||
CFG = {
|
CFG = {
|
||||||
|
|||||||
+19
-15
@@ -13,13 +13,12 @@ Tests cover:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import time
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Add parent directory to path for imports
|
# Add parent directory to path for imports
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
from hbd.threshold import (
|
from hbd.server.threshold import ( # noqa: E402
|
||||||
ThresholdChecker,
|
ThresholdChecker,
|
||||||
ThresholdConfig,
|
ThresholdConfig,
|
||||||
AlertLevel,
|
AlertLevel,
|
||||||
@@ -170,23 +169,23 @@ def test_alert_state():
|
|||||||
|
|
||||||
# Update to WARNING - should trigger notification
|
# Update to WARNING - should trigger notification
|
||||||
changed = alert.update(AlertLevel.WARNING, 85.0)
|
changed = alert.update(AlertLevel.WARNING, 85.0)
|
||||||
assert changed == True, "State change should return True"
|
assert changed is True, "State change should return True"
|
||||||
assert alert.level == AlertLevel.WARNING
|
assert alert.level == AlertLevel.WARNING
|
||||||
assert alert.last_value == 85.0
|
assert alert.last_value == 85.0
|
||||||
|
|
||||||
# Update with same level - no notification
|
# Update with same level - no notification
|
||||||
changed = alert.update(AlertLevel.WARNING, 86.0)
|
changed = alert.update(AlertLevel.WARNING, 86.0)
|
||||||
assert changed == False, "No state change should return False"
|
assert changed is False, "No state change should return False"
|
||||||
assert alert.last_value == 86.0
|
assert alert.last_value == 86.0
|
||||||
|
|
||||||
# Escalate to CRITICAL
|
# Escalate to CRITICAL
|
||||||
changed = alert.update(AlertLevel.CRITICAL, 95.0)
|
changed = alert.update(AlertLevel.CRITICAL, 95.0)
|
||||||
assert changed == True, "Escalation should trigger notification"
|
assert changed is True, "Escalation should trigger notification"
|
||||||
assert alert.level == AlertLevel.CRITICAL
|
assert alert.level == AlertLevel.CRITICAL
|
||||||
|
|
||||||
# Recover to OK
|
# Recover to OK
|
||||||
changed = alert.update(AlertLevel.OK, 50.0)
|
changed = alert.update(AlertLevel.OK, 50.0)
|
||||||
assert changed == True, "Recovery should trigger notification"
|
assert changed is True, "Recovery should trigger notification"
|
||||||
assert alert.level == AlertLevel.OK
|
assert alert.level == AlertLevel.OK
|
||||||
|
|
||||||
print(" ✓ Alert state tracking works correctly")
|
print(" ✓ Alert state tracking works correctly")
|
||||||
@@ -257,7 +256,16 @@ def test_threshold_checker_parsing():
|
|||||||
print(f" ✓ Parsed {len(checker.thresholds)} thresholds correctly")
|
print(f" ✓ Parsed {len(checker.thresholds)} thresholds correctly")
|
||||||
|
|
||||||
|
|
||||||
def test_check_plugin_data():
|
def _checker_with_notifications(config, notifications, monkeypatch):
|
||||||
|
"""ThresholdChecker with no grace period that records notifications instead of sending them."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ThresholdChecker, "_send_notification",
|
||||||
|
lambda self, host, lvl, message, *a: notifications.append(f"[{lvl}] {host} {message}"),
|
||||||
|
)
|
||||||
|
return ThresholdChecker({"grace": 0, **config})
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_plugin_data(monkeypatch):
|
||||||
"""Test checking plugin data against thresholds."""
|
"""Test checking plugin data against thresholds."""
|
||||||
print("\nTest 6: Plugin data checking...")
|
print("\nTest 6: Plugin data checking...")
|
||||||
|
|
||||||
@@ -277,11 +285,7 @@ def test_check_plugin_data():
|
|||||||
}
|
}
|
||||||
|
|
||||||
notifications = []
|
notifications = []
|
||||||
|
checker = _checker_with_notifications(config, notifications, monkeypatch)
|
||||||
def notification_callback(msg):
|
|
||||||
notifications.append(msg)
|
|
||||||
|
|
||||||
checker = ThresholdChecker(config, notification_callback=notification_callback)
|
|
||||||
alert_states = {}
|
alert_states = {}
|
||||||
|
|
||||||
# First check - OK
|
# First check - OK
|
||||||
@@ -353,12 +357,12 @@ def test_check_plugin_data():
|
|||||||
|
|
||||||
assert len(state_changes) == 2, "Two metrics should recover"
|
assert len(state_changes) == 2, "Two metrics should recover"
|
||||||
assert len(notifications) == 2, "Two recovery notifications"
|
assert len(notifications) == 2, "Two recovery notifications"
|
||||||
assert any("RECOVERED" in n for n in notifications), "Should have recovery notification"
|
assert any("RECOVER" in n for n in notifications), "Should have recovery notification"
|
||||||
|
|
||||||
print(" ✓ Plugin data checking and notifications work")
|
print(" ✓ Plugin data checking and notifications work")
|
||||||
|
|
||||||
|
|
||||||
def test_nested_metrics():
|
def test_nested_metrics(monkeypatch):
|
||||||
"""Test checking nested metrics like disk partitions."""
|
"""Test checking nested metrics like disk partitions."""
|
||||||
print("\nTest 7: Nested metrics (partitions)...")
|
print("\nTest 7: Nested metrics (partitions)...")
|
||||||
|
|
||||||
@@ -384,7 +388,7 @@ def test_nested_metrics():
|
|||||||
}
|
}
|
||||||
|
|
||||||
notifications = []
|
notifications = []
|
||||||
checker = ThresholdChecker(config, notification_callback=lambda m: notifications.append(m))
|
checker = _checker_with_notifications(config, notifications, monkeypatch)
|
||||||
alert_states = {}
|
alert_states = {}
|
||||||
|
|
||||||
plugin_data = {
|
plugin_data = {
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
from hbd.udp import parse_message
|
from hbd.server.udp import parse_message
|
||||||
from hbd.proto import dicttos
|
from hbd.common.proto import dicttos
|
||||||
|
|
||||||
|
|
||||||
def test_parse_message_uncompressed():
|
def test_parse_message_uncompressed():
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Datagrams that are not heartbeat traffic (internet scanners) must not create hosts."""
|
||||||
|
from hbd.common.proto import dicttos
|
||||||
|
from hbd.server import hbdclass
|
||||||
|
from hbd.server.udp import handle_datagram, parse_message
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeTransport:
|
||||||
|
sent = 0
|
||||||
|
|
||||||
|
def sendto(self, data, addr):
|
||||||
|
_FakeTransport.sent += 1
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx():
|
||||||
|
return {"config": {}, "hbdclass": hbdclass, "msg_to_websockets": None,
|
||||||
|
"DEBUG": 0, "verbose": False}
|
||||||
|
|
||||||
|
|
||||||
|
def _handle(raw):
|
||||||
|
handle_datagram(parse_message(raw), ("192.0.2.9", 50000), _FakeTransport(), _ctx())
|
||||||
|
|
||||||
|
|
||||||
|
def test_sip_invite_does_not_create_a_host():
|
||||||
|
hbdclass.Host.hosts.pop("unknown", None)
|
||||||
|
_handle(b"INVITE sip:10000@192.0.2.1:50003 SIP/2.0\r\nTo: <sip:10000@192.0.2.1>\r\n\r\n")
|
||||||
|
assert "unknown" not in hbdclass.Host.hosts
|
||||||
|
|
||||||
|
|
||||||
|
def test_nameless_heartbeat_does_not_create_a_host_or_get_acked():
|
||||||
|
hbdclass.Host.hosts.pop("unknown", None)
|
||||||
|
_FakeTransport.sent = 0
|
||||||
|
_handle(dicttos("HTB", {"id": 0}))
|
||||||
|
assert "unknown" not in hbdclass.Host.hosts
|
||||||
|
assert _FakeTransport.sent == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_named_heartbeat_still_creates_its_host():
|
||||||
|
hbdclass.Host.hosts.pop("stray-ok", None)
|
||||||
|
_handle(dicttos("HTB", {"name": "stray-ok", "id": 0}))
|
||||||
|
assert "stray-ok" in hbdclass.Host.hosts
|
||||||
Reference in New Issue
Block a user