feat: trusted release keys setting; drop the duplicate release registration route [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
ci/woodpecker/push/main-amd64-build Pipeline was successful
The panel now lets the deployer decide which release keys it trusts. The official CI key is built in and always trusted; an own P-256 public key can be added (off by default, the private key is rejected) so manifests signed with it are accepted too. Removing it drops the manifests only it signed. Changes are audited and need update.confirm. Admin UI: System > Trust > "Trusted release keys" with a collapsible guide on generating a key pair and signing a manifest with openssl. Registration now has one path, POST /api/release/offer (no token, signature verified). The admin POST /releases route, the release.publish capability and the ci-release preset are removed.
This commit is contained in:
1 parent
904ea2b0ae
commit
0f8f3f9a54
24 files changed
+914
-325
No files matched your search
+241
-115
@@ -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"
|
||||
},
|
||||
|
||||
@@ -36,15 +36,6 @@ public static class BuildEndpoints
|
||||
.Requires(Capability.UpdateRead);
|
||||
}
|
||||
|
||||
/// <summary>登记发布清单:挂在 <see cref="ReleaseGuard"/> 下,除了管理员,CI 用的 <c>release</c> 令牌也能调用(它只能做这一件事)。</summary>
|
||||
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<ReleaseDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409)
|
||||
.Requires(Capability.ReleasePublish);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CI 登记清单的入口,<b>不需要令牌</b>:请求里的清单必须带有效的 CI 发布签名(用 Backend 内嵌的发布公钥验)才收下。
|
||||
/// 这样登记不依赖面板里的任何凭证(清库、重装、还没有 passkey 的时候照常工作),也没有一把能被偷的长期令牌;
|
||||
@@ -57,12 +48,12 @@ public static class BuildEndpoints
|
||||
.Returns<ReleaseDto>(StatusCodes.Status201Created).ProducesProblems(400, 403, 409);
|
||||
}
|
||||
|
||||
private static async Task<IResult> OfferReleaseAsync(HttpContext http, ReleaseService releases, IReleaseKey releaseKey, PublishReleaseRequest request)
|
||||
private static async Task<IResult> 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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>可信资产来源:官方发布密钥(只读)和可选的自有发布公钥(默认没有)。改动写审计。</summary>
|
||||
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<ReleaseKeysDto>().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<ReleaseKeysDto>().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<ReleaseKeysDto>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.UpdateConfirm);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAsync(ReleaseKeyService keys, CancellationToken ct) => Json(await keys.GetAsync(ct));
|
||||
|
||||
private static async Task<IResult> SetAsync(HttpContext http, ReleaseKeyService keys, SetReleaseKeyRequest request)
|
||||
{
|
||||
Result<ReleaseKeys> 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<IResult> 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<ReleaseKeysDto> 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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -198,6 +198,14 @@ public sealed record ReleaseDto(string Component, long Seq, string Tag, string V
|
||||
|
||||
public sealed record PublishReleaseRequest(string? Manifest, string? Signature);
|
||||
|
||||
/// <summary>一把发布公钥:指纹是 SPKI 的 SHA-256(小写十六进制)。</summary>
|
||||
public sealed record ReleaseKeyDto(string Fingerprint, string PublicKey);
|
||||
|
||||
/// <summary>可信资产来源:官方发布密钥(始终信任)和可选的自有发布公钥(默认没有)。</summary>
|
||||
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);
|
||||
|
||||
@@ -45,6 +45,8 @@ namespace MoShiro.Backend.Api;
|
||||
[JsonSerializable(typeof(ReleaseDto))]
|
||||
[JsonSerializable(typeof(PageDto<ReleaseDto>))]
|
||||
[JsonSerializable(typeof(PublishReleaseRequest))]
|
||||
[JsonSerializable(typeof(ReleaseKeysDto))]
|
||||
[JsonSerializable(typeof(SetReleaseKeyRequest))]
|
||||
[JsonSerializable(typeof(AboutDto))]
|
||||
[JsonSerializable(typeof(NodeBuildDto))]
|
||||
[JsonSerializable(typeof(NodeOwnerDto))]
|
||||
|
||||
@@ -40,6 +40,7 @@ public static class ApplicationServiceCollectionExtensions
|
||||
services.AddSingleton<ServerIdentityService>();
|
||||
services.AddSingleton<SetupService>();
|
||||
services.AddSingleton<ReleaseFeed>();
|
||||
services.AddSingleton<ReleaseKeyService>();
|
||||
services.AddSingleton<NodeOwnerService>();
|
||||
services.AddSingleton<Fleet.Server.IServerIdentity>(sp => sp.GetRequiredService<ServerIdentityService>());
|
||||
services.AddSingleton<TrustService>();
|
||||
|
||||
@@ -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)]),
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
using System.Security.Cryptography;
|
||||
using Fleet.Core;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>一把发布公钥的指纹(SPKI 的 SHA-256,小写十六进制)和 PEM。</summary>
|
||||
public sealed record ReleaseKeyInfo(string Fingerprint, string PublicKey);
|
||||
|
||||
/// <summary>可信资产来源:官方 CI 发布密钥(内嵌,始终信任)和可选的自有发布公钥(默认没有)。</summary>
|
||||
public sealed record ReleaseKeys(ReleaseKeyInfo Official, ReleaseKeyInfo? Custom);
|
||||
|
||||
/// <summary>
|
||||
/// 面板信任哪些发布密钥:内嵌的官方 CI 公钥始终信任,不能关;部署的人可以另外导入<b>自己的发布公钥</b>(默认没有),
|
||||
/// 用自己的私钥给自己构建的清单签名。验签(登记清单、取镜像仓库里的清单)通过官方或自有任意一把即可。
|
||||
/// 私钥只在部署的人手里,面板只存公钥,拒绝带私钥的 PEM。
|
||||
/// </summary>
|
||||
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<ReleaseKeys> GetAsync(CancellationToken ct) =>
|
||||
new(Describe(_official.Pem)!, Describe(await CustomAsync(ct)));
|
||||
|
||||
/// <summary>清单的签名由官方密钥或自有密钥(开启时)验过。</summary>
|
||||
public async ValueTask<bool> 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<Result<ReleaseKeys>> SetCustomAsync(string? publicKey, CancellationToken ct)
|
||||
{
|
||||
if (!TryNormalize(publicKey, out string? pem, out string? problem))
|
||||
{
|
||||
return ValidationError.Fail<ReleaseKeys>("public_key", problem);
|
||||
}
|
||||
|
||||
await _settings.SetAsync(SettingKey, pem, ct);
|
||||
return Result<ReleaseKeys>.Ok(await GetAsync(ct));
|
||||
}
|
||||
|
||||
public async ValueTask ClearCustomAsync(CancellationToken ct) => await _settings.SetAsync(SettingKey, string.Empty, ct);
|
||||
|
||||
private async ValueTask<string?> 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());
|
||||
}
|
||||
|
||||
/// <summary>只收 P-256 的公钥:带私钥的 PEM 直接拒绝(私钥不该离开你的机器)。</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, long> _feedChecked = new();
|
||||
private readonly ConcurrentDictionary<string, UpdateStatus> _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<ReleaseOffer>("manifest", problem);
|
||||
}
|
||||
|
||||
if (!ReleaseSignature.Verify(_key.Pem, manifest, signature))
|
||||
if (!await _keys.VerifyAsync(manifest, signature, ct))
|
||||
{
|
||||
return Result<ReleaseOffer>.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<ReleaseOffer> published = await PublishAsync(fetched.Manifest, fetched.Signature, ct);
|
||||
if (published.IsSuccess)
|
||||
@@ -121,6 +121,21 @@ public sealed partial class ReleaseService : IDisposable
|
||||
return stored;
|
||||
}
|
||||
|
||||
/// <summary>不再信任某把密钥之后(删除自有发布密钥):已登记的清单里,现在验不过的丢掉,免得继续分发。</summary>
|
||||
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<ReleaseOffer?> 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)
|
||||
{
|
||||
|
||||
@@ -33,7 +33,7 @@ public sealed class BackendUpdateTests : IAsyncLifetime
|
||||
private async Task<HttpResponseMessage> 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<JsonNode> ViewAsync() => _backend.GetJsonAsync(_backend.Admin, "/api/admin/backend/update");
|
||||
|
||||
@@ -121,12 +121,4 @@ public sealed class CapabilityTests
|
||||
Assert.Contains(catalog["presets"]!.AsArray(), p => p!["id"]!.GetValue<string>() == "all");
|
||||
Assert.Contains(catalog["groups"]!.AsArray().SelectMany(g => g!["capabilities"]!.AsArray()), c => c!["id"]!.GetValue<string>() == Capability.FleetRevoke && c["trust"]!.GetValue<bool>());
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
@@ -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<string>();
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 可信资产来源:官方发布密钥始终信任;部署的人可以另外导入自己的发布公钥(默认没有),用自己的私钥给自己构建的清单签名。
|
||||
/// 面板只收公钥,拒绝私钥;删除之后只有它签过的清单不再被接受,已登记的也丢掉。
|
||||
/// </summary>
|
||||
[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<HttpResponseMessage> PutKeyAsync(string? pem) =>
|
||||
await _backend.Admin.PutAsync("/api/admin/release-key", BackendFixture.Json(new JsonObject { ["public_key"] = pem }));
|
||||
|
||||
private async Task<HttpResponseMessage> 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<string>().Length);
|
||||
Assert.StartsWith("-----BEGIN PUBLIC KEY-----", keys["official"]!["public_key"]!.GetValue<string>(), 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<string>());
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, (await OfferAsync(Manifest(first), Sign(own, Manifest(first)))).StatusCode);
|
||||
ReleaseService releases = _backend.Services.GetRequiredService<ReleaseService>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<HttpResponseMessage> 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<HttpResponseMessage> 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<string> 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<string>();
|
||||
}
|
||||
|
||||
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]
|
||||
|
||||
+1
-2
@@ -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:自动化添加节点
|
||||
|
||||
+3
-2
@@ -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) |
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -19,7 +19,6 @@ const NAMES: Record<string, [string, string]> = {
|
||||
'settings.write': ['修改设置', '站点设置、保留策略、重启计划'],
|
||||
'update.read': ['查看更新', '版本、发布清单、Backend 更新状态'],
|
||||
'update.confirm': ['确认更新', '确认 Backend 更新、请求节点更新'],
|
||||
'release.publish': ['登记发布清单', '给 CI 用,只能做这一件事'],
|
||||
'keys.read': ['查看 API 令牌', ''],
|
||||
'keys.manage': ['管理 API 令牌', '创建、吊销;只能授予自己拥有的能力'],
|
||||
'accounts.manage': ['管理账户', '面板用户、密码'],
|
||||
|
||||
@@ -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<ReleaseKeysDto>('/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 (
|
||||
<div className="card stack" style={{ marginTop: 16 }}>
|
||||
<div>
|
||||
<b>可信资产来源</b>
|
||||
<p className="muted">
|
||||
发布清单(镜像 digest)必须带有效的发布签名才会被面板登记和分发;节点宿主机的更新器之后还会用它本机的公钥再验一次。这里决定面板认哪些发布密钥。
|
||||
</p>
|
||||
</div>
|
||||
<ErrorNotice error={keys.error} />
|
||||
|
||||
{data ? (
|
||||
<>
|
||||
<Field label="官方 CI 发布密钥" hint="内嵌在程序里,始终信任,不能关闭。指纹可以和 packaging/update/release-key.pub.pem 核对。">
|
||||
<div className="row" style={{ gap: 8, flexWrap: 'wrap' }}>
|
||||
<Chip tone="ok">始终信任</Chip>
|
||||
<span className="mono" style={{ wordBreak: 'break-all' }}>{data.official.fingerprint}</span>
|
||||
<CopyButton text={data.official.fingerprint} />
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field label="自有发布密钥" hint="默认关闭。开启后,用你自己的私钥签名的清单也会被接受,适合自己构建、自己发布的部署。">
|
||||
{data.custom ? (
|
||||
<div className="stack">
|
||||
<div className="row" style={{ gap: 8, flexWrap: 'wrap' }}>
|
||||
<Chip tone="accent">已开启</Chip>
|
||||
<span className="mono" style={{ wordBreak: 'break-all' }}>{data.custom.fingerprint}</span>
|
||||
<CopyButton text={data.custom.fingerprint} />
|
||||
<button type="button" className="btn small danger" disabled={busy} onClick={() => setRemoving(true)}>
|
||||
停用
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
className="stack"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
void run(async () => {
|
||||
await api.put('/api/admin/release-key', { public_key: pem })
|
||||
setPem('')
|
||||
keys.reload()
|
||||
}, '已开启自有发布密钥')
|
||||
}}
|
||||
>
|
||||
<textarea className="input mono" rows={5} value={pem} onChange={(e) => setPem(e.target.value)} placeholder={'-----BEGIN PUBLIC KEY-----\n…\n-----END PUBLIC KEY-----'} spellCheck={false} />
|
||||
{looksPrivate ? <div className="notice bad">这看起来是私钥。私钥不要上传,留在你自己的机器上;这里只要公钥(BEGIN PUBLIC KEY)。</div> : null}
|
||||
<div className="notice warn">
|
||||
开启之后,持有这把私钥的人签出的清单都会被本面板接受:私钥泄露就等于有人能给你的节点推更新;私钥丢了就签不出新的构建。官方密钥不受影响。
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" className="btn primary" disabled={busy || !pem.trim() || looksPrivate}>
|
||||
开启并信任这把公钥
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<details>
|
||||
<summary>怎么用自己的密钥给资产签名</summary>
|
||||
<div className="stack" style={{ marginTop: 8 }}>
|
||||
<p className="muted">① 生成密钥对(ECDSA P-256)。<b>私钥 release.key 留在你自己的机器或 CI 密钥库里,不要上传到这里</b>;把 release.pub.pem 的内容粘贴到上面。</p>
|
||||
<pre className="code">{KEYGEN}</pre>
|
||||
<CopyButton text={KEYGEN} />
|
||||
<p className="muted">② 给你自己构建的发布清单签名(DER 编码的 ECDSA P-256 / SHA-256,和官方同一种格式;签名对象是清单文件的原始字节)。</p>
|
||||
<pre className="code" style={{ whiteSpace: 'pre-wrap' }}>{SIGN}</pre>
|
||||
<CopyButton text={SIGN} />
|
||||
<p className="muted">③ 把清单和签名交给面板:POST /api/release/offer(不需要令牌,面板用上面开启的公钥验签),或者像官方那样放进你镜像仓库里的清单镜像。</p>
|
||||
<p className="muted">
|
||||
④ <b>节点宿主机的更新器另外要放你的公钥</b>(它只认本机文件 release-key.pub.pem,面板改不了),并且清单里的 image 必须等于它本机配置的镜像仓库;
|
||||
否则这些节点不会自动更新到你签的版本。
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{removing ? (
|
||||
<Confirm
|
||||
title="停用自有发布密钥"
|
||||
danger
|
||||
confirmText="停用"
|
||||
onClose={() => setRemoving(false)}
|
||||
onConfirm={async () => {
|
||||
await run(() => api.del('/api/admin/release-key'), '已停用')
|
||||
keys.reload()
|
||||
}}
|
||||
>
|
||||
停用之后,只有这把密钥签过的清单不再被接受,面板里已经登记的这类清单也会被丢掉。官方密钥不受影响。
|
||||
</Confirm>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { Chip, Confirm, CopyButton, Dialog, Empty, ErrorNotice, Field, Segmented
|
||||
import { CapabilityPicker, capabilityName } from './CapabilityPicker'
|
||||
import { PasskeyAddForm } from './PasskeyAdd'
|
||||
import { Trust } from './Trust'
|
||||
import { ReleaseKeys } from './ReleaseKeys'
|
||||
|
||||
type Tab = 'settings' | 'versions' | 'tokens' | 'users' | 'trust' | 'audit' | 'events' | 'raw' | 'analysis'
|
||||
|
||||
@@ -866,7 +867,12 @@ export function System() {
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
/>
|
||||
{tab === 'settings' ? <Settings /> : tab === 'versions' ? <Versions /> : tab === 'tokens' ? <Tokens /> : tab === 'users' ? <Users /> : tab === 'trust' ? <Trust /> : tab === 'audit' ? <Audit /> : tab === 'events' ? <Events /> : tab === 'raw' ? <Raw /> : <Analysis />}
|
||||
{tab === 'settings' ? <Settings /> : tab === 'versions' ? <Versions /> : tab === 'tokens' ? <Tokens /> : tab === 'users' ? <Users /> : tab === 'trust' ? (
|
||||
<>
|
||||
<Trust />
|
||||
<ReleaseKeys />
|
||||
</>
|
||||
) : tab === 'audit' ? <Audit /> : tab === 'events' ? <Events /> : tab === 'raw' ? <Raw /> : <Analysis />}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
+212
-116
@@ -379,24 +379,6 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/releases": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** The latest signed release manifest of each component */
|
||||
get: operations["releases_list"];
|
||||
put?: never;
|
||||
/** Register a CI-signed release manifest (the signature is checked against the release key); online nodes get it right away. Admin or release tokens */
|
||||
post: operations["releases_publish"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/auth/logout": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -989,6 +971,23 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/releases": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** The latest signed release manifest of each component */
|
||||
get: operations["releases_list"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/nodes/{id}/update": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1041,6 +1040,25 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/release-key": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** The release keys this panel trusts: the built-in official key and the optional own key */
|
||||
get: operations["release_key_get"];
|
||||
/** Trust your own release public key (P-256 PEM; never the private key) in addition to the official one */
|
||||
put: operations["release_key_set"];
|
||||
post?: never;
|
||||
/** Stop trusting your own release key; manifests only it signed are dropped */
|
||||
delete: operations["release_key_clear"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/links": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2017,6 +2035,14 @@ export interface components {
|
||||
/** @default release */
|
||||
channel: string;
|
||||
};
|
||||
ReleaseKeyDto: {
|
||||
fingerprint: string;
|
||||
public_key: string;
|
||||
};
|
||||
ReleaseKeysDto: {
|
||||
official: components["schemas"]["ReleaseKeyDto"];
|
||||
custom: null | components["schemas"]["ReleaseKeyDto"];
|
||||
};
|
||||
ScoreDto: {
|
||||
/** Format: double */
|
||||
score: null | number | string;
|
||||
@@ -2055,6 +2081,9 @@ export interface components {
|
||||
SetLinkRequest: {
|
||||
enabled: null | boolean;
|
||||
};
|
||||
SetReleaseKeyRequest: {
|
||||
public_key: null | string;
|
||||
};
|
||||
SettingDto: {
|
||||
key: string;
|
||||
type: string;
|
||||
@@ -3235,104 +3264,6 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
releases_list: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PageDtoOfReleaseDto"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
releases_publish: {
|
||||
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 Unauthorized */
|
||||
401: {
|
||||
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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
auth_logout: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -5658,6 +5589,44 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
releases_list: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PageDtoOfReleaseDto"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
nodes_update: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -5841,6 +5810,133 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
release_key_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ReleaseKeysDto"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
release_key_set: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["SetReleaseKeyRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ReleaseKeysDto"];
|
||||
};
|
||||
};
|
||||
/** @description Bad Request */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDto"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
release_key_clear: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ReleaseKeysDto"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
links_list: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -21,6 +21,8 @@ export type NodeBuildDto = Narrow<S['NodeBuildDto']>
|
||||
export type NodeUpdateDto = Narrow<S['NodeUpdateDto']>
|
||||
export type NodeOwnerDto = Narrow<S['NodeOwnerDto']>
|
||||
export type ReleaseDto = Narrow<S['ReleaseDto']>
|
||||
export type ReleaseKeysDto = Narrow<S['ReleaseKeysDto']>
|
||||
export type ReleaseKeyDto = Narrow<S['ReleaseKeyDto']>
|
||||
export type LinkDto = Narrow<S['LinkDto']>
|
||||
export type LinkMatrixDto = Narrow<S['LinkMatrixDto']>
|
||||
export type OverviewDto = Narrow<S['OverviewDto']>
|
||||
|
||||
Reference in new issue
Block a user