feat: monitor public listener exposure

This commit is contained in:
Codex Local
2026-08-26 22:38:36 +08:00
parent 7435af7c07
commit 3786db0de8
9 changed files with 164 additions and 9 deletions
+1 -1
View File
@@ -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异地备份适配
+19
View File
@@ -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
+70
View File
@@ -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()