diff --git a/backend/agent/src/MoShiro.Agent/AgentApp.cs b/backend/agent/src/MoShiro.Agent/AgentApp.cs index 9433a5f..2f4d843 100644 --- a/backend/agent/src/MoShiro.Agent/AgentApp.cs +++ b/backend/agent/src/MoShiro.Agent/AgentApp.cs @@ -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)], diff --git a/backend/fleet/src/Fleet.Core/BinaryAttestation.cs b/backend/fleet/src/Fleet.Core/BinaryAttestation.cs deleted file mode 100644 index 0657435..0000000 --- a/backend/fleet/src/Fleet.Core/BinaryAttestation.cs +++ /dev/null @@ -1,43 +0,0 @@ -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 f91cfdd..b80e184 100644 --- a/backend/fleet/src/Fleet.Core/ErrorCode.cs +++ b/backend/fleet/src/Fleet.Core/ErrorCode.cs @@ -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"); - /// 节点现在跑的二进制和它第一次握手时绑定的不一致,也不在 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 e152272..e31649d 100644 --- a/backend/fleet/src/Fleet.Link/FleetLink.cs +++ b/backend/fleet/src/Fleet.Link/FleetLink.cs @@ -72,9 +72,6 @@ public sealed class FleetLinkOptions /// 服务器必须在挑战里证明自己,之后这条链路用应用层加密;否则按旧的方式(没有固定信任根的节点)。 /// public IServerVerifier? ServerVerifier { get; init; } - - /// 握手时自述正在运行的二进制(哈希和 CI 签名的清单);没有就不上报(旧行为)。 - public Func? Attestation { get; init; } } /// @@ -226,16 +223,6 @@ 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); @@ -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); } diff --git a/backend/fleet/src/Fleet.Rpc/FleetMethods.cs b/backend/fleet/src/Fleet.Rpc/FleetMethods.cs index 3e185b6..a1c5e61 100644 --- a/backend/fleet/src/Fleet.Rpc/FleetMethods.cs +++ b/backend/fleet/src/Fleet.Rpc/FleetMethods.cs @@ -75,17 +75,6 @@ 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 51505aa..bb8e4be 100644 --- a/backend/fleet/src/Fleet.Server.Abstractions/ServerContracts.cs +++ b/backend/fleet/src/Fleet.Server.Abstractions/ServerContracts.cs @@ -19,21 +19,6 @@ 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 776702c..d4d4b82 100644 --- a/backend/fleet/src/Fleet.Server.Link/FleetLinkHost.cs +++ b/backend/fleet/src/Fleet.Server.Link/FleetLinkHost.cs @@ -24,11 +24,9 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier IEnumerable inboxes, IEnumerable 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 /// 服务器的身份密钥;没有时节点连接用旧的明文认证。 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 e360d94..0c99b75 100644 --- a/backend/fleet/src/Fleet.Server.Link/FleetServerBuilder.cs +++ b/backend/fleet/src/Fleet.Server.Link/FleetServerBuilder.cs @@ -110,8 +110,7 @@ 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 1c4b393..57c87a8 100644 --- a/backend/fleet/src/Fleet.Server.Link/NodeConnection.Auth.cs +++ b/backend/fleet/src/Fleet.Server.Link/NodeConnection.Auth.cs @@ -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), diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index 1a86df0..9dc9124 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -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", diff --git a/backend/probe/src/MoShiro.Probe/ProbeApp.cs b/backend/probe/src/MoShiro.Probe/ProbeApp.cs index c9d1309..c5b1804 100644 --- a/backend/probe/src/MoShiro.Probe/ProbeApp.cs +++ b/backend/probe/src/MoShiro.Probe/ProbeApp.cs @@ -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], diff --git a/backend/shared/MoShiro.Measurement/Update/UpdateBridge.cs b/backend/shared/MoShiro.Measurement/Update/UpdateBridge.cs index ba4feef..236088c 100644 --- a/backend/shared/MoShiro.Measurement/Update/UpdateBridge.cs +++ b/backend/shared/MoShiro.Measurement/Update/UpdateBridge.cs @@ -96,34 +96,5 @@ 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 499aae4..c6929de 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs @@ -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(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); @@ -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(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409) .Requires(Capability.ReleasePublish); } @@ -60,12 +57,12 @@ public static class BuildEndpoints .Returns(StatusCodes.Status201Created).ProducesProblems(400, 403, 409); } - private static async Task OfferReleaseAsync(HttpContext http, ReleaseService releases, PublishReleaseRequest request) + private static async Task 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 GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, BinaryBindingService bindings, NodeOwnerService owners, CancellationToken ct) + private static async Task GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, NodeOwnerService owners, CancellationToken ct) { IReadOnlyDictionary> ownerOf = await owners.ListAsync(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))), bound.TryGetValue(n.NodeId, out NodeBinding? binding) ? ToDto(binding) : null, ownerOf.TryGetValue(n.NodeId, out IReadOnlyList? 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? 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); - - /// 管理员确认新的二进制是自己要的:清除绑定,节点下一次握手重新绑定(没有绑定可清也返回 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/Contracts/AdminDtos.cs b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs index 1670290..7d14674 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs @@ -185,13 +185,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、提交和构建时间,以及系统。 -/// 节点二进制的绑定:第一次握手成功时绑定的 SHA-256。Signed 表示它出现在一份 CI 签名的清单里(否则只是首次握手时记下的);Violation 是最近一次不符的原因,下一次握手符合了就清掉。 -public sealed record NodeBinaryDto(string Sha256, bool Signed, long Seq, DateTimeOffset BoundAt, string? Violation, DateTimeOffset? ViolationAt); - /// 节点的一个属主:节点报上来的(安装时固定在节点本地的)passkey 凭证 id,以及它在面板上对应的 passkey 名称和账户(对不上任何已注册的 passkey 时为空)。 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? 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? Owners = 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 278049a..2341ec5 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs @@ -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))] diff --git a/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs b/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs index 9418a53..462fa5b 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs @@ -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); diff --git a/backend/src/MoShiro.Backend.Application/Agents/BinaryBindingService.cs b/backend/src/MoShiro.Backend.Application/Agents/BinaryBindingService.cs deleted file mode 100644 index 55eca2e..0000000 --- a/backend/src/MoShiro.Backend.Application/Agents/BinaryBindingService.cs +++ /dev/null @@ -1,175 +0,0 @@ -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 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> 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"); - } - - // 节点自己带来的清单(宿主机更新器验过的那份);没有更新器的节点(一键容器安装、二进制安装)没有,就用面板自己手里的: - // 面板登记它们时已经验过 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 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 async ValueTask 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(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 0d945f6..7e05d4f 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/ProbeService.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/ProbeService.cs @@ -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(); diff --git a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs index c5bb491..c9863d2 100644 --- a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs +++ b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs @@ -40,9 +40,7 @@ public static class ApplicationServiceCollectionExtensions services.AddSingleton(); 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 bbcb546..3336c7a 100644 --- a/backend/src/MoShiro.Backend.Application/BackendOptions.cs +++ b/backend/src/MoShiro.Backend.Application/BackendOptions.cs @@ -21,11 +21,6 @@ public sealed class BackendOptions /// 宿主机更新器的只读状态目录;留空使用数据目录下的 update-status/。 public string? BackendUpdateStatusDirectory { get; set; } - /// - /// 节点二进制绑定的策略:enforce(默认)二进制和绑定不符、又不在 CI 签名的更新清单里就拒绝连接;warn 只记录不拒绝;off 不检查。 - /// - public string BinaryPolicy { get; set; } = "enforce"; - /// /// 每个 Agent 和 Probe 都必须有属主(passkey):创建加入码时必须已经建立信任根,并且至少选一把操作员集合里的 passkey 作为属主。 /// 属主是节点敏感内容(extension 会话、加密数据)绑定的人,装上时固定在节点本地,面板改不了。默认开;没有信任根的老部署才需要关掉。 diff --git a/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs b/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs index 7824097..7f1e666 100644 --- a/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs +++ b/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs @@ -144,20 +144,6 @@ 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/IReleaseKey.cs b/backend/src/MoShiro.Backend.Application/Updates/IReleaseKey.cs new file mode 100644 index 0000000..a2a255f --- /dev/null +++ b/backend/src/MoShiro.Backend.Application/Updates/IReleaseKey.cs @@ -0,0 +1,7 @@ +namespace MoShiro.Backend.Application; + +/// CI 发布公钥(PEM):验登记的发布清单。由 Api 层提供(它就是内嵌在程序里的那一把)。 +public interface IReleaseKey +{ + string Pem { get; } +} diff --git a/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs b/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs index 41ebd5f..b1456fd 100644 --- a/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs +++ b/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs @@ -45,7 +45,7 @@ public sealed partial class ReleaseService : IDisposable public void Dispose() => BackendGate.Dispose(); - /// 登记一份新清单并推给在线的同类节点。只要格式对、seq 比现有的新就收下(签名由节点验证)。 + /// 登记一份新清单并推给在线的同类节点。格式对、带 CI 发布密钥的有效签名、seq 比现有的新才收下;所有登记入口(CI、镜像仓库、管理员)都走这里,所以没有不验签的路径。节点的更新器之后还会用本机公钥再验一次。 public async ValueTask> 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("manifest", problem); } + if (!ReleaseSignature.Verify(_key.Pem, manifest, signature)) + { + return Result.Fail(MoShiroErrors.Forbidden, "the manifest does not carry a valid release signature"); + } + await BackendGate.WaitAsync(ct); try { diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs index ac26db0..3434073 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 = 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(); + } } /// diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteNodeBindingStore.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteNodeBindingStore.cs deleted file mode 100644 index e4bb333..0000000 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteNodeBindingStore.cs +++ /dev/null @@ -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 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 569b93e..5370a80 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs @@ -24,7 +24,6 @@ public static class StorageServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/backend/src/MoShiro.Backend/BackendHost.cs b/backend/src/MoShiro.Backend/BackendHost.cs index 3b42a1a..3433f79 100644 --- a/backend/src/MoShiro.Backend/BackendHost.cs +++ b/backend/src/MoShiro.Backend/BackendHost.cs @@ -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? configureOptions = null, TimeProvider? time = null) + public static WebApplication Build(string[] args, Action? configureOptions = null, TimeProvider? time = null, Action? 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 就关闭,这样面板上的在线状态才可信 diff --git a/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs b/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs index 6b33085..fb27609 100644 --- a/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs +++ b/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs @@ -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(TestReleaseKey.Instance)); // 登记清单要验发布签名:测试用自己的密钥签 await App.StartAsync(); string address = App.Services.GetRequiredService().Features.Get()!.Addresses.First(); BaseAddress = new Uri(address); diff --git a/backend/tests/MoShiro.Backend.Tests/BackendUpdateTests.cs b/backend/tests/MoShiro.Backend.Tests/BackendUpdateTests.cs index c57d772..56f8536 100644 --- a/backend/tests/MoShiro.Backend.Tests/BackendUpdateTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/BackendUpdateTests.cs @@ -33,7 +33,7 @@ public sealed class BackendUpdateTests : IAsyncLifetime private async Task PublishAsync(long seq, string channel = "dev") { string manifest = $$"""{"component":"backend","channel":"{{channel}}","image":"registry.example/moshiro-{{channel}}/moshiro-backend","digest":"{{Digest}}","tag":"{{channel}}-{{seq}}","version":"0.1.1","seq":{{seq}}}"""; - return await _backend.Admin.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = "MEUC" })); + return await _backend.Admin.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = TestReleaseKey.Sign(manifest) })); } private Task ViewAsync() => _backend.GetJsonAsync(_backend.Admin, "/api/admin/backend/update"); diff --git a/backend/tests/MoShiro.Backend.Tests/BinaryBindingTests.cs b/backend/tests/MoShiro.Backend.Tests/BinaryBindingTests.cs deleted file mode 100644 index 5febd21..0000000 --- a/backend/tests/MoShiro.Backend.Tests/BinaryBindingTests.cs +++ /dev/null @@ -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; - -/// -/// 节点二进制绑定:第一次握手成功的那一刻绑定正在运行的二进制的哈希,之后对不上又不在 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 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)); - - /// 节点握手时带的证据:哈希,加上(可选)某把密钥签名的清单。 - 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); - } - - /// 面板手里的、CI 签名的清单(登记时存下的):没有更新器的节点握手时没有自己的清单,就拿它来认官方二进制。 - 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 Values { get; } = []; - - public ValueTask 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> GetAllAsync(CancellationToken ct) => new(Values); - - public ValueTask IncrementAsync(string key, CancellationToken ct) => new(0); - } - - 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/InstallTests.cs b/backend/tests/MoShiro.Backend.Tests/InstallTests.cs index 95529b3..614ee2f 100644 --- a/backend/tests/MoShiro.Backend.Tests/InstallTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/InstallTests.cs @@ -85,7 +85,7 @@ public sealed class InstallTests public async Task TheScript_GetsTheSignedManifestOfItsRole_ByteForByte() { string manifest = """{"schema":1,"component":"probe","channel":"dev","image":"registry.example/moshiro-dev/moshiro-probe","digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000","tag":"dev-1","version":"0.1.1","commit":"abc","built_at":"2026-10-06T00:00:00Z","seq":1}"""; - using HttpResponseMessage published = await _backend.Admin.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = "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(); diff --git a/backend/tests/MoShiro.Backend.Tests/ReleaseOfferTests.cs b/backend/tests/MoShiro.Backend.Tests/ReleaseOfferTests.cs index 63e0605..40cc367 100644 --- a/backend/tests/MoShiro.Backend.Tests/ReleaseOfferTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/ReleaseOfferTests.cs @@ -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() { diff --git a/backend/tests/MoShiro.Backend.Tests/TestReleaseKey.cs b/backend/tests/MoShiro.Backend.Tests/TestReleaseKey.cs new file mode 100644 index 0000000..c932b96 --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/TestReleaseKey.cs @@ -0,0 +1,21 @@ +using System.Security.Cryptography; +using System.Text; +using MoShiro.Backend.Application; + +namespace MoShiro.Backend.Tests; + +/// +/// 测试用的发布密钥:真正的 CI 私钥不在仓库里,测试后端用这把公钥验签( 替换了内嵌的那把), +/// 登记清单的用例用它签名——和 CI 一样是 DER 编码的 ECDSA P-256 / SHA-256,签名对象是清单的原始字节。 +/// +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)); +} diff --git a/backend/tests/MoShiro.Backend.Tests/UpdateTests.cs b/backend/tests/MoShiro.Backend.Tests/UpdateTests.cs index 113cee2..9d48f5d 100644 --- a/backend/tests/MoShiro.Backend.Tests/UpdateTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/UpdateTests.cs @@ -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 PublishAsync(string manifest, string signature = "MEUCIQDc") => - await _backend.Admin.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = signature })); + private async Task PublishAsync(string manifest, string? signature = null) => + await _backend.Admin.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = signature ?? TestReleaseKey.Sign(manifest) })); [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()); Assert.Equal(Digest, release["digest"]!.GetValue()); - 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] diff --git a/ci/binary-hash.sh b/ci/binary-hash.sh deleted file mode 100644 index f9d07ea..0000000 --- a/ci/binary-hash.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/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}" -# 镜像是本地刚构建的,不需要仓库认证;CI 里 REGISTRY_AUTH_FILE 指向的文件在登录之前还不存在,buildah 会因此报错 -unset REGISTRY_AUTH_FILE -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 855a77c..15d75ab 100644 --- a/ci/build-main-amd64.sh +++ b/ci/build-main-amd64.sh @@ -54,11 +54,6 @@ 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 @@ -91,5 +86,5 @@ done # 发布清单:给镜像的 digest 签名(私钥只在 CI 密钥里),放进镜像仓库(<仓库>-manifest:dev),面板在线时再登记一份加速。 # 没有配置签名密钥(例如 fork)就只构建镜像、不发清单。 if [[ -n "${RELEASE_SIGNING_KEY:-}" ]]; then - RELEASE_BINARIES="$binary_sha256" bash "$root/ci/publish-release-offer.sh" "$component" "$repository" "$(cat "$work/$component.digest")" "$dev_tag" dev "$work/$component-release" + 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-binary-hashes.sh b/ci/release-binary-hashes.sh deleted file mode 100644 index 5484c00..0000000 --- a/ci/release-binary-hashes.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env bash -# 一个组件在所有平台、所有架构的发布压缩包里的可执行文件的 SHA-256(逗号分隔,输出到标准输出)。 -# ci/release-binary-hashes.sh <版本,不带 v> 环境变量:GITEA_BASE、NETRC(curl 的 netrc 文件) -# 签名的发布清单里的 binaries 就是它:节点握手时上报正在运行的可执行文件的哈希,Backend 凭 CI 签名的清单认「官方二进制」—— -# 容器、Linux glibc/musl 压缩包、macOS、Windows 的 x64/arm64 都一样,不只是容器镜像里的那两个。 -set -euo pipefail -component="${1:?usage: release-binary-hashes.sh }"; version="${2:?version}" -[[ "$component" =~ ^(agent|probe)$ ]] || { echo "component must be agent or probe" >&2; exit 2; } -gitea_base="${GITEA_BASE:?GITEA_BASE is required}"; gitea_base="${gitea_base%/}" -netrc=(); [[ -n "${NETRC:-}" ]] && netrc=(--netrc-file "$NETRC") -work="$(mktemp -d)" -trap 'rm -rf "$work"' EXIT - -# 和 ci/publish-gitea-release.sh 里发布的那 8 种一样(每个组件) -targets=(linux-amd64 linux-arm64 linux-musl-amd64 linux-musl-arm64 macos-x64 macos-arm64 windows-amd64 windows-arm64) -hashes=() -for target in "${targets[@]}"; do - if [[ "$target" == windows-* ]]; then archive="moshiro-$component-v$version-$target.zip"; member="moshiro-$component.exe" - else archive="moshiro-$component-v$version-$target.tar.gz"; member="moshiro-$component"; fi - curl --fail --silent --show-error --retry 3 ${netrc[@]+"${netrc[@]}"} "$gitea_base/api/packages/justme/generic/moshiro-node/$version/$archive" -o "$work/$archive" - if [[ "$archive" == *.zip ]]; then - hash="$(cd "$work" && python3 -c 'import hashlib,sys,zipfile; print(hashlib.sha256(zipfile.ZipFile(sys.argv[1]).read(sys.argv[2])).hexdigest())' "$archive" "$member")" - else - hash="$(tar -xzOf "$work/$archive" "$member" | sha256sum | cut -d' ' -f1)" - fi - [[ "$hash" =~ ^[0-9a-f]{64}$ ]] || { echo "could not hash $member in $archive" >&2; exit 1; } - hashes+=("$hash") - rm -f "$work/$archive" -done -# 不同平台的二进制哈希不会相同;去重只是防御 -printf '%s\n' "${hashes[@]}" | sort -u | paste -sd, diff --git a/ci/release-manifest.sh b/ci/release-manifest.sh index 843b952..2594838 100644 --- a/ci/release-manifest.sh +++ b/ci/release-manifest.sh @@ -28,16 +28,9 @@ 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%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" +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" key="$(mktemp)" trap 'rm -f "$key" "$outdir/manifest.sig.bin"' EXIT diff --git a/ci/release-node-images.sh b/ci/release-node-images.sh index 24adc81..0d58b2c 100644 --- a/ci/release-node-images.sh +++ b/ci/release-node-images.sh @@ -117,8 +117,6 @@ for component in agent probe; do # 没有配置签名密钥(例如 fork)就只发镜像、不发清单。 if [[ -n "${RELEASE_SIGNING_KEY:-}" ]]; then digest="$(cat "$work/$component.digest")" - # 清单里的 binaries:所有平台、所有架构的发布压缩包里的可执行文件(不只是镜像里的 musl 那两个),节点握手时上报的哈希对得上其中任何一个 - binaries="$(NETRC="$netrc" GITEA_BASE="$gitea_base" bash "$root/ci/release-binary-hashes.sh" "$component" "$version")" - RELEASE_BINARIES="$binaries" bash "$root/ci/publish-release-offer.sh" "$component" "$image_root/moshiro-$component" "$digest" "$tag" release "$work/$component-release" + bash "$root/ci/publish-release-offer.sh" "$component" "$image_root/moshiro-$component" "$digest" "$tag" release "$work/$component-release" fi done diff --git a/ci/tests/binary-hashes-test.sh b/ci/tests/binary-hashes-test.sh deleted file mode 100644 index b162878..0000000 --- a/ci/tests/binary-hashes-test.sh +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env bash -# release-binary-hashes.sh 的测试:用本地的假 Gitea 包仓库(python http.server),造 8 个平台的压缩包(tar.gz 和 zip), -# 核对脚本算出来的哈希就是压缩包里可执行文件的 SHA-256。运行:bash ci/tests/binary-hashes-test.sh -set -eu -ROOT="$(cd "$(dirname "$0")/../.." && pwd)" -T="$(mktemp -d)" -SERVER= -trap '[ -z "$SERVER" ] || kill "$SERVER" 2>/dev/null || true; rm -rf "$T" 2>/dev/null || true' EXIT -V=9.9.9 -PKG="$T/srv/api/packages/justme/generic/moshiro-node/$V" -mkdir -p "$PKG" -fail=0 -expected=() -for component in agent probe; do - for target in linux-amd64 linux-arm64 linux-musl-amd64 linux-musl-arm64 macos-x64 macos-arm64 windows-amd64 windows-arm64; do - stage="$T/stage-$component-$target"; mkdir -p "$stage" - if [[ "$target" == windows-* ]]; then member="moshiro-$component.exe"; else member="moshiro-$component"; fi - printf 'binary of %s for %s\n' "$component" "$target" > "$stage/$member" - printf 'noise' > "$stage/fleet-enroll"; printf 'readme' > "$stage/README.md" - if [[ "$target" == windows-* ]]; then - (cd "$stage" && python3 -c "import zipfile,sys; z=zipfile.ZipFile('out.zip','w'); [z.write(n) for n in sys.argv[1:]]" "$member" fleet-enroll README.md && mv out.zip "$PKG/moshiro-$component-v$V-$target.zip") - else - tar -czf "$PKG/moshiro-$component-v$V-$target.tar.gz" -C "$stage" "$member" fleet-enroll README.md - fi - [[ "$component" == agent ]] && expected+=("$(sha256sum "$stage/$member" | cut -d' ' -f1)") - done -done -want="$(printf '%s\n' "${expected[@]}" | sort -u | paste -sd,)" - -(cd "$T/srv" && python3 -u -m http.server 0 --bind 127.0.0.1 >"$T/server.log" 2>&1 & echo $! > "$T/pid") -SERVER="$(cat "$T/pid")" -for _ in $(seq 1 50); do port="$(grep -o 'port [0-9]*' "$T/server.log" | head -1 | cut -d' ' -f2 || true)"; [[ -n "$port" ]] && break; sleep 0.2; done -[[ -n "${port:-}" ]] || { echo "FAIL the fake package server did not start"; cat "$T/server.log"; exit 1; } - -got="$(GITEA_BASE="http://127.0.0.1:$port" bash "$ROOT/ci/release-binary-hashes.sh" agent "$V")" -if [[ "$got" == "$want" ]]; then echo "ok agent: all 8 platform binaries hashed ($(echo "$got" | tr ',' '\n' | wc -l) distinct)"; else echo "FAIL agent hashes differ"; echo " want $want"; echo " got $got"; fail=1; fi -[[ "$(echo "$got" | tr ',' '\n' | wc -l)" -eq 8 ]] && echo "ok eight different binaries, none dropped" || { echo "FAIL expected 8 hashes"; fail=1; } - -# 缺一个平台的压缩包:整个脚本失败,不能悄悄少签一个 -rm "$PKG/moshiro-probe-v$V-macos-arm64.tar.gz" -if GITEA_BASE="http://127.0.0.1:$port" bash "$ROOT/ci/release-binary-hashes.sh" probe "$V" >/dev/null 2>&1; then echo "FAIL a missing archive was ignored"; fail=1; else echo "ok a missing archive fails the script instead of signing fewer binaries"; fi -if bash "$ROOT/ci/release-binary-hashes.sh" other "$V" >/dev/null 2>&1; then echo "FAIL unknown component accepted"; fail=1; else echo "ok unknown component is refused"; fi -exit "$fail" diff --git a/docs/access.md b/docs/access.md index 8b0769b..f1d1587 100644 --- a/docs/access.md +++ b/docs/access.md @@ -10,7 +10,7 @@ ```text 两个根,各管一摊 - 根 A:CI(代码) 发布公钥写在安装脚本和节点二进制里 + 根 A:CI(代码) 发布公钥写在安装脚本、宿主机更新器的本机配置和 Backend 里 └─ CI 签名的发布清单 → 镜像 digest → 节点跑的二进制;安装脚本的 SHA-256 也记在清单里,并公布在发布页 假设:CI(Woodpecker、Gitea、镜像仓库、发布密钥)可信;CI 被攻破等于能给所有节点签恶意更新 根 B:你的 passkey(运维) @@ -32,9 +32,9 @@ **发布密钥可以换**:见 `docs/deployment.md` 的「换发布密钥」;回滚已经由清单的 `seq` 防住(更新器只收比已安装更大的)。 -**节点二进制绑定**(第 16 节):节点第一次握手成功的那一刻,Backend 把它正在运行的二进制的 SHA-256 绑定到这个节点;之后哈希对不上、又不在 CI 签名的更新清单里,就拒绝连接。 +**运行中的二进制不做校验**(第 16 节):节点自报的哈希不可信,来源证明在安装时(签名的清单和 `SHA256SUMS`)。 -**还没有的**:吊销表;任务和配置内容的操作员签名;节点向别人**证明**自己没有撒谎(二进制绑定抓的是文件被换、降级、起了不对的二进制,抓不到故意照抄哈希的恶意二进制,见第 16 节);extension 本身和属主的 `controllers`、接收者的实际加密(只是被固定在节点本地,没有行为)。 +**还没有的**:吊销表;任务和配置内容的操作员签名;节点向别人**证明**自己运行的就是官方二进制(需要容器之外的背书,见第 16 节);extension 本身和属主的 `controllers`、接收者的实际加密(只是被固定在节点本地,没有行为)。 ## 1. 名词 @@ -428,8 +428,6 @@ curl -fsSL https://panel/install -o /tmp/moshiro-install.sh && echo " / **② 镜像按 digest 拉取,二进制按 SHA-256 校验** - 脚本向面板取已签名的清单(和更新用的是同一份,发布密钥签名、`seq` 防回滚),用**脚本里内置的发布公钥**验签,再按清单里的 digest 拉镜像(`repo@sha256:…`);原生二进制则核对清单里的 SHA-256 再安装。Backend 改不了 digest,最多让节点装到一个旧的合法版本。 -- 发布公钥因此既在脚本里,也编进 Agent 和 Probe 二进制(一个常量,来自 `packaging/update/release-key.pub.pem`),节点之后的更新由节点自己验,不依赖宿主机更新器和 Backend 转发的内容。发布密钥轮换时,新公钥必须由旧密钥签名的清单带下来,否则节点不认。 -- 更新建议要求**双签**:CI 的发布签名,加操作员 passkey 对清单摘要的批准签名。只攻破 CI 或只攻破 Backend 都不够。 **③ 其余** - 角色(Agent 还是 Probe)、镜像仓库地址仍由代码决定,但镜像身份由签名清单决定。 @@ -486,25 +484,16 @@ curl -fsSL https://panel/install -o /tmp/moshiro-install.sh && echo " / 11. 节点预算的数字(常驻内存 ≤ 1 MB、二进制 ≤ 150 KB、每个成员验证 ≤ 100 毫秒、成员上限 256)是否合适;超出预算时优先砍什么(比如先去掉操作员集合的 64 个凭证上限换成更小的)。 12. webhook 和数据拉取的配额默认值(每分钟请求数、每天行数)和事件清单。 -## 16. 节点二进制绑定 +## 16. 运行中的二进制不做校验 -目的:被换掉的、版本被改的、降级的二进制,不能继续当这个节点运行。 +曾经做过「节点二进制绑定」:节点握手时自报正在运行的二进制的 SHA-256,Backend 在第一次握手时绑定,之后对不上就标红或拒绝。**已经移除。** -```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 自己验签,所以不依赖面板有没有收到过这次发布;被攻破的 Backend 也加不进「官方」哈希(没有 CI 私钥)。 -- **旧节点**(不上报哈希):没绑定过就放行(显示未绑定);绑定过的不许退回不上报。 -- **没有更新器的节点**(二进制安装)没有清单,首次握手按上报的哈希绑定(显示「首次绑定」);手工升级之后哈希变了,会被拒绝,管理员在「系统 → 版本」点「接受当前二进制」清除绑定,下一次连接重新绑定。 -- **策略** `MoShiro__BinaryPolicy`:`enforce`(默认)拒绝;`warn` 只记录不拒绝;`off` 不检查。 -- **边界**:哈希是节点自己报的。一个被换进来、故意撒谎的恶意二进制可以照抄原来的哈希,Backend 分辨不出——上报者就是被怀疑的对象。要堵这一点,必须让容器之外的东西背书:宿主机更新器用安装时绑定的密钥对「当前运行的镜像 digest + 挑战」签名,由 agent 转发;容器里的进程被换掉也伪造不了。这一步还没做。 +「这个资产是我们的」靠**安装时的来源证明**,那里的哈希来自另一个信道,伪造不了: +- 安装脚本(SHA-256 记在签名的清单里,并公布在发布页),按清单里的 digest 拉镜像(`repo@sha256:…`); +- 下载压缩包的人用签名的 `SHA256SUMS` 核对(验签用 `packaging/update/release-key.pub.pem`); +- 节点的更新由宿主机更新器用本机公钥验签,只认本机配置的镜像仓库。 + +要让运行中的二进制真的无法伪造,必须有容器之外的东西背书:宿主机更新器持有一把节点进程读不到的密钥,对「当前运行的镜像 digest + Backend 的挑战」签名,由节点转发;容器里的进程被换掉也伪造不了,除非攻破宿主机。这一步没有做(一键容器安装的节点也没有宿主机更新器)。 diff --git a/docs/api.md b/docs/api.md index 5b64664..49c167e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -150,7 +150,6 @@ | 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/docs/deployment.md b/docs/deployment.md index f8cfc6e..bff3c31 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -336,10 +336,8 @@ CI 给每个镜像的 digest 签名之后,除了(面板在线时)登记给 - **更新器**:配置里 `FEED=auto`(默认)时先看仓库里的清单,验签通过才用;取不到或验不过就用面板放进信箱的那份。`MANIFEST_REF` 可以指定清单镜像,`FEED=off` 关掉;通道取配置里的 `CHANNEL`,没有就按仓库路径推断(`moshiro-dev` → dev,`moshiro` → release)。仓库里的清单验签失败而信箱里又没有东西时,状态会报「无效清单」而不是悄悄当作没有更新。 - **面板自己取**:安装脚本向面板要某个组件的签名清单时,面板里没有(清过库、CI 登记那一步网络失败)或者已经放了十分钟以上,面板就按 OCI 分发协议**匿名**读一次仓库里的清单镜像(只读清单和它的配置两个小 JSON,不拉镜像层),用内嵌的 CI 发布公钥验过签才登记;验不过(仓库或路上被改)就当没有。所以清库之后不用手工补登记,也不会被一份伪造的清单骗到。仓库要匿名可读(`moshiro/**`、`moshiro-dev/**`)。 -- **没有宿主机更新器的节点**(一键容器安装、二进制安装)握手时带不来清单:面板用自己手里(登记时验过 CI 签名)的那份把节点的二进制标成「已签名」;不在清单里的只是不标「已签名」,不会因此被拒绝。 -- **发版**:打 `vMAJOR.MINOR.PATCH` tag 时,节点镜像(Agent、Probe 的多架构镜像)也和 Backend 一样签清单,放到 `moshiro/…-manifest:release`。清单里的 `binaries` 是**所有平台、所有架构**的发布压缩包里可执行文件的 SHA-256(Linux glibc 和 musl、macOS、Windows,x64 和 arm64,每个组件 8 个,`ci/release-binary-hashes.sh`),不只是容器镜像里的那两个:节点握手时上报正在运行的可执行文件的哈希,Backend 凭 CI 签名的清单认「官方二进制」,所以容器和直接下载压缩包安装的节点是一回事。 +- **发版**:打 `vMAJOR.MINOR.PATCH` tag 时,节点镜像(Agent、Probe 的多架构镜像)也和 Backend 一样签清单,放到 `moshiro/…-manifest:release`。 - **压缩包的校验和也签名**:Release 里除了每个压缩包的 `.sha256`,还有汇总的 `SHA256SUMS` 和 `SHA256SUMS.sig`(用发布私钥签,base64 的 DER ECDSA P-256,和清单同一把密钥)。验证:`base64 -d SHA256SUMS.sig > s.bin && openssl dgst -sha256 -verify packaging/update/release-key.pub.pem -signature s.bin SHA256SUMS`,通过之后再 `sha256sum -c SHA256SUMS`。macOS 的 Developer ID 签名和公证、Windows 的 Authenticode 需要苹果和微软的证书,目前没有做。 -- 测试:`bash ci/tests/binary-hashes-test.sh`(本地的假包仓库,8 种平台的 tar.gz 和 zip,缺任何一个压缩包都会失败,不会悄悄少签)。 - **首次部署**:`deploy-node.sh` 默认就走这条路,不需要面板,也不用手动指定镜像(Backend 第一次部署时本来就没有面板)。`--tag` 指定标签时不验签并有警告;`--image` 直接用你给的引用。 - **别人自己部署后端,什么都不用改**:仓库对匿名读开放(`moshiro/*` 和各 `*-manifest` 仓库),然后 `sudo sh deploy-node.sh backend --channel release --public-url https://<自己的域名>`,只是配置,不改代码、不需要自己的 CI;passkey 绑定在自己的域名上。自己 fork 并自建镜像的人要换自己的发布公钥和仓库地址(`--image-repo`)并重新构建,这是 fork 的正常代价。 - 测试:`bash packaging/update/tests/feed-test.sh`(假的 podman + 真的 openssl,覆盖篡改、别的密钥、旧 seq、换钥、仓库不可用回退、首次部署的验签)。 diff --git a/docs/enrollment.md b/docs/enrollment.md index 512c7d3..aa548f0 100644 --- a/docs/enrollment.md +++ b/docs/enrollment.md @@ -10,7 +10,7 @@ | Probe | Fleet 节点身份(连面板)+ 另一把独立的密钥(连 Agent,放在 `tls.pfx`) | `fleet-enroll`,和 Agent 一样用加入令牌(`role=probe`);可以有多个 Probe,每个独立吊销 | 两把公钥 | | 面板用户 | 账号 + 密码(PBKDF2-SHA256) | 面板 | 密码哈希 | | API 令牌 | 随机高熵令牌,带一组能力(见 [access.md](access.md)) | 面板 | SHA-256 | -| 发布签名 | ECDSA P-256 发布密钥 | CI(Woodpecker 密钥 `release_signing_key`)或离线环境 | 只在节点宿主机的更新器里配置公钥(容器节点是 `moshiro-update`,见 [deployment.md](deployment.md);纯二进制节点的更新是待办),**面板不保存私钥,也不验签** | +| 发布签名 | ECDSA P-256 发布密钥 | CI(Woodpecker 密钥 `release_signing_key`)或离线环境 | 只在节点宿主机的更新器里配置公钥(容器节点是 `moshiro-update`,见 [deployment.md](deployment.md);纯二进制节点的更新是待办),**面板不保存私钥**;登记清单时面板用公钥验签,节点宿主机的更新器之后再验一次 | ## 2. Agent 接入流程 diff --git a/packaging/update/moshiro-update.sh b/packaging/update/moshiro-update.sh index 3b68697..54883c2 100644 --- a/packaging/update/moshiro-update.sh +++ b/packaging/update/moshiro-update.sh @@ -122,8 +122,7 @@ fetch_offer() { cp "$OFFER_MANIFEST" "$WORK/feed.manifest"; cp "$OFFER_SIG" "$WORK/feed.sig" fi fetch_mailbox - # 信箱里没有东西,而仓库里的清单没通过(验证失败,或者就是已经装的这一份):留下仓库的那份,让后面照常报出原因, - # 而不是悄悄当作没有更新;「已经装的这一份」也要留下,因为要把它交给节点握手时带给 Backend + # 信箱里没有东西,而仓库里的清单没通过(验证失败,或者就是已经装的这一份):留下仓库的那份,让后面照常报出原因,而不是悄悄当作没有更新 if [ -n "$feed_error" ] && [ ! -s "$OFFER_MANIFEST" ]; then cp "$WORK/feed.manifest" "$OFFER_MANIFEST"; cp "$WORK/feed.sig" "$OFFER_SIG" fi @@ -165,29 +164,6 @@ 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 @@ -283,8 +259,6 @@ do_check() { case "$ERR" in "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 @@ -293,7 +267,6 @@ do_check() { adopt_next_key seed_installed if [ "$M_DIGEST" = "$(current_digest)" ]; then - write_installed write_status up_to_date null "" return 0 fi @@ -339,7 +312,6 @@ 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 808e41a..cb5d78f 100644 --- a/packaging/update/tests/feed-test.sh +++ b/packaging/update/tests/feed-test.sh @@ -111,22 +111,13 @@ 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 → 记成已安装(不当作有更新),并把验过的清单交给节点握手时带上 +# --- 第一次部署:正在跑的镜像就是清单里的这个 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."* +rm -f "$T/state/agent.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 2464533..1fce008 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, NodeBinaryDto, NodeOwnerDto, CapabilityCatalogDto, MeDto, PasskeyDto, NodeUpdateDto, ReleaseDto, CreatedApiTokenDto, AuditDto, EventDto, Page, ProbeDto, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types' +import type { AgentDto, ApiTokenDto, BuildInfoDto, NodeOwnerDto, 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' @@ -207,10 +207,6 @@ 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 } @@ -310,7 +290,6 @@ function Versions() { 构建时间 系统 更新 - 二进制 属主 @@ -333,9 +312,6 @@ 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 4102822..edb3947 100644 --- a/web/shared/api/schema.ts +++ b/web/shared/api/schema.ts @@ -389,7 +389,7 @@ export interface paths { /** The latest signed release manifest of each component */ get: operations["releases_list"]; put?: never; - /** Register a CI-signed release manifest; online nodes get it right away (their updaters verify the signature). Admin or release tokens */ + /** Register a CI-signed release manifest (the signature is checked against the release key); online nodes get it right away. Admin or release tokens */ post: operations["releases_publish"]; delete?: never; options?: never; @@ -1006,23 +1006,6 @@ 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; @@ -1715,17 +1698,6 @@ 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; @@ -1737,7 +1709,6 @@ export interface components { commit: null | string; built_at: null | string; update?: null | components["schemas"]["NodeUpdateDto"]; - binary?: null | components["schemas"]["NodeBinaryDto"]; owners?: null | components["schemas"]["NodeOwnerDto"][]; }; NodeInfoDto: { @@ -5736,46 +5707,6 @@ 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 618df03..1448d77 100644 --- a/web/shared/api/types.ts +++ b/web/shared/api/types.ts @@ -19,7 +19,6 @@ export type BuildInfoDto = Narrow export type BackendUpdateDto = Narrow export type NodeBuildDto = Narrow export type NodeUpdateDto = Narrow -export type NodeBinaryDto = Narrow export type NodeOwnerDto = Narrow export type ReleaseDto = Narrow export type LinkDto = Narrow