feat: monitor public listener exposure
This commit is contained in:
@@ -54,7 +54,7 @@ sudo systemctl show platform-health-monitor.service -p Result -p ExecMainStatus
|
||||
python3 -m json.tool /var/lib/data-terminal-cloud-monitor/status.json
|
||||
```
|
||||
|
||||
监测任务每五分钟执行,正常状态为`healthy`。默认只记录结构化状态和systemd日志;在`/etc/data-terminal-cloud/monitor.env`配置`ALERT_WEBHOOK_URL`后,状态变化会发送带时间戳和HMAC-SHA256签名的JSON通知。完整检查项、签名规则、阈值和失败分支见[《单机云端健康监测与告警手册》](../../docs/server-health-monitoring.md)。
|
||||
监测任务每五分钟执行,正常状态为`healthy`。它还要求宿主机所有非回环TCP监听只能使用22、80和443,避免安全组误放行与错误容器端口映射叠加后暴露数据库或缓存。默认只记录结构化状态和systemd日志;在`/etc/data-terminal-cloud/monitor.env`配置`ALERT_WEBHOOK_URL`后,状态变化会发送带时间戳和HMAC-SHA256签名的JSON通知。完整检查项、签名规则、阈值和失败分支见[《单机云端健康监测与告警手册》](../../docs/server-health-monitoring.md)。
|
||||
|
||||
## OSS异地备份适配
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ alert_webhook_url=${ALERT_WEBHOOK_URL:-}
|
||||
alert_webhook_secret_file=${ALERT_WEBHOOK_SECRET_FILE:-/etc/data-terminal-cloud/monitor-webhook-secret}
|
||||
platform_health_report_url=${PLATFORM_HEALTH_REPORT_URL:-}
|
||||
platform_health_report_secret_file=${PLATFORM_HEALTH_REPORT_SECRET_FILE:-/etc/data-terminal-cloud/platform-health-callback-secret}
|
||||
expected_public_tcp_ports=${EXPECTED_PUBLIC_TCP_PORTS:-22,80,443}
|
||||
if [[ -f "$offsite_config_file" ]]; then
|
||||
# shellcheck disable=SC1090
|
||||
source "$offsite_config_file"
|
||||
@@ -36,6 +37,10 @@ if (( disk_warning_percent >= disk_critical_percent || disk_critical_percent > 1
|
||||
echo "Disk thresholds are invalid" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! "$expected_public_tcp_ports" =~ ^[0-9]+(,[0-9]+)*$ ]]; then
|
||||
echo "EXPECTED_PUBLIC_TCP_PORTS must contain comma-separated integers" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
compose=(docker compose -f "$project_root/compose.yaml" -f "$project_root/deploy/server/compose.override.yaml" --env-file "$project_root/deploy/server/.env")
|
||||
mkdir -p "$state_dir"
|
||||
@@ -81,6 +86,20 @@ else
|
||||
record_check public_https critical request_failed
|
||||
fi
|
||||
|
||||
if ! command -v ss >/dev/null 2>&1; then
|
||||
record_check public_tcp_listeners critical probe_unavailable
|
||||
else
|
||||
listener_evidence=$(ss -H -ltn | python3 "$project_root/deploy/server/public_listener_policy.py" "$expected_public_tcp_ports" 2>/dev/null || true)
|
||||
IFS='|' read -r listener_status listener_detail <<<"$listener_evidence"
|
||||
if [[ "$listener_status" == healthy ]]; then
|
||||
record_check public_tcp_listeners healthy "$listener_detail"
|
||||
elif [[ "$listener_status" == critical ]]; then
|
||||
record_check public_tcp_listeners critical "$listener_detail"
|
||||
else
|
||||
record_check public_tcp_listeners critical invalid_probe_result
|
||||
fi
|
||||
fi
|
||||
|
||||
expected_issuer="$public_origin/realms/voice-local"
|
||||
if curl --fail --silent --show-error --max-time 10 \
|
||||
"$expected_issuer/.well-known/openid-configuration" -o "$oidc_tmp"; then
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
import ipaddress
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def parse_endpoint(value: str) -> tuple[str, int] | None:
|
||||
value = value.strip()
|
||||
if value.startswith("[") and "]:" in value:
|
||||
address, port_text = value[1:].rsplit("]:", 1)
|
||||
elif ":" in value:
|
||||
address, port_text = value.rsplit(":", 1)
|
||||
else:
|
||||
return None
|
||||
if not port_text.isdigit():
|
||||
return None
|
||||
return address, int(port_text)
|
||||
|
||||
|
||||
def externally_reachable(address: str) -> bool:
|
||||
if address in {"*", "0.0.0.0", "::", "[::]"}:
|
||||
return True
|
||||
try:
|
||||
return not ipaddress.ip_address(address.split("%", 1)[0]).is_loopback
|
||||
except ValueError:
|
||||
return True
|
||||
|
||||
|
||||
def public_ports(lines: list[str]) -> set[int]:
|
||||
ports: set[int] = set()
|
||||
for line in lines:
|
||||
fields = line.split()
|
||||
if len(fields) < 4:
|
||||
continue
|
||||
endpoint = parse_endpoint(fields[3])
|
||||
if endpoint is not None and externally_reachable(endpoint[0]):
|
||||
ports.add(endpoint[1])
|
||||
return ports
|
||||
|
||||
|
||||
def parse_allowed(value: str) -> set[int]:
|
||||
if not re.fullmatch(r"[0-9]+(?:,[0-9]+)*", value):
|
||||
raise ValueError("allowed ports must be comma-separated integers")
|
||||
ports = {int(item) for item in value.split(",")}
|
||||
if any(port < 1 or port > 65535 for port in ports):
|
||||
raise ValueError("allowed port is outside 1..65535")
|
||||
return ports
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) != 2:
|
||||
print("usage: public_listener_policy.py ALLOWED_PORTS", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
allowed = parse_allowed(sys.argv[1])
|
||||
except ValueError as error:
|
||||
print(str(error), file=sys.stderr)
|
||||
return 2
|
||||
exposed = public_ports(list(sys.stdin))
|
||||
unexpected = sorted(exposed - allowed)
|
||||
if unexpected:
|
||||
print("critical|unexpected_ports=" + ",".join(map(str, unexpected)))
|
||||
else:
|
||||
detail = ",".join(map(str, sorted(exposed))) or "none"
|
||||
print("healthy|ports=" + detail)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env python3
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
module_path = pathlib.Path(__file__).with_name("public_listener_policy.py")
|
||||
spec = importlib.util.spec_from_file_location("public_listener_policy", module_path)
|
||||
policy = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(policy)
|
||||
|
||||
|
||||
class PublicListenerPolicyTest(unittest.TestCase):
|
||||
def test_allows_expected_public_ports_and_ignores_loopback(self) -> None:
|
||||
lines = [
|
||||
"LISTEN 0 4096 0.0.0.0:443 0.0.0.0:*",
|
||||
"LISTEN 0 4096 [::]:80 [::]:*",
|
||||
"LISTEN 0 128 *:22 *:*",
|
||||
"LISTEN 0 128 127.0.0.1:5432 0.0.0.0:*",
|
||||
"LISTEN 0 128 [::1]:6379 [::]:*",
|
||||
]
|
||||
self.assertEqual(policy.public_ports(lines), {22, 80, 443})
|
||||
|
||||
def test_reports_wildcard_and_interface_bound_database_ports(self) -> None:
|
||||
lines = [
|
||||
"LISTEN 0 4096 0.0.0.0:3306 0.0.0.0:*",
|
||||
"LISTEN 0 4096 172.16.0.8:5432 0.0.0.0:*",
|
||||
"LISTEN 0 4096 [::]:6379 [::]:*",
|
||||
]
|
||||
self.assertEqual(policy.public_ports(lines) - {22, 80, 443}, {3306, 5432, 6379})
|
||||
|
||||
def test_validates_allowed_port_range(self) -> None:
|
||||
self.assertEqual(policy.parse_allowed("22,80,443"), {22, 80, 443})
|
||||
with self.assertRaises(ValueError):
|
||||
policy.parse_allowed("22,0,443")
|
||||
with self.assertRaises(ValueError):
|
||||
policy.parse_allowed("22, 443")
|
||||
|
||||
def test_cli_marks_unexpected_port_critical(self) -> None:
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(module_path), "22,80,443"],
|
||||
input="LISTEN 0 4096 0.0.0.0:5432 0.0.0.0:*\n",
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
self.assertEqual(result.stdout.strip(), "critical|unexpected_ports=5432")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user