diff --git a/.agents/skills/xstats-release/references/runbook.md b/.agents/skills/xstats-release/references/runbook.md index 6751d94..0ff9df1 100644 --- a/.agents/skills/xstats-release/references/runbook.md +++ b/.agents/skills/xstats-release/references/runbook.md @@ -137,7 +137,7 @@ TAP=ysicing/homebrew-tap Its required order is: 1. Verify provenance, clean worktree, and `HEAD == origin/main`. -Before uploading or publishing a Sparkle client, run `python3 scripts/publish_api.py --check-sparkle`. Both regional fixed XML endpoints must already be deployed and reject a missing installation ID with 400 + no-store. Old JSON POST clients and publication fields remain unchanged. The API serves the current manifest's immutable signed XML without rewriting its signature or forwarding the installation ID to the CDN. +Before uploading or publishing a Sparkle client, run `python3 scripts/publish_api.py --check-sparkle`. After the JSON manifest is published, `python3 scripts/publish_api.py --verify-sparkle dist/XStats--AppleSilicon.xml` fetches each regional XML endpoint with the reserved probe installation ID (64 zeros, not counted in statistics) and requires the bytes to match the local signed XML; `publish_release.sh` runs it automatically. Both regional fixed XML endpoints must already be deployed and reject a missing installation ID with 400 + no-store. Old JSON POST clients and publication fields remain unchanged. The API serves the current manifest's immutable signed XML without rewriting its signature or forwarding the installation ID to the CDN. 2. Verify Ed25519 signatures, then upload DMG, ZIP and versioned XML to `c-ip`. 3. Download each public `c.ysicing.net` URL and compare SHA-256. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index cc56b1b..e1b5620 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -172,7 +172,7 @@ git push ``` 发布需要本地装有 `mc`(MinIO 客户端,别名 `c-ip` 指向对象存储源站)和 `gh`(GitHub CLI)。 -安装包与已签名的 XML 放在 `https://c.ysicing.net/oss/apps/macOS/XStats/`;上传并回读核验 DMG、ZIP、XML 后才发布原有 JSON API,API 字段和旧客户端协议保持不变。新客户端通过固定 `/api/v1/update/appcast.xml` 获取当前版本的签名 XML,服务端在 256 KiB 上限内核验 Ed25519 签名、RSS 结构和与当前发布一致的版本、构建号、系统要求及安装包元数据,成功后原样代理版本文件,合并统计并禁止缓存。无效内容返回 502,不增加检查统计;旧 JSON 接口独立工作。后端公钥须与 `App/Info.plist` 的 `SUPublicEDKey` 一致,轮换发布密钥时同步更新两套后端。必须先部署双区域 API;发布脚本通过无安装 ID 的只读探测确认入口已上线(400 + no-store),未就绪则阻止发布。GitHub Release 只挂 dmg, +安装包与已签名的 XML 放在 `https://c.ysicing.net/oss/apps/macOS/XStats/`;上传并回读核验 DMG、ZIP、XML 后才发布原有 JSON API,API 字段和旧客户端协议保持不变。新客户端通过固定 `/api/v1/update/appcast.xml` 获取当前版本的签名 XML,服务端在 256 KiB 上限内核验 Ed25519 签名、RSS 结构和与当前发布一致的版本、构建号、系统要求及安装包元数据,成功后原样代理版本文件,合并统计并禁止缓存。无效内容返回 502,不增加检查统计;旧 JSON 接口独立工作。后端公钥须与 `App/Info.plist` 的 `SUPublicEDKey` 一致(`go test` 会校验),轮换发布密钥时同步更新两套后端。JSON 发布后,`publish_api.py --verify-sparkle` 以保留探测标识(64 个 `0`,不计入统计)逐区域取回 XML,须与本地已签名文件逐字节一致。必须先部署双区域 API;发布脚本通过无安装 ID 的只读探测确认入口已上线(400 + no-store),未就绪则阻止发布。GitHub Release 只挂 dmg, 作为应用内「手动下载」和 README 的下载入口。版本徽章需在四个 README 的第 9 行手动更新, `scripts/sync_changelog.py` 不处理徽章。 diff --git a/scripts/publish_api.py b/scripts/publish_api.py index 3b123bf..6dab9e2 100644 --- a/scripts/publish_api.py +++ b/scripts/publish_api.py @@ -20,25 +20,51 @@ DEFAULT_ENDPOINTS = ( ) +# 与服务端 releaseProbeInstallationID 一致:核验更新源但不计入安装统计。 +RELEASE_PROBE_INSTALLATION_ID = "0" * 64 +MAXIMUM_FEED_BYTES = 256 * 1024 + + +def sparkle_feed_url(endpoint: str, query: str = "") -> str: + parts = urlsplit(endpoint) + if not parts.path.endswith("/api/v1/releases/current"): + raise ValueError(f"无法推导 Sparkle 更新源:{endpoint}") + return urlunsplit((parts.scheme, parts.netloc, parts.path.removesuffix("releases/current") + "update/appcast.xml", query, "")) + + def check_sparkle_endpoints(endpoints: list[str]) -> None: """不携带安装标识的探测必须返回 400,防止先发布客户端而服务端仍是旧版。""" if not endpoints: raise ValueError("至少需要一个版本发布接口") for endpoint in endpoints: - parts = urlsplit(endpoint) - if not parts.path.endswith("/api/v1/releases/current"): - raise ValueError(f"无法推导 Sparkle 更新源:{endpoint}") - url = urlunsplit((parts.scheme, parts.netloc, parts.path.removesuffix("releases/current") + "update/appcast.xml", "", "")) + url = sparkle_feed_url(endpoint) request = urllib.request.Request(url, headers={"User-Agent": "XStats-Release"}) try: with urllib.request.urlopen(request, timeout=20) as response: raise RuntimeError(f"{url} 未拒绝缺少安装标识的请求:{response.status}") except urllib.error.HTTPError as error: with error: - if error.code != 400 or error.headers.get("Cache-Control") != "no-store": + # 代理可能合并缓存指令,按 token 判断而不是整串比较。 + directives = {item.strip().lower() for item in (error.headers.get("Cache-Control") or "").split(",")} + if error.code != 400 or "no-store" not in directives: raise RuntimeError(f"请先部署支持 Sparkle 的 API:{url} 返回 {error.code}") from error +def verify_sparkle_feeds(feed: Path, endpoints: list[str]) -> None: + """发布 JSON 后逐区域取回 Sparkle 清单,必须与本地已签名 XML 逐字节一致。""" + if not endpoints: + raise ValueError("至少需要一个版本发布接口") + expected = feed.read_bytes() + query = f"current_version=release-probe&installation_id={RELEASE_PROBE_INSTALLATION_ID}" + for endpoint in endpoints: + url = sparkle_feed_url(endpoint, query) + request = urllib.request.Request(url, headers={"User-Agent": "XStats-Release"}) + with urllib.request.urlopen(request, timeout=30) as response: + if response.status != 200 or response.read(MAXIMUM_FEED_BYTES + 1) != expected: + raise RuntimeError(f"{url} 返回的 Sparkle 清单与 {feed.name} 不一致") + print(f"✅ Sparkle 更新源已生效:{urlsplit(url).netloc}") + + def publish(appcast: Path, endpoints: list[str], token: str) -> None: if not token: raise ValueError("XSTATS_RELEASE_TOKEN 不能为空") @@ -75,15 +101,19 @@ def configured_endpoints(environment: Mapping[str, str]) -> list[str]: def main() -> None: - if len(sys.argv) != 2: - raise SystemExit(f"用法:{sys.argv[0]} ") + usage = f"用法:{sys.argv[0]} | --check-sparkle | --verify-sparkle " + arguments = sys.argv[1:] + if len(arguments) not in (1, 2) or (len(arguments) == 2) != (arguments[0] == "--verify-sparkle"): + raise SystemExit(usage) endpoints = configured_endpoints(os.environ) token = os.environ.get("XSTATS_RELEASE_TOKEN", "") try: - if sys.argv[1] == "--check-sparkle": + if arguments[0] == "--check-sparkle": check_sparkle_endpoints(endpoints) + elif arguments[0] == "--verify-sparkle": + verify_sparkle_feeds(Path(arguments[1]), endpoints) else: - publish(Path(sys.argv[1]), endpoints, token) + publish(Path(arguments[0]), endpoints, token) except Exception as error: raise SystemExit(f"发布版本到 API 失败:{error}") from error diff --git a/scripts/publish_api_test.py b/scripts/publish_api_test.py index 0f409a5..659b1c3 100644 --- a/scripts/publish_api_test.py +++ b/scripts/publish_api_test.py @@ -39,6 +39,42 @@ class PublishAPITest(unittest.TestCase): self.assertIsNone(call.args[0].data) self.assertFalse(call.args[0].has_header("Authorization")) + def test_sparkle_gate_accepts_no_store_merged_by_proxy(self) -> None: + errors = [urllib.error.HTTPError(url, 400, "invalid request", {"Cache-Control": "private, No-Store"}, io.BytesIO(b"")) + for url in publish_api.DEFAULT_ENDPOINTS] + with patch("publish_api.urllib.request.urlopen", side_effect=errors): + publish_api.check_sparkle_endpoints(list(publish_api.DEFAULT_ENDPOINTS)) + + def test_verify_fetches_each_region_with_release_probe_and_compares_bytes(self) -> None: + with tempfile.TemporaryDirectory() as directory: + feed = Path(directory) / "XStats-1.0.0-AppleSilicon.xml" + feed.write_bytes(b"signed") + served = [b"signed", b"signed"] + responses = [contextlib.nullcontext(type("Response", (), {"status": 200, "read": lambda self, limit, body=body: body})()) + for body in served] + with patch("publish_api.urllib.request.urlopen", side_effect=responses) as send, \ + contextlib.redirect_stdout(io.StringIO()): + publish_api.verify_sparkle_feeds(feed, list(publish_api.DEFAULT_ENDPOINTS)) + urls = [call.args[0].full_url for call in send.call_args_list] + self.assertEqual(urls, [ + "https://xstats-apps.12306.work/api/v1/update/appcast.xml?current_version=release-probe&installation_id=" + "0" * 64, + "https://x-stats.china.12306.work/api/v1/update/appcast.xml?current_version=release-probe&installation_id=" + "0" * 64, + ]) + for call in send.call_args_list: + self.assertFalse(call.args[0].has_header("Authorization")) + + def test_verify_rejects_region_serving_different_feed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + feed = Path(directory) / "XStats-1.0.0-AppleSilicon.xml" + feed.write_bytes(b"signed") + stale = type("Response", (), {"status": 200, "read": lambda self, limit: b"previous"})() + ok = type("Response", (), {"status": 200, "read": lambda self, limit: b"signed"})() + with patch("publish_api.urllib.request.urlopen", + side_effect=[contextlib.nullcontext(ok), contextlib.nullcontext(stale)]), \ + contextlib.redirect_stdout(io.StringIO()): + with self.assertRaisesRegex(RuntimeError, "x-stats.china.12306.work"): + publish_api.verify_sparkle_feeds(feed, list(publish_api.DEFAULT_ENDPOINTS)) + def test_sparkle_gate_rejects_old_server_or_cached_validation(self) -> None: for status, headers in ((404, {}), (400, {}), (503, {"Cache-Control": "no-store"})): error = urllib.error.HTTPError("https://example.test", status, "error", headers, io.BytesIO(b"")) diff --git a/scripts/publish_release.sh b/scripts/publish_release.sh index 9e1f698..9d2c453 100755 --- a/scripts/publish_release.sh +++ b/scripts/publish_release.sh @@ -95,6 +95,8 @@ echo "✅ https://github.com/ysicing/xstats/releases/tag/v${VERSION}" # 清单最后提交:安装包已在对象存储就位并校验通过后,已安装的应用才会看到新版本。 # Token 只从环境变量读取,不出现在命令参数里。 python3 scripts/publish_api.py "$APPCAST" +# JSON 生效后逐区域取回 Sparkle 清单,确认服务端验签通过且与本地 XML 一致;保留探测标识不计入统计。 +python3 scripts/publish_api.py --verify-sparkle "dist/XStats-${VERSION}-AppleSilicon.xml" # Homebrew tap WORK="$(mktemp -d)" diff --git a/server/api/main.go b/server/api/main.go index 11ffe55..32ec1fc 100644 --- a/server/api/main.go +++ b/server/api/main.go @@ -26,6 +26,9 @@ import ( var installationIDPattern = regexp.MustCompile(`^[a-f0-9]{64}$`) +// 发布脚本端到端核验更新源时使用的保留标识;清单照常校验返回,但不计入安装统计。 +var releaseProbeInstallationID = strings.Repeat("0", 64) + func validHTTPS(raw string, optional bool) bool { if raw == "" { return optional @@ -110,16 +113,19 @@ func loadConfig(getenv func(string) string) (config, error) { func newApplication(databasePath string, releaseToken string) (*fiber.App, error) { return newApplicationWithFeedClient(databasePath, releaseToken, &http.Client{ - Timeout: 10 * time.Second, - CheckRedirect: func(request *http.Request, via []*http.Request) error { - if request.URL.Scheme != "https" || len(via) >= 5 { - return errors.New("invalid feed redirect") - } - return nil - }, + Timeout: 10 * time.Second, + CheckRedirect: feedRedirectPolicy, }) } +// 更新清单只允许有限次 HTTPS 跳转,降级到 HTTP 或跳转过多都视为上游故障。 +func feedRedirectPolicy(request *http.Request, via []*http.Request) error { + if request.URL.Scheme != "https" || len(via) >= 5 { + return errors.New("invalid feed redirect") + } + return nil +} + func newApplicationWithFeedClient(databasePath string, releaseToken string, feedClient *http.Client) (*fiber.App, error) { databaseLogger := gormlogger.New(log.Default(), gormlogger.Config{ SlowThreshold: 500 * time.Millisecond, @@ -237,8 +243,10 @@ func newApplicationWithFeedClient(databasePath string, releaseToken string, feed if err := validateSparkleFeed(data, current); err != nil { return c.SendStatus(http.StatusBadGateway) } - if err := recordUpdateCheck(database, check); err != nil { - return c.SendStatus(http.StatusInternalServerError) + if check.InstallationID != releaseProbeInstallationID { + if err := recordUpdateCheck(database, check); err != nil { + return c.SendStatus(http.StatusInternalServerError) + } } c.Type("xml", "utf-8") return c.Send(data) diff --git a/server/api/sparkle_test.go b/server/api/sparkle_test.go index adc5e9c..20479ba 100644 --- a/server/api/sparkle_test.go +++ b/server/api/sparkle_test.go @@ -4,11 +4,13 @@ package main import ( + "errors" "fmt" "io" "net/http" "net/http/httptest" "os" + "regexp" "strings" "testing" @@ -31,6 +33,24 @@ func signedSparkleFixture(t *testing.T, version string) string { return string(data) } +func trackedInstallations(t *testing.T, databasePath string) int64 { + t.Helper() + database, err := gorm.Open(sqlite.Open(databasePath), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + connection, err := database.DB() + if err != nil { + t.Fatal(err) + } + defer connection.Close() + var count int64 + if err := database.Model(&installation{}).Count(&count).Error; err != nil { + t.Fatal(err) + } + return count +} + func publishSparkleFixture(t *testing.T, app *fiber.App, version, build string) { t.Helper() body := fmt.Sprintf(`{"version":%q,"build":%q,"date":"2026-09-30","minimumSystem":"14.0","url":"https://example.test/XStats-%s.zip","sha256":"%s","size":1000,"notes":["test"]}`, version, build, version, strings.Repeat("a", 64)) @@ -151,19 +171,8 @@ func TestSparkleFeedRejectsInvalidInputsAndBoundedUpstreamFailures(t *testing.T) if res.StatusCode != 502 { t.Fatal(res.StatusCode) } - db, err := gorm.Open(sqlite.Open(databasePath), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - connection, err := db.DB() - if err != nil { - t.Fatal(err) - } - var count int64 - err = db.Model(&installation{}).Count(&count).Error - connection.Close() - if err != nil || count != 0 { - t.Fatalf("invalid feed tracked %d: %v", count, err) + if count := trackedInstallations(t, databasePath); count != 0 { + t.Fatalf("invalid feed tracked %d", count) } legacy := httptest.NewRequest(http.MethodPost, "/api/v1/update/check", strings.NewReader(`{"current_version":"0.12.1","installation_id":"`+strings.Repeat("a", 64)+`"}`)) legacy.Header.Set("Content-Type", "application/json") @@ -215,3 +224,118 @@ func TestSparkleFeedValidatesPublishedMetadataAndSignature(t *testing.T) { } } } + +// 生产客户端的跳转策略与上游故障都必须返回 502,且不计入检查统计。 +func TestSparkleFeedUpstreamFailuresUseProductionRedirectPolicy(t *testing.T) { + feed := signedSparkleFixture(t, "0.14.1") + respond := func(r *http.Request, status int, location, body string) *http.Response { + header := make(http.Header) + if location != "" { + header.Set("Location", location) + } + return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: header, Request: r} + } + cases := []struct { + name string + status int + tracked int64 + requests int + reply func(*http.Request) (*http.Response, error) + }{ + {"https redirect", 200, 1, 2, func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/final.xml" { + return respond(r, 200, "", feed), nil + } + return respond(r, 302, "https://cdn.example.test/final.xml", ""), nil + }}, + {"http downgrade", 502, 0, 1, func(r *http.Request) (*http.Response, error) { + return respond(r, 302, "http://example.test/XStats-0.14.1.xml", ""), nil + }}, + {"redirect loop", 502, 0, 5, func(r *http.Request) (*http.Response, error) { + return respond(r, 302, "https://example.test/loop", ""), nil + }}, + {"not found", 502, 0, 1, func(r *http.Request) (*http.Response, error) { + return respond(r, 404, "", feed), nil + }}, + {"transport error", 502, 0, 1, func(r *http.Request) (*http.Response, error) { + return nil, errors.New("connection reset") + }}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + var requests []string + transport := feedTransport(func(r *http.Request) (*http.Response, error) { + requests = append(requests, r.URL.String()) + return test.reply(r) + }) + client := &http.Client{Transport: transport, CheckRedirect: feedRedirectPolicy} + databasePath := t.TempDir() + "/xstats.sqlite" + app, err := newApplicationWithFeedClient(databasePath, "release-secret", client) + if err != nil { + t.Fatal(err) + } + publishSparkleFixture(t, app, "0.14.1", "126") + res, err := app.Test(httptest.NewRequest(http.MethodGet, "/api/v1/update/appcast.xml?current_version=0.14.0&installation_id="+strings.Repeat("c", 64), nil)) + if err != nil { + t.Fatal(err) + } + res.Body.Close() + if res.StatusCode != test.status { + t.Fatalf("status %d, want %d", res.StatusCode, test.status) + } + if count := trackedInstallations(t, databasePath); count != test.tracked { + t.Fatalf("tracked %d, want %d", count, test.tracked) + } + // 策略必须在发出 HTTP 请求或超过 5 次跳转之前拦截,而不只依赖处理函数兜底。 + if len(requests) != test.requests { + t.Fatalf("upstream requests %v, want %d", requests, test.requests) + } + }) + } +} + +// 服务端验签公钥必须与客户端内置公钥一致,轮换时漏改会让所有 Sparkle 检查返回 502。 +func TestSparklePublicKeyMatchesClient(t *testing.T) { + sources := map[string]*regexp.Regexp{ + "../../App/Info.plist": regexp.MustCompile(`SUPublicEDKey\s*([^<]+)`), + "../../project.yml": regexp.MustCompile(`SUPublicEDKey:\s*"([^"]+)"`), + } + for path, pattern := range sources { + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + match := pattern.FindSubmatch(data) + if match == nil { + t.Fatalf("%s: SUPublicEDKey not found", path) + } + if string(match[1]) != sparklePublicKey { + t.Fatalf("%s: SUPublicEDKey %s != server %s", path, match[1], sparklePublicKey) + } + } +} + +func TestSparkleReleaseProbeReturnsFeedWithoutTracking(t *testing.T) { + feed := signedSparkleFixture(t, "0.14.1") + client := &http.Client{Transport: feedTransport(func(r *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(feed)), Header: make(http.Header), Request: r}, nil + })} + databasePath := t.TempDir() + "/xstats.sqlite" + app, err := newApplicationWithFeedClient(databasePath, "release-secret", client) + if err != nil { + t.Fatal(err) + } + publishSparkleFixture(t, app, "0.14.1", "126") + res, err := app.Test(httptest.NewRequest(http.MethodGet, "/api/v1/update/appcast.xml?current_version=0.14.1&installation_id="+releaseProbeInstallationID, nil)) + if err != nil { + t.Fatal(err) + } + data, _ := io.ReadAll(res.Body) + res.Body.Close() + if res.StatusCode != 200 || string(data) != feed { + t.Fatalf("probe: %d", res.StatusCode) + } + if count := trackedInstallations(t, databasePath); count != 0 { + t.Fatalf("release probe tracked %d", count) + } +}