refactor: remove the runtime binary binding; every release registration path verifies the CI signature [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful

The hash a node reports about itself cannot be trusted (a swapped binary can
copy the old hash), so the runtime binding only caught files that were replaced
by something that did not lie. Provenance stays where it can be verified: the
signed release manifest (digest) and the signed SHA256SUMS.

- Remove BinaryBindingService, the node_bindings table (schema v15 drops it),
  BinaryPolicy, the handshake hash/manifest fields and the admin binary column.
- Drop the per-platform "binaries" list from the release manifest, the hash
  scripts and the updater's installed.manifest copy.
- ReleaseService.PublishAsync now verifies the release signature, so the admin
  POST /releases route is no longer an unsigned path; tests sign with a test key.
- Docs: section 16 explains why the runtime check is gone.
This commit is contained in:
justme committed 2026-10-08 18:30:05 +08:00
1 parent a4f3d1892a
commit 904ea2b0ae
49 files changed
+115 -1143

No files matched your search

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