feat: bind each node to the binary it runs at its first handshake; refuse changes that are not in a CI-signed manifest [CI]
ci/woodpecker/push/main-amd64-build Pipeline failed
ci/woodpecker/push/main-amd64-build Pipeline failed
This commit is contained in:
1 parent
117d34a2af
commit
4841f7b15b
40 files changed
+1011
-21
No files matched your search
@@ -25,6 +25,9 @@ public static class BuildEndpoints
|
||||
group.MapPost("/nodes/{id}/update", RequestUpdateAsync)
|
||||
.Api("nodes_update", "Ask an online agent or probe to install the release it has been offered", Tag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 409)
|
||||
.Requires(Capability.UpdateConfirm);
|
||||
group.MapDelete("/nodes/{id}/binary", ClearBinaryAsync)
|
||||
.Api("nodes_binary_clear", "Accept the node's current binary: clear its binding so the next handshake binds it again", Tag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403)
|
||||
.Requires(Capability.FleetWrite);
|
||||
group.MapGet("/backend/update", BackendUpdateAsync)
|
||||
.Api("backend_update_get", "Current channel and host-verified backend update", Tag).Returns<BackendUpdateDto>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.UpdateRead);
|
||||
@@ -70,12 +73,13 @@ public static class BuildEndpoints
|
||||
return await PublishReleaseAsync(http, releases, request);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, CancellationToken ct)
|
||||
private static async Task<IResult> GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, BinaryBindingService bindings, CancellationToken ct)
|
||||
{
|
||||
Dictionary<string, NodeBinding> bound = (await bindings.ListAsync(ct)).ToDictionary(b => b.NodeId, StringComparer.Ordinal);
|
||||
var items = (await nodes.ListAsync(ct))
|
||||
.OrderBy(n => n.Kind, StringComparer.Ordinal)
|
||||
.ThenBy(n => n.NodeId, StringComparer.Ordinal)
|
||||
.Select(n => new NodeBuildDto(n.NodeId, n.Kind, n.Os, n.OsName, n.Arch, n.Version, n.Tag, n.Commit, n.BuiltAt, ToDto(releases.StatusOf(new NodeId(n.NodeId)))))
|
||||
.Select(n => new NodeBuildDto(n.NodeId, n.Kind, n.Os, n.OsName, n.Arch, n.Version, n.Tag, n.Commit, n.BuiltAt, ToDto(releases.StatusOf(new NodeId(n.NodeId))), bound.TryGetValue(n.NodeId, out NodeBinding? binding) ? ToDto(binding) : null))
|
||||
.ToList();
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
return TypedResults.Json(new BuildInfoDto(NodeEndpoints.Backend(withCommit: true), items, ToDto(await backendUpdates.GetAsync(ct))), ApiJsonContext.Default.BuildInfoDto);
|
||||
@@ -133,6 +137,17 @@ public static class BuildEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
private static NodeBinaryDto ToDto(NodeBinding b) =>
|
||||
new(b.Sha256, b.Signed, b.Seq, DateTimeOffset.FromUnixTimeMilliseconds(b.BoundAtMs), b.Violation, b.ViolationAtMs is { } at ? DateTimeOffset.FromUnixTimeMilliseconds(at) : null);
|
||||
|
||||
/// <summary>管理员确认新的二进制是自己要的:清除绑定,节点下一次握手重新绑定(没有绑定可清也返回 204,幂等)。</summary>
|
||||
private static async Task<IResult> ClearBinaryAsync(HttpContext http, BinaryBindingService bindings, string id)
|
||||
{
|
||||
http.SetDetail("binary binding cleared");
|
||||
await bindings.ClearAsync(id, http.RequestAborted);
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
|
||||
private static NodeUpdateDto? ToDto(UpdateStatus? status) =>
|
||||
status is null ? null : new NodeUpdateDto(status.State, status.Current, status.Available?.Tag, status.Available?.Version, status.Error, status.At > 0 ? DateTimeOffset.FromUnixTimeSeconds(status.At) : null);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace MoShiro.Backend.Api;
|
||||
|
||||
/// <summary>
|
||||
/// 发布清单的签名校验:用 Backend 内嵌的 CI 发布公钥(<c>packaging/update/release-key.pub.pem</c>)验 ECDSA P-256 / SHA-256(DER)签名,签名对象是清单的原始字节。
|
||||
/// 登记清单因此不需要任何令牌:能被登记的只有 CI 签过的清单,节点的更新器之后还会用本机的公钥再验一遍。
|
||||
/// </summary>
|
||||
public static class ReleaseSignature
|
||||
{
|
||||
public static bool Verify(string publicKeyPem, string manifest, string signatureBase64)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = ECDsa.Create();
|
||||
key.ImportFromPem(publicKeyPem);
|
||||
return key.VerifyData(Encoding.UTF8.GetBytes(manifest), Convert.FromBase64String(signatureBase64), HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence);
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or CryptographicException or ArgumentException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ public static class ApiModule
|
||||
public static IServiceCollection AddMoShiroApi(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<SseLimiter>();
|
||||
services.AddSingleton<MoShiro.Backend.Application.IReleaseKey, EmbeddedReleaseKey>();
|
||||
services.AddSingleton<ClientAddressResolver>();
|
||||
services.Configure<JsonOptions>(options =>
|
||||
{
|
||||
|
||||
@@ -178,7 +178,10 @@ public sealed record ChangePasswordRequest(string Password);
|
||||
public sealed record RecomputeDto(string State, IReadOnlyList<string> Metrics, long WindowsDone, long WindowsTotal, DateTimeOffset? From, DateTimeOffset? To, int QueueLength);
|
||||
|
||||
/// <summary>管理端看的构建信息:每个节点自报的版本、tag、提交和构建时间,以及系统。</summary>
|
||||
public sealed record NodeBuildDto(string Id, string Kind, string? Os, string? OsName, string? Arch, string? Version, string? Tag, string? Commit, string? BuiltAt, NodeUpdateDto? Update = null);
|
||||
/// <summary>节点二进制的绑定:第一次握手成功时绑定的 SHA-256。<c>Signed</c> 表示它出现在一份 CI 签名的清单里(否则只是首次握手时记下的);<c>Violation</c> 是最近一次不符的原因,下一次握手符合了就清掉。</summary>
|
||||
public sealed record NodeBinaryDto(string Sha256, bool Signed, long Seq, DateTimeOffset BoundAt, string? Violation, DateTimeOffset? ViolationAt);
|
||||
|
||||
public sealed record NodeBuildDto(string Id, string Kind, string? Os, string? OsName, string? Arch, string? Version, string? Tag, string? Commit, string? BuiltAt, NodeUpdateDto? Update = null, NodeBinaryDto? Binary = null);
|
||||
|
||||
/// <summary>节点宿主机上的更新器报告的状态(<c>up_to_date</c>、<c>available</c>、<c>applying</c>、<c>failed</c>、<c>rolled_back</c>、<c>invalid</c>);没装更新器的节点没有这个字段。</summary>
|
||||
public sealed record NodeUpdateDto(string State, string? CurrentDigest, string? AvailableTag, string? AvailableVersion, string? Error, DateTimeOffset? At);
|
||||
|
||||
@@ -47,6 +47,7 @@ namespace MoShiro.Backend.Api;
|
||||
[JsonSerializable(typeof(PublishReleaseRequest))]
|
||||
[JsonSerializable(typeof(AboutDto))]
|
||||
[JsonSerializable(typeof(NodeBuildDto))]
|
||||
[JsonSerializable(typeof(NodeBinaryDto))]
|
||||
[JsonSerializable(typeof(BuildInfoDto))]
|
||||
[JsonSerializable(typeof(BackendUpdateDto))]
|
||||
[JsonSerializable(typeof(ConfirmBackendUpdateRequest))]
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using MoShiro.Backend.Application;
|
||||
|
||||
namespace MoShiro.Backend.Api;
|
||||
|
||||
/// <summary>内嵌在程序里的 CI 发布公钥(<c>packaging/update/release-key.pub.pem</c>)。</summary>
|
||||
public sealed class EmbeddedReleaseKey : IReleaseKey
|
||||
{
|
||||
public string Pem => InstallScripts.ReleaseKey();
|
||||
}
|
||||
Reference in new issue
Block a user