refactor: remove the runtime binary binding; every release registration path verifies the CI signature [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
ci/woodpecker/push/main-amd64-build Pipeline was successful
The hash a node reports about itself cannot be trusted (a swapped binary can copy the old hash), so the runtime binding only caught files that were replaced by something that did not lie. Provenance stays where it can be verified: the signed release manifest (digest) and the signed SHA256SUMS. - Remove BinaryBindingService, the node_bindings table (schema v15 drops it), BinaryPolicy, the handshake hash/manifest fields and the admin binary column. - Drop the per-platform "binaries" list from the release manifest, the hash scripts and the updater's installed.manifest copy. - ReleaseService.PublishAsync now verifies the release signature, so the admin POST /releases route is no longer an unsigned path; tests sign with a test key. - Docs: section 16 explains why the runtime check is gone.
This commit is contained in:
1 parent
a4f3d1892a
commit
904ea2b0ae
49 files changed
+115
-1143
No files matched your search
@@ -47,7 +47,6 @@ public sealed class AgentApp : IDisposable
|
||||
_ownedRestart = restart is null ? new RestartController() : null;
|
||||
_restart = restart ?? _ownedRestart!;
|
||||
_update = new UpdateBridge(dataDirectory, new ConfigStore(dataDirectory).Write, log);
|
||||
NodeBinary.Prime(); // 正在运行的二进制的哈希:先在后台算好,握手时用
|
||||
_config = config;
|
||||
_identity = identity;
|
||||
_time = time;
|
||||
@@ -277,7 +276,6 @@ public sealed class AgentApp : IDisposable
|
||||
Server = new Uri(_config.Fleet.Server),
|
||||
Identity = _identity.Identity,
|
||||
ServerVerifier = _trust,
|
||||
Attestation = _update.Attest,
|
||||
NodeId = _identity.NodeId,
|
||||
NodeVersion = Version,
|
||||
Capabilities = [MoShiroCapabilities.Base, MoShiroCapabilities.KindsPrefix + string.Join(',', BlockCatalog.AgentKinds)],
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace Fleet.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 节点握手时自述的「我跑的是哪个二进制」:正在运行的可执行文件的 SHA-256,以及(容器节点)宿主机更新器验过的 CI 签名清单和签名。
|
||||
/// 服务端在节点第一次握手成功的那一刻把哈希绑定到这个节点,之后哈希对不上就不让它继续运行。
|
||||
/// </summary>
|
||||
public sealed record BinaryAttestation(string Sha256, string? Manifest, string? Signature);
|
||||
|
||||
/// <summary>
|
||||
/// 正在运行的二进制的哈希。Linux 上读 <c>/proc/self/exe</c>:它指向进程启动时映射的那个映像,磁盘上的文件之后被换掉也不影响,
|
||||
/// 所以「运行期间被替换、重启后换了别的」的情况下一次握手就会对不上。算一次,之后缓存。
|
||||
/// </summary>
|
||||
public static class NodeBinary
|
||||
{
|
||||
private static readonly Lazy<string?> s_hash = new(Compute);
|
||||
|
||||
/// <summary>小写十六进制;读不到可执行文件时为 null(这样的节点不做绑定)。</summary>
|
||||
public static string? Sha256Hex => s_hash.Value;
|
||||
|
||||
/// <summary>在后台先算好,免得第一次握手等它。</summary>
|
||||
public static void Prime() => _ = Task.Run(() => s_hash.Value);
|
||||
|
||||
private static string? Compute()
|
||||
{
|
||||
try
|
||||
{
|
||||
string? path = OperatingSystem.IsLinux() && File.Exists("/proc/self/exe") ? "/proc/self/exe" : Environment.ProcessPath;
|
||||
if (path is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 1 << 16);
|
||||
return Convert.ToHexStringLower(SHA256.HashData(stream));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,4 @@ public static class FleetErrors
|
||||
public static readonly ErrorCode JoinTokenExpired = new("fleet_join_token_expired");
|
||||
public static readonly ErrorCode ProofInvalid = new("fleet_proof_invalid");
|
||||
public static readonly ErrorCode FrameTooLarge = new("fleet_frame_too_large");
|
||||
/// <summary>节点现在跑的二进制和它第一次握手时绑定的不一致,也不在 CI 签名的更新清单里。</summary>
|
||||
public static readonly ErrorCode BinaryMismatch = new("fleet_binary_mismatch");
|
||||
}
|
||||
@@ -72,9 +72,6 @@ public sealed class FleetLinkOptions
|
||||
/// 服务器必须在挑战里证明自己,之后这条链路用应用层加密;否则按旧的方式(没有固定信任根的节点)。
|
||||
/// </summary>
|
||||
public IServerVerifier? ServerVerifier { get; init; }
|
||||
|
||||
/// <summary>握手时自述正在运行的二进制(哈希和 CI 签名的清单);没有就不上报(旧行为)。</summary>
|
||||
public Func<BinaryAttestation?>? Attestation { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -226,16 +223,6 @@ public sealed class FleetLink
|
||||
}
|
||||
|
||||
/// <summary>挑战 → 签名 → 认证结果。失败返回原因;服务端禁用该节点时不再重试。</summary>
|
||||
private void Attest(AuthParams auth)
|
||||
{
|
||||
if (_options.Attestation?.Invoke() is { } attestation)
|
||||
{
|
||||
auth.Binary = attestation.Sha256;
|
||||
auth.Release = attestation.Manifest;
|
||||
auth.ReleaseSignature = attestation.Signature;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> AuthenticateAsync(SecureTransport transport, CancellationToken ct)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15), _time);
|
||||
@@ -270,7 +257,6 @@ public sealed class FleetLink
|
||||
Signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.AuthSecure, _options.NodeId.Value, nonce.Nonce, serverEphemeralText, nodeEphemeralText)),
|
||||
NodeEphemeral = nodeEphemeralText,
|
||||
};
|
||||
Attest(authParams);
|
||||
RpcMessage secureRequest = RpcMessage.Request(1, FleetMethods.Auth, authParams, FleetRpcContext.Default.AuthParams);
|
||||
await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(secureRequest, FleetRpcContext.Default.RpcMessage)), linked.Token);
|
||||
transport.Enable(LinkCipher.ForNode(ephemeral, serverEphemeral, nonce.Nonce));
|
||||
@@ -278,7 +264,6 @@ public sealed class FleetLink
|
||||
else
|
||||
{
|
||||
authParams = new AuthParams { NodeId = _options.NodeId.Value, Signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.Auth, _options.NodeId.Value, nonce.Nonce)) };
|
||||
Attest(authParams);
|
||||
RpcMessage request = RpcMessage.Request(1, FleetMethods.Auth, authParams, FleetRpcContext.Default.AuthParams);
|
||||
await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(request, FleetRpcContext.Default.RpcMessage)), linked.Token);
|
||||
}
|
||||
|
||||
@@ -75,17 +75,6 @@ public sealed class AuthParams
|
||||
/// <summary>节点的临时 ECDH 公钥(SPKI,base64):有它就是安全链路,签名覆盖双方的临时公钥,认证结果起所有帧都是加密的。</summary>
|
||||
[JsonPropertyName("node_eph")]
|
||||
public string? NodeEphemeral { get; set; }
|
||||
|
||||
/// <summary>正在运行的二进制的 SHA-256(小写十六进制):服务端在第一次握手时绑定,之后核对。旧节点没有这个字段。</summary>
|
||||
[JsonPropertyName("binary")]
|
||||
public string? Binary { get; set; }
|
||||
|
||||
/// <summary>安装这个二进制时宿主机更新器验过的 CI 签名清单(原文)和签名(base64):服务端用 CI 公钥验过,才相信它列出的官方哈希。</summary>
|
||||
[JsonPropertyName("release")]
|
||||
public string? Release { get; set; }
|
||||
|
||||
[JsonPropertyName("release_sig")]
|
||||
public string? ReleaseSignature { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AuthResult
|
||||
|
||||
@@ -19,21 +19,6 @@ public interface IServerIdentity
|
||||
}
|
||||
|
||||
/// <summary>操作员集合的链(passkey 签的,创世在前)。没有初始化信任根时是空的。节点注册时用它确认「这个面板确实是我的操作员的」。</summary>
|
||||
/// <summary>节点握手时自述的二进制(旧节点没有)。</summary>
|
||||
public sealed record BinaryEvidence(string? Sha256, string? Manifest, string? Signature);
|
||||
|
||||
/// <summary>是否让这个节点继续:不放行时 <c>Reason</c> 会原样告诉节点(这是它自己的信息,不是秘密)。</summary>
|
||||
public sealed record BinaryDecision(bool Accept, string Reason = "");
|
||||
|
||||
/// <summary>
|
||||
/// 服务端对节点二进制的把关:节点第一次握手成功的那一刻绑定它的二进制哈希,之后哈希对不上、又不在 CI 签名的更新清单里,就不让它继续运行。
|
||||
/// 没有注册实现时不做任何检查。
|
||||
/// </summary>
|
||||
public interface IBinaryAttestor
|
||||
{
|
||||
ValueTask<BinaryDecision> CheckAsync(NodeRecord node, BinaryEvidence evidence, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IOperatorTrustSource
|
||||
{
|
||||
ValueTask<IReadOnlyList<SignedStatementDto>> ChainAsync(CancellationToken ct);
|
||||
|
||||
@@ -24,11 +24,9 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
|
||||
IEnumerable<IInbox> inboxes,
|
||||
IEnumerable<INodeEventSink> sinks,
|
||||
FleetRouter router,
|
||||
IServerIdentity? serverIdentity = null,
|
||||
IBinaryAttestor? binaryAttestor = null)
|
||||
IServerIdentity? serverIdentity = null)
|
||||
{
|
||||
ServerIdentity = serverIdentity;
|
||||
BinaryAttestor = binaryAttestor;
|
||||
Store = store;
|
||||
Options = options;
|
||||
Time = time;
|
||||
@@ -45,9 +43,6 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
|
||||
/// <summary>服务器的身份密钥;没有时节点连接用旧的明文认证。</summary>
|
||||
internal IServerIdentity? ServerIdentity { get; }
|
||||
|
||||
/// <summary>节点二进制的把关;没有时不检查。</summary>
|
||||
internal IBinaryAttestor? BinaryAttestor { get; }
|
||||
|
||||
internal FleetServerOptions Options { get; }
|
||||
|
||||
internal TimeProvider Time { get; }
|
||||
|
||||
@@ -110,8 +110,7 @@ public static class FleetServerExtensions
|
||||
provider.GetServices<IInbox>(),
|
||||
provider.GetServices<INodeEventSink>(),
|
||||
provider.GetRequiredService<FleetRouter>(),
|
||||
provider.GetService<IServerIdentity>(),
|
||||
provider.GetService<IBinaryAttestor>()));
|
||||
provider.GetService<IServerIdentity>()));
|
||||
services.AddSingleton<IFleetConnections>(provider => provider.GetRequiredService<FleetLinkHost>());
|
||||
services.AddSingleton<IStateNotifier>(provider => provider.GetRequiredService<FleetLinkHost>());
|
||||
return builder;
|
||||
|
||||
@@ -88,18 +88,6 @@ internal sealed partial class NodeConnection
|
||||
return false;
|
||||
}
|
||||
|
||||
// 二进制:第一次握手成功的这一刻绑定,之后哈希对不上又不在 CI 签名的更新清单里,就不让它继续运行
|
||||
if (_host.BinaryAttestor is { } attestor)
|
||||
{
|
||||
BinaryDecision decision = await attestor.CheckAsync(node, new BinaryEvidence(auth!.Binary, auth.Release, auth.ReleaseSignature), ct);
|
||||
if (!decision.Accept)
|
||||
{
|
||||
await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.BinaryMismatch, decision.Reason)), ct);
|
||||
await RejectAsync(null, "binary mismatch: " + decision.Reason, ct);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
_session = new NodeSessionInfo(node.Id, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), node.NodeVersion, [], null);
|
||||
await SendAsync(
|
||||
RpcMessage.Success(message.Id.Value, new AuthResult { Status = StatusName(node.Status) }, FleetRpcContext.Default.AuthResult),
|
||||
|
||||
+1
-100
@@ -1374,7 +1374,7 @@
|
||||
"tags": [
|
||||
"admin-build"
|
||||
],
|
||||
"summary": "Register a CI-signed release manifest; online nodes get it right away (their updaters verify the signature). Admin or release tokens",
|
||||
"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": {
|
||||
@@ -4472,56 +4472,6 @@
|
||||
"x-moshiro-capability": "update.confirm"
|
||||
}
|
||||
},
|
||||
"/api/admin/nodes/{id}/binary": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
"admin-build"
|
||||
],
|
||||
"summary": "Accept the node's current binary: clear its binding so the next handshake binds it again",
|
||||
"operationId": "nodes_binary_clear",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { }
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": "fleet.write"
|
||||
}
|
||||
},
|
||||
"/api/admin/backend/update": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -7139,45 +7089,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"NodeBinaryDto": {
|
||||
"required": [
|
||||
"sha256",
|
||||
"signed",
|
||||
"seq",
|
||||
"bound_at",
|
||||
"violation",
|
||||
"violation_at"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sha256": {
|
||||
"type": "string"
|
||||
},
|
||||
"signed": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"seq": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": [
|
||||
"integer",
|
||||
"string"
|
||||
],
|
||||
"format": "int64"
|
||||
},
|
||||
"bound_at": {
|
||||
"format": "date-time"
|
||||
},
|
||||
"violation": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"violation_at": {
|
||||
"format": "date-time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"NodeBuildDto": {
|
||||
"required": [
|
||||
"id",
|
||||
@@ -7250,16 +7161,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"binary": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/NodeBinaryDto"
|
||||
}
|
||||
]
|
||||
},
|
||||
"owners": {
|
||||
"type": [
|
||||
"null",
|
||||
|
||||
@@ -33,13 +33,11 @@ public sealed class ProbeApp : IDisposable
|
||||
directory.Changed += () => _changed.Release();
|
||||
|
||||
var update = new UpdateBridge(configDirectory, new ConfigStore(configDirectory).Write, log);
|
||||
NodeBinary.Prime(); // 正在运行的二进制的哈希:先在后台算好,握手时用
|
||||
var options = new FleetLinkOptions
|
||||
{
|
||||
Server = new Uri(file.Fleet.Server),
|
||||
Identity = identity.Identity,
|
||||
ServerVerifier = trust,
|
||||
Attestation = update.Attest,
|
||||
NodeId = identity.NodeId,
|
||||
NodeVersion = Version,
|
||||
Capabilities = [MoShiroCapabilities.Base],
|
||||
|
||||
@@ -96,34 +96,5 @@ public sealed class UpdateBridge
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 握手时自述的二进制:正在运行的可执行文件的哈希,加上宿主机更新器验过的、安装这个版本时用的 CI 签名清单(更新器写在只读的 <c>update-status/</c>)。
|
||||
/// 容器节点以外(没有更新器)没有清单,只有哈希;读不到可执行文件时返回 null(不做绑定)。
|
||||
/// </summary>
|
||||
public BinaryAttestation? Attest()
|
||||
{
|
||||
if (NodeBinary.Sha256Hex is not { } hash)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? manifest = ReadSmall("installed.manifest", MaxManifestBytes);
|
||||
string? signature = ReadSmall("installed.sig", MaxSignatureBytes);
|
||||
return manifest is not null && signature is not null ? new BinaryAttestation(hash, manifest, signature) : new BinaryAttestation(hash, null, null);
|
||||
}
|
||||
|
||||
private string? ReadSmall(string name, int maxBytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
string path = Path.Combine(Path.GetDirectoryName(_statusFile)!, name);
|
||||
return File.Exists(path) && new FileInfo(path).Length <= maxBytes ? File.ReadAllText(path, Encoding.UTF8) : null;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool Same(string path, byte[] content) => File.Exists(path) && File.ReadAllBytes(path).AsSpan().SequenceEqual(content);
|
||||
}
|
||||
@@ -25,9 +25,6 @@ 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);
|
||||
@@ -43,7 +40,7 @@ public static class BuildEndpoints
|
||||
public static void MapPublish(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapPost("/releases", PublishReleaseAsync)
|
||||
.Api("releases_publish", "Register a CI-signed release manifest; online nodes get it right away (their updaters verify the signature). Admin or release tokens", Tag)
|
||||
.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);
|
||||
}
|
||||
@@ -60,12 +57,12 @@ public static class BuildEndpoints
|
||||
.Returns<ReleaseDto>(StatusCodes.Status201Created).ProducesProblems(400, 403, 409);
|
||||
}
|
||||
|
||||
private static async Task<IResult> OfferReleaseAsync(HttpContext http, ReleaseService releases, PublishReleaseRequest request)
|
||||
private static async Task<IResult> OfferReleaseAsync(HttpContext http, ReleaseService releases, IReleaseKey releaseKey, PublishReleaseRequest request)
|
||||
{
|
||||
string manifest = request.Manifest ?? "";
|
||||
string signature = request.Signature ?? "";
|
||||
if (manifest.Length is 0 or > ReleaseService.MaxManifestBytes || signature.Length is 0 or > ReleaseService.MaxSignatureBytes
|
||||
|| !ReleaseSignature.Verify(InstallScripts.ReleaseKey(), manifest, signature))
|
||||
|| !ReleaseSignature.Verify(releaseKey.Pem, manifest, signature))
|
||||
{
|
||||
return Problems.Create(http, MoShiroErrors.Forbidden, "the manifest does not carry a valid release signature");
|
||||
}
|
||||
@@ -73,14 +70,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, BinaryBindingService bindings, NodeOwnerService owners, CancellationToken ct)
|
||||
private static async Task<IResult> GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, NodeOwnerService owners, CancellationToken ct)
|
||||
{
|
||||
IReadOnlyDictionary<string, IReadOnlyList<NodeOwner>> ownerOf = await owners.ListAsync(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))), bound.TryGetValue(n.NodeId, out NodeBinding? binding) ? ToDto(binding) : null, ownerOf.TryGetValue(n.NodeId, out IReadOnlyList<NodeOwner>? list) ? [.. list.Select(o => new NodeOwnerDto(o.CredentialId, o.PasskeyName, o.Username))] : null))
|
||||
.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))), ownerOf.TryGetValue(n.NodeId, out IReadOnlyList<NodeOwner>? list) ? [.. list.Select(o => new NodeOwnerDto(o.CredentialId, o.PasskeyName, o.Username))] : 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);
|
||||
@@ -138,17 +134,6 @@ 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);
|
||||
}
|
||||
@@ -185,13 +185,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>
|
||||
/// <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);
|
||||
|
||||
/// <summary>节点的一个属主:节点报上来的(安装时固定在节点本地的)passkey 凭证 id,以及它在面板上对应的 passkey 名称和账户(对不上任何已注册的 passkey 时为空)。</summary>
|
||||
public sealed record NodeOwnerDto(string CredentialId, string? Passkey, string? Account);
|
||||
|
||||
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, IReadOnlyList<NodeOwnerDto>? Owners = null);
|
||||
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, IReadOnlyList<NodeOwnerDto>? Owners = 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,7 +47,6 @@ namespace MoShiro.Backend.Api;
|
||||
[JsonSerializable(typeof(PublishReleaseRequest))]
|
||||
[JsonSerializable(typeof(AboutDto))]
|
||||
[JsonSerializable(typeof(NodeBuildDto))]
|
||||
[JsonSerializable(typeof(NodeBinaryDto))]
|
||||
[JsonSerializable(typeof(NodeOwnerDto))]
|
||||
[JsonSerializable(typeof(BuildInfoDto))]
|
||||
[JsonSerializable(typeof(BackendUpdateDto))]
|
||||
|
||||
@@ -26,12 +26,10 @@ public sealed partial class AgentService
|
||||
private readonly LinkService _links;
|
||||
private readonly TrustService _trust;
|
||||
|
||||
private readonly BinaryBindingService _bindings;
|
||||
private readonly NodeOwnerService _owners;
|
||||
|
||||
public AgentService(BinaryBindingService bindings, NodeOwnerService owners, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings, NodeInfoService nodes, LinkService links, TrustService trust)
|
||||
public AgentService(NodeOwnerService owners, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings, NodeInfoService nodes, LinkService links, TrustService trust)
|
||||
{
|
||||
_bindings = bindings;
|
||||
_owners = owners;
|
||||
_trust = trust;
|
||||
_links = links;
|
||||
@@ -152,7 +150,6 @@ public sealed partial class AgentService
|
||||
await _gateway.DisconnectAsync(id, "deleted", ct);
|
||||
await _agents.DeleteAsync(id, ct);
|
||||
await _nodes.ForgetAsync(id.Value, ct);
|
||||
await _bindings.ForgetAsync(id.Value, ct);
|
||||
_owners.Forget(id.Value);
|
||||
await _links.ForgetNodeAsync(id.Value, ct);
|
||||
_runtime.Remove(id);
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using Fleet.Server;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>CI 发布公钥(PEM):验节点握手时带来的签名清单。由 Api 层提供(它就是内嵌在程序里的那一把)。</summary>
|
||||
public interface IReleaseKey
|
||||
{
|
||||
string Pem { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 节点二进制的绑定:节点第一次握手成功的那一刻,把它正在运行的二进制的 SHA-256 和这个节点绑在一起;之后哈希对不上,就不让它继续运行。
|
||||
/// <para>
|
||||
/// 合法的升级:新哈希出现在一份 <b>CI 签名的清单</b>里(节点握手时带着宿主机更新器验过的清单,这里用内嵌的 CI 公钥再验一遍,
|
||||
/// 所以不依赖面板有没有收到过这次发布),并且清单的 seq 不低于绑定时的——不许回退到更旧的版本。其余情况(文件被换掉、降级、没人签过的二进制、
|
||||
/// 手工替换了二进制的非容器节点)都算不符;管理员确认过之后可以清除绑定,下一次握手重新绑定。
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 边界:哈希是节点自己报的。一个被换进来、故意撒谎的恶意二进制可以照抄原来的哈希;这一层抓的是文件被换、版本被改、降级和起了不对的二进制。
|
||||
/// 容器节点要堵住撒谎,需要宿主机更新器背书(容器之外),见 docs/access.md。
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class BinaryBindingService : IBinaryAttestor
|
||||
{
|
||||
public const string Enforce = "enforce";
|
||||
public const string Warn = "warn";
|
||||
public const string Off = "off";
|
||||
|
||||
private readonly INodeBindingStore _store;
|
||||
private readonly ISettingsStore _settings;
|
||||
private readonly IReleaseKey _key;
|
||||
private readonly BackendOptions _options;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public BinaryBindingService(INodeBindingStore store, ISettingsStore settings, IReleaseKey key, BackendOptions options, TimeProvider time)
|
||||
{
|
||||
_settings = settings;
|
||||
_store = store;
|
||||
_key = key;
|
||||
_options = options;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<NodeBinding>> ListAsync(CancellationToken ct) => _store.ListAsync(ct);
|
||||
|
||||
/// <summary>管理员确认新的二进制是自己要的(比如手工升级了非容器节点):清除绑定,下一次握手重新绑定。</summary>
|
||||
public ValueTask<bool> ClearAsync(string nodeId, CancellationToken ct) => _store.DeleteAsync(nodeId, ct);
|
||||
|
||||
/// <summary>节点删除之后顺手清掉它的绑定。</summary>
|
||||
public async ValueTask ForgetAsync(string nodeId, CancellationToken ct) => await _store.DeleteAsync(nodeId, ct);
|
||||
|
||||
public async ValueTask<BinaryDecision> CheckAsync(NodeRecord node, BinaryEvidence evidence, CancellationToken ct)
|
||||
{
|
||||
string policy = _options.BinaryPolicy;
|
||||
if (policy == Off || ReleaseService.ComponentOf(node.Id) is not { } component)
|
||||
{
|
||||
return new BinaryDecision(true);
|
||||
}
|
||||
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
NodeBinding? binding = await _store.GetAsync(node.Id.Value, ct);
|
||||
|
||||
if (evidence.Sha256 is null)
|
||||
{
|
||||
// 旧节点不上报:还没绑定过就放行(未绑定);绑定过的不许退回去,否则换个旧版本就绕过了
|
||||
return binding is null ? new BinaryDecision(true) : await ViolationAsync(binding, "the node stopped reporting its binary hash", policy, now, ct);
|
||||
}
|
||||
|
||||
string hash = evidence.Sha256;
|
||||
if (!IsHash(hash))
|
||||
{
|
||||
return new BinaryDecision(policy != Enforce, "the reported binary hash is malformed");
|
||||
}
|
||||
|
||||
// 节点自己带来的清单(宿主机更新器验过的那份);没有更新器的节点(一键容器安装、二进制安装)没有,就用面板自己手里的:
|
||||
// 面板登记它们时已经验过 CI 签名,这里再验一遍。只有节点自带的清单才能因为「不在清单里」而被拒绝,面板手里的只用来把绑定标成已签名。
|
||||
Official? official = VerifyManifest(evidence.Manifest, evidence.Signature, component);
|
||||
bool carried = official is not null;
|
||||
official ??= await StoredOfficialAsync(component, ct);
|
||||
bool listed = official is not null && official.Binaries.Contains(hash);
|
||||
|
||||
if (binding is null)
|
||||
{
|
||||
if (carried && !listed)
|
||||
{
|
||||
// 带着一份有效的签名清单,自己的哈希却不在里面:它不是这份清单对应的官方二进制
|
||||
return new BinaryDecision(policy != Enforce, "this binary is not in the CI-signed manifest it was installed from");
|
||||
}
|
||||
|
||||
await _store.UpsertAsync(new NodeBinding(node.Id.Value, hash, listed ? official!.Seq : 0, listed, now, null, null), ct);
|
||||
return new BinaryDecision(true);
|
||||
}
|
||||
|
||||
if (binding.Sha256 == hash)
|
||||
{
|
||||
if (listed && (official!.Seq > binding.Seq || !binding.Signed || binding.Violation is not null))
|
||||
{
|
||||
await _store.UpsertAsync(binding with { Seq = Math.Max(binding.Seq, official!.Seq), Signed = true, Violation = null, ViolationAtMs = null }, ct);
|
||||
}
|
||||
else if (binding.Violation is not null)
|
||||
{
|
||||
await _store.UpsertAsync(binding with { Violation = null, ViolationAtMs = null }, ct);
|
||||
}
|
||||
|
||||
return new BinaryDecision(true);
|
||||
}
|
||||
|
||||
// 哈希变了:必须是一份不低于绑定时 seq 的 CI 签名清单里的官方二进制(合法升级),否则不符
|
||||
if (listed && official!.Seq >= binding.Seq)
|
||||
{
|
||||
await _store.UpsertAsync(new NodeBinding(node.Id.Value, hash, official.Seq, true, now, null, null), ct);
|
||||
return new BinaryDecision(true);
|
||||
}
|
||||
|
||||
return await ViolationAsync(binding, $"the running binary changed (bound {Short(binding.Sha256)}, now {Short(hash)}) and is not in a newer CI-signed manifest", policy, now, ct);
|
||||
}
|
||||
|
||||
private async ValueTask<BinaryDecision> ViolationAsync(NodeBinding binding, string reason, string policy, long now, CancellationToken ct)
|
||||
{
|
||||
await _store.UpsertAsync(binding with { Violation = reason, ViolationAtMs = now }, ct);
|
||||
return new BinaryDecision(policy != Enforce, reason);
|
||||
}
|
||||
|
||||
private sealed record Official(long Seq, HashSet<string> Binaries);
|
||||
|
||||
/// <summary>用内嵌的 CI 公钥验清单;验过且是这个组件的才返回它列出的官方二进制哈希。</summary>
|
||||
private async ValueTask<Official?> StoredOfficialAsync(string component, CancellationToken ct) =>
|
||||
VerifyManifest(await _settings.GetAsync(ReleaseService.Key(component, "manifest"), ct), await _settings.GetAsync(ReleaseService.Key(component, "signature"), ct), component);
|
||||
|
||||
private Official? VerifyManifest(string? manifestText, string? signatureText, string component)
|
||||
{
|
||||
if (manifestText is not { Length: > 0 and <= ReleaseService.MaxManifestBytes } manifest
|
||||
|| signatureText is not { Length: > 0 and <= ReleaseService.MaxSignatureBytes } signature
|
||||
|| !ReleaseSignature.Verify(_key.Pem, manifest, signature))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(manifest);
|
||||
JsonElement root = document.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object
|
||||
|| !root.TryGetProperty("component", out JsonElement c) || c.GetString() != component
|
||||
|| !root.TryGetProperty("seq", out JsonElement s) || !s.TryGetInt64(out long seq) || seq <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var binaries = new HashSet<string>(StringComparer.Ordinal);
|
||||
if (root.TryGetProperty("binaries", out JsonElement list) && list.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
foreach (string item in (list.GetString() ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (IsHash(item))
|
||||
{
|
||||
binaries.Add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Official(seq, binaries);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsHash(string value) => value.Length == 64 && value.All(ch => ch is (>= '0' and <= '9') or (>= 'a' and <= 'f'));
|
||||
|
||||
private static string Short(string hash) => hash.Length > 12 ? hash[..12] : hash;
|
||||
}
|
||||
@@ -29,12 +29,10 @@ public sealed partial class ProbeService
|
||||
private readonly NodeInfoService _nodes;
|
||||
private readonly LinkService _links;
|
||||
|
||||
private readonly BinaryBindingService _bindings;
|
||||
private readonly NodeOwnerService _owners;
|
||||
|
||||
public ProbeService(BinaryBindingService bindings, NodeOwnerService owners, IProbeStore probes, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TimeProvider time, NodeInfoService nodes, LinkService links)
|
||||
public ProbeService(NodeOwnerService owners, IProbeStore probes, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TimeProvider time, NodeInfoService nodes, LinkService links)
|
||||
{
|
||||
_bindings = bindings;
|
||||
_owners = owners;
|
||||
_links = links;
|
||||
_nodes = nodes;
|
||||
@@ -99,7 +97,6 @@ public sealed partial class ProbeService
|
||||
if (deleted)
|
||||
{
|
||||
await _nodes.ForgetAsync(id, ct);
|
||||
await _bindings.ForgetAsync(id, ct);
|
||||
_owners.Forget(id);
|
||||
await _links.ForgetNodeAsync(id, ct);
|
||||
_gateway.NotifyAllTaskSetsChanged();
|
||||
|
||||
@@ -40,9 +40,7 @@ public static class ApplicationServiceCollectionExtensions
|
||||
services.AddSingleton<ServerIdentityService>();
|
||||
services.AddSingleton<SetupService>();
|
||||
services.AddSingleton<ReleaseFeed>();
|
||||
services.AddSingleton<BinaryBindingService>();
|
||||
services.AddSingleton<NodeOwnerService>();
|
||||
services.AddSingleton<Fleet.Server.IBinaryAttestor>(sp => sp.GetRequiredService<BinaryBindingService>());
|
||||
services.AddSingleton<Fleet.Server.IServerIdentity>(sp => sp.GetRequiredService<ServerIdentityService>());
|
||||
services.AddSingleton<TrustService>();
|
||||
services.AddSingleton<Fleet.Server.IOperatorTrustSource>(sp => sp.GetRequiredService<TrustService>());
|
||||
|
||||
@@ -21,11 +21,6 @@ public sealed class BackendOptions
|
||||
/// <summary>宿主机更新器的只读状态目录;留空使用数据目录下的 update-status/。</summary>
|
||||
public string? BackendUpdateStatusDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 节点二进制绑定的策略:<c>enforce</c>(默认)二进制和绑定不符、又不在 CI 签名的更新清单里就拒绝连接;<c>warn</c> 只记录不拒绝;<c>off</c> 不检查。
|
||||
/// </summary>
|
||||
public string BinaryPolicy { get; set; } = "enforce";
|
||||
|
||||
/// <summary>
|
||||
/// 每个 Agent 和 Probe 都必须有属主(passkey):创建加入码时必须已经建立信任根,并且至少选一把操作员集合里的 passkey 作为属主。
|
||||
/// 属主是节点敏感内容(extension 会话、加密数据)绑定的人,装上时固定在节点本地,面板改不了。默认开;没有信任根的老部署才需要关掉。
|
||||
|
||||
@@ -144,20 +144,6 @@ public interface IEventStore
|
||||
ValueTask<int> DeleteOlderThanAsync(long cutoffMs, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>节点的二进制绑定:第一次握手成功时绑定的 SHA-256,对应的签名清单 seq(没有签名清单时为 0),以及最近一次不符的原因。</summary>
|
||||
public sealed record NodeBinding(string NodeId, string Sha256, long Seq, bool Signed, long BoundAtMs, string? Violation, long? ViolationAtMs);
|
||||
|
||||
public interface INodeBindingStore
|
||||
{
|
||||
ValueTask<NodeBinding?> GetAsync(string nodeId, CancellationToken ct);
|
||||
|
||||
ValueTask<IReadOnlyList<NodeBinding>> ListAsync(CancellationToken ct);
|
||||
|
||||
ValueTask UpsertAsync(NodeBinding binding, CancellationToken ct);
|
||||
|
||||
ValueTask<bool> DeleteAsync(string nodeId, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>操作员集合的链(passkey 签的版本,按版本号递增)。</summary>
|
||||
public interface IOperatorSetStore
|
||||
{
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>CI 发布公钥(PEM):验登记的发布清单。由 Api 层提供(它就是内嵌在程序里的那一把)。</summary>
|
||||
public interface IReleaseKey
|
||||
{
|
||||
string Pem { get; }
|
||||
}
|
||||
@@ -45,7 +45,7 @@ public sealed partial class ReleaseService : IDisposable
|
||||
|
||||
public void Dispose() => BackendGate.Dispose();
|
||||
|
||||
/// <summary>登记一份新清单并推给在线的同类节点。只要格式对、<c>seq</c> 比现有的新就收下(签名由节点验证)。</summary>
|
||||
/// <summary>登记一份新清单并推给在线的同类节点。格式对、带 CI 发布密钥的有效签名、<c>seq</c> 比现有的新才收下;所有登记入口(CI、镜像仓库、管理员)都走这里,所以没有不验签的路径。节点的更新器之后还会用本机公钥再验一次。</summary>
|
||||
public async ValueTask<Result<ReleaseOffer>> PublishAsync(string manifest, string signature, CancellationToken ct)
|
||||
{
|
||||
if (!TryParse(manifest, signature, out ReleaseOffer? offer, out string? problem))
|
||||
@@ -53,6 +53,11 @@ public sealed partial class ReleaseService : IDisposable
|
||||
return ValidationError.Fail<ReleaseOffer>("manifest", problem);
|
||||
}
|
||||
|
||||
if (!ReleaseSignature.Verify(_key.Pem, manifest, signature))
|
||||
{
|
||||
return Result<ReleaseOffer>.Fail(MoShiroErrors.Forbidden, "the manifest does not carry a valid release signature");
|
||||
}
|
||||
|
||||
await BackendGate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace MoShiro.Backend.Storage.Sqlite;
|
||||
/// <summary>表结构。版本记录在 <c>PRAGMA user_version</c>;以后的迁移是按版本号追加的内置 SQL。</summary>
|
||||
internal static class Schema
|
||||
{
|
||||
public const int CurrentVersion = 14;
|
||||
public const int CurrentVersion = 15;
|
||||
|
||||
public static void Migrate(SqliteConnection connection)
|
||||
{
|
||||
@@ -241,6 +241,15 @@ internal static class Schema
|
||||
connection.Exec("PRAGMA user_version = 14");
|
||||
transaction.Commit();
|
||||
}
|
||||
|
||||
if (version < 15)
|
||||
{
|
||||
// 运行时二进制绑定整体移除(节点自报的哈希不可信,来源证明在安装时):旧版本留下的表不再使用
|
||||
using SqliteTransaction transaction = connection.BeginTransaction();
|
||||
connection.Exec("DROP TABLE IF EXISTS node_bindings");
|
||||
connection.Exec("PRAGMA user_version = 15");
|
||||
transaction.Commit();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
using MoShiro.Backend.Application;
|
||||
|
||||
namespace MoShiro.Backend.Storage.Sqlite;
|
||||
|
||||
public sealed class SqliteNodeBindingStore : INodeBindingStore
|
||||
{
|
||||
private const string Columns = "node_id, sha256, seq, signed, bound_at, violation, violation_at";
|
||||
|
||||
private readonly SqliteDatabase _db;
|
||||
|
||||
public SqliteNodeBindingStore(SqliteDatabase db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public ValueTask<NodeBinding?> GetAsync(string nodeId, CancellationToken ct) =>
|
||||
_db.ReadAsync<NodeBinding?>(c => c.QueryOne($"SELECT {Columns} FROM node_bindings WHERE node_id = @id", Read, ("@id", nodeId)), ct);
|
||||
|
||||
public ValueTask<IReadOnlyList<NodeBinding>> ListAsync(CancellationToken ct) =>
|
||||
_db.ReadAsync<IReadOnlyList<NodeBinding>>(c => c.Query($"SELECT {Columns} FROM node_bindings ORDER BY node_id", Read), ct);
|
||||
|
||||
public async ValueTask UpsertAsync(NodeBinding binding, CancellationToken ct) =>
|
||||
await _db.WriteAsync(
|
||||
c => c.Exec(
|
||||
$"INSERT INTO node_bindings ({Columns}) VALUES (@id, @sha, @seq, @signed, @at, @violation, @violation_at) "
|
||||
+ "ON CONFLICT(node_id) DO UPDATE SET sha256 = excluded.sha256, seq = excluded.seq, signed = excluded.signed, bound_at = excluded.bound_at, violation = excluded.violation, violation_at = excluded.violation_at",
|
||||
("@id", binding.NodeId), ("@sha", binding.Sha256), ("@seq", binding.Seq), ("@signed", binding.Signed ? 1 : 0), ("@at", binding.BoundAtMs), ("@violation", binding.Violation), ("@violation_at", binding.ViolationAtMs)),
|
||||
ct);
|
||||
|
||||
public ValueTask<bool> DeleteAsync(string nodeId, CancellationToken ct) =>
|
||||
_db.WriteAsync(c => c.Exec("DELETE FROM node_bindings WHERE node_id = @id", ("@id", nodeId)) > 0, ct);
|
||||
|
||||
private static NodeBinding Read(Microsoft.Data.Sqlite.SqliteDataReader r) =>
|
||||
new(r.GetString(0), r.GetString(1), r.GetInt64(2), r.GetInt64(3) != 0, r.GetInt64(4), r.NullableString(5), r.NullableLong(6));
|
||||
}
|
||||
@@ -24,7 +24,6 @@ public static class StorageServiceCollectionExtensions
|
||||
services.AddSingleton<IAuthStore, SqliteAuthStore>();
|
||||
services.AddSingleton<IAuditStore, SqliteAuditStore>();
|
||||
services.AddSingleton<IOperatorSetStore, SqliteOperatorSetStore>();
|
||||
services.AddSingleton<INodeBindingStore, SqliteNodeBindingStore>();
|
||||
services.AddSingleton<IRawStore, SqliteRawStore>();
|
||||
services.AddSingleton<IMetricStore, SqliteMetricStore>();
|
||||
services.AddSingleton<IStorageHealth, SqliteStorageHealth>();
|
||||
|
||||
@@ -19,7 +19,7 @@ public static class BackendHost
|
||||
public const string ConfigurationSection = "MoShiro";
|
||||
public const int MaxRequestBodyBytes = 64 * 1024;
|
||||
|
||||
public static WebApplication Build(string[] args, Action<BackendOptions>? configureOptions = null, TimeProvider? time = null)
|
||||
public static WebApplication Build(string[] args, Action<BackendOptions>? configureOptions = null, TimeProvider? time = null, Action<IServiceCollection>? configureServices = null)
|
||||
{
|
||||
WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(args);
|
||||
|
||||
@@ -51,6 +51,7 @@ public static class BackendHost
|
||||
services.AddMoShiroApplication(options);
|
||||
services.AddMoShiroGateway();
|
||||
services.AddMoShiroApi();
|
||||
configureServices?.Invoke(services); // 测试替换发布公钥这类服务
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
// 静默死亡的 Agent(断电、断网)不会关闭连接:15 秒一个 Ping,20 秒没有 Pong 就关闭,这样面板上的在线状态才可信
|
||||
|
||||
@@ -265,7 +265,8 @@ public class BackendFixture : IAsyncLifetime
|
||||
options.RequireOwner = false; // 大部分测试不建立信任根;属主必选的测试自己打开(OwnerTests)
|
||||
options.GeoLookupUrl = string.Empty; // 测试里不访问外部服务
|
||||
options.PhaseDurationScale = 0.2; // 端到端测试里 connect、hold 这些固定 30 秒的阶段缩成 6 秒
|
||||
});
|
||||
},
|
||||
configureServices: services => services.AddSingleton<MoShiro.Backend.Application.IReleaseKey>(TestReleaseKey.Instance)); // 登记清单要验发布签名:测试用自己的密钥签
|
||||
await App.StartAsync();
|
||||
string address = App.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First();
|
||||
BaseAddress = new Uri(address);
|
||||
|
||||
@@ -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"] = "MEUC" }));
|
||||
return await _backend.Admin.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = TestReleaseKey.Sign(manifest) }));
|
||||
}
|
||||
|
||||
private Task<JsonNode> ViewAsync() => _backend.GetJsonAsync(_backend.Admin, "/api/admin/backend/update");
|
||||
|
||||
@@ -1,348 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using Fleet.Core;
|
||||
using Fleet.Server;
|
||||
using MoShiro.Backend.Application;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 节点二进制绑定:第一次握手成功的那一刻绑定正在运行的二进制的哈希,之后对不上又不在 CI 签名的更新清单里,就不让它继续运行。
|
||||
/// 官方哈希集合来自 CI 签名的清单(节点握手时带着),所以被攻破的 Backend 伪造不了,也不依赖面板有没有收到过这次发布。
|
||||
/// </summary>
|
||||
public sealed class BinaryBindingTests : IDisposable
|
||||
{
|
||||
private const string A = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1";
|
||||
private const string B = "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2";
|
||||
private const string C = "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3";
|
||||
|
||||
private readonly ECDsa _ci = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
private readonly ECDsa _other = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
private readonly MemoryStore _store = new();
|
||||
private readonly MemorySettings _settings = new();
|
||||
private readonly NodeRecord _agent = Node(AgentIds.Prefix + "_test1");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_ci.Dispose();
|
||||
_other.Dispose();
|
||||
}
|
||||
|
||||
private static string Hash(string seed) => seed[..2] + new string(seed[1], 62);
|
||||
|
||||
private static NodeRecord Node(string id) => new(new NodeId(id), "n", NodeStatus.Active, [1], [2], [], null, null, 0, null);
|
||||
|
||||
private BinaryBindingService Service(string policy = "enforce") =>
|
||||
new(_store, _settings, new TestKey(_ci), new BackendOptions { BinaryPolicy = policy }, TimeProvider.System);
|
||||
|
||||
private sealed class TestKey(ECDsa key) : IReleaseKey
|
||||
{
|
||||
public string Pem => key.ExportSubjectPublicKeyInfoPem();
|
||||
}
|
||||
|
||||
private static string Sign(ECDsa key, string manifest) =>
|
||||
Convert.ToBase64String(key.SignData(Encoding.UTF8.GetBytes(manifest), HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence));
|
||||
|
||||
/// <summary>节点握手时带的证据:哈希,加上(可选)某把密钥签名的清单。</summary>
|
||||
private BinaryEvidence Evidence(string hash, long? seq = null, string[]? binaries = null, string component = "agent", ECDsa? signer = null)
|
||||
{
|
||||
if (seq is null)
|
||||
{
|
||||
return new BinaryEvidence(hash, null, null);
|
||||
}
|
||||
|
||||
string manifest = $"{{\"schema\":1,\"component\":\"{component}\",\"seq\":{seq},\"binaries\":\"{string.Join(',', binaries ?? [hash])}\"}}";
|
||||
return new BinaryEvidence(hash, manifest, Sign(signer ?? _ci, manifest));
|
||||
}
|
||||
|
||||
private static string H(char c) => new(c, 64);
|
||||
|
||||
[Fact]
|
||||
public async Task TheFirstHandshakeBinds_AndTheSameBinaryKeepsWorking()
|
||||
{
|
||||
BinaryBindingService service = Service();
|
||||
|
||||
Assert.True((await service.CheckAsync(_agent, Evidence(H('a')), default)).Accept);
|
||||
Assert.Equal(H('a'), (await _store.GetAsync(_agent.Id.Value, default))!.Sha256);
|
||||
Assert.True((await service.CheckAsync(_agent, Evidence(H('a')), default)).Accept);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AReplacedBinary_IsRefused_AndTheReasonIsKept()
|
||||
{
|
||||
BinaryBindingService service = Service();
|
||||
await service.CheckAsync(_agent, Evidence(H('a')), default);
|
||||
|
||||
BinaryDecision decision = await service.CheckAsync(_agent, Evidence(H('b')), default);
|
||||
|
||||
Assert.False(decision.Accept);
|
||||
Assert.Contains("changed", decision.Reason, StringComparison.Ordinal);
|
||||
NodeBinding bound = (await _store.GetAsync(_agent.Id.Value, default))!;
|
||||
Assert.Equal(H('a'), bound.Sha256); // 绑定不会因为一次不符而被改写
|
||||
Assert.NotNull(bound.Violation);
|
||||
|
||||
// 原来的二进制回来了:恢复,原因清掉
|
||||
Assert.True((await service.CheckAsync(_agent, Evidence(H('a')), default)).Accept);
|
||||
Assert.Null((await _store.GetAsync(_agent.Id.Value, default))!.Violation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnUpgrade_NeedsACiSignedManifestThatListsTheNewBinary()
|
||||
{
|
||||
BinaryBindingService service = Service();
|
||||
await service.CheckAsync(_agent, Evidence(H('a'), seq: 100, binaries: [H('a')]), default);
|
||||
|
||||
// 新二进制在一份更新的 CI 签名清单里:合法升级,绑定跟着改
|
||||
Assert.True((await service.CheckAsync(_agent, Evidence(H('b'), seq: 200, binaries: [H('b')]), default)).Accept);
|
||||
NodeBinding upgraded = (await _store.GetAsync(_agent.Id.Value, default))!;
|
||||
Assert.Equal(H('b'), upgraded.Sha256);
|
||||
Assert.Equal(200, upgraded.Seq);
|
||||
Assert.True(upgraded.Signed);
|
||||
|
||||
// 退回旧版本(清单 seq 更低):降级,拒绝
|
||||
Assert.False((await service.CheckAsync(_agent, Evidence(H('a'), seq: 100, binaries: [H('a')]), default)).Accept);
|
||||
// 同一份清单里的另一个官方二进制(比如另一个平台的构建)不算降级
|
||||
Assert.True((await service.CheckAsync(_agent, Evidence(H('c'), seq: 200, binaries: [H('b'), H('c')]), default)).Accept);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AManifestSignedByAnotherKey_OrForAnotherComponent_ProvesNothing()
|
||||
{
|
||||
BinaryBindingService service = Service();
|
||||
await service.CheckAsync(_agent, Evidence(H('a')), default);
|
||||
|
||||
Assert.False((await service.CheckAsync(_agent, Evidence(H('b'), seq: 500, binaries: [H('b')], signer: _other), default)).Accept);
|
||||
Assert.False((await service.CheckAsync(_agent, Evidence(H('b'), seq: 500, binaries: [H('b')], component: "probe"), default)).Accept);
|
||||
Assert.Equal(H('a'), (await _store.GetAsync(_agent.Id.Value, default))!.Sha256);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ASignedManifestThatDoesNotListTheRunningBinary_IsRefusedAtTheFirstHandshake()
|
||||
{
|
||||
BinaryBindingService service = Service();
|
||||
|
||||
BinaryDecision decision = await service.CheckAsync(_agent, Evidence(H('a'), seq: 100, binaries: [H('b')]), default);
|
||||
|
||||
Assert.False(decision.Accept);
|
||||
Assert.Null(await _store.GetAsync(_agent.Id.Value, default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnUnsignedBinding_BecomesSignedWhenTheManifestArrivesLater()
|
||||
{
|
||||
// 容器节点第一次握手时更新器可能还没写出清单:先按首次握手绑定,清单到了再标成已签名
|
||||
BinaryBindingService service = Service();
|
||||
await service.CheckAsync(_agent, Evidence(H('a')), default);
|
||||
Assert.False((await _store.GetAsync(_agent.Id.Value, default))!.Signed);
|
||||
|
||||
Assert.True((await service.CheckAsync(_agent, Evidence(H('a'), seq: 100, binaries: [H('a')]), default)).Accept);
|
||||
|
||||
NodeBinding bound = (await _store.GetAsync(_agent.Id.Value, default))!;
|
||||
Assert.True(bound.Signed);
|
||||
Assert.Equal(100, bound.Seq);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ALegacyNode_IsTolerated_ButCannotGoBackOnceBound()
|
||||
{
|
||||
BinaryBindingService service = Service();
|
||||
NodeRecord legacy = Node(ProbeId.Prefix + "_old");
|
||||
|
||||
Assert.True((await service.CheckAsync(legacy, new BinaryEvidence(null, null, null), default)).Accept);
|
||||
Assert.Null(await _store.GetAsync(legacy.Id.Value, default));
|
||||
|
||||
await service.CheckAsync(_agent, Evidence(H('a')), default);
|
||||
Assert.False((await service.CheckAsync(_agent, new BinaryEvidence(null, null, null), default)).Accept); // 绑定过的不许退回不上报
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MalformedHashes_AreRefused()
|
||||
{
|
||||
BinaryBindingService service = Service();
|
||||
|
||||
Assert.False((await service.CheckAsync(_agent, new BinaryEvidence("not-a-hash", null, null), default)).Accept);
|
||||
Assert.False((await service.CheckAsync(_agent, new BinaryEvidence(new string('A', 64), null, null), default)).Accept); // 要小写十六进制
|
||||
Assert.Null(await _store.GetAsync(_agent.Id.Value, default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheAdministratorCanAcceptANewBinary_ByClearingTheBinding()
|
||||
{
|
||||
BinaryBindingService service = Service();
|
||||
await service.CheckAsync(_agent, Evidence(H('a')), default);
|
||||
Assert.False((await service.CheckAsync(_agent, Evidence(H('b')), default)).Accept);
|
||||
|
||||
Assert.True(await service.ClearAsync(_agent.Id.Value, default));
|
||||
|
||||
Assert.True((await service.CheckAsync(_agent, Evidence(H('b')), default)).Accept);
|
||||
Assert.Equal(H('b'), (await _store.GetAsync(_agent.Id.Value, default))!.Sha256);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WarnRecordsButDoesNotRefuse_AndOffDoesNothing()
|
||||
{
|
||||
BinaryBindingService warn = Service("warn");
|
||||
await warn.CheckAsync(_agent, Evidence(H('a')), default);
|
||||
BinaryDecision decision = await warn.CheckAsync(_agent, Evidence(H('b')), default);
|
||||
Assert.True(decision.Accept);
|
||||
Assert.NotNull((await _store.GetAsync(_agent.Id.Value, default))!.Violation);
|
||||
|
||||
BinaryBindingService off = Service("off");
|
||||
NodeRecord other = Node(AgentIds.Prefix + "_test2");
|
||||
Assert.True((await off.CheckAsync(other, Evidence(H('a')), default)).Accept);
|
||||
Assert.Null(await _store.GetAsync(other.Id.Value, default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EachNodeHasItsOwnBinding()
|
||||
{
|
||||
BinaryBindingService service = Service();
|
||||
NodeRecord second = Node(AgentIds.Prefix + "_test3");
|
||||
|
||||
await service.CheckAsync(_agent, Evidence(H('a')), default);
|
||||
await service.CheckAsync(second, Evidence(H('b')), default);
|
||||
|
||||
Assert.False((await service.CheckAsync(_agent, Evidence(H('b')), default)).Accept); // 别的节点的二进制,不是自己绑定的
|
||||
Assert.True((await service.CheckAsync(second, Evidence(H('b')), default)).Accept);
|
||||
}
|
||||
|
||||
/// <summary>面板手里的、CI 签名的清单(登记时存下的):没有更新器的节点握手时没有自己的清单,就拿它来认官方二进制。</summary>
|
||||
private void PanelHolds(string component, long seq, string[] binaries, ECDsa? signer = null)
|
||||
{
|
||||
string manifest = $"{{\"schema\":1,\"component\":\"{component}\",\"seq\":{seq},\"binaries\":\"{string.Join(',', binaries)}\"}}";
|
||||
_settings.Values["release." + component + ".manifest"] = manifest;
|
||||
_settings.Values["release." + component + ".signature"] = Sign(signer ?? _ci, manifest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANodeWithoutAnUpdater_IsMarkedSigned_WhenThePanelHoldsAManifestThatListsItsBinary()
|
||||
{
|
||||
// 一键容器安装的节点没有宿主机更新器,握手时带不来清单:面板用自己手里(登记时验过 CI 签名)的那份
|
||||
PanelHolds("agent", seq: 100, [H('a')]);
|
||||
BinaryBindingService service = Service();
|
||||
|
||||
Assert.True((await service.CheckAsync(_agent, Evidence(H('a')), default)).Accept);
|
||||
|
||||
NodeBinding bound = (await _store.GetAsync(_agent.Id.Value, default))!;
|
||||
Assert.True(bound.Signed);
|
||||
Assert.Equal(100, bound.Seq);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheManifestThePanelHolds_NeverRefusesANodeAtFirstBinding_OnlyLabelsIt()
|
||||
{
|
||||
// 老一点的官方版本、自己构建的版本:不在面板手里最新的清单里,但没有证据说它不是官方的,所以不拒绝,只是不标成已签名
|
||||
PanelHolds("agent", seq: 100, [H('b')]);
|
||||
BinaryBindingService service = Service();
|
||||
|
||||
Assert.True((await service.CheckAsync(_agent, Evidence(H('a')), default)).Accept);
|
||||
|
||||
NodeBinding bound = (await _store.GetAsync(_agent.Id.Value, default))!;
|
||||
Assert.False(bound.Signed);
|
||||
Assert.Equal(0, bound.Seq);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AManifestThePanelHoldsThatIsSignedByAnotherKey_ProvesNothing()
|
||||
{
|
||||
PanelHolds("agent", seq: 100, [H('a')], signer: _other);
|
||||
BinaryBindingService service = Service();
|
||||
|
||||
Assert.True((await service.CheckAsync(_agent, Evidence(H('a')), default)).Accept);
|
||||
|
||||
Assert.False((await _store.GetAsync(_agent.Id.Value, default))!.Signed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnUpgradeOfANodeWithoutAnUpdater_IsFollowedFromTheManifestThePanelHolds()
|
||||
{
|
||||
PanelHolds("agent", seq: 100, [H('a')]);
|
||||
BinaryBindingService service = Service();
|
||||
await service.CheckAsync(_agent, Evidence(H('a')), default);
|
||||
|
||||
// 之后发布了新版,面板登记了新的清单;节点换成新二进制重连
|
||||
PanelHolds("agent", seq: 200, [H('b')]);
|
||||
Assert.True((await service.CheckAsync(_agent, Evidence(H('b')), default)).Accept);
|
||||
Assert.Equal(200, (await _store.GetAsync(_agent.Id.Value, default))!.Seq);
|
||||
|
||||
// 没人签过的二进制仍然被拒绝
|
||||
Assert.False((await service.CheckAsync(_agent, Evidence(H('c')), default)).Accept);
|
||||
}
|
||||
|
||||
private sealed class MemorySettings : ISettingsStore
|
||||
{
|
||||
public Dictionary<string, string> Values { get; } = [];
|
||||
|
||||
public ValueTask<string?> GetAsync(string key, CancellationToken ct) => new(Values.GetValueOrDefault(key));
|
||||
|
||||
public ValueTask SetAsync(string key, string value, CancellationToken ct)
|
||||
{
|
||||
Values[key] = value;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyDictionary<string, string>> GetAllAsync(CancellationToken ct) => new(Values);
|
||||
|
||||
public ValueTask<long> IncrementAsync(string key, CancellationToken ct) => new(0);
|
||||
}
|
||||
|
||||
private sealed class MemoryStore : INodeBindingStore
|
||||
{
|
||||
private readonly Dictionary<string, NodeBinding> _rows = [];
|
||||
|
||||
public ValueTask<NodeBinding?> GetAsync(string nodeId, CancellationToken ct) => new(_rows.GetValueOrDefault(nodeId));
|
||||
|
||||
public ValueTask<IReadOnlyList<NodeBinding>> ListAsync(CancellationToken ct) => new([.. _rows.Values]);
|
||||
|
||||
public ValueTask UpsertAsync(NodeBinding binding, CancellationToken ct)
|
||||
{
|
||||
_rows[binding.NodeId] = binding;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask<bool> DeleteAsync(string nodeId, CancellationToken ct) => new(_rows.Remove(nodeId));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>真实的 Agent 在进程里连上 Backend:第一次握手就绑定了它正在运行的二进制,管理端看得到,也可以清除。</summary>
|
||||
[Collection("probe-e2e")]
|
||||
public sealed class BinaryBindingEndToEndTests
|
||||
{
|
||||
private readonly BackendFixture _backend;
|
||||
|
||||
public BinaryBindingEndToEndTests(BackendFixture backend)
|
||||
{
|
||||
_backend = backend;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ARealAgent_IsBoundOnItsFirstHandshake_AndTheAdministratorCanClearIt()
|
||||
{
|
||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
||||
string? expected = NodeBinary.Sha256Hex;
|
||||
Assert.NotNull(expected);
|
||||
|
||||
JsonNode? binary = null;
|
||||
await BackendFixture.EventuallyAsync(
|
||||
async () =>
|
||||
{
|
||||
JsonNode info = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/build-info");
|
||||
binary = info["nodes"]!.AsArray().FirstOrDefault(n => n!["id"]!.GetValue<string>() == agent.Id)?["binary"];
|
||||
return binary is not null;
|
||||
},
|
||||
TimeSpan.FromSeconds(30),
|
||||
"the agent is bound on its first handshake");
|
||||
|
||||
Assert.Equal(expected, binary!["sha256"]!.GetValue<string>());
|
||||
Assert.False(binary["signed"]!.GetValue<bool>()); // 测试里的 Agent 没有更新器,也就没有 CI 签名的清单
|
||||
Assert.Null(binary["violation"]);
|
||||
|
||||
using HttpResponseMessage cleared = await _backend.Admin.DeleteAsync("/api/admin/nodes/" + agent.Id + "/binary");
|
||||
Assert.Equal(HttpStatusCode.NoContent, cleared.StatusCode);
|
||||
JsonNode after = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/build-info");
|
||||
Assert.Null(after["nodes"]!.AsArray().First(n => n!["id"]!.GetValue<string>() == agent.Id)!["binary"]);
|
||||
}
|
||||
}
|
||||
@@ -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"] = "MEUCIQDc" }));
|
||||
using HttpResponseMessage published = await _backend.Admin.PostAsync("/api/admin/releases", 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>();
|
||||
|
||||
@@ -74,6 +74,22 @@ public sealed class ReleaseOfferTests
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OfferWithAValidSignature_IsAccepted_WithoutAnyToken_AndTheAdminEndpointChecksTheSignatureToo()
|
||||
{
|
||||
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}}}""";
|
||||
using HttpClient anonymous = _backend.NewClient();
|
||||
|
||||
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()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using MoShiro.Backend.Application;
|
||||
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 测试用的发布密钥:真正的 CI 私钥不在仓库里,测试后端用这把公钥验签(<see cref="BackendFixture"/> 替换了内嵌的那把),
|
||||
/// 登记清单的用例用它签名——和 CI 一样是 DER 编码的 ECDSA P-256 / SHA-256,签名对象是清单的原始字节。
|
||||
/// </summary>
|
||||
public sealed class TestReleaseKey : IReleaseKey
|
||||
{
|
||||
private static readonly ECDsa s_key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
|
||||
public static TestReleaseKey Instance { get; } = new();
|
||||
|
||||
public string Pem => s_key.ExportSubjectPublicKeyInfoPem();
|
||||
|
||||
public static string Sign(string manifest) =>
|
||||
Convert.ToBase64String(s_key.SignData(Encoding.UTF8.GetBytes(manifest), HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence));
|
||||
}
|
||||
@@ -22,12 +22,12 @@ public sealed class UpdateTests
|
||||
_backend = backend;
|
||||
}
|
||||
|
||||
// 面板不验签(节点宿主机上的更新器验),所以测试里签名随便写一个格式对的
|
||||
// 面板验发布签名(节点宿主机上的更新器之后还会再验):测试后端换成了测试密钥,清单用同一把签
|
||||
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 = "MEUCIQDc") =>
|
||||
await _backend.Admin.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = signature }));
|
||||
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) }));
|
||||
|
||||
[Fact]
|
||||
public async Task Release_IsForwardedByteForByte_ToOnlineNodesOfThatComponent_AndToNodesThatConnectLater()
|
||||
@@ -35,14 +35,15 @@ public sealed class UpdateTests
|
||||
long seq = NextSeq();
|
||||
await using AgentSimulator online = await _backend.ConnectAgentAsync();
|
||||
string manifest = Manifest("agent", seq);
|
||||
string signature = TestReleaseKey.Sign(manifest); // ECDSA 每次签出来都不一样:签一次,登记和比较共用
|
||||
|
||||
using HttpResponseMessage published = await PublishAsync(manifest);
|
||||
using HttpResponseMessage published = await PublishAsync(manifest, signature);
|
||||
Assert.Equal(HttpStatusCode.Created, published.StatusCode);
|
||||
JsonNode release = JsonNode.Parse(await published.Content.ReadAsStringAsync())!;
|
||||
Assert.Equal("agent", release["component"]!.GetValue<string>());
|
||||
Assert.Equal(Digest, release["digest"]!.GetValue<string>());
|
||||
|
||||
await BackendFixture.EventuallyAsync(() => Task.FromResult(online.UpdateOffers.ToArray().Any(o => o.Manifest == manifest && o.Signature == "MEUCIQDc")), because: "the offer reaches online agents unchanged");
|
||||
await BackendFixture.EventuallyAsync(() => Task.FromResult(online.UpdateOffers.ToArray().Any(o => o.Manifest == manifest && o.Signature == signature)), because: "the offer reaches online agents unchanged");
|
||||
|
||||
// 之后才连上的节点,hello 之后补发
|
||||
await using AgentSimulator late = await _backend.ConnectAgentAsync();
|
||||
@@ -64,11 +65,16 @@ public sealed class UpdateTests
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await PublishAsync(Manifest("unknown", seq))).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (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));
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await PublishAsync(Manifest("agent", seq), foreign)).StatusCode); // 别人的密钥签的
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await PublishAsync(Manifest("agent", seq), "MEUCIQDc")).StatusCode); // 格式对、签名不对
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await PublishAsync(Manifest("agent", seq), TestReleaseKey.Sign(Manifest("agent", seq + 5)))).StatusCode); // 签的是另一份清单
|
||||
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"] = "MEUC" }));
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -92,9 +98,9 @@ public sealed class UpdateTests
|
||||
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"] = "MEUC" });
|
||||
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"] = "MEUC" }));
|
||||
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);
|
||||
@@ -103,7 +109,7 @@ public sealed class UpdateTests
|
||||
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"] = "MEUC" }))).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]
|
||||
|
||||
Reference in new issue
Block a user