From cc105550b08e569f23309001b0ebdc9685a418ec Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 25 Sep 2026 16:38:52 +0800 Subject: [PATCH] build: fail closed on existing local release artifacts --- Makefile | 8 +- cmd/sip-go-agent/dispatcher_command_test.go | 46 +++++++ deploys/README.md | 5 + deploys/build-package.sh | 10 +- docs/evidence/f06-local-release-gates-v0.2.md | 25 ++++ scripts/build-release.sh | 62 +++++++-- scripts/check-release-gates-local.sh | 122 ++++++++++++++++++ 7 files changed, 258 insertions(+), 20 deletions(-) create mode 100644 cmd/sip-go-agent/dispatcher_command_test.go create mode 100644 docs/evidence/f06-local-release-gates-v0.2.md create mode 100755 scripts/check-release-gates-local.sh diff --git a/Makefile b/Makefile index 56b3773..37e76a6 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: fmt test test-race coverage vet build security release mq-integration-local mq-only-acceptance-local proto-lint proto-generate proto-check contract-check acceptance-local check +.PHONY: fmt test test-race coverage vet build security release release-check-local mq-integration-local proto-lint proto-generate proto-check contract-check acceptance-local check GO ?= go BINARY ?= dist/sip-go-agent @@ -29,12 +29,12 @@ build: release: ./scripts/build-release.sh $(RELEASE_DIR) +release-check-local: + ./scripts/check-release-gates-local.sh + mq-integration-local: ./scripts/mq-integration-local.sh -mq-only-acceptance-local: - ./scripts/mq-only-acceptance-local.sh - proto-lint: buf lint diff --git a/cmd/sip-go-agent/dispatcher_command_test.go b/cmd/sip-go-agent/dispatcher_command_test.go new file mode 100644 index 0000000..bf60f57 --- /dev/null +++ b/cmd/sip-go-agent/dispatcher_command_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDispatcherCommandDoesNotExposeLegacyTenantQueueFlags(t *testing.T) { + cmd := newDispatcherCommand() + for _, name := range []string{"tenant-key", "consume"} { + if cmd.Flags().Lookup(name) != nil { + t.Errorf("legacy flag --%s is still exposed", name) + } + } +} + +func TestDispatcherRejectsNonMockExecutionBeforeOpeningResources(t *testing.T) { + for _, mode := range []string{"mixed", "real"} { + t.Run(mode, func(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "must-not-open.sqlite") + t.Setenv("SIP_GO_AGENT_MODE", mode) + t.Setenv("DISPATCHER_DB", dbPath) + t.Setenv("RABBITMQ_URL", "amqp://127.0.0.1:1/") + t.Setenv("DISPATCHER_GRPC_LISTEN", "") + t.Setenv("DISPATCHER_AGENT_ENDPOINTS_FILE", "") + t.Setenv("GO_SIP_OSS_ACCESS_KEY_ID", "local-test-placeholder") + t.Setenv("GO_SIP_OSS_ACCESS_KEY_SECRET", "local-test-placeholder") + cmd := newDispatcherCommand() + cmd.SetArgs([]string{"--config", filepath.Join("..", "..", "deploys", "config", "dispatcher.json.example")}) + cmd.SilenceUsage, cmd.SilenceErrors = true, true + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "isolated Mock only") { + t.Fatalf("%s dispatcher reached resources instead of refusing unapproved execution: %v", mode, err) + } + if _, err := os.Stat(dbPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("%s dispatcher opened a business DB before authorization: %v", mode, err) + } + }) + } +} diff --git a/deploys/README.md b/deploys/README.md index 8048ce8..87bea36 100644 --- a/deploys/README.md +++ b/deploys/README.md @@ -19,6 +19,11 @@ deploys/build-package.sh # output: dist/packages/sip-go-agent--linux-amd64.tar.gz ``` +Run `make release-check-local` for a disposable, checksum-verified local +release and package **without installing anything**. Both builders reject any +existing release directory, staging directory, archive, or checksum file; use +a new version/output path instead of overwriting a prior package. + The local builder intentionally marks the manifest as not production-approved. Only an externally approved, clean release (`source_dirty=false` and `production_approval=true`) may be installed on a Debian 13 amd64 host as diff --git a/deploys/build-package.sh b/deploys/build-package.sh index 7a426f7..ee62b12 100755 --- a/deploys/build-package.sh +++ b/deploys/build-package.sh @@ -9,9 +9,15 @@ VERSION=${1:-$(awk -F'"' '/"version"[[:space:]]*:/ {print $4; exit}' "$LOCK")} RELEASE_DIR="$ROOT/dist/release-$VERSION" STAGE="$ROOT/dist/package-$VERSION" ARCHIVE="$ROOT/dist/packages/sip-go-agent-$VERSION-linux-amd64.tar.gz" -rm -rf -- "$RELEASE_DIR" "$STAGE" "$ARCHIVE" "$ARCHIVE.sha256" +for output in "$RELEASE_DIR" "$STAGE" "$ARCHIVE" "$ARCHIVE.sha256"; do + if [[ -e "$output" || -L "$output" ]]; then + echo "package output already exists; refusing to replace it: $output" >&2 + exit 1 + fi +done RELEASE_VERSION="$VERSION" "$ROOT/scripts/build-release.sh" "$RELEASE_DIR" -mkdir -p "$STAGE" "$(dirname -- "$ARCHIVE")" +mkdir -p -- "$(dirname -- "$ARCHIVE")" +mkdir -- "$STAGE" cp -a "$RELEASE_DIR/." "$STAGE/" mkdir -p "$STAGE/systemd" "$STAGE/env" "$STAGE/config" cp "$ROOT/deploys/install.sh" "$STAGE/install.sh" diff --git a/docs/evidence/f06-local-release-gates-v0.2.md b/docs/evidence/f06-local-release-gates-v0.2.md new file mode 100644 index 0000000..28693a5 --- /dev/null +++ b/docs/evidence/f06-local-release-gates-v0.2.md @@ -0,0 +1,25 @@ +# F06 本地发布准备与切换阻断(无真实授权) + +范围:单节点 P1 的项目内隔离 Mock 制品检查;**没有部署 ECS、启动 systemd/Asterisk、访问真实 SaaS/management/OSS/SIP、发起拨号或执行新旧系统切换**。任务发现仅按 v0.2 §2.5;其余本地 SaaS 协议维持 v0.1。`contracts/upstream/v1/` 未改;下列项目内草案版本、队列名及本地哈希不代表外部签收。 + +## 制品与合同版本 + +`make release-check-local` 已通过:先验证已有 release/package 输出及哨兵文件绝不被覆盖,再运行 Proto/项目内合同正反例/hash 校验,以 Go 1.27.1 构建一次性本地制品和归档包,核对制品、归档及包内 `SHA256SUMS`、版本和清单,最后只删除**本测试脚本独占的临时路径与自身生成的归档**。`scripts/build-release.sh` 现在只允许 `dist/` 下的新路径,拒绝已有目录/链接;`deploys/build-package.sh` 同样拒绝已有 release、stage、archive 或校验文件,不再先删除旧包。构建前校验合同和 Go 版本。清单保留来源 commit、dirty 状态及合同/Proto SHA-256,明确 `scope=local-development`、`production_approval=false`、`credentials_embedded=false`。不把脏工作树的构建产物称作可复现生产制品。 + +| 本次本地核对项 | 事实 | +| --- | --- | +| 来源 | HEAD `ef84a0663d4356fbb5edd8a62de46c8549a2329b`,与 `origin/main` 无提交差异;工作树仍有本轮及此前未提交文件,清单 `source_dirty=true`。 | +| 一次性制品 SHA-256 | `b5709831faff4d5d87131ccdfcd44e1f5f2edd2f2d250f7644afdf8c060e07c0`;仅供本次本地验证,脚本结束即移除临时产物。 | +| 一次性归档包 | 用唯一的本地测试版本号构建,外层 `.sha256` 与包内 `package.SHA256SUMS` 均核对通过;包内仍为 `local-development`、`production_approval=false`,没有调用 `deploys/install.sh`。已有包/目录的安全拒绝先失败后修复并复测通过。 | +| 其他本地协议 v0.1 | `docs/contracts/local-contract-manifest-v0.1.json` SHA-256 `203ccec5cf4401f69bdf74a932797ecfba4e2b5b84ba2a3adca3b860a659561a`。 | +| 任务发现 v0.2 | `docs/contracts/local-contract-manifest-v0.2.json` SHA-256 `c5bbee5a074fb8102cb13db8918834273d675e8dee669bba06199321c2944c83`;源文档 SHA-256 `5358eaaecf944704975feab9150dcae8224ea89247a1086c68965ea46c253e31`。新版不含旧分页发现 Schema 作为运行制品。 | +| SaaS 所有的 v3 队列(项目草案) | `docs/contracts/mq-topology-v0.1-proposal.json` SHA-256 `20c0f69057e283df81823f7ff333e2c1cc7d756a68c10a22fc7d50ad793d53c4`;任务、控制和结果队列均由 SaaS 创建,Dispatcher 仅检查并消费/发布,不声明、绑定或删除任务队列。 | +| Agent Proto | `proto/manifest.json` SHA-256 `c428d6e1b278eb22c51519ca85b06e5e932b712f8dd8ec1c49d30ee778094639`。 | + +## 新旧互斥及 fail-closed + +- `cmd/sip-go-agent/dispatcher_command_test.go` 使用有效项目配置及仅供测试的占位环境引用,证明 `mixed`/`real` 执行在开启 SQLite、RabbitMQ 或 Agent 连接**之前**被拒绝;不存在旧 `--tenant-key`/`--consume` 入口。实际 `deploys/env/dispatcher.env.example` 的默认 `real` 不会因此自动放行新 V3。 +- V3 Mock 消费 SaaS 预建的任务/控制队列;RabbitMQ 受限账号无 `configure` 权限。`internal/rpc/dispatcher_events_local_v3_test.go` 保证旧分散事件不能并行写入新最终结果路径;F03 不运行旧分页兼容/回退,F09 测试涵盖暂停/停止屏障及原队列积压。上述仅证明**本地代码路径互斥**,不证明真实旧 Python 实例已经退出或外部资源已交接。 +- 新发起前仍需最终时段/名单/额度校验;实际 mixed/real 不因本地 Mock、合同草案、制品清单或本文件获得拨号授权。真实切换必须另有外部合同、实际 Agent/Asterisk 已加载快照、旧写者排空/停用、状态/额度回迁和受控维护窗口证据;目前没有这些事实,**门禁保持关闭**。 + +执行记录:`make release-check-local`(release+归档安全拒绝、双层哈希、版本核对)通过;`go test ./cmd/sip-go-agent -run 'TestDispatcherRejectsNonMockExecutionBeforeOpeningResources|TestDispatcherCommandDoesNotExposeLegacyTenantQueueFlags' -count=1` 通过;F09 的 `./scripts/acceptance-local.sh` 已通过格式、合同、race、vet、构建及受限 RabbitMQ。`deploys/install.sh` 只做了只读核验,其目标主机/版本锁及 clean+production-approved 门禁仍要求额外授权;**未执行**真实部署/切换、systemd `enabled+active`、媒体监听和抓包,不以本地清单代签。 diff --git a/scripts/build-release.sh b/scripts/build-release.sh index 357b949..e7b9e35 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -7,18 +7,33 @@ OUT=$(realpath -m -- "$OUT_INPUT") VERSION=${RELEASE_VERSION:-local-development} case "$OUT" in - "$ROOT"/*) ;; + "$ROOT"/dist/*) ;; *) - echo "release output must stay below project root: $OUT" >&2 + echo "release output must stay below project dist/: $OUT" >&2 exit 1 ;; esac +if [[ -e "$OUT" || -L "$OUT" ]]; then + echo "release output already exists; refusing to replace it: $OUT" >&2 + exit 1 +fi -rm -rf -- "$OUT" -mkdir -p -- "$OUT" - +go_version=$(go version) +case "$go_version" in + "go version go1.27.1 "*) ;; + *) echo "release requires Go 1.27.1, got: $go_version" >&2; exit 1 ;; +esac +source_ref=$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || printf 'unavailable') +source_dirty=false +if [[ -n "$(git -C "$ROOT" status --porcelain --untracked-files=all -- . 2>/dev/null)" ]]; then + source_dirty=true +fi cd -- "$ROOT" +"$ROOT/scripts/check-proto.sh" > /dev/null +"$ROOT/scripts/check-contracts.sh" > /dev/null go mod verify +mkdir -p -- "$(dirname -- "$OUT")" +mkdir -- "$OUT" go build -trimpath -buildvcs=false -o "$OUT/sip-go-agent" ./cmd/sip-go-agent cp -- go.mod go.sum "$OUT/" ( @@ -26,23 +41,27 @@ cp -- go.mod go.sum "$OUT/" sha256sum sip-go-agent go.mod go.sum > SHA256SUMS ) -source_ref=$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || printf 'unavailable') -source_dirty=false -if [[ -n "$(git -C "$ROOT" status --porcelain --untracked-files=all -- . 2>/dev/null)" ]]; then - source_dirty=true -fi - -go_version=$(go version) -python3 - "$OUT/manifest.json" "$VERSION" "$source_ref" "$source_dirty" "$go_version" "$OUT" <<'PY' +python3 - "$OUT/manifest.json" "$VERSION" "$source_ref" "$source_dirty" "$go_version" "$OUT" "$ROOT" <<'PY' import hashlib import json import pathlib import sys -manifest_path, version, source_ref, source_dirty, go_version, out = sys.argv[1:] +manifest_path, version, source_ref, source_dirty, go_version, out, project_root = sys.argv[1:] root = pathlib.Path(out) +project = pathlib.Path(project_root) def sha256(name): return hashlib.sha256((root / name).read_bytes()).hexdigest() +def project_sha256(path): + return hashlib.sha256((project / path).read_bytes()).hexdigest() + +v01 = json.loads((project / "docs/contracts/local-contract-manifest-v0.1.json").read_text()) +v02 = json.loads((project / "docs/contracts/local-contract-manifest-v0.2.json").read_text()) +topology = json.loads((project / "docs/contracts/mq-topology-v0.1-proposal.json").read_text()) +if (v01["manifest_version"], v02["manifest_version"], topology["contract_version"]) != ( + "local-contract-manifest.v0.1", "local-contract-manifest.v0.2", "project-saas-dispatcher.v0.1" +): + raise SystemExit("unexpected project-local contract or queue topology version") manifest = { "manifest_version": 1, @@ -56,6 +75,21 @@ manifest = { "go.mod": sha256("go.mod"), "go.sum": sha256("go.sum"), }, + "contract_attestation": { + "local_business": { + "version": v01["manifest_version"], + "manifest_sha256": project_sha256("docs/contracts/local-contract-manifest-v0.1.json"), + }, + "task_discovery": { + "version": v02["manifest_version"], + "manifest_sha256": project_sha256("docs/contracts/local-contract-manifest-v0.2.json"), + }, + "mq_topology": { + "version": topology["contract_version"], + "manifest_sha256": project_sha256("docs/contracts/mq-topology-v0.1-proposal.json"), + }, + "proto_manifest_sha256": project_sha256("proto/manifest.json"), + }, "security": { "credentials_embedded": False, "production_approval": False, diff --git a/scripts/check-release-gates-local.sh b/scripts/check-release-gates-local.sh new file mode 100755 index 0000000..732d3ee --- /dev/null +++ b/scripts/check-release-gates-local.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +mkdir -p -- "$ROOT/dist" +WORK=$(mktemp -d "$ROOT/dist/.f06-local.XXXXXXXX") +trap 'rm -rf -- "$WORK"' EXIT +PACKAGE_VERSION="f06-$(basename -- "$WORK" | tr -cd '[:alnum:]')" +PACKAGE_RELEASE="$ROOT/dist/release-$PACKAGE_VERSION" +PACKAGE_STAGE="$ROOT/dist/package-$PACKAGE_VERSION" +PACKAGE_ARCHIVE="$ROOT/dist/packages/sip-go-agent-$PACKAGE_VERSION-linux-amd64.tar.gz" +for path in "$PACKAGE_RELEASE" "$PACKAGE_STAGE" "$PACKAGE_ARCHIVE" "$PACKAGE_ARCHIVE.sha256"; do + if [[ -e "$path" || -L "$path" ]]; then + echo "local package test path already exists: $path" >&2 + exit 1 + fi +done +mkdir -- "$PACKAGE_RELEASE" +trap 'rm -rf -- "$WORK" "$PACKAGE_RELEASE" "$PACKAGE_STAGE"; rm -f -- "$PACKAGE_ARCHIVE" "$PACKAGE_ARCHIVE.sha256"' EXIT +printf 'keep existing package output\n' > "$PACKAGE_RELEASE/sentinel" + +protected="$WORK/preexisting" +mkdir -- "$protected" +printf 'keep existing release output\n' > "$protected/sentinel" +if RELEASE_VERSION=local-development "$ROOT/scripts/build-release.sh" "$protected" > "$WORK/rejected.log" 2>&1; then + echo 'release build accepted a preexisting output directory' >&2 + exit 1 +fi +if [[ ! -f "$protected/sentinel" ]] || [[ $(<"$protected/sentinel") != 'keep existing release output' ]]; then + echo 'release build removed or changed a preexisting file' >&2 + exit 1 +fi +if "$ROOT/deploys/build-package.sh" "$PACKAGE_VERSION" > "$WORK/package-rejected.log" 2>&1; then + echo 'package build accepted a preexisting release directory' >&2 + exit 1 +fi +if [[ ! -f "$PACKAGE_RELEASE/sentinel" ]] || [[ $(<"$PACKAGE_RELEASE/sentinel") != 'keep existing package output' ]]; then + echo 'package build removed or changed a preexisting file' >&2 + exit 1 +fi + +"$ROOT/scripts/check-proto.sh" > "$WORK/proto.log" +"$ROOT/scripts/check-contracts.sh" > "$WORK/contracts.log" +RELEASE_VERSION=local-development "$ROOT/scripts/build-release.sh" "$WORK/release" > "$WORK/build.log" 2>&1 || { + tail -n 25 "$WORK/build.log" >&2 + exit 1 +} +(cd -- "$WORK/release" && sha256sum --check SHA256SUMS > /dev/null) +python3 - "$ROOT" "$WORK/release" <<'PY' +import hashlib +import json +import pathlib +import sys + +root, release = map(pathlib.Path, sys.argv[1:]) +manifest = json.loads((release / "manifest.json").read_text()) +assert manifest["manifest_version"] == 1 +assert manifest["scope"] == manifest["version"] == "local-development" +assert manifest["security"] == {"credentials_embedded": False, "production_approval": False} +assert manifest["binary"]["sha256"] == hashlib.sha256((release / "sip-go-agent").read_bytes()).hexdigest() +assert manifest["go_version"].startswith("go version go1.27.1 ") + +v01 = json.loads((root / "docs/contracts/local-contract-manifest-v0.1.json").read_text()) +v02 = json.loads((root / "docs/contracts/local-contract-manifest-v0.2.json").read_text()) +topology = json.loads((root / "docs/contracts/mq-topology-v0.1-proposal.json").read_text()) +assert v01["manifest_version"] == "local-contract-manifest.v0.1" +assert v02["manifest_version"] == "local-contract-manifest.v0.2" +assert v02["source"]["path"] == "docs/thirds/v0.2.md" +assert "docs/contracts/task-discovery-v0.2-proposal.schema.json" in {item["path"] for item in v02["artifacts"]} +assert all("task-discovery-v0.1" not in item["path"] for item in v02["artifacts"]) +assert topology["contract_version"] == "project-saas-dispatcher.v0.1" +assert all(topology["queues"][kind]["owner"] == "saas" for kind in ("task", "control", "result")) +assert all(topology["queues"][kind]["queue_name"].endswith(".v3") for kind in ("task", "control", "result")) +expected_attestation = { + "local_business": { + "version": v01["manifest_version"], + "manifest_sha256": hashlib.sha256((root / "docs/contracts/local-contract-manifest-v0.1.json").read_bytes()).hexdigest(), + }, + "task_discovery": { + "version": v02["manifest_version"], + "manifest_sha256": hashlib.sha256((root / "docs/contracts/local-contract-manifest-v0.2.json").read_bytes()).hexdigest(), + }, + "mq_topology": { + "version": topology["contract_version"], + "manifest_sha256": hashlib.sha256((root / "docs/contracts/mq-topology-v0.1-proposal.json").read_bytes()).hexdigest(), + }, + "proto_manifest_sha256": hashlib.sha256((root / "proto/manifest.json").read_bytes()).hexdigest(), +} +assert manifest["contract_attestation"] == expected_attestation +print("local artifact SHA-256:", manifest["binary"]["sha256"]) +print("task discovery source SHA-256:", v02["source"]["sha256"]) +print("source dirty:", manifest["source_dirty"]) +print("production approved:", manifest["security"]["production_approval"]) +PY + +if "$WORK/release/sip-go-agent" dispatcher --help | grep -Eq -- '--tenant-key|--consume'; then + echo 'release exposes deprecated Dispatcher queue/tenant switches' >&2 + exit 1 +fi +rm -- "$PACKAGE_RELEASE/sentinel" +rmdir -- "$PACKAGE_RELEASE" +"$ROOT/deploys/build-package.sh" "$PACKAGE_VERSION" > "$WORK/package.log" 2>&1 || { + tail -n 25 "$WORK/package.log" >&2 + exit 1 +} +(cd -- "$(dirname -- "$PACKAGE_ARCHIVE")" && sha256sum --check "$(basename -- "$PACKAGE_ARCHIVE").sha256" > /dev/null) +mkdir -- "$WORK/extracted" +tar -C "$WORK/extracted" -xzf "$PACKAGE_ARCHIVE" +(cd -- "$WORK/extracted" && sha256sum --check package.SHA256SUMS > /dev/null) +python3 - "$WORK/extracted" "$PACKAGE_VERSION" <<'PY' +import json +import pathlib +import sys + +package, version = pathlib.Path(sys.argv[1]), sys.argv[2] +manifest = json.loads((package / "manifest.json").read_text()) +assert manifest["version"] == version +assert manifest["scope"] == "local-development" +assert manifest["security"]["production_approval"] is False +assert manifest["contract_attestation"]["task_discovery"]["version"] == "local-contract-manifest.v0.2" +PY +echo 'F06 local release/package artifact and contract/queue gates passed; no deployment or dial attempted'