From cb1aca121893cb8443405263d1325e69a7412219 Mon Sep 17 00:00:00 2001 From: pikastech Date: Sun, 19 Jul 2026 13:39:13 +0200 Subject: [PATCH] fix: make public edge DNS checks non-blocking --- .agents/skills/unidesk-cicd/SKILL.md | 5 +++ docs/reference/platform-infra.md | 4 ++ .../specs/PJ2026-010604-public-entry.md | 6 +++ scripts/src/platform-infra-public-edge.ts | 40 ++++++++++++++----- 4 files changed, 44 insertions(+), 11 deletions(-) diff --git a/.agents/skills/unidesk-cicd/SKILL.md b/.agents/skills/unidesk-cicd/SKILL.md index c2414640..ac99525d 100644 --- a/.agents/skills/unidesk-cicd/SKILL.md +++ b/.agents/skills/unidesk-cicd/SKILL.md @@ -156,6 +156,11 @@ bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help - installed authority 与 YAML `authorityId` 一致; - host-local lock 已独占; - managed block、Compose 与 Caddy候选已验证。 +- public-edge 候选验证的阻塞边界: + - provenance、source ancestry、监听器、Compose 和 Caddy 配置无效继续阻塞; + - 全站 DNS 与逐站点 public health 只输出具名 `blocking=false` warning; + - 单个站点 DNS 漂移不得阻塞其他站点路由交付; + - 禁止把 DNS 聚合检查重新加入 candidate rejection 条件。 - 乱序自动交付处理: - installed commit 与 candidate 相同则幂等返回; - installed commit 不是 candidate 祖先时,以 `stale-or-diverged-source-commit` 拒绝; diff --git a/docs/reference/platform-infra.md b/docs/reference/platform-infra.md index deef8df5..ea6ba8b2 100644 --- a/docs/reference/platform-infra.md +++ b/docs/reference/platform-infra.md @@ -70,6 +70,10 @@ - candidate 使用 `public-edge-global` 与 `public-edge-site:` managed block; - 合并只替换 `public-edge-*` blocks,并保留其他 owner blocks; - Caddy/Compose候选验证成功后才安装和重建; + - 候选硬校验只覆盖 provenance、source ancestry、监听器、 + Compose 与 Caddy 配置有效性; + - 全站 DNS 与逐站点 public health 只生成具名 + `blocking=false` warning,不参与 candidate rejection; - 重建或 provenance 写入失败时恢复上一运行面。 - `status` 必须披露: - installed/desired source commit; diff --git a/project-management/PJ2026-01/specs/PJ2026-010604-public-entry.md b/project-management/PJ2026-01/specs/PJ2026-010604-public-entry.md index ba33d449..0ac8e5e0 100644 --- a/project-management/PJ2026-01/specs/PJ2026-010604-public-entry.md +++ b/project-management/PJ2026-01/specs/PJ2026-010604-public-entry.md @@ -117,6 +117,12 @@ Caddy 更新必须使用受控 managed block 合并,不能用某个服务的 installed 为 desired 后继、分叉和无法验证。 - installed 为 desired 祖先且受管配置指纹与 desired 完全一致时, 应判定配置当前,不应要求无关后继提交强制重写运行面。 +- 候选安装前的硬校验只覆盖 provenance、source ancestry、监听器、 + Compose 与 Caddy 配置有效性,以及继续安装会直接损害共享边缘的条件。 +- 逐站点 DNS 与 public health 属于运行面漂移诊断: + - 必须输出具名、可下钻且 `blocking=false` 的 warning; + - 任一站点 DNS 不一致不得阻塞其他站点的候选安装; + - 不得把全站 DNS 聚合结果重新包装为共享边缘交付门禁。 FRP、Caddy 和 TLS 输出只能包含目标、状态、fingerprint、health 和错误摘要,不输出 Secret、token、证书私钥或可复制凭据。 diff --git a/scripts/src/platform-infra-public-edge.ts b/scripts/src/platform-infra-public-edge.ts index 3a79e403..d83e5553 100644 --- a/scripts/src/platform-infra-public-edge.ts +++ b/scripts/src/platform-infra-public-edge.ts @@ -764,15 +764,24 @@ ss -ltn | grep -Eq '[:.]${target.listener.httpsPort}[[:space:]]' || listeners_rc udp_rc=0 ${target.isolation.preservedUdpPorts.map((value) => `ss -lun | grep -Eq '[:.]${value}[[:space:]]' || udp_rc=1`).join("\n")} dns_rc=0 -for item in ${dnsItems}; do host="${"${item%%|*}"}"; expected="${"${item#*|}"}"; getent ahostsv4 "$host" | awk '{print $1}' | grep -Fxq "$expected" || dns_rc=1; done +dns_warnings="$tmp/dns-warnings.tsv"; : >"$dns_warnings" +for item in ${dnsItems}; do + host="${"${item%%|*}"}"; expected="${"${item#*|}"}" + if ! getent ahostsv4 "$host" | awk '{print $1}' | grep -Fxq "$expected"; then + dns_rc=1 + printf '%s\t%s\\n' "$host" "$expected" >>"$dns_warnings" + fi +done compose_rc=0; docker compose -f "$compose_candidate" config >/dev/null 2>"$tmp/compose.err" || compose_rc=$? adapt_rc=0; docker run --rm -v "$caddy_candidate:/etc/caddy/Caddyfile:ro" ${shQuote(target.runtime.image)} caddy adapt --validate --config /etc/caddy/Caddyfile >/dev/null 2>"$tmp/adapt.err" || adapt_rc=$? validate_rc=0; docker run --rm -v "$caddy_candidate:/etc/caddy/Caddyfile:ro" ${shQuote(target.runtime.image)} caddy validate --config /etc/caddy/Caddyfile >/dev/null 2>"$tmp/validate.err" || validate_rc=$? -if [ "$listeners_rc" -ne 0 ] || [ "$udp_rc" -ne 0 ] || [ "$dns_rc" -ne 0 ] || [ "$compose_rc" -ne 0 ] || [ "$adapt_rc" -ne 0 ] || [ "$validate_rc" -ne 0 ]; then +if [ "$listeners_rc" -ne 0 ] || [ "$udp_rc" -ne 0 ] || [ "$compose_rc" -ne 0 ] || [ "$adapt_rc" -ne 0 ] || [ "$validate_rc" -ne 0 ]; then python3 - "$listeners_rc" "$udp_rc" "$dns_rc" "$compose_rc" "$adapt_rc" "$validate_rc" <<'PY' import json, sys names = ["tcpListeners", "preservedUdpListeners", "dns", "compose", "caddyAdapt", "caddyValidate"] -print(json.dumps({"ok": False, "mutation": False, "error": "candidate-validation-failed", "checks": {name: {"exitCode": int(value)} for name, value in zip(names, sys.argv[1:])}, "valuesPrinted": False})) +checks = {name: {"exitCode": int(value)} for name, value in zip(names, sys.argv[1:])} +checks["dns"]["blocking"] = False +print(json.dumps({"ok": False, "mutation": False, "error": "candidate-validation-failed", "checks": checks, "valuesPrinted": False})) PY exit 1 fi @@ -793,9 +802,14 @@ if [ "$new_running" -eq 1 ] \ && [ "$installed_compose_sha" = ${shQuote(desiredComposeFingerprint)} ] \ && [ "$current_managed_sha" = ${shQuote(desiredCaddyFingerprint)} ] \ && [ "$current_compose_sha" = ${shQuote(desiredComposeFingerprint)} ]; then - python3 - ${shQuote(provenance.authorityId)} ${shQuote(provenance.sourceCommit)} <<'PY' + python3 - ${shQuote(provenance.authorityId)} ${shQuote(provenance.sourceCommit)} "$dns_warnings" <<'PY' import json, sys -print(json.dumps({"ok": True, "mutation": False, "mode": "already-current", "authorityId": sys.argv[1], "sourceCommit": sys.argv[2], "valuesPrinted": False})) +warnings = [] +with open(sys.argv[3], encoding="utf-8") as handle: + for line in handle: + hostname, expected_a = line.rstrip("\\n").split("\\t", 1) + warnings.append({"code": "public-edge-dns-mismatch", "level": "warning", "blocking": False, "hostname": hostname, "expectedA": expected_a}) +print(json.dumps({"ok": True, "mutation": False, "mode": "already-current", "authorityId": sys.argv[1], "sourceCommit": sys.argv[2], "warnings": warnings, "valuesPrinted": False})) PY exit 0 fi @@ -874,19 +888,23 @@ for failure in "$tmp"/probe-failure-*.tsv; do probe_rc=1 cat "$failure" >>"$probe_failures" done -python3 - "$probe_rc" "$probe_failures" ${shQuote(provenance.authorityId)} ${shQuote(provenance.sourceCommit)} "$legacy_running" <<'PY' +python3 - "$probe_rc" "$probe_failures" "$dns_warnings" ${shQuote(provenance.authorityId)} ${shQuote(provenance.sourceCommit)} "$legacy_running" <<'PY' import json, sys warnings = [] with open(sys.argv[2], encoding="utf-8") as handle: for line in handle: hostname, path = line.rstrip("\\n").split("\\t", 1) warnings.append({"code": "site-probe-failed", "level": "warning", "blocking": False, "hostname": hostname, "path": path}) +with open(sys.argv[3], encoding="utf-8") as handle: + for line in handle: + hostname, expected_a = line.rstrip("\\n").split("\\t", 1) + warnings.append({"code": "public-edge-dns-mismatch", "level": "warning", "blocking": False, "hostname": hostname, "expectedA": expected_a}) print(json.dumps({ "ok": True, "mutation": True, - "authorityId": sys.argv[3], - "sourceCommit": sys.argv[4], - "legacyRetired": sys.argv[5] == "1", + "authorityId": sys.argv[4], + "sourceCommit": sys.argv[5], + "legacyRetired": sys.argv[6] == "1", "probe": {"attempted": True, "exitCode": int(sys.argv[1]), "blocking": False}, "warnings": warnings, "valuesPrinted": False, @@ -982,8 +1000,8 @@ state_matches_runtime = ( and state.get("composeFingerprint") == current_compose ) config_matches_desired = bool(desired) and current == desired and bool(desired_compose) and current_compose == desired_compose -ok = all(value == 0 for value in [container, listeners, udp, validate, dns, sites]) and legacy != 0 and state_matches_runtime and config_matches_desired -print(json.dumps({"ok": ok, "container": {"exitCode": container}, "legacyContainer": {"exitCode": legacy, "retired": legacy != 0}, "tcpListeners": {"exitCode": listeners}, "preservedUdpListeners": {"exitCode": udp}, "validate": {"exitCode": validate}, "dns": {"exitCode": dns, "failures": dns_failures}, "siteProbes": {"exitCode": sites, "failures": site_failures}, "provenance": {"installed": state, "desiredSourceCommit": desired_commit or None, "matchesRuntime": state_matches_runtime}, "config": {"currentManagedFingerprint": current or None, "desiredManagedFingerprint": desired or None, "currentComposeFingerprint": current_compose or None, "desiredComposeFingerprint": desired_compose or None, "matchesDesired": config_matches_desired}, "valuesPrinted": False})) +ok = all(value == 0 for value in [container, listeners, udp, validate, sites]) and legacy != 0 and state_matches_runtime and config_matches_desired +print(json.dumps({"ok": ok, "container": {"exitCode": container}, "legacyContainer": {"exitCode": legacy, "retired": legacy != 0}, "tcpListeners": {"exitCode": listeners}, "preservedUdpListeners": {"exitCode": udp}, "validate": {"exitCode": validate}, "dns": {"exitCode": dns, "blocking": False, "failures": dns_failures}, "siteProbes": {"exitCode": sites, "failures": site_failures}, "provenance": {"installed": state, "desiredSourceCommit": desired_commit or None, "matchesRuntime": state_matches_runtime}, "config": {"currentManagedFingerprint": current or None, "desiredManagedFingerprint": desired or None, "currentComposeFingerprint": current_compose or None, "desiredComposeFingerprint": desired_compose or None, "matchesDesired": config_matches_desired}, "valuesPrinted": False})) raise SystemExit(0 if ok else 1) PY `;