diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index 9dc9124..ae49dbf 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -1369,118 +1369,6 @@ } } }, - "/api/admin/releases": { - "post": { - "tags": [ - "admin-build" - ], - "summary": "Register a CI-signed release manifest (the signature is checked against the release key); online nodes get it right away. Admin or release tokens", - "operationId": "releases_publish", - "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" - } - } - } - }, - "401": { - "description": "Unauthorized", - "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" - } - } - } - } - }, - "x-moshiro-capability": "release.publish" - }, - "get": { - "tags": [ - "admin-build" - ], - "summary": "The latest signed release manifest of each component", - "operationId": "releases_list", - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PageDtoOfReleaseDto" - } - } - } - }, - "401": { - "description": "Unauthorized", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDto" - } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDto" - } - } - } - } - }, - "x-moshiro-capability": "update.read" - } - }, "/api/admin/auth/logout": { "post": { "tags": [ @@ -4412,6 +4300,48 @@ "x-moshiro-capability": "update.read" } }, + "/api/admin/releases": { + "get": { + "tags": [ + "admin-build" + ], + "summary": "The latest signed release manifest of each component", + "operationId": "releases_list", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageDtoOfReleaseDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + }, + "x-moshiro-capability": "update.read" + } + }, "/api/admin/nodes/{id}/update": { "post": { "tags": [ @@ -4617,6 +4547,148 @@ "x-moshiro-capability": "update.read" } }, + "/api/admin/release-key": { + "get": { + "tags": [ + "admin-release-key" + ], + "summary": "The release keys this panel trusts: the built-in official key and the optional own key", + "operationId": "release_key_get", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReleaseKeysDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + }, + "x-moshiro-capability": "update.read" + }, + "put": { + "tags": [ + "admin-release-key" + ], + "summary": "Trust your own release public key (P-256 PEM; never the private key) in addition to the official one", + "operationId": "release_key_set", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetReleaseKeyRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReleaseKeysDto" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + }, + "x-moshiro-capability": "update.confirm" + }, + "delete": { + "tags": [ + "admin-release-key" + ], + "summary": "Stop trusting your own release key; manifests only it signed are dropped", + "operationId": "release_key_clear", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReleaseKeysDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + }, + "x-moshiro-capability": "update.confirm" + } + }, "/api/admin/links": { "get": { "tags": [ @@ -8575,6 +8647,43 @@ } } }, + "ReleaseKeyDto": { + "required": [ + "fingerprint", + "public_key" + ], + "type": "object", + "properties": { + "fingerprint": { + "type": "string" + }, + "public_key": { + "type": "string" + } + } + }, + "ReleaseKeysDto": { + "required": [ + "official", + "custom" + ], + "type": "object", + "properties": { + "official": { + "$ref": "#/components/schemas/ReleaseKeyDto" + }, + "custom": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ReleaseKeyDto" + } + ] + } + } + }, "ScoreDto": { "required": [ "score", @@ -8737,6 +8846,20 @@ } } }, + "SetReleaseKeyRequest": { + "required": [ + "public_key" + ], + "type": "object", + "properties": { + "public_key": { + "type": [ + "null", + "string" + ] + } + } + }, "SettingDto": { "required": [ "key", @@ -9343,9 +9466,6 @@ { "name": "admin-auth" }, - { - "name": "admin-build" - }, { "name": "admin-agents" }, @@ -9379,6 +9499,12 @@ { "name": "admin-users" }, + { + "name": "admin-build" + }, + { + "name": "admin-release-key" + }, { "name": "admin-links" }, diff --git a/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs index c6929de..388b9b6 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs @@ -36,15 +36,6 @@ public static class BuildEndpoints .Requires(Capability.UpdateRead); } - /// 登记发布清单:挂在 下,除了管理员,CI 用的 release 令牌也能调用(它只能做这一件事)。 - public static void MapPublish(RouteGroupBuilder group) - { - group.MapPost("/releases", PublishReleaseAsync) - .Api("releases_publish", "Register a CI-signed release manifest (the signature is checked against the release key); online nodes get it right away. Admin or release tokens", Tag) - .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409) - .Requires(Capability.ReleasePublish); - } - /// /// CI 登记清单的入口,不需要令牌:请求里的清单必须带有效的 CI 发布签名(用 Backend 内嵌的发布公钥验)才收下。 /// 这样登记不依赖面板里的任何凭证(清库、重装、还没有 passkey 的时候照常工作),也没有一把能被偷的长期令牌; @@ -57,12 +48,12 @@ public static class BuildEndpoints .Returns(StatusCodes.Status201Created).ProducesProblems(400, 403, 409); } - private static async Task OfferReleaseAsync(HttpContext http, ReleaseService releases, IReleaseKey releaseKey, PublishReleaseRequest request) + private static async Task OfferReleaseAsync(HttpContext http, ReleaseService releases, ReleaseKeyService keys, 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(releaseKey.Pem, manifest, signature)) + || !await keys.VerifyAsync(manifest, signature, http.RequestAborted)) { return Problems.Create(http, MoShiroErrors.Forbidden, "the manifest does not carry a valid release signature"); } diff --git a/backend/src/MoShiro.Backend.Api/Admin/ReleaseKeyEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/ReleaseKeyEndpoints.cs new file mode 100644 index 0000000..30b8d09 --- /dev/null +++ b/backend/src/MoShiro.Backend.Api/Admin/ReleaseKeyEndpoints.cs @@ -0,0 +1,54 @@ +using Fleet.Core; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using MoShiro.Backend.Application; + +namespace MoShiro.Backend.Api; + +/// 可信资产来源:官方发布密钥(只读)和可选的自有发布公钥(默认没有)。改动写审计。 +public static class ReleaseKeyEndpoints +{ + private const string Tag = "admin-release-key"; + + public static void Map(RouteGroupBuilder group) + { + group.MapGet("/release-key", GetAsync) + .Api("release_key_get", "The release keys this panel trusts: the built-in official key and the optional own key", Tag) + .Returns().ProducesProblems(401, 403) + .Requires(Capability.UpdateRead); + group.MapPut("/release-key", SetAsync) + .Api("release_key_set", "Trust your own release public key (P-256 PEM; never the private key) in addition to the official one", Tag) + .Returns().ProducesProblems(400, 401, 403) + .Requires(Capability.UpdateConfirm); + group.MapDelete("/release-key", ClearAsync) + .Api("release_key_clear", "Stop trusting your own release key; manifests only it signed are dropped", Tag) + .Returns().ProducesProblems(401, 403) + .Requires(Capability.UpdateConfirm); + } + + private static async Task GetAsync(ReleaseKeyService keys, CancellationToken ct) => Json(await keys.GetAsync(ct)); + + private static async Task SetAsync(HttpContext http, ReleaseKeyService keys, SetReleaseKeyRequest request) + { + Result result = await keys.SetCustomAsync(request.PublicKey, http.RequestAborted); + if (!result.TryGet(out ReleaseKeys? view, out FleetError? error)) + { + return Problems.From(http, error); + } + + http.SetDetail("own release key " + view.Custom!.Fingerprint); + return Json(view); + } + + private static async Task ClearAsync(HttpContext http, ReleaseKeyService keys, ReleaseService releases) + { + await keys.ClearCustomAsync(http.RequestAborted); + await releases.PurgeUnverifiedAsync(http.RequestAborted); + http.SetDetail("own release key removed"); + return Json(await keys.GetAsync(http.RequestAborted)); + } + + private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult Json(ReleaseKeys keys) => + TypedResults.Json(new ReleaseKeysDto(new ReleaseKeyDto(keys.Official.Fingerprint, keys.Official.PublicKey), keys.Custom is null ? null : new ReleaseKeyDto(keys.Custom.Fingerprint, keys.Custom.PublicKey)), ApiJsonContext.Default.ReleaseKeysDto); +} diff --git a/backend/src/MoShiro.Backend.Api/ApiModule.cs b/backend/src/MoShiro.Backend.Api/ApiModule.cs index e17cc71..60bfc5e 100644 --- a/backend/src/MoShiro.Backend.Api/ApiModule.cs +++ b/backend/src/MoShiro.Backend.Api/ApiModule.cs @@ -105,8 +105,6 @@ public static class ApiModule RouteGroupBuilder admin = app.MapGroup("/api/admin"); AuthEndpoints.MapLogin(admin); - BuildEndpoints.MapPublish(admin.MapGroup(string.Empty).AddEndpointFilter(CapabilityGuard.InvokeAsync)); - RouteGroupBuilder guarded = admin.MapGroup(string.Empty).AddEndpointFilter(CapabilityGuard.InvokeAsync); AuthEndpoints.MapSession(guarded); AgentEndpoints.Map(guarded); @@ -116,6 +114,7 @@ public static class ApiModule TrustEndpoints.Map(guarded); SystemEndpoints.Map(guarded); BuildEndpoints.Map(guarded); + ReleaseKeyEndpoints.Map(guarded); LinkEndpoints.Map(guarded); OpsEndpoints.Map(app); diff --git a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs index 7d14674..4acf36e 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs @@ -198,6 +198,14 @@ public sealed record ReleaseDto(string Component, long Seq, string Tag, string V public sealed record PublishReleaseRequest(string? Manifest, string? Signature); +/// 一把发布公钥:指纹是 SPKI 的 SHA-256(小写十六进制)。 +public sealed record ReleaseKeyDto(string Fingerprint, string PublicKey); + +/// 可信资产来源:官方发布密钥(始终信任)和可选的自有发布公钥(默认没有)。 +public sealed record ReleaseKeysDto(ReleaseKeyDto Official, ReleaseKeyDto? Custom); + +public sealed record SetReleaseKeyRequest(string? PublicKey); + public sealed record BackendUpdateDto(string Channel, bool Enabled, string State, string? CurrentTag, string? CurrentDigest, ReleaseDto? Available, string? Error); public sealed record ConfirmBackendUpdateRequest(long Seq); diff --git a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs index 2341ec5..0c46eb0 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs @@ -45,6 +45,8 @@ namespace MoShiro.Backend.Api; [JsonSerializable(typeof(ReleaseDto))] [JsonSerializable(typeof(PageDto))] [JsonSerializable(typeof(PublishReleaseRequest))] +[JsonSerializable(typeof(ReleaseKeysDto))] +[JsonSerializable(typeof(SetReleaseKeyRequest))] [JsonSerializable(typeof(AboutDto))] [JsonSerializable(typeof(NodeBuildDto))] [JsonSerializable(typeof(NodeOwnerDto))] diff --git a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs index c9863d2..257cc71 100644 --- a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs +++ b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs @@ -40,6 +40,7 @@ public static class ApplicationServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); diff --git a/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs b/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs index c93544f..e34718c 100644 --- a/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs +++ b/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs @@ -27,7 +27,6 @@ public static class Capability public const string UpdateRead = "update.read"; public const string UpdateConfirm = "update.confirm"; - public const string ReleasePublish = "release.publish"; public const string KeysRead = "keys.read"; public const string KeysManage = "keys.manage"; @@ -74,7 +73,6 @@ public static class CapabilityCatalog new(Capability.UpdateRead, "update"), new(Capability.UpdateConfirm, "update", false, false, Capability.UpdateRead), - new(Capability.ReleasePublish, "update"), new(Capability.KeysRead, "keys"), new(Capability.KeysManage, "keys", false, false, Capability.KeysRead), @@ -99,7 +97,6 @@ public static class CapabilityCatalog new("read-only", [.. All.Where(c => !c.Reserved && c.Id.EndsWith(".read", StringComparison.Ordinal)).Select(c => c.Id)]), new("operations", Expand([Capability.PublicRead, Capability.FleetWrite, Capability.TasksWrite, Capability.DataWrite, Capability.SettingsWrite, Capability.UpdateConfirm, Capability.EventsRead, Capability.AuditRead])), new("enroll", Expand([Capability.FleetEnroll])), - new("ci-release", [Capability.ReleasePublish]), new("all", [.. All.Select(c => c.Id)]), ]; diff --git a/backend/src/MoShiro.Backend.Application/Updates/ReleaseKeyService.cs b/backend/src/MoShiro.Backend.Application/Updates/ReleaseKeyService.cs new file mode 100644 index 0000000..414298f --- /dev/null +++ b/backend/src/MoShiro.Backend.Application/Updates/ReleaseKeyService.cs @@ -0,0 +1,105 @@ +using System.Security.Cryptography; +using Fleet.Core; + +namespace MoShiro.Backend.Application; + +/// 一把发布公钥的指纹(SPKI 的 SHA-256,小写十六进制)和 PEM。 +public sealed record ReleaseKeyInfo(string Fingerprint, string PublicKey); + +/// 可信资产来源:官方 CI 发布密钥(内嵌,始终信任)和可选的自有发布公钥(默认没有)。 +public sealed record ReleaseKeys(ReleaseKeyInfo Official, ReleaseKeyInfo? Custom); + +/// +/// 面板信任哪些发布密钥:内嵌的官方 CI 公钥始终信任,不能关;部署的人可以另外导入自己的发布公钥(默认没有), +/// 用自己的私钥给自己构建的清单签名。验签(登记清单、取镜像仓库里的清单)通过官方或自有任意一把即可。 +/// 私钥只在部署的人手里,面板只存公钥,拒绝带私钥的 PEM。 +/// +public sealed class ReleaseKeyService +{ + internal const string SettingKey = "release.custom_key"; + private const int MaxPemBytes = 1024; + private const string P256Oid = "1.2.840.10045.3.1.7"; + + private readonly IReleaseKey _official; + private readonly ISettingsStore _settings; + + public ReleaseKeyService(IReleaseKey official, ISettingsStore settings) + { + _official = official; + _settings = settings; + } + + public async ValueTask GetAsync(CancellationToken ct) => + new(Describe(_official.Pem)!, Describe(await CustomAsync(ct))); + + /// 清单的签名由官方密钥或自有密钥(开启时)验过。 + public async ValueTask VerifyAsync(string manifest, string signature, CancellationToken ct) => + ReleaseSignature.Verify(_official.Pem, manifest, signature) + || (await CustomAsync(ct) is { } custom && ReleaseSignature.Verify(custom, manifest, signature)); + + public async ValueTask> SetCustomAsync(string? publicKey, CancellationToken ct) + { + if (!TryNormalize(publicKey, out string? pem, out string? problem)) + { + return ValidationError.Fail("public_key", problem); + } + + await _settings.SetAsync(SettingKey, pem, ct); + return Result.Ok(await GetAsync(ct)); + } + + public async ValueTask ClearCustomAsync(CancellationToken ct) => await _settings.SetAsync(SettingKey, string.Empty, ct); + + private async ValueTask CustomAsync(CancellationToken ct) => + await _settings.GetAsync(SettingKey, ct) is { Length: > 0 } pem ? pem : null; + + private static ReleaseKeyInfo? Describe(string? pem) + { + if (pem is null) + { + return null; + } + + using var key = ECDsa.Create(); + key.ImportFromPem(pem); + return new ReleaseKeyInfo(Convert.ToHexStringLower(SHA256.HashData(key.ExportSubjectPublicKeyInfo())), key.ExportSubjectPublicKeyInfoPem().Trim()); + } + + /// 只收 P-256 的公钥:带私钥的 PEM 直接拒绝(私钥不该离开你的机器)。 + private static bool TryNormalize(string? text, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out string? pem, [System.Diagnostics.CodeAnalysis.NotNullWhen(false)] out string? problem) + { + pem = null; + problem = null; + string trimmed = (text ?? string.Empty).Trim(); + if (trimmed.Length is 0 or > MaxPemBytes) + { + problem = "must be a PEM public key"; + return false; + } + + if (trimmed.Contains("PRIVATE", StringComparison.OrdinalIgnoreCase)) + { + problem = "must be a public key: never upload the private key, keep it on your own machine"; + return false; + } + + try + { + using var key = ECDsa.Create(); + key.ImportFromPem(trimmed); + if (key.ExportParameters(false).Curve.Oid.Value != P256Oid) + { + problem = "must be an ECDSA P-256 public key"; + return false; + } + + pem = key.ExportSubjectPublicKeyInfoPem().Trim(); + return true; + } + catch (Exception ex) when (ex is CryptographicException or ArgumentException) + { + problem = "is not a valid PEM public key"; + return false; + } + } +} diff --git a/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs b/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs index b1456fd..0275c31 100644 --- a/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs +++ b/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs @@ -26,14 +26,14 @@ public sealed partial class ReleaseService : IDisposable private readonly IAgentGateway _gateway; private readonly IBackendUpdateFiles _backendFiles; private readonly BackendOptions _options; - private readonly IReleaseKey _key; + private readonly ReleaseKeyService _keys; private readonly ReleaseFeed _feed; private readonly ConcurrentDictionary _feedChecked = new(); private readonly ConcurrentDictionary _status = new(); - public ReleaseService(ISettingsStore settings, IAgentGateway gateway, IBackendUpdateFiles backendFiles, BackendOptions options, IReleaseKey key, ReleaseFeed feed) + public ReleaseService(ISettingsStore settings, IAgentGateway gateway, IBackendUpdateFiles backendFiles, BackendOptions options, ReleaseKeyService keys, ReleaseFeed feed) { - _key = key; + _keys = keys; _feed = feed; _settings = settings; _gateway = gateway; @@ -53,7 +53,7 @@ public sealed partial class ReleaseService : IDisposable return ValidationError.Fail("manifest", problem); } - if (!ReleaseSignature.Verify(_key.Pem, manifest, signature)) + if (!await _keys.VerifyAsync(manifest, signature, ct)) { return Result.Fail(MoShiroErrors.Forbidden, "the manifest does not carry a valid release signature"); } @@ -109,7 +109,7 @@ public sealed partial class ReleaseService : IDisposable } _feedChecked[component] = now; - if (await _feed.FetchAsync(component, BackendUpdates.ChannelOf(_options), ct) is { } fetched && ReleaseSignature.Verify(_key.Pem, fetched.Manifest, fetched.Signature)) + if (await _feed.FetchAsync(component, BackendUpdates.ChannelOf(_options), ct) is { } fetched && await _keys.VerifyAsync(fetched.Manifest, fetched.Signature, ct)) { Result published = await PublishAsync(fetched.Manifest, fetched.Signature, ct); if (published.IsSuccess) @@ -121,6 +121,21 @@ public sealed partial class ReleaseService : IDisposable return stored; } + /// 不再信任某把密钥之后(删除自有发布密钥):已登记的清单里,现在验不过的丢掉,免得继续分发。 + public async ValueTask PurgeUnverifiedAsync(CancellationToken ct) + { + foreach (string key in new[] { NodeKinds.Agent, NodeKinds.Probe, BackendComponent + ".dev", BackendComponent + ".release" }) + { + string? manifest = await _settings.GetAsync(Key(key, "manifest"), ct); + string? signature = await _settings.GetAsync(Key(key, "signature"), ct); + if (!string.IsNullOrEmpty(manifest) && !string.IsNullOrEmpty(signature) && !await _keys.VerifyAsync(manifest, signature, ct)) + { + await _settings.SetAsync(Key(key, "manifest"), string.Empty, ct); + await _settings.SetAsync(Key(key, "signature"), string.Empty, ct); + } + } + } + public async ValueTask GetAsync(string component, CancellationToken ct) { string? manifest = await _settings.GetAsync(Key(component, "manifest"), ct); @@ -187,7 +202,7 @@ public sealed partial class ReleaseService : IDisposable : node.Value.StartsWith(ProbeId.Prefix, StringComparison.Ordinal) ? NodeKinds.Probe : null; - internal static string Key(string component, string part) => $"release.{component}.{part}"; + private static string Key(string component, string part) => $"release.{component}.{part}"; private static bool TryParse(string manifest, string signature, [NotNullWhen(true)] out ReleaseOffer? offer, [NotNullWhen(false)] out string? problem) { diff --git a/backend/tests/MoShiro.Backend.Tests/BackendUpdateTests.cs b/backend/tests/MoShiro.Backend.Tests/BackendUpdateTests.cs index 56f8536..aee6078 100644 --- a/backend/tests/MoShiro.Backend.Tests/BackendUpdateTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/BackendUpdateTests.cs @@ -33,7 +33,7 @@ public sealed class BackendUpdateTests : IAsyncLifetime private async Task PublishAsync(long seq, string channel = "dev") { string manifest = $$"""{"component":"backend","channel":"{{channel}}","image":"registry.example/moshiro-{{channel}}/moshiro-backend","digest":"{{Digest}}","tag":"{{channel}}-{{seq}}","version":"0.1.1","seq":{{seq}}}"""; - return await _backend.Admin.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = TestReleaseKey.Sign(manifest) })); + return await _backend.Anonymous.PostAsync("/api/release/offer", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = TestReleaseKey.Sign(manifest) })); } private Task ViewAsync() => _backend.GetJsonAsync(_backend.Admin, "/api/admin/backend/update"); diff --git a/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs b/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs index a0647a1..a3362f7 100644 --- a/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs @@ -121,12 +121,4 @@ public sealed class CapabilityTests Assert.Contains(catalog["presets"]!.AsArray(), p => p!["id"]!.GetValue() == "all"); Assert.Contains(catalog["groups"]!.AsArray().SelectMany(g => g!["capabilities"]!.AsArray()), c => c!["id"]!.GetValue() == Capability.FleetRevoke && c["trust"]!.GetValue()); } - - [Fact] - public async Task ReleaseKey_CanOnlyPublishReleases() - { - using HttpClient ci = await KeyAsync(Capability.ReleasePublish); - Assert.Equal(HttpStatusCode.Forbidden, (await ci.GetAsync("/api/admin/agents")).StatusCode); - Assert.Equal(HttpStatusCode.Forbidden, (await ci.GetAsync("/api/admin/backend/update")).StatusCode); - } } diff --git a/backend/tests/MoShiro.Backend.Tests/InstallTests.cs b/backend/tests/MoShiro.Backend.Tests/InstallTests.cs index 614ee2f..0ab7f43 100644 --- a/backend/tests/MoShiro.Backend.Tests/InstallTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/InstallTests.cs @@ -85,7 +85,7 @@ public sealed class InstallTests public async Task TheScript_GetsTheSignedManifestOfItsRole_ByteForByte() { string manifest = """{"schema":1,"component":"probe","channel":"dev","image":"registry.example/moshiro-dev/moshiro-probe","digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000","tag":"dev-1","version":"0.1.1","commit":"abc","built_at":"2026-10-06T00:00:00Z","seq":1}"""; - using HttpResponseMessage published = await _backend.Admin.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = TestReleaseKey.Sign(manifest) })); + using HttpResponseMessage published = await _backend.Anonymous.PostAsync("/api/release/offer", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = TestReleaseKey.Sign(manifest) })); Assert.True(published.StatusCode is HttpStatusCode.Created or HttpStatusCode.Conflict, await published.Content.ReadAsStringAsync()); string code = (await CreateAsync("probe"))["plaintext"]!.GetValue(); diff --git a/backend/tests/MoShiro.Backend.Tests/ReleaseKeyTests.cs b/backend/tests/MoShiro.Backend.Tests/ReleaseKeyTests.cs new file mode 100644 index 0000000..725c50e --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/ReleaseKeyTests.cs @@ -0,0 +1,123 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; +using Microsoft.Extensions.DependencyInjection; +using MoShiro.Backend.Application; + +namespace MoShiro.Backend.Tests; + +/// +/// 可信资产来源:官方发布密钥始终信任;部署的人可以另外导入自己的发布公钥(默认没有),用自己的私钥给自己构建的清单签名。 +/// 面板只收公钥,拒绝私钥;删除之后只有它签过的清单不再被接受,已登记的也丢掉。 +/// +[Collection("probe-e2e")] +public sealed class ReleaseKeyTests +{ + private static long s_seq = TimeProvider.System.GetUtcNow().ToUnixTimeSeconds() * 1000 + 500_000; + + private readonly BackendFixture _backend; + + public ReleaseKeyTests(BackendFixture backend) + { + _backend = backend; + } + + private static string Manifest(long seq) => + $$"""{"schema":1,"component":"backend","channel":"dev","image":"registry.example/moshiro-dev/moshiro-backend","digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","tag":"own-{{seq}}","version":"0.1.1","seq":{{seq}}}"""; + + private static string Sign(ECDsa key, string manifest) => + Convert.ToBase64String(key.SignData(Encoding.UTF8.GetBytes(manifest), HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence)); + + private async Task PutKeyAsync(string? pem) => + await _backend.Admin.PutAsync("/api/admin/release-key", BackendFixture.Json(new JsonObject { ["public_key"] = pem })); + + private async Task OfferAsync(string manifest, string signature) + { + using HttpClient anonymous = _backend.NewClient(); + return await anonymous.PostAsync("/api/release/offer", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = signature })); + } + + [Fact] + public async Task ByDefaultOnlyTheOfficialKeyIsTrusted_AndItCannotBeRemoved() + { + using HttpResponseMessage cleared = await _backend.Admin.DeleteAsync("/api/admin/release-key"); + Assert.Equal(HttpStatusCode.OK, cleared.StatusCode); + + JsonNode keys = JsonNode.Parse(await cleared.Content.ReadAsStringAsync())!; + Assert.Null(keys["custom"]); + Assert.Equal(64, keys["official"]!["fingerprint"]!.GetValue().Length); + Assert.StartsWith("-----BEGIN PUBLIC KEY-----", keys["official"]!["public_key"]!.GetValue(), StringComparison.Ordinal); + } + + [Theory] + [InlineData("")] + [InlineData("not a key")] + [InlineData(null)] + public async Task ABadKey_IsRefused(string? pem) + { + using HttpResponseMessage response = await PutKeyAsync(pem); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task ThePrivateKey_IsNeverAccepted_AndOnlyP256IsAllowed() + { + using var p256 = ECDsa.Create(ECCurve.NamedCurves.nistP256); + using var p384 = ECDsa.Create(ECCurve.NamedCurves.nistP384); + + using HttpResponseMessage privateKey = await PutKeyAsync(p256.ExportPkcs8PrivateKeyPem()); + using HttpResponseMessage ecPrivateKey = await PutKeyAsync(p256.ExportECPrivateKeyPem()); + using HttpResponseMessage otherCurve = await PutKeyAsync(p384.ExportSubjectPublicKeyInfoPem()); + + Assert.Equal(HttpStatusCode.BadRequest, privateKey.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, ecPrivateKey.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, otherCurve.StatusCode); + Assert.Contains("private key", await privateKey.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task AManifestSignedByTheOwnKey_IsAccepted_UntilTheKeyIsRemoved() + { + using var own = ECDsa.Create(ECCurve.NamedCurves.nistP256); + long first = Interlocked.Increment(ref s_seq); + long second = Interlocked.Increment(ref s_seq); + + // 还没有导入:自己的密钥签的清单不被接受 + Assert.Equal(HttpStatusCode.Forbidden, (await OfferAsync(Manifest(first), Sign(own, Manifest(first)))).StatusCode); + + using HttpResponseMessage set = await PutKeyAsync(own.ExportSubjectPublicKeyInfoPem()); + Assert.Equal(HttpStatusCode.OK, set.StatusCode); + string fingerprint = Convert.ToHexStringLower(SHA256.HashData(own.ExportSubjectPublicKeyInfo())); + Assert.Equal(fingerprint, JsonNode.Parse(await set.Content.ReadAsStringAsync())!["custom"]!["fingerprint"]!.GetValue()); + + Assert.Equal(HttpStatusCode.Created, (await OfferAsync(Manifest(first), Sign(own, Manifest(first)))).StatusCode); + ReleaseService releases = _backend.Services.GetRequiredService(); + Assert.Equal(first, (await releases.GetBackendAsync("dev", default))!.Seq); + + // 别人的密钥照样不行 + using var stranger = ECDsa.Create(ECCurve.NamedCurves.nistP256); + Assert.Equal(HttpStatusCode.Forbidden, (await OfferAsync(Manifest(second), Sign(stranger, Manifest(second)))).StatusCode); + + // 删除之后:只有它签过的清单不再被接受,已经登记的也丢掉 + using HttpResponseMessage cleared = await _backend.Admin.DeleteAsync("/api/admin/release-key"); + Assert.Equal(HttpStatusCode.OK, cleared.StatusCode); + Assert.Null(JsonNode.Parse(await cleared.Content.ReadAsStringAsync())!["custom"]); + Assert.Equal(HttpStatusCode.Forbidden, (await OfferAsync(Manifest(second), Sign(own, Manifest(second)))).StatusCode); + Assert.Null(await releases.GetBackendAsync("dev", default)); + } + + [Fact] + public async Task OnlyAnAdministrator_MayChangeTheTrustedKeys() + { + using HttpClient anonymous = _backend.NewClient(); + using var own = ECDsa.Create(ECCurve.NamedCurves.nistP256); + + using HttpResponseMessage read = await anonymous.GetAsync("/api/admin/release-key"); + using HttpResponseMessage write = await anonymous.PutAsync("/api/admin/release-key", BackendFixture.Json(new JsonObject { ["public_key"] = own.ExportSubjectPublicKeyInfoPem() })); + + Assert.Equal(HttpStatusCode.Unauthorized, read.StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, write.StatusCode); + } +} diff --git a/backend/tests/MoShiro.Backend.Tests/ReleaseOfferTests.cs b/backend/tests/MoShiro.Backend.Tests/ReleaseOfferTests.cs index 40cc367..9f20e68 100644 --- a/backend/tests/MoShiro.Backend.Tests/ReleaseOfferTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/ReleaseOfferTests.cs @@ -75,7 +75,7 @@ public sealed class ReleaseOfferTests } [Fact] - public async Task OfferWithAValidSignature_IsAccepted_WithoutAnyToken_AndTheAdminEndpointChecksTheSignatureToo() + public async Task OfferWithAValidSignature_IsAccepted_WithoutAnyToken() { long seq = TimeProvider.System.GetUtcNow().ToUnixTimeSeconds() * 1000; // backend 的 release 通道:不和别的用例抢 agent、probe 的 seq string manifest = $$"""{"schema":1,"component":"backend","channel":"release","image":"registry.example/moshiro/moshiro-backend","digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000","tag":"dev-x","version":"0.1.1","seq":{{seq}}}"""; @@ -83,19 +83,5 @@ public sealed class ReleaseOfferTests using HttpResponseMessage offered = await anonymous.PostAsync("/api/release/offer", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = TestReleaseKey.Sign(manifest) })); Assert.Equal(HttpStatusCode.Created, offered.StatusCode); - - // 管理员通道不再是不验签的口子:签名不对一样拒绝 - string next = manifest.Replace($"\"seq\":{seq}", $"\"seq\":{seq + 1}", StringComparison.Ordinal); - using HttpResponseMessage forged = await _backend.Admin.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = next, ["signature"] = OpenSslSignature })); - Assert.Equal(HttpStatusCode.Forbidden, forged.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/backend/tests/MoShiro.Backend.Tests/UpdateTests.cs b/backend/tests/MoShiro.Backend.Tests/UpdateTests.cs index 9d48f5d..33a6b21 100644 --- a/backend/tests/MoShiro.Backend.Tests/UpdateTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/UpdateTests.cs @@ -26,8 +26,11 @@ public sealed class UpdateTests private static string Manifest(string component, long seq, string digest = Digest) => $$"""{"schema":1,"component":"{{component}}","channel":"dev","image":"registry.example/moshiro-dev/moshiro-{{component}}","digest":"{{digest}}","tag":"dev-{{seq}}","version":"0.1.1","commit":"abc","built_at":"2026-10-06T00:00:00Z","seq":{{seq}}}"""; - private async Task PublishAsync(string manifest, string? signature = null) => - await _backend.Admin.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = signature ?? TestReleaseKey.Sign(manifest) })); + private async Task PublishAsync(string manifest, string? signature = null) + { + signature ??= TestReleaseKey.Sign(manifest); + return await _backend.Anonymous.PostAsync("/api/release/offer", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = signature })); + } [Fact] public async Task Release_IsForwardedByteForByte_ToOnlineNodesOfThatComponent_AndToNodesThatConnectLater() @@ -57,13 +60,13 @@ public sealed class UpdateTests } [Fact] - public async Task Release_Registration_RefusesBadManifests_StaleSeq_AndNeedsAnAdmin() + public async Task Release_Registration_RefusesBadManifests_BadSignatures_AndStaleSeq() { long seq = NextSeq(); Assert.Equal(HttpStatusCode.BadRequest, (await PublishAsync("{not json")).StatusCode); Assert.Equal(HttpStatusCode.BadRequest, (await PublishAsync(Manifest("agent", seq, digest: "sha256:short"))).StatusCode); Assert.Equal(HttpStatusCode.BadRequest, (await PublishAsync(Manifest("unknown", seq))).StatusCode); - Assert.Equal(HttpStatusCode.BadRequest, (await PublishAsync(Manifest("agent", seq), signature: "not base64!")).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await PublishAsync(Manifest("agent", seq), signature: "not base64!")).StatusCode); using var other = System.Security.Cryptography.ECDsa.Create(System.Security.Cryptography.ECCurve.NamedCurves.nistP256); string foreign = Convert.ToBase64String(other.SignData(System.Text.Encoding.UTF8.GetBytes(Manifest("agent", seq)), System.Security.Cryptography.HashAlgorithmName.SHA256, System.Security.Cryptography.DSASignatureFormat.Rfc3279DerSequence)); @@ -73,43 +76,6 @@ public sealed class UpdateTests Assert.Equal(HttpStatusCode.Created, (await PublishAsync(Manifest("agent", seq))).StatusCode); Assert.Equal(HttpStatusCode.Conflict, (await PublishAsync(Manifest("agent", seq))).StatusCode); // 同一个 seq:重放 Assert.Equal(HttpStatusCode.Conflict, (await PublishAsync(Manifest("agent", seq - 1))).StatusCode); // 更旧的 seq - - using HttpResponseMessage anonymous = await _backend.Anonymous.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = Manifest("agent", seq + 1), ["signature"] = TestReleaseKey.Sign(Manifest("agent", seq + 1)) })); - Assert.Contains(anonymous.StatusCode, new[] { HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden }); - } - - [Fact] - public async Task ReleaseToken_CanOnlyRegisterReleases_AndAReadTokenCannotEvenDoThat() - { - async Task NewTokenAsync(params string[] capabilities) - { - using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = _backend.UniqueName("ci"), ["capabilities"] = BackendFixture.Capabilities(capabilities) })); - Assert.Equal(HttpStatusCode.Created, created.StatusCode); - return JsonNode.Parse(await created.Content.ReadAsStringAsync())!["plaintext"]!.GetValue(); - } - - HttpClient Client(string token) - { - var client = new HttpClient { BaseAddress = _backend.BaseAddress }; - client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); - return client; - } - - long seq = NextSeq(); - using HttpClient release = Client(await NewTokenAsync("release.publish")); - using HttpClient read = Client(await NewTokenAsync("public.read", "fleet.read")); - var body = BackendFixture.Json(new JsonObject { ["manifest"] = Manifest("probe", seq), ["signature"] = TestReleaseKey.Sign(Manifest("probe", seq)) }); - - using HttpResponseMessage forbidden = await read.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = Manifest("probe", seq), ["signature"] = TestReleaseKey.Sign(Manifest("probe", seq)) })); - Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode); - using HttpResponseMessage published = await release.PostAsync("/api/admin/releases", body); - Assert.Equal(HttpStatusCode.Created, published.StatusCode); - - // release 令牌只能登记发布清单:读不了任何东西,也不能做别的管理操作 - Assert.Equal(HttpStatusCode.Forbidden, (await release.GetAsync("/api/admin/agents")).StatusCode); - Assert.Equal(HttpStatusCode.Forbidden, (await release.GetAsync("/api/admin/releases")).StatusCode); - Assert.Equal(HttpStatusCode.Forbidden, (await release.PostAsync("/api/admin/nodes/agt_x/update", null)).StatusCode); - Assert.Equal(HttpStatusCode.Unauthorized, (await _backend.Anonymous.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = Manifest("probe", seq + 1), ["signature"] = TestReleaseKey.Sign(Manifest("probe", seq + 1)) }))).StatusCode); } [Fact] diff --git a/docs/access.md b/docs/access.md index f1d1587..d0e2b33 100644 --- a/docs/access.md +++ b/docs/access.md @@ -65,7 +65,6 @@ | 数据 | `data.read` · `data.write` | 原始块、导出;清理 | | | 设置 | `settings.read` · `settings.write` | 站点设置、保留策略、重启计划 | | | 更新 | `update.read` · `update.confirm` | 看 Backend 更新、确认更新 | | -| | `release.publish` | CI 登记发布清单(只此一件事) | | | 令牌 | `keys.read` · `keys.manage` | 列出、创建、撤销 API key | | | 账户 | `accounts.manage` | 账户、passkey、操作员集合 | 是 | | 运维 | `audit.read` · `ops.metrics` | 审计日志;`/metrics` | | @@ -133,7 +132,7 @@ | 普通 key | 不要 | 勾选的非信任类能力(只读、运维、CI 发布等),只能是创建者能力的子集 | | `enroll` key | **要,签一次** | 勾选的非信任类能力 + `fleet.enroll`(可吊销自己加的节点) | -- 现有令牌迁移(破坏性):`read` → 全部 `*.read`;`admin` → 全部非信任类能力;`release` → `release.publish`。迁移后,旧的 admin 令牌不能再生成加入代码,需要换成 `enroll` key。 +- 现有令牌迁移(破坏性):`read` → 全部 `*.read`;`admin` → 全部非信任类能力;`release` 令牌已取消(CI 登记发布清单不需要令牌,见 `POST /api/release/offer`)。迁移后,旧的 admin 令牌不能再生成加入代码,需要换成 `enroll` key。 - 每把 key 可设:有效期、可选的 IP 白名单(CIDR,**待确认**)、`enroll` 类的数量上限和单券 TTL 上限;预留:资源范围、速率和数据配额(见 5.4)。 ### 5.2 `enroll` key:自动化添加节点 diff --git a/docs/api.md b/docs/api.md index 49c167e..a4ef5f8 100644 --- a/docs/api.md +++ b/docs/api.md @@ -146,8 +146,9 @@ | 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}`,需要 `release.publish`,原样转给在线的节点和之后连上的节点),GET 看每个组件最新的一份 | -| POST | `/api/release/offer` | CI 登记发布清单的入口,**不需要令牌**:`{manifest, signature}` 的签名必须能用 Backend 内嵌的发布公钥验过(ECDSA P-256 / SHA-256,DER),否则 403;`seq` 必须比已登记的新,否则 409。登记不依赖面板里的任何凭证,清库、重装、还没有 passkey 时照常工作 | +| GET | `/api/admin/releases` | 每个组件最新的一份签名发布清单 | +| GET/PUT/DELETE | `/api/admin/release-key` | 可信资产来源:GET 看官方发布密钥和(如果有)自有发布公钥的指纹;PUT `{public_key}` 另外信任自己的发布公钥(P-256 的 PEM,拒绝私钥);DELETE 停用它,只有它签过的已登记清单会被丢掉。官方密钥始终信任。PUT/DELETE 需要 `update.confirm`,写审计 | +| 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 bff3c31..824d5aa 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -315,6 +315,8 @@ sh packaging/update/install-updater.sh agent --user \ 发布私钥只放在 Woodpecker 的 `release_signing_key` secret;发布公钥位于 `packaging/update/release-key.pub.pem`。`RELEASE_API_URL` 指向面板,CI 把签名清单 POST 到 `/api/release/offer`:这个入口不需要令牌,面板用内嵌的发布公钥验签,签名无效就拒绝,所以 CI 不需要在面板里持有任何凭证(清库或重装后照常登记,也没有长期令牌可偷)。面板保存并转发签名清单,Agent/Probe 将它与更新请求写进配置 volume;更新器验签后才调用容器引擎和 systemd。管理端仍能看到可更新、更新中、已回滚和无效清单状态。纯二进制更新器尚未实现。 +**自有发布密钥**(自己构建、自己发布的部署;默认关闭):管理端「系统 → 信任 → 可信资产来源」。官方 CI 发布密钥始终信任,不能关;另外导入**自己的发布公钥**(P-256 的 PEM,面板拒绝私钥,私钥留在你自己的机器或 CI 密钥库)之后,用你的私钥签的清单也会被面板登记和分发。签名方法:`openssl dgst -sha256 -sign release.key -out manifest.sig.bin manifest.json`,再 `base64 -w0` 得到签名;清单是一行扁平的 JSON(字段见 `ci/release-manifest.sh`),交给面板用 `POST /api/release/offer`,或者像官方那样放进你镜像仓库里的清单镜像。节点宿主机的更新器只认本机的公钥文件,所以用你自己的密钥发布时,还要把你的公钥放进这些节点的 `release-key.pub.pem`,并且清单里的 `image` 必须等于它本机配置的镜像仓库。停用自有密钥时,只有它签过的已登记清单会被丢掉。 + **换发布密钥**(泄露或定期轮换,不用重装节点): 1. 生成新密钥对;CI 给发布流水线加 `RELEASE_NEXT_KEY`(新公钥 PEM),仍用旧私钥签名。清单里会带 `next_key`(SPKI DER 的 base64)。 diff --git a/web/admin/src/pages/CapabilityPicker.tsx b/web/admin/src/pages/CapabilityPicker.tsx index 6c38b3c..cf40b07 100644 --- a/web/admin/src/pages/CapabilityPicker.tsx +++ b/web/admin/src/pages/CapabilityPicker.tsx @@ -19,7 +19,6 @@ const NAMES: Record = { 'settings.write': ['修改设置', '站点设置、保留策略、重启计划'], 'update.read': ['查看更新', '版本、发布清单、Backend 更新状态'], 'update.confirm': ['确认更新', '确认 Backend 更新、请求节点更新'], - 'release.publish': ['登记发布清单', '给 CI 用,只能做这一件事'], 'keys.read': ['查看 API 令牌', ''], 'keys.manage': ['管理 API 令牌', '创建、吊销;只能授予自己拥有的能力'], 'accounts.manage': ['管理账户', '面板用户、密码'], diff --git a/web/admin/src/pages/ReleaseKeys.tsx b/web/admin/src/pages/ReleaseKeys.tsx new file mode 100644 index 0000000..b50ba24 --- /dev/null +++ b/web/admin/src/pages/ReleaseKeys.tsx @@ -0,0 +1,119 @@ +import { useState } from 'react' +import { api } from '@shared/api/client' +import type { ReleaseKeysDto } from '@shared/api/types' +import { useQuery } from '@shared/hooks' +import { Chip, Confirm, CopyButton, ErrorNotice, Field, useAction } from '@shared/ui' + +const KEYGEN = `openssl ecparam -name prime256v1 -genkey -noout -out release.key +openssl ec -in release.key -pubout -out release.pub.pem` + +const SIGN = `# manifest.json 是一行扁平的 JSON(字段见 docs/deployment.md),对它的原始字节签名: +openssl dgst -sha256 -sign release.key -out manifest.sig.bin manifest.json +base64 -w0 manifest.sig.bin > manifest.sig` + +/** + * 可信资产来源:面板信任哪些发布密钥。官方 CI 密钥始终信任;自有发布公钥默认关闭,需要时再导入。 + * 只收公钥(私钥留在自己的机器上),改动都会写进审计。 + */ +export function ReleaseKeys() { + const keys = useQuery(() => api.get('/api/admin/release-key'), []) + const { busy, run } = useAction() + const [pem, setPem] = useState('') + const [removing, setRemoving] = useState(false) + const data = keys.data + const looksPrivate = /PRIVATE/i.test(pem) + + return ( +
+
+ 可信资产来源 +

+ 发布清单(镜像 digest)必须带有效的发布签名才会被面板登记和分发;节点宿主机的更新器之后还会用它本机的公钥再验一次。这里决定面板认哪些发布密钥。 +

+
+ + + {data ? ( + <> + +
+ 始终信任 + {data.official.fingerprint} + +
+
+ + + {data.custom ? ( +
+
+ 已开启 + {data.custom.fingerprint} + + +
+
+ ) : ( +
{ + e.preventDefault() + void run(async () => { + await api.put('/api/admin/release-key', { public_key: pem }) + setPem('') + keys.reload() + }, '已开启自有发布密钥') + }} + > +