From f704c66dd9ad9d17f20426a7dd424aa6b0f2bd20 Mon Sep 17 00:00:00 2001 From: JustMe Date: Thu, 8 Oct 2026 04:22:01 +0800 Subject: [PATCH] feat: CI registers release manifests without a token; the panel verifies the CI signature instead [CI] --- .woodpecker/linux-release.yml | 2 - .woodpecker/main-amd64-build.yml | 6 -- AGENTS.md | 2 +- backend/openapi/v1.json | 64 ++++++++++++++ .../Admin/BuildEndpoints.cs | 25 ++++++ .../Admin/ReleaseSignature.cs | 25 ++++++ backend/src/MoShiro.Backend.Api/ApiModule.cs | 1 + .../Updates/ReleaseService.cs | 2 +- .../OpenApiConformanceTests.cs | 2 +- .../ReleaseOfferTests.cs | 84 +++++++++++++++++++ ci/build-main-amd64.sh | 4 +- ci/publish-release-offer.sh | 7 +- docs/api.md | 3 +- docs/deployment.md | 2 +- web/shared/api/schema.ts | 68 +++++++++++++++ 15 files changed, 278 insertions(+), 19 deletions(-) create mode 100644 backend/src/MoShiro.Backend.Api/Admin/ReleaseSignature.cs create mode 100644 backend/tests/MoShiro.Backend.Tests/ReleaseOfferTests.cs diff --git a/.woodpecker/linux-release.yml b/.woodpecker/linux-release.yml index 098ec4a..727233a 100644 --- a/.woodpecker/linux-release.yml +++ b/.woodpecker/linux-release.yml @@ -50,8 +50,6 @@ steps: RELEASE_API_URL: https://mo.oeo.one RELEASE_SIGNING_KEY: from_secret: release_signing_key - RELEASE_API_TOKEN: - from_secret: release_api_token commands: - bash ci/index-backend-image.sh depends_on: diff --git a/.woodpecker/main-amd64-build.yml b/.woodpecker/main-amd64-build.yml index b1a1f0a..feacd4e 100644 --- a/.woodpecker/main-amd64-build.yml +++ b/.woodpecker/main-amd64-build.yml @@ -27,8 +27,6 @@ steps: RELEASE_API_URL: https://mo.oeo.one RELEASE_SIGNING_KEY: from_secret: release_signing_key - RELEASE_API_TOKEN: - from_secret: release_api_token commands: - bash ci/build-main-amd64.sh backend depends_on: [] @@ -44,8 +42,6 @@ steps: RELEASE_API_URL: https://mo.oeo.one RELEASE_SIGNING_KEY: from_secret: release_signing_key - RELEASE_API_TOKEN: - from_secret: release_api_token commands: - bash ci/build-main-amd64.sh agent depends_on: [] @@ -61,8 +57,6 @@ steps: RELEASE_API_URL: https://mo.oeo.one RELEASE_SIGNING_KEY: from_secret: release_signing_key - RELEASE_API_TOKEN: - from_secret: release_api_token commands: - bash ci/build-main-amd64.sh probe depends_on: [] diff --git a/AGENTS.md b/AGENTS.md index 4b59fea..1e1c30d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ MoShiro is a self-hosted network path quality monitor. Backend coordinates Agent - Treat `main` as the stable release branch and `dev` as the integration branch. Keep ordinary implementation work on `dev` or a feature branch; integrate into `main` through a pull request. - A push to `main` builds images only if its commit message contains `[CI]` (for a merged PR, put it in the PR or merge commit title). Without `[CI]` nothing is built; use `[CI SKIP]` to skip the pipeline entirely. When squashing or pushing to `main`, add `[CI]` unless a build is explicitly not wanted. - Before editing, inspect `git status`. Preserve pre-existing user changes: do not reset, clean, stash, or include unrelated files in a commit. -- Do not commit credentials, API keys, registry passwords, or generated runtime data. CI credentials belong in Woodpecker secrets (`zot_secret`, `gitea_token`, `release_signing_key` for signing release manifests, `release_api_token` with the `release` scope). The release signing private key never goes into the repository or the panel; only the public key in `packaging/update/` does. +- Do not commit credentials, API keys, registry passwords, or generated runtime data. CI credentials belong in Woodpecker secrets (`zot_secret`, `gitea_token`, `release_signing_key` for signing release manifests). CI registers the signed manifests at `/api/release/offer`, which needs no token because the panel verifies the signature. The release signing private key never goes into the repository or the panel; only the public key in `packaging/update/` does. - Keep development OCI images in the `moshiro-dev/` repository path and release images in `moshiro/`. Do not let a development build overwrite release tags such as `latest`. ## Project layout diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index 30ce08f..27696c5 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -5020,6 +5020,67 @@ } } }, + "/api/release/offer": { + "post": { + "tags": [ + "release" + ], + "summary": "Register a release manifest that carries a valid CI signature (no token needed)", + "operationId": "release_offer", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublishReleaseRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReleaseDto" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, "/api/agent/v1/enroll": { "post": { "tags": [ @@ -9384,6 +9445,9 @@ { "name": "ops" }, + { + "name": "release" + }, { "name": "agent" } diff --git a/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs index 501c6b5..2419be6 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs @@ -45,6 +45,31 @@ public static class BuildEndpoints .Requires(Capability.ReleasePublish); } + /// + /// CI 登记清单的入口,不需要令牌:请求里的清单必须带有效的 CI 发布签名(用 Backend 内嵌的发布公钥验)才收下。 + /// 这样登记不依赖面板里的任何凭证(清库、重装、还没有 passkey 的时候照常工作),也没有一把能被偷的长期令牌; + /// 旧清单重放由 seq 递增挡住,节点的更新器还会用本机公钥再验一次。 + /// + public static void MapOffer(IEndpointRouteBuilder app) + { + app.MapPost("/api/release/offer", OfferReleaseAsync) + .Api("release_offer", "Register a release manifest that carries a valid CI signature (no token needed)", "release") + .Returns(StatusCodes.Status201Created).ProducesProblems(400, 403, 409); + } + + private static async Task OfferReleaseAsync(HttpContext http, ReleaseService releases, PublishReleaseRequest request) + { + string manifest = request.Manifest ?? ""; + string signature = request.Signature ?? ""; + if (manifest.Length is 0 or > ReleaseService.MaxManifestBytes || signature.Length is 0 or > ReleaseService.MaxSignatureBytes + || !ReleaseSignature.Verify(InstallScripts.ReleaseKey(), manifest, signature)) + { + return Problems.Create(http, MoShiroErrors.Forbidden, "the manifest does not carry a valid release signature"); + } + + return await PublishReleaseAsync(http, releases, request); + } + private static async Task GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, CancellationToken ct) { var items = (await nodes.ListAsync(ct)) diff --git a/backend/src/MoShiro.Backend.Api/Admin/ReleaseSignature.cs b/backend/src/MoShiro.Backend.Api/Admin/ReleaseSignature.cs new file mode 100644 index 0000000..9934ed7 --- /dev/null +++ b/backend/src/MoShiro.Backend.Api/Admin/ReleaseSignature.cs @@ -0,0 +1,25 @@ +using System.Security.Cryptography; +using System.Text; + +namespace MoShiro.Backend.Api; + +/// +/// 发布清单的签名校验:用 Backend 内嵌的 CI 发布公钥(packaging/update/release-key.pub.pem)验 ECDSA P-256 / SHA-256(DER)签名,签名对象是清单的原始字节。 +/// 登记清单因此不需要任何令牌:能被登记的只有 CI 签过的清单,节点的更新器之后还会用本机的公钥再验一遍。 +/// +public static class ReleaseSignature +{ + public static bool Verify(string publicKeyPem, string manifest, string signatureBase64) + { + try + { + using var key = ECDsa.Create(); + key.ImportFromPem(publicKeyPem); + return key.VerifyData(Encoding.UTF8.GetBytes(manifest), Convert.FromBase64String(signatureBase64), HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence); + } + catch (Exception ex) when (ex is FormatException or CryptographicException or ArgumentException) + { + return false; + } + } +} diff --git a/backend/src/MoShiro.Backend.Api/ApiModule.cs b/backend/src/MoShiro.Backend.Api/ApiModule.cs index d47b1f3..1711bce 100644 --- a/backend/src/MoShiro.Backend.Api/ApiModule.cs +++ b/backend/src/MoShiro.Backend.Api/ApiModule.cs @@ -118,6 +118,7 @@ public static class ApiModule LinkEndpoints.Map(guarded); OpsEndpoints.Map(app); + BuildEndpoints.MapOffer(app); app.MapOpenApi("/openapi/v1.json"); app.MapStaticSite(); return app; diff --git a/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs b/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs index 0f88664..e0f773f 100644 --- a/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs +++ b/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs @@ -12,7 +12,7 @@ public sealed record ReleaseOffer(string Component, long Seq, string Tag, string /// /// 发布清单的分发:CI 签名之后推给面板,面板存下来并原样转给在线的 Agent、Probe(节点连上时再补发一次)。 -/// 面板不验签、也不决定拉什么:节点宿主机上的更新器用本机的发布公钥验签,并且只认本机配置的镜像仓库; +/// 登记入口(/api/release/offer)只收带有效 CI 签名的清单;面板不决定拉什么:节点宿主机上的更新器用本机的发布公钥验签,并且只认本机配置的镜像仓库; /// 所以面板即使被攻破,也推不出一个能被装上的假镜像。面板只负责「通知」和「请求更新」,状态由节点随心跳报回来。 /// public sealed partial class ReleaseService : IDisposable diff --git a/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs b/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs index cc2b24a..a4c2c12 100644 --- a/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs @@ -53,7 +53,7 @@ public sealed partial class OpenApiConformanceTests foreach ((string path, _) in doc["paths"]!.AsObject()) { string[] prefixes = ["/api/public/", "/api/admin/", "/api/agent/v1/"]; - string[] roots = ["/healthz", "/metrics", "/install", "/install.ps1", "/install/manifest", "/install/manifest.sig"]; + string[] roots = ["/healthz", "/metrics", "/install", "/install.ps1", "/install/manifest", "/install/manifest.sig", "/api/release/offer"]; Assert.True(prefixes.Any(p => path.StartsWith(p, StringComparison.Ordinal)) || roots.Contains(path), $"{path} is outside the documented layers"); foreach (string segment in path.Split('/', StringSplitOptions.RemoveEmptyEntries)) { diff --git a/backend/tests/MoShiro.Backend.Tests/ReleaseOfferTests.cs b/backend/tests/MoShiro.Backend.Tests/ReleaseOfferTests.cs new file mode 100644 index 0000000..d9a7a88 --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/ReleaseOfferTests.cs @@ -0,0 +1,84 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; +using MoShiro.Backend.Api; + +namespace MoShiro.Backend.Tests; + +/// +/// CI 登记清单不需要令牌,但清单必须带 CI 发布密钥的有效签名:令牌存在面板的数据库里,清库、重装或还没有 passkey 的时候就拿不到, +/// 而清单本来就是签名的,面板直接验签更简单,也没有长期令牌可偷。 +/// +[Collection("probe-e2e")] +public sealed class ReleaseOfferTests +{ + // openssl 生成的测试密钥对和签名(`openssl dgst -sha256 -sign`,DER):证明验签接受 CI 脚本实际产出的格式 + private const string TestKey = "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEQwlE2KjYWszeaTsng6CfPxhOqeIV\n+7dm940f7XfHJr9A6/OTiZ2YnhFWWipZ4Z4poPtb6hMW04tIARO8+Km34w==\n-----END PUBLIC KEY-----"; + private const string Manifest = "{\"schema\":1,\"component\":\"agent\",\"seq\":7}"; + private const string OpenSslSignature = "MEUCIDrOloi3otWBiCFcXEsPg6kHCS7G09PrO3hW+3AKXAiZAiEAvXgt9K+iZXnWiEgKdanBKbEoL0LyT/xuaEW6dAG0jDY="; + + private readonly BackendFixture _backend; + + public ReleaseOfferTests(BackendFixture backend) + { + _backend = backend; + } + + [Fact] + public void ASignatureFromOpenSsl_Verifies_AndAnyChangeDoesNot() + { + Assert.True(ReleaseSignature.Verify(TestKey, Manifest, OpenSslSignature)); + + Assert.False(ReleaseSignature.Verify(TestKey, Manifest.Replace("7", "8", StringComparison.Ordinal), OpenSslSignature)); + Assert.False(ReleaseSignature.Verify(TestKey, Manifest + " ", OpenSslSignature)); + + byte[] tampered = Convert.FromBase64String(OpenSslSignature); + tampered[10] ^= 1; + Assert.False(ReleaseSignature.Verify(TestKey, Manifest, Convert.ToBase64String(tampered))); + } + + [Fact] + public void AnotherKey_GarbageAndEmptyInputs_AreRejected() + { + using var other = ECDsa.Create(ECCurve.NamedCurves.nistP256); + string signature = Convert.ToBase64String(other.SignData(Encoding.UTF8.GetBytes(Manifest), HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence)); + + Assert.False(ReleaseSignature.Verify(TestKey, Manifest, signature)); + Assert.False(ReleaseSignature.Verify(TestKey, Manifest, "not base64!!")); + Assert.False(ReleaseSignature.Verify(TestKey, Manifest, "")); + Assert.False(ReleaseSignature.Verify("not a key", Manifest, OpenSslSignature)); + // P1363(节点和 WebAuthn 用的格式)不是 CI 签名的格式 + Assert.False(ReleaseSignature.Verify(TestKey, Manifest, Convert.ToBase64String(new byte[64]))); + } + + [Fact] + public void TheEmbeddedReleaseKey_IsAUsableP256PublicKey() + { + using var key = ECDsa.Create(); + key.ImportFromPem(InstallScripts.ReleaseKey()); + + Assert.Equal(256, key.KeySize); + } + + [Theory] + [InlineData("{\"component\":\"agent\",\"channel\":\"dev\",\"image\":\"r/a\",\"digest\":\"sha256:0000000000000000000000000000000000000000000000000000000000000000\",\"seq\":99999999999}", "AAAA")] + [InlineData("", "")] + [InlineData("not json", "MEUCIDrOloi3otWBiCFcXEsPg6kHCS7G09PrO3hW+3AKXAiZAiEAvXgt9K+iZXnWiEgKdanBKbEoL0LyT/xuaEW6dAG0jDY=")] + public async Task OfferWithoutAValidCiSignature_IsForbidden_EvenWithoutAnyToken(string manifest, string signature) + { + using HttpClient anonymous = _backend.NewClient(); + using HttpResponseMessage response = await anonymous.PostAsync("/api/release/offer", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = signature })); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task TheAdminEndpoint_StillNeedsTheCapability() + { + using HttpClient anonymous = _backend.NewClient(); + using HttpResponseMessage response = await anonymous.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = Manifest, ["signature"] = OpenSslSignature })); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } +} diff --git a/ci/build-main-amd64.sh b/ci/build-main-amd64.sh index 4d96a25..2e8e5c0 100644 --- a/ci/build-main-amd64.sh +++ b/ci/build-main-amd64.sh @@ -89,8 +89,8 @@ if [[ -n "${RELEASE_SIGNING_KEY:-}" ]]; then digest="$(cat "$work/$component.digest")" release_dir="$work/$component-release" bash "$root/ci/release-manifest.sh" "$component" "$repository" "$digest" "$dev_tag" dev "$release_dir" - if [[ -n "${RELEASE_API_URL:-}" && -n "${RELEASE_API_TOKEN:-}" ]]; then - python3 -c 'import json,sys; print(json.dumps({"manifest": open(sys.argv[1]).read(), "signature": open(sys.argv[2]).read()}))' "$release_dir/manifest.json" "$release_dir/manifest.sig" | curl --fail-with-body --silent --show-error --retry 3 -X POST "${RELEASE_API_URL%/}/api/admin/releases" -H "Authorization: Bearer $RELEASE_API_TOKEN" -H 'Content-Type: application/json' --data-binary @- >/dev/null + if [[ -n "${RELEASE_API_URL:-}" ]]; then + python3 -c 'import json,sys; print(json.dumps({"manifest": open(sys.argv[1]).read(), "signature": open(sys.argv[2]).read()}))' "$release_dir/manifest.json" "$release_dir/manifest.sig" | curl --fail-with-body --silent --show-error --retry 3 -X POST "${RELEASE_API_URL%/}/api/release/offer" -H 'Content-Type: application/json' --data-binary @- >/dev/null echo "registered the signed $component release with the panel" fi fi diff --git a/ci/publish-release-offer.sh b/ci/publish-release-offer.sh index 4ede65e..a4eac75 100644 --- a/ci/publish-release-offer.sh +++ b/ci/publish-release-offer.sh @@ -1,16 +1,15 @@ #!/usr/bin/env bash -# CI 发布 webhook:镜像已推送成功后,签名并登记清单。面板不会因此自动重启 Backend。 +# CI 发布 webhook:镜像已推送成功后,签名并登记清单(面板验 CI 签名,不需要令牌)。面板不会因此自动重启 Backend。 set -euo pipefail component="${1:?component}"; repository="${2:?repository}"; digest="${3:?digest}"; tag="${4:?tag}"; channel="${5:?channel}"; outdir="${6:?outdir}" [[ -n "${RELEASE_SIGNING_KEY:-}" ]] || { echo "release signing is not configured; no update offer published"; exit 0; } root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" bash "$root/ci/release-manifest.sh" "$component" "$repository" "$digest" "$tag" "$channel" "$outdir" -if [[ -n "${RELEASE_API_URL:-}" && -n "${RELEASE_API_TOKEN:-}" ]]; then +if [[ -n "${RELEASE_API_URL:-}" ]]; then python3 -c 'import json,sys; print(json.dumps({"manifest": open(sys.argv[1]).read(), "signature": open(sys.argv[2]).read()}))' \ "$outdir/manifest.json" "$outdir/manifest.sig" > "$outdir/payload.json" response="$outdir/response.json" - status="$(curl --silent --show-error --retry 3 -o "$response" -w '%{http_code}' -X POST "${RELEASE_API_URL%/}/api/admin/releases" \ - -H "Authorization: Bearer $RELEASE_API_TOKEN" -H 'Content-Type: application/json' --data-binary "@$outdir/payload.json")" + status="$(curl --silent --show-error --retry 3 -o "$response" -w '%{http_code}' -X POST "${RELEASE_API_URL%/}/api/release/offer" \ -H 'Content-Type: application/json' --data-binary "@$outdir/payload.json")" if [[ "$status" != 2* ]]; then if [[ "$component" == backend && "$status" == 400 ]] && grep -Fq 'manifest needs a known component' "$response"; then echo "the panel does not support Backend update offers yet; bootstrap it once before future Backend offers" diff --git a/docs/api.md b/docs/api.md index 7756dfc..49c167e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -146,7 +146,8 @@ | GET | `/api/admin/capabilities` | 能力目录:分组、哪些改变信任、哪些是预留、依赖关系和预设(只读、运维、接入、CI 发布、全部) | | GET/PATCH | `/api/admin/settings` | 站点公开或私有、是否对访客显示构建信息(`site.show_build`)、保留期、节点定时重启(`fleet.restart_enabled`、`fleet.restart_hour`、`fleet.restart_minute`、`fleet.restart_utc_offset_minutes`,默认开、04:30、UTC+8;改完立即通知在线节点)等 | | GET | `/api/admin/build-info` | 后端和每个 Agent、Probe 的版本、tag、提交、构建时间、系统,以及节点宿主机更新器报告的更新状态(管理端「系统 → 版本」) | -| GET/POST | `/api/admin/releases` | CI 签名的发布清单:POST 登记(`{manifest, signature}`,面板不验签、原样转给在线的节点和之后连上的节点),GET 看每个组件最新的一份 | +| GET/POST | `/api/admin/releases` | CI 签名的发布清单:POST 登记(`{manifest, signature}`,需要 `release.publish`,原样转给在线的节点和之后连上的节点),GET 看每个组件最新的一份 | +| POST | `/api/release/offer` | CI 登记发布清单的入口,**不需要令牌**:`{manifest, signature}` 的签名必须能用 Backend 内嵌的发布公钥验过(ECDSA P-256 / SHA-256,DER),否则 403;`seq` 必须比已登记的新,否则 409。登记不依赖面板里的任何凭证,清库、重装、还没有 passkey 时照常工作 | | GET | `/api/admin/links` | 每一对(Probe,Agent)的线路开关:`linked`(最终结果)、`override`(手动设置,`null` 跟随自动连接)、`connected`,以及 `auto_link` | | PUT | `/api/admin/links/{probe_id}/{agent_id}` | `{enabled: true|false|null}`:手动强制连或不连,`null` 取消手动设置;改完立即重新下发 | | POST | `/api/admin/nodes/{id}/update` | 请求一个在线的 Agent 或 Probe 更新到它收到的清单(节点不在线返回 409) | diff --git a/docs/deployment.md b/docs/deployment.md index 150fd42..1daba5e 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -307,7 +307,7 @@ sh packaging/update/install-updater.sh agent --user \ 更新器依赖 Linux、systemd、OpenSSL、util-linux/flock 和所选引擎。用户部署把配置、公钥和镜像引用放在 `~/.config/moshiro/`、版本记录放在 `~/.local/state/moshiro-update/`;系统部署使用 `/etc/moshiro-update/` 和 `/var/lib/moshiro-update/`。私有仓库的 `--authfile` 对 Podman 使用其认证文件,对 Docker 使用标准 `config.json`,不改写全局 Docker 配置。旧 `--data-dir` 安装方式保留兼容,但新部署统一使用 `--volume`。 -发布私钥只放在 Woodpecker 的 `release_signing_key` secret;发布公钥位于 `packaging/update/release-key.pub.pem`。CI 的 `release_api_token` 只有发布登记权限,`RELEASE_API_URL` 指向面板。面板保存并转发签名清单,Agent/Probe 将它与更新请求写进配置 volume;更新器验签后才调用容器引擎和 systemd。管理端仍能看到可更新、更新中、已回滚和无效清单状态。纯二进制更新器尚未实现。 +发布私钥只放在 Woodpecker 的 `release_signing_key` secret;发布公钥位于 `packaging/update/release-key.pub.pem`。`RELEASE_API_URL` 指向面板,CI 把签名清单 POST 到 `/api/release/offer`:这个入口不需要令牌,面板用内嵌的发布公钥验签,签名无效就拒绝,所以 CI 不需要在面板里持有任何凭证(清库或重装后照常登记,也没有长期令牌可偷)。面板保存并转发签名清单,Agent/Probe 将它与更新请求写进配置 volume;更新器验签后才调用容器引擎和 systemd。管理端仍能看到可更新、更新中、已回滚和无效清单状态。纯二进制更新器尚未实现。 **换发布密钥**(泄露或定期轮换,不用重装节点): diff --git a/web/shared/api/schema.ts b/web/shared/api/schema.ts index db73696..585d36c 100644 --- a/web/shared/api/schema.ts +++ b/web/shared/api/schema.ts @@ -1195,6 +1195,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/release/offer": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Register a release manifest that carries a valid CI signature (no token needed) */ + post: operations["release_offer"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/agent/v1/enroll": { parameters: { query?: never; @@ -6194,6 +6211,57 @@ export interface operations { }; }; }; + release_offer: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PublishReleaseRequest"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReleaseDto"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; fleet_enroll: { parameters: { query?: never;