fix(release): 发布后逐区域核验 Sparkle 更新源并覆盖跳转策略

- 服务端保留探测标识(64 个 0):照常验签返回清单但不计入统计
- publish_api.py 新增 --verify-sparkle,JSON 发布后逐区域取回 XML 与本地签名文件逐字节比对,publish_release.sh 自动执行
- Cache-Control 探测按指令 token 判断,兼容代理合并的缓存头
- 抽出 feedRedirectPolicy 供测试复用,覆盖 HTTPS 跳转、HTTP 降级、跳转过多、404、传输错误
- 新增测试校验服务端公钥与 Info.plist、project.yml 的 SUPublicEDKey 一致

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ysicingandClaude Opus 5.5 authored and 缘生 committed 2026-09-30 15:20:08 +08:00
1 parent 3dd5aa3e6c
commit 1623effd61
7 files changed
+233 -33

No files matched your search

@@ -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-<version>-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.
+1 -1
View File
@@ -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` 不处理徽章。
+39 -9
View File
@@ -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]} <appcast.json>")
usage = f"用法:{sys.argv[0]} <appcast.json> | --check-sparkle | --verify-sparkle <feed.xml>"
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
+36
View File
@@ -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"<rss>signed</rss>")
served = [b"<rss>signed</rss>", b"<rss>signed</rss>"]
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"<rss>signed</rss>")
stale = type("Response", (), {"status": 200, "read": lambda self, limit: b"<rss>previous</rss>"})()
ok = type("Response", (), {"status": 200, "read": lambda self, limit: b"<rss>signed</rss>"})()
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""))
+2
View File
@@ -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)"
+17 -9
View File
@@ -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)
+137 -13
View File
@@ -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(`<key>SUPublicEDKey</key>\s*<string>([^<]+)</string>`),
"../../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)
}
}