From 3786db0de83262cdd8e60df8a09aae4c2a48b5d7 Mon Sep 17 00:00:00 2001 From: Codex Local Date: Wed, 26 Aug 2026 22:38:36 +0800 Subject: [PATCH] feat: monitor public listener exposure --- .github/workflows/ci.yml | 4 +- .gitignore | 2 + deploy/server/README.md | 2 +- deploy/server/health-monitor.sh | 19 ++++++ deploy/server/public_listener_policy.py | 70 ++++++++++++++++++++ deploy/server/test_public_listener_policy.py | 54 +++++++++++++++ docs/development-status.md | 4 +- docs/server-health-monitoring.md | 12 +++- web/src/pages/OpsDiagnosticsWorkspace.tsx | 6 +- 9 files changed, 164 insertions(+), 9 deletions(-) create mode 100644 deploy/server/public_listener_policy.py create mode 100644 deploy/server/test_public_listener_policy.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3bb223c..da5b6c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,9 @@ jobs: - name: Validate compose run: docker compose config --quiet - name: Validate offsite backup authentication and round trip - run: bash deploy/server/test-sync-backup-offsite.sh + run: | + bash deploy/server/test-sync-backup-offsite.sh + python3 -B deploy/server/test_public_listener_policy.py - name: Resolve immutable build identity run: | openapi_version="$(awk '/^ version:/{print $2; exit}' openapi/openapi.yaml)" diff --git a/.gitignore b/.gitignore index 28d01b8..f6db02a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ .idea/ .vscode/ .DS_Store +__pycache__/ +*.py[cod] *.log coverage.out coverage*.out diff --git a/deploy/server/README.md b/deploy/server/README.md index 60c7a07..d30b6b9 100644 --- a/deploy/server/README.md +++ b/deploy/server/README.md @@ -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异地备份适配 diff --git a/deploy/server/health-monitor.sh b/deploy/server/health-monitor.sh index 7f8d24a..e899a07 100755 --- a/deploy/server/health-monitor.sh +++ b/deploy/server/health-monitor.sh @@ -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 diff --git a/deploy/server/public_listener_policy.py b/deploy/server/public_listener_policy.py new file mode 100644 index 0000000..12a3fba --- /dev/null +++ b/deploy/server/public_listener_policy.py @@ -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()) diff --git a/deploy/server/test_public_listener_policy.py b/deploy/server/test_public_listener_policy.py new file mode 100644 index 0000000..df3b838 --- /dev/null +++ b/deploy/server/test_public_listener_policy.py @@ -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() diff --git a/docs/development-status.md b/docs/development-status.md index e1a817f..7757e12 100644 --- a/docs/development-status.md +++ b/docs/development-status.md @@ -181,7 +181,7 @@ | 隐私删除并发 | 100个并发受理全部成功、HTTP失败率0%、提交p95 385.15ms;11.58秒内600个子任务、100个墓碑和100个主体冻结全部对账 | | PostgreSQL隔离恢复 | 实际`pg_dump`/`pg_restore`到随机隔离库,迁移`22|false`、66张表、190个外键和关键计数一致;本地约5.14秒,临时库/备份已清理 | | 阿里云单机加密备份与恢复 | 目标服务器已安装每日systemd备份任务,覆盖PostgreSQL、认证Redis RDB、MinIO全部桶和暂停后的Keycloak数据目录;归档使用AES-256-CBC/PBKDF2加密、逐文件SHA-256并保留14天。首次实际归档成功,数据库从V38备份恢复到随机隔离库后`38|false|2|3|73|215`六项证据一致,归档内Redis RDB格式有效、Keycloak 3个文件一致、临时恢复库为0;同机备份尚不作为跨故障域生产恢复证据 | -| 阿里云单机健康监测 | 目标服务器已安装五分钟systemd健康检查,结构化状态覆盖HTTPS、OIDC签发地址、11个常驻容器、本机/异地备份状态与摘要、备份执行结果、三个运行timer、短期IP证书剩余时间和磁盘阈值。异地适配安装后的实际正常路径22项全部healthy;把本机备份最大年龄临时设为0秒后仅`backup_freshness`进入critical并退出1,测试目录已清理且线上容器未停止。可选Webhook仅在状态变化时发送带时间戳和HMAC-SHA256的脱敏状态;真实接收器尚未配置,因此不作为值班通知送达证据 | +| 阿里云单机健康监测 | 目标服务器已安装五分钟systemd健康检查,结构化状态覆盖HTTPS、宿主机公网监听端口、OIDC签发地址、11个常驻容器、本机/异地备份状态与摘要、备份执行结果、三个运行timer、短期IP证书剩余时间和磁盘阈值。监听策略只允许非回环TCP 22/80/443,回环数据库端口不误报,额外端口会进入critical。实际正常路径23项全部healthy;把本机备份最大年龄临时设为0秒后仅`backup_freshness`进入critical并退出1。可选Webhook仅在状态变化时发送带时间戳和HMAC-SHA256的脱敏状态;真实接收器尚未配置,因此不作为值班通知送达证据 | | 阿里云OSS异地备份适配 | 目标服务器已按官方SHA-256安装ossutil 2.3.0,新增每日OSS同步任务、加密归档及摘要上传、远端回读复算、原子状态和健康强制门禁。ECS正式路径现使用IMDSv2和`EcsRamRole`自动短期凭据,不保存长期AK;隔离配置模式验证上传/回读2个文件并得到`verified`,目标实例缺角色时退出4并记录`failed/ecs_ram_role_missing`。2026-08-26实测地域`cn-hangzhou`且角色元数据404;当前仍无RAM角色和Bucket,正式任务保持`disabled/not_enabled`且健康状态为`not_required`,不能作为真实异地备份证据 | | 进程与Redis恢复 | API/Callbacks/Worker重启恢复;停机期间Outbox仅执行一次;Redis 14个热状态键清空后由签名设备请求重建,PostgreSQL权威计数不变 | | 供应商故障恢复 | V23持久化按租户/供应商的可用状态;连续临时失败暂停新启动但停止任务继续尝试;演练确认暂停后新外呼0、停止尝试2次、历史接口200、CRITICAL告警出现,恢复后全部收敛;V23单版本回退/重升通过 | @@ -250,7 +250,7 @@ | 真实Agent生成、Embedding和内容安全质量门禁 | 新增`real-agent-models-capture-v1`、`real-agent-models-conformance-report-v1`和`cmd/agent-models-conformance`。内容生成复用现有固定10类Agent评测,并使用评测集、脱敏观测和报告重新计算防篡改;Embedding按版本化阈值覆盖精确/改写召回、跨长者隔离、撤销/敏感排除和无匹配降级六类;内容安全覆盖验证码、金融账户、提示注入、敏感医疗、正常内容和模型输出六类。真实证据还必须绑定受控提供器、账号/区域、数据条款、额度和审计引用;本地生成器、哈希向量和规则固定为`LOCAL_AGENT_TOOLCHAIN/external_validation_required=true`。v3证据包新增`real_agent_models_validation`,该门禁不可豁免。按低强度策略执行聚合器/证据包定向测试和一条重算现有模拟报告的本地工具链冒烟:既有10类模拟报告重新计算一致,两组六类结构完整,输出`PASS/LOCAL_AGENT_TOOLCHAIN`并继续外部待验;证据包保留真实报告、三提供器和12类别共16项缺口,临时文件已清理。 | | 托管内容安全外部预检与告警 | OpenAPI 0.64.0把`content_safety_tls`纳入外部依赖查询和候选验收快照。生产Worker仅执行DNS、TCP和受信TLS握手,不发送待分类正文;结果复用依赖可用性指标、数据库脱敏状态和运营诊断页面,连续5分钟不可用进入统一CRITICAL告警,记录缺失/失败/过期均可阻断候选技术快照但不驱逐Pod。按低强度策略执行App/探针/诊断定向测试、前端类型生成/类型检查/生产构建和Prometheus规则校验。 | | 云端主机健康状态接入 | OpenAPI 0.65.0新增独立HMAC签名的主机健康回调、30天脱敏快照表和运营只读状态页面。五分钟周期上报覆盖公网入口、容器、备份、证书、磁盘和定时器;无签名、过期、重复检查及综合状态不一致报告被拒绝,页面超过15分钟未更新明确标记过期。该能力不替代整机、Callbacks或数据库失联时必须使用的外部探针。 | -| 云端主机健康发布门禁 | OpenAPI 0.66.0把最新主机健康快照纳入发布候选技术验收:无上报、超过15分钟、综合预警或严重异常均阻断,只有当前有效且22项检查全部正常才通过。快照仅归档状态、时间和检查数量,不复制磁盘、证书或备份明细值。 | +| 云端主机健康发布门禁 | OpenAPI 0.66.0把最新主机健康快照纳入发布候选技术验收:无上报、超过15分钟、综合预警或严重异常均阻断,只有当前有效且23项检查全部正常才通过,其中包括宿主机公网监听端口策略。快照仅归档状态、时间和检查数量,不复制磁盘、证书、监听地址或备份明细值。 | | 阶段8外部执行轻量预检 | 新增`scripts/stage8-preflight.ps1`,以单一只读入口核对候选Git提交、远程仓库、Compose本地基线、外部配置变量名称和十类证据索引结构/状态;报告明确不读取密钥值,也不把环境变量或本地服务可用性当作验收证据。默认仅报告缺口,`-Strict`可供自动化阻断未就绪交接;当前已建立本地`main`候选提交,本地运行基线就绪且索引结构有效,仍缺远程仓库,样例构建摘要为占位值,十类外部证据均为`PENDING`。 | | 候选构建身份贯通 | Compose新增统一候选构建参数,后端二进制和后端/Web镜像OCI标签共享版本、完整Git提交及UTC构建时间;远程CI从OpenAPI版本和运行编号形成候选版本,构建后重新读取两个镜像标签并与`GITHUB_SHA`和构建时间逐项比对,再把候选版本、提交、时间和双镜像摘要写入构建元数据。修复了此前Dockerfile虽支持注入但Compose/CI未传参、运行版本仍为`dev/unknown`的问题。 | diff --git a/docs/server-health-monitoring.md b/docs/server-health-monitoring.md index 9470cdd..673568f 100644 --- a/docs/server-health-monitoring.md +++ b/docs/server-health-monitoring.md @@ -2,7 +2,7 @@ ## 1. 目标与边界 -`deploy/server/health-monitor.sh`为单机阿里云演示环境提供五分钟一次的轻量健康检查。它用于发现“公网入口不可用、身份签发地址漂移、容器退出、备份过期或损坏、证书接近到期、定时任务停止、磁盘空间不足”等直接影响可用性的问题。 +`deploy/server/health-monitor.sh`为单机阿里云演示环境提供五分钟一次的轻量健康检查。它用于发现“公网入口不可用、宿主机出现未授权公网监听端口、身份签发地址漂移、容器退出、备份过期或损坏、证书接近到期、定时任务停止、磁盘空间不足”等直接影响可用性或暴露面的事件。 该脚本不替代Prometheus、Grafana、云监控或真实值班系统。默认只写结构化状态文件和systemd日志;配置外部接收器后,状态发生变化时才发送一次HMAC签名Webhook,避免每五分钟重复通知。 @@ -12,6 +12,7 @@ |---|---|---| | 公网地址 | `PUBLIC_ORIGIN`为HTTPS | `invalid_https_origin` | | HTTPS入口 | `/api/v1/version`在10秒内返回成功 | `request_failed` | +| 公网监听端口 | 宿主机非回环TCP监听端口只能属于`22,80,443`;回环地址上的数据库或监控端口不计入公网暴露 | `unexpected_ports=...`、`probe_unavailable`或`invalid_probe_result` | | OIDC | Discovery中的`issuer`与公网地址完全一致 | `issuer_mismatch`或`request_failed` | | 容器 | PostgreSQL、Redis、MinIO、Keycloak、API、Callbacks、Worker、Web、Gateway和两个模拟服务均为运行态;有健康检查的容器必须为healthy | `missing`、容器状态或健康状态 | | 备份 | 最新加密归档不超过36小时且外层SHA-256有效 | `missing`、`age_seconds=...`或`checksum_failed` | @@ -89,11 +90,12 @@ PLATFORM_HEALTH_REPORT_SECRET_FILE=/etc/data-terminal-cloud/platform-health-call | `CERTIFICATE_MIN_SECONDS` | `129600` | 证书最小剩余时间,36小时 | | `DISK_WARNING_PERCENT` | `80` | 磁盘警告阈值 | | `DISK_CRITICAL_PERCENT` | `90` | 磁盘严重阈值 | +| `EXPECTED_PUBLIC_TCP_PORTS` | `22,80,443` | 宿主机允许绑定非回环地址的TCP端口,逗号分隔 | | `MONITOR_STATE_DIR` | `/var/lib/data-terminal-cloud-monitor` | 原子状态文件目录 | | `MONITOR_CONFIG_FILE` | `/etc/data-terminal-cloud/monitor.env` | 可选通知配置 | | `TLS_CERTIFICATE_FILE` | 根据`PUBLIC_ORIGIN`主机名推导 | 当前活动证书文件 | -阈值只能使用非负整数,磁盘警告阈值必须小于严重阈值。普通调整无需重建镜像,但应记录变更理由并重新执行一次手工检查。 +阈值只能使用非负整数,磁盘警告阈值必须小于严重阈值。端口必须位于1—65535;修改允许端口前必须同步复核阿里云安全组,不能用扩大允许列表掩盖错误的数据库或缓存端口映射。普通调整无需重建镜像,但应记录变更理由并重新执行一次手工检查。 ## 7. 验收与排障 @@ -114,6 +116,12 @@ MONITOR_STATE_DIR="$test_dir" MONITOR_CONFIG_FILE=/dev/null \ 期望退出1,状态为critical,唯一人工注入的严重检查为`backup_freshness`。测试目录验证后应精确删除。 +监听策略解析器的正常、回环忽略和异常端口分支可独立验证: + +```bash +python3 -B deploy/server/test_public_listener_policy.py +``` + 常用排障命令: ```bash diff --git a/web/src/pages/OpsDiagnosticsWorkspace.tsx b/web/src/pages/OpsDiagnosticsWorkspace.tsx index 8722266..1ddf643 100644 --- a/web/src/pages/OpsDiagnosticsWorkspace.tsx +++ b/web/src/pages/OpsDiagnosticsWorkspace.tsx @@ -101,7 +101,7 @@ export function OpsDiagnosticsWorkspace({ accessToken, accounts }: { accessToken {canOperate?

RELEASE CANDIDATE EVIDENCE

发布候选验收快照

一次冻结候选构建、迁移、必需依赖、联调问题、可靠任务和严重告警;输出仅是技术预检证据,不代替发布批准。

void exportAcceptanceSnapshot(event)}>