From 4841f7b15b2c1e2157a38a6fe5959794b8ea90b9 Mon Sep 17 00:00:00 2001 From: JustMe Date: Thu, 8 Oct 2026 05:39:13 +0800 Subject: [PATCH] feat: bind each node to the binary it runs at its first handshake; refuse changes that are not in a CI-signed manifest [CI] --- backend/agent/src/MoShiro.Agent/AgentApp.cs | 2 + .../fleet/src/Fleet.Core/BinaryAttestation.cs | 43 +++ backend/fleet/src/Fleet.Core/ErrorCode.cs | 2 + backend/fleet/src/Fleet.Link/FleetLink.cs | 15 + backend/fleet/src/Fleet.Rpc/FleetMethods.cs | 11 + .../ServerContracts.cs | 15 + .../src/Fleet.Server.Link/FleetLinkHost.cs | 7 +- .../Fleet.Server.Link/FleetServerBuilder.cs | 3 +- .../Fleet.Server.Link/NodeConnection.Auth.cs | 12 + backend/openapi/v1.json | 99 +++++++ backend/probe/src/MoShiro.Probe/ProbeApp.cs | 2 + .../Update/UpdateBridge.cs | 29 ++ .../Admin/BuildEndpoints.cs | 19 +- backend/src/MoShiro.Backend.Api/ApiModule.cs | 1 + .../Contracts/AdminDtos.cs | 5 +- .../Contracts/ApiJsonContext.cs | 1 + .../MoShiro.Backend.Api/EmbeddedReleaseKey.cs | 9 + .../Agents/AgentService.cs | 6 +- .../Agents/BinaryBindingService.cs | 166 +++++++++++ .../Agents/ProbeService.cs | 6 +- .../ApplicationServiceCollectionExtensions.cs | 2 + .../BackendOptions.cs | 5 + .../Ports/StorePorts.cs | 14 + .../Updates/ReleaseService.cs | 2 +- .../Updates}/ReleaseSignature.cs | 2 +- .../MoShiro.Backend.Storage.Sqlite/Schema.cs | 22 +- .../SqliteNodeBindingStore.cs | 35 +++ .../StorageServiceCollectionExtensions.cs | 1 + .../BinaryBindingTests.cs | 267 ++++++++++++++++++ .../ReleaseOfferTests.cs | 1 + ci/binary-hash.sh | 18 ++ ci/build-main-amd64.sh | 7 +- ci/release-manifest.sh | 11 +- docs/access.md | 27 +- docs/api.md | 1 + packaging/update/moshiro-update.sh | 49 +++- packaging/update/tests/feed-test.sh | 18 ++ web/admin/src/pages/System.tsx | 27 +- web/shared/api/schema.ts | 69 +++++ web/shared/api/types.ts | 1 + 40 files changed, 1011 insertions(+), 21 deletions(-) create mode 100644 backend/fleet/src/Fleet.Core/BinaryAttestation.cs create mode 100644 backend/src/MoShiro.Backend.Api/EmbeddedReleaseKey.cs create mode 100644 backend/src/MoShiro.Backend.Application/Agents/BinaryBindingService.cs rename backend/src/{MoShiro.Backend.Api/Admin => MoShiro.Backend.Application/Updates}/ReleaseSignature.cs (96%) create mode 100644 backend/src/MoShiro.Backend.Storage.Sqlite/SqliteNodeBindingStore.cs create mode 100644 backend/tests/MoShiro.Backend.Tests/BinaryBindingTests.cs create mode 100644 ci/binary-hash.sh diff --git a/backend/agent/src/MoShiro.Agent/AgentApp.cs b/backend/agent/src/MoShiro.Agent/AgentApp.cs index 2c23bd6..83c674d 100644 --- a/backend/agent/src/MoShiro.Agent/AgentApp.cs +++ b/backend/agent/src/MoShiro.Agent/AgentApp.cs @@ -47,6 +47,7 @@ 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; @@ -276,6 +277,7 @@ 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)], diff --git a/backend/fleet/src/Fleet.Core/BinaryAttestation.cs b/backend/fleet/src/Fleet.Core/BinaryAttestation.cs new file mode 100644 index 0000000..0657435 --- /dev/null +++ b/backend/fleet/src/Fleet.Core/BinaryAttestation.cs @@ -0,0 +1,43 @@ +using System.Security.Cryptography; + +namespace Fleet.Core; + +/// +/// 节点握手时自述的「我跑的是哪个二进制」:正在运行的可执行文件的 SHA-256,以及(容器节点)宿主机更新器验过的 CI 签名清单和签名。 +/// 服务端在节点第一次握手成功的那一刻把哈希绑定到这个节点,之后哈希对不上就不让它继续运行。 +/// +public sealed record BinaryAttestation(string Sha256, string? Manifest, string? Signature); + +/// +/// 正在运行的二进制的哈希。Linux 上读 /proc/self/exe:它指向进程启动时映射的那个映像,磁盘上的文件之后被换掉也不影响, +/// 所以「运行期间被替换、重启后换了别的」的情况下一次握手就会对不上。算一次,之后缓存。 +/// +public static class NodeBinary +{ + private static readonly Lazy s_hash = new(Compute); + + /// 小写十六进制;读不到可执行文件时为 null(这样的节点不做绑定)。 + public static string? Sha256Hex => s_hash.Value; + + /// 在后台先算好,免得第一次握手等它。 + 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; + } + } +} diff --git a/backend/fleet/src/Fleet.Core/ErrorCode.cs b/backend/fleet/src/Fleet.Core/ErrorCode.cs index b80e184..f91cfdd 100644 --- a/backend/fleet/src/Fleet.Core/ErrorCode.cs +++ b/backend/fleet/src/Fleet.Core/ErrorCode.cs @@ -27,4 +27,6 @@ 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"); + /// 节点现在跑的二进制和它第一次握手时绑定的不一致,也不在 CI 签名的更新清单里。 + public static readonly ErrorCode BinaryMismatch = new("fleet_binary_mismatch"); } diff --git a/backend/fleet/src/Fleet.Link/FleetLink.cs b/backend/fleet/src/Fleet.Link/FleetLink.cs index e31649d..e152272 100644 --- a/backend/fleet/src/Fleet.Link/FleetLink.cs +++ b/backend/fleet/src/Fleet.Link/FleetLink.cs @@ -72,6 +72,9 @@ public sealed class FleetLinkOptions /// 服务器必须在挑战里证明自己,之后这条链路用应用层加密;否则按旧的方式(没有固定信任根的节点)。 /// public IServerVerifier? ServerVerifier { get; init; } + + /// 握手时自述正在运行的二进制(哈希和 CI 签名的清单);没有就不上报(旧行为)。 + public Func? Attestation { get; init; } } /// @@ -223,6 +226,16 @@ public sealed class FleetLink } /// 挑战 → 签名 → 认证结果。失败返回原因;服务端禁用该节点时不再重试。 + 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 AuthenticateAsync(SecureTransport transport, CancellationToken ct) { using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15), _time); @@ -257,6 +270,7 @@ 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)); @@ -264,6 +278,7 @@ 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); } diff --git a/backend/fleet/src/Fleet.Rpc/FleetMethods.cs b/backend/fleet/src/Fleet.Rpc/FleetMethods.cs index a1c5e61..3e185b6 100644 --- a/backend/fleet/src/Fleet.Rpc/FleetMethods.cs +++ b/backend/fleet/src/Fleet.Rpc/FleetMethods.cs @@ -75,6 +75,17 @@ public sealed class AuthParams /// 节点的临时 ECDH 公钥(SPKI,base64):有它就是安全链路,签名覆盖双方的临时公钥,认证结果起所有帧都是加密的。 [JsonPropertyName("node_eph")] public string? NodeEphemeral { get; set; } + + /// 正在运行的二进制的 SHA-256(小写十六进制):服务端在第一次握手时绑定,之后核对。旧节点没有这个字段。 + [JsonPropertyName("binary")] + public string? Binary { get; set; } + + /// 安装这个二进制时宿主机更新器验过的 CI 签名清单(原文)和签名(base64):服务端用 CI 公钥验过,才相信它列出的官方哈希。 + [JsonPropertyName("release")] + public string? Release { get; set; } + + [JsonPropertyName("release_sig")] + public string? ReleaseSignature { get; set; } } public sealed class AuthResult diff --git a/backend/fleet/src/Fleet.Server.Abstractions/ServerContracts.cs b/backend/fleet/src/Fleet.Server.Abstractions/ServerContracts.cs index bb8e4be..51505aa 100644 --- a/backend/fleet/src/Fleet.Server.Abstractions/ServerContracts.cs +++ b/backend/fleet/src/Fleet.Server.Abstractions/ServerContracts.cs @@ -19,6 +19,21 @@ public interface IServerIdentity } /// 操作员集合的链(passkey 签的,创世在前)。没有初始化信任根时是空的。节点注册时用它确认「这个面板确实是我的操作员的」。 +/// 节点握手时自述的二进制(旧节点没有)。 +public sealed record BinaryEvidence(string? Sha256, string? Manifest, string? Signature); + +/// 是否让这个节点继续:不放行时 Reason 会原样告诉节点(这是它自己的信息,不是秘密)。 +public sealed record BinaryDecision(bool Accept, string Reason = ""); + +/// +/// 服务端对节点二进制的把关:节点第一次握手成功的那一刻绑定它的二进制哈希,之后哈希对不上、又不在 CI 签名的更新清单里,就不让它继续运行。 +/// 没有注册实现时不做任何检查。 +/// +public interface IBinaryAttestor +{ + ValueTask CheckAsync(NodeRecord node, BinaryEvidence evidence, CancellationToken ct); +} + public interface IOperatorTrustSource { ValueTask> ChainAsync(CancellationToken ct); diff --git a/backend/fleet/src/Fleet.Server.Link/FleetLinkHost.cs b/backend/fleet/src/Fleet.Server.Link/FleetLinkHost.cs index d4d4b82..776702c 100644 --- a/backend/fleet/src/Fleet.Server.Link/FleetLinkHost.cs +++ b/backend/fleet/src/Fleet.Server.Link/FleetLinkHost.cs @@ -24,9 +24,11 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier IEnumerable inboxes, IEnumerable sinks, FleetRouter router, - IServerIdentity? serverIdentity = null) + IServerIdentity? serverIdentity = null, + IBinaryAttestor? binaryAttestor = null) { ServerIdentity = serverIdentity; + BinaryAttestor = binaryAttestor; Store = store; Options = options; Time = time; @@ -43,6 +45,9 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier /// 服务器的身份密钥;没有时节点连接用旧的明文认证。 internal IServerIdentity? ServerIdentity { get; } + /// 节点二进制的把关;没有时不检查。 + internal IBinaryAttestor? BinaryAttestor { get; } + internal FleetServerOptions Options { get; } internal TimeProvider Time { get; } diff --git a/backend/fleet/src/Fleet.Server.Link/FleetServerBuilder.cs b/backend/fleet/src/Fleet.Server.Link/FleetServerBuilder.cs index 0c99b75..e360d94 100644 --- a/backend/fleet/src/Fleet.Server.Link/FleetServerBuilder.cs +++ b/backend/fleet/src/Fleet.Server.Link/FleetServerBuilder.cs @@ -110,7 +110,8 @@ public static class FleetServerExtensions provider.GetServices(), provider.GetServices(), provider.GetRequiredService(), - provider.GetService())); + provider.GetService(), + provider.GetService())); services.AddSingleton(provider => provider.GetRequiredService()); services.AddSingleton(provider => provider.GetRequiredService()); return builder; diff --git a/backend/fleet/src/Fleet.Server.Link/NodeConnection.Auth.cs b/backend/fleet/src/Fleet.Server.Link/NodeConnection.Auth.cs index 57c87a8..1c4b393 100644 --- a/backend/fleet/src/Fleet.Server.Link/NodeConnection.Auth.cs +++ b/backend/fleet/src/Fleet.Server.Link/NodeConnection.Auth.cs @@ -88,6 +88,18 @@ 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), diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index 2036968..060bc21 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -4382,6 +4382,56 @@ "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": [ @@ -6999,6 +7049,45 @@ } } }, + "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", @@ -7070,6 +7159,16 @@ "$ref": "#/components/schemas/NodeUpdateDto" } ] + }, + "binary": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NodeBinaryDto" + } + ] } } }, diff --git a/backend/probe/src/MoShiro.Probe/ProbeApp.cs b/backend/probe/src/MoShiro.Probe/ProbeApp.cs index a5cfe26..2bd1e75 100644 --- a/backend/probe/src/MoShiro.Probe/ProbeApp.cs +++ b/backend/probe/src/MoShiro.Probe/ProbeApp.cs @@ -33,11 +33,13 @@ 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], diff --git a/backend/shared/MoShiro.Measurement/Update/UpdateBridge.cs b/backend/shared/MoShiro.Measurement/Update/UpdateBridge.cs index 236088c..ba4feef 100644 --- a/backend/shared/MoShiro.Measurement/Update/UpdateBridge.cs +++ b/backend/shared/MoShiro.Measurement/Update/UpdateBridge.cs @@ -96,5 +96,34 @@ public sealed class UpdateBridge } } + /// + /// 握手时自述的二进制:正在运行的可执行文件的哈希,加上宿主机更新器验过的、安装这个版本时用的 CI 签名清单(更新器写在只读的 update-status/)。 + /// 容器节点以外(没有更新器)没有清单,只有哈希;读不到可执行文件时返回 null(不做绑定)。 + /// + 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); } diff --git a/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs index 2419be6..92b1785 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs @@ -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(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(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().ProducesProblems(401, 403) .Requires(Capability.UpdateRead); @@ -70,12 +73,13 @@ public static class BuildEndpoints return await PublishReleaseAsync(http, releases, request); } - private static async Task GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, CancellationToken ct) + private static async Task GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, BinaryBindingService bindings, CancellationToken ct) { + Dictionary 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); + + /// 管理员确认新的二进制是自己要的:清除绑定,节点下一次握手重新绑定(没有绑定可清也返回 204,幂等)。 + private static async Task 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); } diff --git a/backend/src/MoShiro.Backend.Api/ApiModule.cs b/backend/src/MoShiro.Backend.Api/ApiModule.cs index 1711bce..e17cc71 100644 --- a/backend/src/MoShiro.Backend.Api/ApiModule.cs +++ b/backend/src/MoShiro.Backend.Api/ApiModule.cs @@ -18,6 +18,7 @@ public static class ApiModule public static IServiceCollection AddMoShiroApi(this IServiceCollection services) { services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.Configure(options => { diff --git a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs index cfcf5f3..dc12c2c 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs @@ -178,7 +178,10 @@ public sealed record ChangePasswordRequest(string Password); public sealed record RecomputeDto(string State, IReadOnlyList Metrics, long WindowsDone, long WindowsTotal, DateTimeOffset? From, DateTimeOffset? To, int QueueLength); /// 管理端看的构建信息:每个节点自报的版本、tag、提交和构建时间,以及系统。 -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); +/// 节点二进制的绑定:第一次握手成功时绑定的 SHA-256。Signed 表示它出现在一份 CI 签名的清单里(否则只是首次握手时记下的);Violation 是最近一次不符的原因,下一次握手符合了就清掉。 +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); /// 节点宿主机上的更新器报告的状态(up_to_date、available、applying、failed、rolled_back、invalid);没装更新器的节点没有这个字段。 public sealed record NodeUpdateDto(string State, string? CurrentDigest, string? AvailableTag, string? AvailableVersion, string? Error, DateTimeOffset? At); diff --git a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs index 3b87089..334e91c 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs @@ -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))] diff --git a/backend/src/MoShiro.Backend.Api/EmbeddedReleaseKey.cs b/backend/src/MoShiro.Backend.Api/EmbeddedReleaseKey.cs new file mode 100644 index 0000000..414b747 --- /dev/null +++ b/backend/src/MoShiro.Backend.Api/EmbeddedReleaseKey.cs @@ -0,0 +1,9 @@ +using MoShiro.Backend.Application; + +namespace MoShiro.Backend.Api; + +/// 内嵌在程序里的 CI 发布公钥(packaging/update/release-key.pub.pem)。 +public sealed class EmbeddedReleaseKey : IReleaseKey +{ + public string Pem => InstallScripts.ReleaseKey(); +} diff --git a/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs b/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs index c513461..925236d 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs @@ -26,8 +26,11 @@ public sealed partial class AgentService private readonly LinkService _links; private readonly TrustService _trust; - public AgentService(IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings, NodeInfoService nodes, LinkService links, TrustService trust) + private readonly BinaryBindingService _bindings; + + public AgentService(BinaryBindingService bindings, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings, NodeInfoService nodes, LinkService links, TrustService trust) { + _bindings = bindings; _trust = trust; _links = links; _nodes = nodes; @@ -147,6 +150,7 @@ 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); await _links.ForgetNodeAsync(id.Value, ct); _runtime.Remove(id); await AfterAgentSetChangedAsync(ct); diff --git a/backend/src/MoShiro.Backend.Application/Agents/BinaryBindingService.cs b/backend/src/MoShiro.Backend.Application/Agents/BinaryBindingService.cs new file mode 100644 index 0000000..ceaf08c --- /dev/null +++ b/backend/src/MoShiro.Backend.Application/Agents/BinaryBindingService.cs @@ -0,0 +1,166 @@ +using System.Text.Json; +using Fleet.Core; +using Fleet.Server; + +namespace MoShiro.Backend.Application; + +/// CI 发布公钥(PEM):验节点握手时带来的签名清单。由 Api 层提供(它就是内嵌在程序里的那一把)。 +public interface IReleaseKey +{ + string Pem { get; } +} + +/// +/// 节点二进制的绑定:节点第一次握手成功的那一刻,把它正在运行的二进制的 SHA-256 和这个节点绑在一起;之后哈希对不上,就不让它继续运行。 +/// +/// 合法的升级:新哈希出现在一份 CI 签名的清单里(节点握手时带着宿主机更新器验过的清单,这里用内嵌的 CI 公钥再验一遍, +/// 所以不依赖面板有没有收到过这次发布),并且清单的 seq 不低于绑定时的——不许回退到更旧的版本。其余情况(文件被换掉、降级、没人签过的二进制、 +/// 手工替换了二进制的非容器节点)都算不符;管理员确认过之后可以清除绑定,下一次握手重新绑定。 +/// +/// +/// 边界:哈希是节点自己报的。一个被换进来、故意撒谎的恶意二进制可以照抄原来的哈希;这一层抓的是文件被换、版本被改、降级和起了不对的二进制。 +/// 容器节点要堵住撒谎,需要宿主机更新器背书(容器之外),见 docs/access.md。 +/// +/// +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 IReleaseKey _key; + private readonly BackendOptions _options; + private readonly TimeProvider _time; + + public BinaryBindingService(INodeBindingStore store, IReleaseKey key, BackendOptions options, TimeProvider time) + { + _store = store; + _key = key; + _options = options; + _time = time; + } + + public ValueTask> ListAsync(CancellationToken ct) => _store.ListAsync(ct); + + /// 管理员确认新的二进制是自己要的(比如手工升级了非容器节点):清除绑定,下一次握手重新绑定。 + public ValueTask ClearAsync(string nodeId, CancellationToken ct) => _store.DeleteAsync(nodeId, ct); + + /// 节点删除之后顺手清掉它的绑定。 + public async ValueTask ForgetAsync(string nodeId, CancellationToken ct) => await _store.DeleteAsync(nodeId, ct); + + public async ValueTask 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"); + } + + Official? official = VerifyManifest(evidence, component); + bool listed = official is not null && official.Binaries.Contains(hash); + + if (binding is null) + { + if (official is not null && !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, 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 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 Binaries); + + /// 用内嵌的 CI 公钥验清单;验过且是这个组件的才返回它列出的官方二进制哈希。 + private Official? VerifyManifest(BinaryEvidence evidence, string component) + { + if (evidence.Manifest is not { Length: > 0 and <= ReleaseService.MaxManifestBytes } manifest + || evidence.Signature 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(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; +} diff --git a/backend/src/MoShiro.Backend.Application/Agents/ProbeService.cs b/backend/src/MoShiro.Backend.Application/Agents/ProbeService.cs index 4f87aea..cc9853a 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/ProbeService.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/ProbeService.cs @@ -29,8 +29,11 @@ public sealed partial class ProbeService private readonly NodeInfoService _nodes; private readonly LinkService _links; - public ProbeService(IProbeStore probes, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TimeProvider time, NodeInfoService nodes, LinkService links) + private readonly BinaryBindingService _bindings; + + public ProbeService(BinaryBindingService bindings, IProbeStore probes, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TimeProvider time, NodeInfoService nodes, LinkService links) { + _bindings = bindings; _links = links; _nodes = nodes; _probes = probes; @@ -94,6 +97,7 @@ public sealed partial class ProbeService if (deleted) { await _nodes.ForgetAsync(id, ct); + await _bindings.ForgetAsync(id, ct); await _links.ForgetNodeAsync(id, ct); _gateway.NotifyAllTaskSetsChanged(); } diff --git a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs index dfb1799..d8f69ec 100644 --- a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs +++ b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs @@ -38,6 +38,8 @@ public static class ApplicationServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); diff --git a/backend/src/MoShiro.Backend.Application/BackendOptions.cs b/backend/src/MoShiro.Backend.Application/BackendOptions.cs index ac5fb94..99d564c 100644 --- a/backend/src/MoShiro.Backend.Application/BackendOptions.cs +++ b/backend/src/MoShiro.Backend.Application/BackendOptions.cs @@ -21,6 +21,11 @@ public sealed class BackendOptions /// 宿主机更新器的只读状态目录;留空使用数据目录下的 update-status/。 public string? BackendUpdateStatusDirectory { get; set; } + /// + /// 节点二进制绑定的策略:enforce(默认)二进制和绑定不符、又不在 CI 签名的更新清单里就拒绝连接;warn 只记录不拒绝;off 不检查。 + /// + public string BinaryPolicy { get; set; } = "enforce"; + /// 面板的对外地址(用于生成安装命令)。 public string? PublicUrl { get; set; } diff --git a/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs b/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs index 7f1e666..7824097 100644 --- a/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs +++ b/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs @@ -144,6 +144,20 @@ public interface IEventStore ValueTask DeleteOlderThanAsync(long cutoffMs, CancellationToken ct); } +/// 节点的二进制绑定:第一次握手成功时绑定的 SHA-256,对应的签名清单 seq(没有签名清单时为 0),以及最近一次不符的原因。 +public sealed record NodeBinding(string NodeId, string Sha256, long Seq, bool Signed, long BoundAtMs, string? Violation, long? ViolationAtMs); + +public interface INodeBindingStore +{ + ValueTask GetAsync(string nodeId, CancellationToken ct); + + ValueTask> ListAsync(CancellationToken ct); + + ValueTask UpsertAsync(NodeBinding binding, CancellationToken ct); + + ValueTask DeleteAsync(string nodeId, CancellationToken ct); +} + /// 操作员集合的链(passkey 签的版本,按版本号递增)。 public interface IOperatorSetStore { diff --git a/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs b/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs index e0f773f..75658c6 100644 --- a/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs +++ b/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs @@ -145,7 +145,7 @@ public sealed partial class ReleaseService : IDisposable private IEnumerable OnlineOf(string component) => _gateway.OnlineNodes.Where(n => ComponentOf(n) == component); - private static string? ComponentOf(NodeId node) => + internal static string? ComponentOf(NodeId node) => node.Value.StartsWith(AgentIds.Prefix, StringComparison.Ordinal) ? NodeKinds.Agent : node.Value.StartsWith(ProbeId.Prefix, StringComparison.Ordinal) ? NodeKinds.Probe : null; diff --git a/backend/src/MoShiro.Backend.Api/Admin/ReleaseSignature.cs b/backend/src/MoShiro.Backend.Application/Updates/ReleaseSignature.cs similarity index 96% rename from backend/src/MoShiro.Backend.Api/Admin/ReleaseSignature.cs rename to backend/src/MoShiro.Backend.Application/Updates/ReleaseSignature.cs index 9934ed7..b50b302 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/ReleaseSignature.cs +++ b/backend/src/MoShiro.Backend.Application/Updates/ReleaseSignature.cs @@ -1,7 +1,7 @@ using System.Security.Cryptography; using System.Text; -namespace MoShiro.Backend.Api; +namespace MoShiro.Backend.Application; /// /// 发布清单的签名校验:用 Backend 内嵌的 CI 发布公钥(packaging/update/release-key.pub.pem)验 ECDSA P-256 / SHA-256(DER)签名,签名对象是清单的原始字节。 diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs index ba8a7c5..ac26db0 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs @@ -5,7 +5,7 @@ namespace MoShiro.Backend.Storage.Sqlite; /// 表结构。版本记录在 PRAGMA user_version;以后的迁移是按版本号追加的内置 SQL。 internal static class Schema { - public const int CurrentVersion = 13; + public const int CurrentVersion = 14; public static void Migrate(SqliteConnection connection) { @@ -221,6 +221,26 @@ internal static class Schema connection.Exec("PRAGMA user_version = 13"); transaction.Commit(); } + + if (version < 14) + { + // 节点的二进制绑定:第一次握手成功时绑定正在运行的二进制的 SHA-256 + using SqliteTransaction transaction = connection.BeginTransaction(); + connection.Exec( + """ + CREATE TABLE node_bindings ( + node_id TEXT PRIMARY KEY, + sha256 TEXT NOT NULL, + seq INTEGER NOT NULL, + signed INTEGER NOT NULL, + bound_at INTEGER NOT NULL, + violation TEXT, + violation_at INTEGER + ) + """); + connection.Exec("PRAGMA user_version = 14"); + transaction.Commit(); + } } /// diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteNodeBindingStore.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteNodeBindingStore.cs new file mode 100644 index 0000000..e4bb333 --- /dev/null +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteNodeBindingStore.cs @@ -0,0 +1,35 @@ +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 GetAsync(string nodeId, CancellationToken ct) => + _db.ReadAsync(c => c.QueryOne($"SELECT {Columns} FROM node_bindings WHERE node_id = @id", Read, ("@id", nodeId)), ct); + + public ValueTask> ListAsync(CancellationToken ct) => + _db.ReadAsync>(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 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)); +} diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs index 5370a80..569b93e 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs @@ -24,6 +24,7 @@ public static class StorageServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/backend/tests/MoShiro.Backend.Tests/BinaryBindingTests.cs b/backend/tests/MoShiro.Backend.Tests/BinaryBindingTests.cs new file mode 100644 index 0000000..f5b034f --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/BinaryBindingTests.cs @@ -0,0 +1,267 @@ +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; + +/// +/// 节点二进制绑定:第一次握手成功的那一刻绑定正在运行的二进制的哈希,之后对不上又不在 CI 签名的更新清单里,就不让它继续运行。 +/// 官方哈希集合来自 CI 签名的清单(节点握手时带着),所以被攻破的 Backend 伪造不了,也不依赖面板有没有收到过这次发布。 +/// +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 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, 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)); + + /// 节点握手时带的证据:哈希,加上(可选)某把密钥签名的清单。 + 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); + } + + private sealed class MemoryStore : INodeBindingStore + { + private readonly Dictionary _rows = []; + + public ValueTask GetAsync(string nodeId, CancellationToken ct) => new(_rows.GetValueOrDefault(nodeId)); + + public ValueTask> ListAsync(CancellationToken ct) => new([.. _rows.Values]); + + public ValueTask UpsertAsync(NodeBinding binding, CancellationToken ct) + { + _rows[binding.NodeId] = binding; + return ValueTask.CompletedTask; + } + + public ValueTask DeleteAsync(string nodeId, CancellationToken ct) => new(_rows.Remove(nodeId)); + } +} + +/// 真实的 Agent 在进程里连上 Backend:第一次握手就绑定了它正在运行的二进制,管理端看得到,也可以清除。 +[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() == 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()); + Assert.False(binary["signed"]!.GetValue()); // 测试里的 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() == agent.Id)!["binary"]); + } +} diff --git a/backend/tests/MoShiro.Backend.Tests/ReleaseOfferTests.cs b/backend/tests/MoShiro.Backend.Tests/ReleaseOfferTests.cs index d9a7a88..63e0605 100644 --- a/backend/tests/MoShiro.Backend.Tests/ReleaseOfferTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/ReleaseOfferTests.cs @@ -3,6 +3,7 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json.Nodes; using MoShiro.Backend.Api; +using MoShiro.Backend.Application; namespace MoShiro.Backend.Tests; diff --git a/ci/binary-hash.sh b/ci/binary-hash.sh new file mode 100644 index 0000000..a95b64f --- /dev/null +++ b/ci/binary-hash.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# 镜像里某个可执行文件的 SHA-256:ci/binary-hash.sh <镜像> <镜像里的路径> +# 节点握手时上报的就是它正在运行的那个文件的哈希,CI 把这个哈希写进签名的清单(binaries),Backend 据此认「官方二进制」。 +# 先试在镜像里运行 sha256sum(同架构),不行再挂载镜像文件系统直接读。 +set -euo pipefail +image="${1:?usage: binary-hash.sh }"; path="${2:?path in the image}" +container="$(buildah from "$image")" +trap 'buildah rm "$container" >/dev/null 2>&1 || true' EXIT +if out="$(buildah run "$container" -- sha256sum "$path" 2>/dev/null)" && [[ "$out" =~ ^([0-9a-f]{64})[[:space:]] ]]; then + echo "${BASH_REMATCH[1]}" + exit 0 +fi +buildah unshare bash -c ' + set -euo pipefail + mount="$(buildah mount "$1")" + sha256sum "$mount$2" | cut -d" " -f1 + buildah umount "$1" >/dev/null +' _ "$container" "$path" diff --git a/ci/build-main-amd64.sh b/ci/build-main-amd64.sh index 15d75ab..855a77c 100644 --- a/ci/build-main-amd64.sh +++ b/ci/build-main-amd64.sh @@ -54,6 +54,11 @@ buildah bud --layers --network host --tls-verify=true --arch amd64 \ -f "$context/Containerfile" \ -t "$local_image" "$context" +# 镜像里节点二进制的哈希:写进签名的清单(binaries),Backend 凭它认「官方二进制」,节点握手时上报的就是它正在运行的这个文件的哈希 +binary_sha256="$(bash "$root/ci/binary-hash.sh" "$local_image" "/usr/local/bin/moshiro-$component")" +[[ "$binary_sha256" =~ ^[0-9a-f]{64}$ ]] || { echo "could not hash the $component binary in the image" >&2; exit 1; } +echo "$component binary sha256: $binary_sha256" + logged_in=false for attempt in 1 2 3 4; do if buildah login --tls-verify=true --username "$ZOT_USERNAME" --password-stdin "$registry" <<< "$ZOT_SECRET" >/dev/null; then @@ -86,5 +91,5 @@ done # 发布清单:给镜像的 digest 签名(私钥只在 CI 密钥里),放进镜像仓库(<仓库>-manifest:dev),面板在线时再登记一份加速。 # 没有配置签名密钥(例如 fork)就只构建镜像、不发清单。 if [[ -n "${RELEASE_SIGNING_KEY:-}" ]]; then - bash "$root/ci/publish-release-offer.sh" "$component" "$repository" "$(cat "$work/$component.digest")" "$dev_tag" dev "$work/$component-release" + RELEASE_BINARIES="$binary_sha256" bash "$root/ci/publish-release-offer.sh" "$component" "$repository" "$(cat "$work/$component.digest")" "$dev_tag" dev "$work/$component-release" fi diff --git a/ci/release-manifest.sh b/ci/release-manifest.sh index 2594838..843b952 100644 --- a/ci/release-manifest.sh +++ b/ci/release-manifest.sh @@ -28,9 +28,16 @@ if [[ -n "${RELEASE_NEXT_KEY:-}" ]]; then [[ -n "$next_key" ]] || { echo "RELEASE_NEXT_KEY is not a public key" >&2; exit 2; } fi +# 官方二进制的哈希(逗号分隔的小写十六进制):节点握手时上报正在运行的二进制的哈希,Backend 用这份签名的清单认「官方」 +binaries="" +if [[ -n "${RELEASE_BINARIES:-}" ]]; then + [[ "$RELEASE_BINARIES" =~ ^[0-9a-f]{64}(,[0-9a-f]{64})*$ ]] || { echo "RELEASE_BINARIES must be comma-separated sha256 hashes" >&2; exit 2; } + binaries=",\"binaries\":\"$RELEASE_BINARIES\"" +fi + mkdir -p "$outdir" -printf '{"schema":1,"component":"%s","channel":"%s","image":"%s","digest":"%s","tag":"%s","version":"%s","commit":"%s","built_at":"%s","seq":%s,"install_sh":"%s","install_ps1":"%s"%s}' \ - "$component" "$channel" "$repository" "$digest" "$tag" "$version" "$commit" "$built_at" "$seq" "$install_sh" "$install_ps1" "${next_key:+,\"next_key\":\"$next_key\"}" > "$outdir/manifest.json" +printf '{"schema":1,"component":"%s","channel":"%s","image":"%s","digest":"%s","tag":"%s","version":"%s","commit":"%s","built_at":"%s","seq":%s,"install_sh":"%s","install_ps1":"%s"%s%s}' \ + "$component" "$channel" "$repository" "$digest" "$tag" "$version" "$commit" "$built_at" "$seq" "$install_sh" "$install_ps1" "${next_key:+,\"next_key\":\"$next_key\"}" "$binaries" > "$outdir/manifest.json" key="$(mktemp)" trap 'rm -f "$key" "$outdir/manifest.sig.bin"' EXIT diff --git a/docs/access.md b/docs/access.md index 597623d..d87f67b 100644 --- a/docs/access.md +++ b/docs/access.md @@ -32,7 +32,9 @@ **发布密钥可以换**:见 `docs/deployment.md` 的「换发布密钥」;回滚已经由清单的 `seq` 防住(更新器只收比已安装更大的)。 -**还没有的**:吊销表;任务和配置内容的操作员签名;节点自证跑的是官方构建;extension 本身和属主的 `controllers`、接收者的实际加密(只是被固定在节点本地,没有行为)。 +**节点二进制绑定**(第 16 节):节点第一次握手成功的那一刻,Backend 把它正在运行的二进制的 SHA-256 绑定到这个节点;之后哈希对不上、又不在 CI 签名的更新清单里,就拒绝连接。 + +**还没有的**:吊销表;任务和配置内容的操作员签名;节点向别人**证明**自己没有撒谎(二进制绑定抓的是文件被换、降级、起了不对的二进制,抓不到故意照抄哈希的恶意二进制,见第 16 节);extension 本身和属主的 `controllers`、接收者的实际加密(只是被固定在节点本地,没有行为)。 ## 1. 名词 @@ -480,3 +482,26 @@ curl -fsSL https://panel/install -o /tmp/moshiro-install.sh && echo " / 10. 安装脚本的哈希在哪里公布(Gitea 发布页之外还要不要放 DNS TXT 或另一个独立渠道)。 11. 节点预算的数字(常驻内存 ≤ 1 MB、二进制 ≤ 150 KB、每个成员验证 ≤ 100 毫秒、成员上限 256)是否合适;超出预算时优先砍什么(比如先去掉操作员集合的 64 个凭证上限换成更小的)。 12. webhook 和数据拉取的配额默认值(每分钟请求数、每天行数)和事件清单。 + +## 16. 节点二进制绑定 + +目的:被换掉的、版本被改的、降级的二进制,不能继续当这个节点运行。 + +```text + CI 构建:算出镜像里节点二进制的 SHA-256,写进签名的清单(binaries) + 宿主机更新器:把验过的清单和签名写进节点只读挂载的 update-status/(installed.manifest、installed.sig) + 节点握手(auth 里):binary = 正在运行的二进制的哈希(Linux 读 /proc/self/exe,是进程启动时映射的那个映像) + release、release_sig = 上面那份清单和签名 + Backend: + ① 第一次握手成功 → 绑定 (节点, 哈希, 清单 seq) + ② 以后每次握手:哈希和绑定一致 → 放行 + ③ 不一致 → 必须是一份 CI 签名清单(用内嵌的发布公钥验)里列出的官方二进制,并且清单 seq 不低于绑定时的(合法升级,改绑); + 否则拒绝连接,节点页标红「不符」。不许降级。 +``` + +- **清单是节点带来的**,Backend 自己验签,所以不依赖面板有没有收到过这次发布;被攻破的 Backend 也加不进「官方」哈希(没有 CI 私钥)。 +- **旧节点**(不上报哈希):没绑定过就放行(显示未绑定);绑定过的不许退回不上报。 +- **没有更新器的节点**(二进制安装)没有清单,首次握手按上报的哈希绑定(显示「首次绑定」);手工升级之后哈希变了,会被拒绝,管理员在「系统 → 版本」点「接受当前二进制」清除绑定,下一次连接重新绑定。 +- **策略** `MoShiro__BinaryPolicy`:`enforce`(默认)拒绝;`warn` 只记录不拒绝;`off` 不检查。 +- **边界**:哈希是节点自己报的。一个被换进来、故意撒谎的恶意二进制可以照抄原来的哈希,Backend 分辨不出——上报者就是被怀疑的对象。要堵这一点,必须让容器之外的东西背书:宿主机更新器用安装时绑定的密钥对「当前运行的镜像 digest + 挑战」签名,由 agent 转发;容器里的进程被换掉也伪造不了。这一步还没做。 + diff --git a/docs/api.md b/docs/api.md index 49c167e..5b64664 100644 --- a/docs/api.md +++ b/docs/api.md @@ -150,6 +150,7 @@ | 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` 取消手动设置;改完立即重新下发 | +| DELETE | `/api/admin/nodes/{id}/binary` | 接受节点当前的二进制:清除它的二进制绑定,下一次握手重新绑定(需要 `fleet.write`;没有绑定也返回 204)。`GET /api/admin/build-info` 的每个节点带 `binary`(`sha256`、`signed`、`seq`、`bound_at`、`violation`) | | POST | `/api/admin/nodes/{id}/update` | 请求一个在线的 Agent 或 Probe 更新到它收到的清单(节点不在线返回 409) | | GET/POST/PATCH | `/api/admin/users` | 首次启动时通过命令行或环境变量创建初始管理员 | diff --git a/packaging/update/moshiro-update.sh b/packaging/update/moshiro-update.sh index 391550b..3b68697 100644 --- a/packaging/update/moshiro-update.sh +++ b/packaging/update/moshiro-update.sh @@ -122,11 +122,11 @@ fetch_offer() { cp "$OFFER_MANIFEST" "$WORK/feed.manifest"; cp "$OFFER_SIG" "$WORK/feed.sig" fi fetch_mailbox - # 信箱里没有东西,而仓库里的清单有问题(不是「已经装过」):留下仓库的那份,让后面照常报出验证失败的原因,而不是悄悄当作没有更新 - case "$feed_error" in - ""|"stale manifest"*) ;; - *) if [ ! -s "$OFFER_MANIFEST" ]; then cp "$WORK/feed.manifest" "$OFFER_MANIFEST"; cp "$WORK/feed.sig" "$OFFER_SIG"; fi ;; - esac + # 信箱里没有东西,而仓库里的清单没通过(验证失败,或者就是已经装的这一份):留下仓库的那份,让后面照常报出原因, + # 而不是悄悄当作没有更新;「已经装的这一份」也要留下,因为要把它交给节点握手时带给 Backend + if [ -n "$feed_error" ] && [ ! -s "$OFFER_MANIFEST" ]; then + cp "$WORK/feed.manifest" "$OFFER_MANIFEST"; cp "$WORK/feed.sig" "$OFFER_SIG" + fi } # 把业务进程放的清单和签名复制到临时目录;不是普通文件(缺失、符号链接)就当作没有 @@ -165,6 +165,36 @@ write_status() { # state available_json error fi } +# 节点握手时要带给 Backend 的「我是从哪份 CI 签名清单安装的」:Backend 用 CI 公钥验过,才相信清单里列的官方二进制哈希。 +# 写在节点只读挂载的状态目录里(节点自己改不了),清单在前、签名在后;内容没变就不写(保护路由器的闪存)。 +write_installed() { + if cmp -s "$OFFER_MANIFEST" "$STATE_DIR/$COMPONENT.installed.manifest" 2>/dev/null && cmp -s "$OFFER_SIG" "$STATE_DIR/$COMPONENT.installed.sig" 2>/dev/null; then + return 0 + fi + for pair in "installed.manifest:$OFFER_MANIFEST" "installed.sig:$OFFER_SIG"; do + name="${pair%%:*}"; src="${pair#*:}" + if [ -n "$DATA_VOLUME" ]; then + "$ENGINE" run --pull=never --rm -i --user 0 --network none --read-only --cap-drop ALL --cap-add DAC_OVERRIDE --security-opt no-new-privileges \ + -v "$STATUS_VOLUME:/status" --entrypoint /bin/sh "$HELPER_IMAGE" \ + -c 'tmp=$(mktemp /status/.installed.XXXXXX); cat > "$tmp"; chmod 644 "$tmp"; mv -f "$tmp" "/status/$1"' sh "$name" < "$src" || return 0 + else + destination="$(mktemp "$STATUS_DIR/.installed.XXXXXX")" + cp "$src" "$destination" + chmod 644 "$destination" + mv -f "$destination" "$STATUS_DIR/$name" + fi + done + cp "$OFFER_MANIFEST" "$STATE_DIR/$COMPONENT.installed.manifest" + cp "$OFFER_SIG" "$STATE_DIR/$COMPONENT.installed.sig" +} + +# 第一次部署(deploy-node.sh 已经验过签并按 digest 固定)时还没有安装记录:清单的 digest 就是正在跑的这个镜像,就记下来,不当作「有更新」 +seed_installed() { + [ ! -r "$INSTALLED" ] || return 0 + running="$(sed -n 's/^IMAGE=.*@\(sha256:[0-9a-f]*\)$/\1/p' "$ENV_FILE" 2>/dev/null | head -n 1)" + if [ -n "$running" ] && [ "$running" = "$M_DIGEST" ]; then printf '%s\n%s\n' "$M_SEQ" "$M_DIGEST" > "$INSTALLED"; fi +} + current_digest() { [ -r "$INSTALLED" ] && sed -n 2p "$INSTALLED" || echo ""; } installed_seq() { [ -r "$INSTALLED" ] && sed -n 1p "$INSTALLED" || echo 0; } @@ -251,13 +281,19 @@ do_check() { if ! verify_offer; then # 没有新清单,或者清单已经安装过:不算错误,只是没有更新 case "$ERR" in - "no offer"|"stale manifest"*) write_status up_to_date null "" ;; + "no offer") write_status up_to_date null "" ;; + "stale manifest"*) + # 就是已经装的这一份(同一个 seq、同一个 digest):交给节点握手时带给 Backend + if [ "$M_SEQ" = "$(installed_seq)" ] && [ "$M_DIGEST" = "$(current_digest)" ]; then write_installed; fi + write_status up_to_date null "" ;; *) write_status invalid null "$ERR"; echo "offer rejected: $ERR" >&2 ;; esac return 0 fi adopt_next_key + seed_installed if [ "$M_DIGEST" = "$(current_digest)" ]; then + write_installed write_status up_to_date null "" return 0 fi @@ -303,6 +339,7 @@ do_apply() { fi if [ "$healthy" = true ] && service_ctl is-active --quiet "$UNIT" && [ "$before" = "$after" ]; then printf '%s\n%s\n' "$M_SEQ" "$M_DIGEST" > "$INSTALLED" + write_installed write_status up_to_date null "" echo "updated to $M_TAG" return 0 diff --git a/packaging/update/tests/feed-test.sh b/packaging/update/tests/feed-test.sh index 6fe9725..808e41a 100644 --- a/packaging/update/tests/feed-test.sh +++ b/packaging/update/tests/feed-test.sh @@ -111,4 +111,22 @@ expect "first install: wrong signing key is refused" "REFUSED" "$(resolve "$T/c. expect "first install: manifest for another component is refused" "REFUSED" "$(resolve "$T/b.pem" probe "$REPO")" expect "first install: manifest naming another repository is refused" "REFUSED" "$(resolve "$T/b.pem" agent "registry.evil/moshiro-dev/moshiro-agent")" +# --- 第一次部署:正在跑的镜像就是清单里的这个 digest → 记成已安装(不当作有更新),并把验过的清单交给节点握手时带上 +sed -i '/^FEED=off/d' "$T/conf/agent.conf" +rm -f "$T/state/agent.installed" "$T/state/agent.installed.manifest" "$T/state/agent.installed.sig" "$T/data/update-status/installed."* "$T/data/update/offer."* +printf 'IMAGE=%s@%s\n' "$REPO" "$DIGEST" > "$T/env" +publish "$T/b.pem" 50 +expect "first deploy: the running image is already up to date" "up_to_date|" "$(check)" +if [ "$(sed -n 2p "$T/state/agent.installed" 2>/dev/null)" = "$DIGEST" ]; then echo "ok first deploy: install record seeded from the running image"; else echo "FAIL first deploy: no install record"; fail=1; fi +label="$(cat "$T/feed/$REF_KEY")" +if [ "$(cat "$T/data/update-status/installed.manifest" 2>/dev/null | base64 -w0)" = "${label%%|*}" ] && [ "$(cat "$T/data/update-status/installed.sig" 2>/dev/null)" = "${label#*|}" ]; then + echo "ok the verified manifest is left for the node to present at its handshake" +else + echo "FAIL installed.manifest / installed.sig missing or different"; fail=1 +fi +# 老版本的更新器没写过:同一份(seq 相同)下次检查补上 +rm -f "$T/data/update-status/installed."* "$T/state/agent.installed.manifest" "$T/state/agent.installed.sig" +expect "already installed: still up to date" "up_to_date|" "$(check)" +if [ -s "$T/data/update-status/installed.manifest" ] && [ -s "$T/data/update-status/installed.sig" ]; then echo "ok a missing copy is written again for the node"; else echo "FAIL the copy was not written again"; fail=1; fi + exit "$fail" diff --git a/web/admin/src/pages/System.tsx b/web/admin/src/pages/System.tsx index ce9f27d..e84f8fd 100644 --- a/web/admin/src/pages/System.tsx +++ b/web/admin/src/pages/System.tsx @@ -1,7 +1,7 @@ import { Download, KeyRound, Plus, Trash2 } from 'lucide-react' import { useEffect, useMemo, useState, type ReactNode } from 'react' import { api } from '@shared/api/client' -import type { AgentDto, ApiTokenDto, BuildInfoDto, CapabilityCatalogDto, MeDto, PasskeyDto, NodeUpdateDto, ReleaseDto, CreatedApiTokenDto, AuditDto, EventDto, Page, ProbeDto, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types' +import type { AgentDto, ApiTokenDto, BuildInfoDto, NodeBinaryDto, CapabilityCatalogDto, MeDto, PasskeyDto, NodeUpdateDto, ReleaseDto, CreatedApiTokenDto, AuditDto, EventDto, Page, ProbeDto, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types' import { fmtBuiltAt, frontendBuild } from '@shared/buildInfo' import { fmtNum, fmtTime } from '@shared/format' import { translateSetting } from '@shared/i18n' @@ -191,6 +191,7 @@ function BuildRow({ name, kind, version, tag, commit, builtAt, platform, update {fmtBuiltAt(builtAt) || dash} {platform || dash} {update ?? dash} + {dash} ) } @@ -205,6 +206,26 @@ const UPDATE_STATE: Record void }) { + if (!binary) return 未绑定 + const short = binary.sha256.slice(0, 10) + return ( + + {binary.violation ? 不符 : {binary.signed ? '已签名' : '首次绑定'}} + {short} + {binary.violation ? ( + + ) : null} + + ) +} + function UpdateCell({ update, busy, onUpdate }: { update?: NodeUpdateDto | null; busy: boolean; onUpdate: () => void }) { if (!update) return — const s = UPDATE_STATE[update.state] ?? { text: update.state, tone: '' as const } @@ -271,6 +292,7 @@ function Versions() { 构建时间 系统 更新 + 二进制 @@ -292,6 +314,9 @@ function Versions() { requestUpdate([n.id])} /> + + void run(async () => { await api.del(`/api/admin/nodes/${n.id}/binary`); info.reload() }, '已清除绑定,节点下一次连接时重新绑定')} /> + ))} diff --git a/web/shared/api/schema.ts b/web/shared/api/schema.ts index 25f1124..90c4d80 100644 --- a/web/shared/api/schema.ts +++ b/web/shared/api/schema.ts @@ -988,6 +988,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/nodes/{id}/binary": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Accept the node's current binary: clear its binding so the next handshake binds it again */ + delete: operations["nodes_binary_clear"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/backend/update": { parameters: { query?: never; @@ -1680,6 +1697,17 @@ export interface components { version: number | string; needs: string[]; }; + NodeBinaryDto: { + sha256: string; + signed: boolean; + /** Format: int64 */ + seq: number | string; + /** Format: date-time */ + bound_at: unknown; + violation: null | string; + /** Format: date-time */ + violation_at: unknown; + }; NodeBuildDto: { id: string; kind: string; @@ -1691,6 +1719,7 @@ export interface components { commit: null | string; built_at: null | string; update?: null | components["schemas"]["NodeUpdateDto"]; + binary?: null | components["schemas"]["NodeBinaryDto"]; }; NodeInfoDto: { id: string; @@ -5593,6 +5622,46 @@ export interface operations { }; }; }; + nodes_binary_clear: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @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"]; + }; + }; + }; + }; backend_update_get: { parameters: { query?: never; diff --git a/web/shared/api/types.ts b/web/shared/api/types.ts index 3c09900..ed42e81 100644 --- a/web/shared/api/types.ts +++ b/web/shared/api/types.ts @@ -19,6 +19,7 @@ export type BuildInfoDto = Narrow export type BackendUpdateDto = Narrow export type NodeBuildDto = Narrow export type NodeUpdateDto = Narrow +export type NodeBinaryDto = Narrow export type ReleaseDto = Narrow export type LinkDto = Narrow export type LinkMatrixDto = Narrow