Files
homelable/backend/app/services/status_checker.py
T
Pouzor aa17edf1d0 chore(security): drop passlib for direct bcrypt + reject CLI-flag check targets
- Replace passlib.CryptContext with bcrypt 4.2.1 directly (passlib is
  unmaintained and only emitted warnings when paired with bcrypt 4+).
  hash_password / verify_password now call bcrypt.checkpw / .hashpw, and
  verify_password is hardened against empty inputs without raising.
  scripts/hash_password.py + tests/conftest.py updated to match.
- status_checker.check_node() now rejects targets starting with '-' before
  any subprocess invocation, defending ping/tcp paths against arg-injection
  if an admin sets a check_target like '-O'.

Tests added:
- test_auth: expired JWT, malformed JWT, wrong-secret JWT, empty password
  vs empty server hash, verify_password edge cases.
- test_scan: _background_scan failure path marks ScanRun failed, leaves
  non-running terminal status alone, success path invokes run_scan.
- test_status_checker: ping/tcp invocations are bypassed when target or
  ip starts with '-'.

Backend coverage 89% → 90%.
2026-05-14 12:11:40 +02:00

103 lines
3.7 KiB
Python

"""Per-node status checks: ping, http, https, tcp, ssh, prometheus, health, none."""
import asyncio
import logging
import socket
import sys
import time
from typing import Any
import httpx
logger = logging.getLogger(__name__)
async def check_node(check_method: str, target: str | None, ip: str | None) -> dict[str, Any]:
"""
Run the appropriate check and return {status, response_time_ms}.
status is one of: online, offline, unknown.
"""
if check_method == "none":
return {"status": "online", "response_time_ms": None}
# Use only the first IP when the field contains comma-separated addresses
raw_ip = ip.split(",")[0].strip() if ip else None
host = target or raw_ip
if not host:
return {"status": "unknown", "response_time_ms": None}
# Reject hostnames that look like CLI flags — defends ping/tcp invocations
# against arg-injection if a malicious admin sets target like "-O".
if host.startswith("-"):
logger.warning("Rejecting check target that starts with '-': %r", host)
return {"status": "unknown", "response_time_ms": None}
start = time.monotonic()
try:
match check_method:
case "ping":
ok = await _ping(host)
case "http":
url = host if host.startswith("http") else f"http://{host}"
ok = await _http_get(url)
case "https":
url = host if host.startswith("https") else f"https://{host}"
ok = await _http_get(url, verify=True)
case "tcp":
host_part, _, port_str = host.rpartition(":")
port = int(port_str) if port_str.isdigit() else 80
ok = await _tcp_connect(host_part or host, port)
case "ssh":
ok = await _tcp_connect(host, 22)
case "prometheus":
url = host if host.startswith("http") else f"http://{host}/metrics"
ok = await _http_get(url)
case "health":
url = host if host.startswith("http") else f"http://{host}/health"
ok = await _http_get(url)
case _:
ok = await _ping(host)
elapsed_ms = int((time.monotonic() - start) * 1000)
return {"status": "online" if ok else "offline", "response_time_ms": elapsed_ms}
except Exception as exc:
logger.debug("Check failed for %s (%s): %s", host, check_method, exc)
return {"status": "offline", "response_time_ms": None}
async def _ping(host: str) -> bool:
# ping(8) -W flag units differ by OS:
# Linux: seconds (-W 1 = 1s)
# macOS: milliseconds (-W 1 = 1ms — fails for any RTT >1ms)
# Windows: -w in ms (-w 1000 = 1s)
if sys.platform == "win32":
args = ["ping", "-n", "1", "-w", "1000", host]
elif sys.platform == "darwin":
args = ["ping", "-c", "1", "-W", "1000", host]
else:
args = ["ping", "-c", "1", "-W", "1", host]
proc = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await proc.wait()
return proc.returncode == 0
async def _http_get(url: str, verify: bool = False) -> bool:
async with httpx.AsyncClient(verify=verify, timeout=5) as client:
resp = await client.get(url, follow_redirects=True)
return resp.status_code < 500
async def _tcp_connect(host: str, port: int) -> bool:
try:
_, writer = await asyncio.wait_for(
asyncio.open_connection(host, port), timeout=3
)
writer.close()
await writer.wait_closed()
return True
except (TimeoutError, OSError, socket.gaierror):
return False