fix(status): keep non-HTTP services grey instead of red
Per-service checks now only probe HTTP(S)-reachable services. SSH (22) and other non-web ports (DB, mail, DNS, raw TCP) stay 'unknown' (grey category colour) rather than going red — an open TCP socket doesn't prove the service is healthy, and a firewalled port flapped red misleadingly. ha-relevant: yes
This commit is contained in:
@@ -122,10 +122,12 @@ async def _tcp_connect(host: str, port: int) -> bool:
|
|||||||
|
|
||||||
# --- Per-service status checks ---
|
# --- Per-service status checks ---
|
||||||
|
|
||||||
# Ports that are definitely not HTTP/web — mirror of frontend serviceUrl.ts.
|
# Ports that are not HTTP/web. These get NO status check — a service here stays
|
||||||
# SSH (22) is handled as a plain TCP check, so it is intentionally absent here.
|
# grey (unknown) rather than going red. An open TCP socket doesn't prove the
|
||||||
|
# service is healthy, and a closed one flaps red misleadingly (e.g. SSH on a
|
||||||
|
# box that simply firewalls 22). Only HTTP(S)-reachable services are checked.
|
||||||
_NON_HTTP_PORTS = frozenset({
|
_NON_HTTP_PORTS = frozenset({
|
||||||
21, 23, 25, 465, 587, 53, 110, 143, 993, 995, 389, 636, 445, 514,
|
22, 21, 23, 25, 465, 587, 53, 110, 143, 993, 995, 389, 636, 445, 514,
|
||||||
1433, 3306, 5432, 5672, 6379, 9092, 11211, 27017, 27018,
|
1433, 3306, 5432, 5672, 6379, 9092, 11211, 27017, 27018,
|
||||||
})
|
})
|
||||||
_HTTPS_PORTS = frozenset({443, 8443})
|
_HTTPS_PORTS = frozenset({443, 8443})
|
||||||
@@ -139,9 +141,10 @@ def _service_host(svc: dict[str, Any], host: str) -> str:
|
|||||||
async def check_service(svc: dict[str, Any], host: str | None) -> str:
|
async def check_service(svc: dict[str, Any], host: str | None) -> str:
|
||||||
"""Check a single service. Returns 'online' | 'offline' | 'unknown'.
|
"""Check a single service. Returns 'online' | 'offline' | 'unknown'.
|
||||||
|
|
||||||
Web services get an HTTP(S) GET; everything else with a port gets a TCP
|
Only HTTP(S)-reachable services get a real check (an HTTP GET). Everything
|
||||||
connect. UDP services and port-less non-web services are 'unknown' so they
|
else — SSH, databases, mail, DNS, raw TCP, UDP, port-less — stays 'unknown'
|
||||||
keep their category colour rather than flashing red.
|
so it keeps its category colour instead of flashing red. An open TCP socket
|
||||||
|
doesn't prove a non-web service is healthy, so we don't pretend it does.
|
||||||
"""
|
"""
|
||||||
if not host or host.startswith("-"):
|
if not host or host.startswith("-"):
|
||||||
return "unknown"
|
return "unknown"
|
||||||
@@ -151,24 +154,22 @@ async def check_service(svc: dict[str, Any], host: str | None) -> str:
|
|||||||
port = svc.get("port")
|
port = svc.get("port")
|
||||||
port = int(port) if isinstance(port, int) or (isinstance(port, str) and port.isdigit()) else None
|
port = int(port) if isinstance(port, int) or (isinstance(port, str) and port.isdigit()) else None
|
||||||
|
|
||||||
try:
|
# Non-HTTP ports (SSH 22, DB, mail, …) are never checked — keep them grey.
|
||||||
if port is not None and port in _NON_HTTP_PORTS:
|
if port is not None and port in _NON_HTTP_PORTS:
|
||||||
return "online" if await _tcp_connect(host, port) else "offline"
|
|
||||||
|
|
||||||
name = str(svc.get("service_name", "")).lower()
|
|
||||||
is_web = port is None or port not in _NON_HTTP_PORTS
|
|
||||||
if is_web and (port is not None or "http" in name):
|
|
||||||
scheme = "https" if (
|
|
||||||
port in _HTTPS_PORTS or "https" in name or "ssl" in name or "tls" in name
|
|
||||||
) else "http"
|
|
||||||
url_host = _service_host(svc, host)
|
|
||||||
url = f"{scheme}://{url_host}" + (f":{port}" if port is not None else "")
|
|
||||||
return "online" if await _http_get(url, verify=False) else "offline"
|
|
||||||
|
|
||||||
if port is not None:
|
|
||||||
return "online" if await _tcp_connect(host, port) else "offline"
|
|
||||||
|
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
|
name = str(svc.get("service_name", "")).lower()
|
||||||
|
is_web = port is not None or "http" in name
|
||||||
|
if not is_web:
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
try:
|
||||||
|
scheme = "https" if (
|
||||||
|
port in _HTTPS_PORTS or "https" in name or "ssl" in name or "tls" in name
|
||||||
|
) else "http"
|
||||||
|
url_host = _service_host(svc, host)
|
||||||
|
url = f"{scheme}://{url_host}" + (f":{port}" if port is not None else "")
|
||||||
|
return "online" if await _http_get(url, verify=False) else "offline"
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("Service check failed for %s:%s (%s)", host, port, exc)
|
logger.debug("Service check failed for %s:%s (%s)", host, port, exc)
|
||||||
return "offline"
|
return "offline"
|
||||||
|
|||||||
@@ -410,19 +410,25 @@ async def test_check_service_web_offline_when_http_fails():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_check_service_non_http_port_uses_tcp():
|
async def test_check_service_non_http_port_is_unknown():
|
||||||
captured = {}
|
"""Non-HTTP ports (DB, mail, …) stay grey — no TCP check, no red flap."""
|
||||||
|
|
||||||
async def fake_tcp(host, port):
|
|
||||||
captured["host"] = host
|
|
||||||
captured["port"] = port
|
|
||||||
return True
|
|
||||||
|
|
||||||
svc = {"port": 5432, "protocol": "tcp", "service_name": "postgres"}
|
svc = {"port": 5432, "protocol": "tcp", "service_name": "postgres"}
|
||||||
with patch("app.services.status_checker._tcp_connect", side_effect=fake_tcp):
|
with patch("app.services.status_checker._tcp_connect", new_callable=AsyncMock) as mock_tcp, \
|
||||||
|
patch("app.services.status_checker._http_get", new_callable=AsyncMock) as mock_http:
|
||||||
result = await check_service(svc, "10.0.0.1")
|
result = await check_service(svc, "10.0.0.1")
|
||||||
assert result == "online"
|
assert result == "unknown"
|
||||||
assert captured == {"host": "10.0.0.1", "port": 5432}
|
mock_tcp.assert_not_called()
|
||||||
|
mock_http.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_service_ssh_port_22_is_unknown():
|
||||||
|
"""SSH (port 22) is never checked — keep it grey, not red/green."""
|
||||||
|
svc = {"port": 22, "protocol": "tcp", "service_name": "ssh"}
|
||||||
|
with patch("app.services.status_checker._tcp_connect", new_callable=AsyncMock) as mock_tcp:
|
||||||
|
result = await check_service(svc, "10.0.0.1")
|
||||||
|
assert result == "unknown"
|
||||||
|
mock_tcp.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -445,12 +451,11 @@ async def test_check_services_returns_status_per_service():
|
|||||||
{"port": 80, "protocol": "tcp", "service_name": "http"},
|
{"port": 80, "protocol": "tcp", "service_name": "http"},
|
||||||
{"port": 5432, "protocol": "tcp", "service_name": "postgres"},
|
{"port": 5432, "protocol": "tcp", "service_name": "postgres"},
|
||||||
]
|
]
|
||||||
with patch("app.services.status_checker._http_get", new_callable=AsyncMock, return_value=True), \
|
with patch("app.services.status_checker._http_get", new_callable=AsyncMock, return_value=True):
|
||||||
patch("app.services.status_checker._tcp_connect", new_callable=AsyncMock, return_value=False):
|
|
||||||
results = await check_services("10.0.0.1", services)
|
results = await check_services("10.0.0.1", services)
|
||||||
assert results == [
|
assert results == [
|
||||||
{"port": 80, "protocol": "tcp", "status": "online"},
|
{"port": 80, "protocol": "tcp", "status": "online"},
|
||||||
{"port": 5432, "protocol": "tcp", "status": "offline"},
|
{"port": 5432, "protocol": "tcp", "status": "unknown"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user