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;