feat: the node itself requires an owner passkey (shared by agent and probe) and reports it; the panel derives the account and passkey it is bound to [CI]

This commit is contained in:
justme committed 2026-10-08 06:15:07 +08:00
1 parent 8c37db1577
commit 2ae63653c4
23 files changed
+328 -10

No files matched your search

@@ -73,13 +73,14 @@ 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, CancellationToken ct)
private static async Task<IResult> GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, BinaryBindingService bindings, 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))
.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))
.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);
@@ -181,7 +181,10 @@ public sealed record RecomputeDto(string State, IReadOnlyList<string> Metrics, l
/// <summary>节点二进制的绑定:第一次握手成功时绑定的 SHA-256。<c>Signed</c> 表示它出现在一份 CI 签名的清单里(否则只是首次握手时记下的);<c>Violation</c> 是最近一次不符的原因,下一次握手符合了就清掉。</summary>
public sealed record NodeBinaryDto(string Sha256, bool Signed, long Seq, DateTimeOffset BoundAt, string? Violation, DateTimeOffset? ViolationAt);
public sealed record NodeBuildDto(string Id, string Kind, string? Os, string? OsName, string? Arch, string? Version, string? Tag, string? Commit, string? BuiltAt, NodeUpdateDto? Update = null, NodeBinaryDto? Binary = null);
/// <summary>节点的一个属主:节点报上来的(安装时固定在节点本地的)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);
/// <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);
@@ -48,6 +48,7 @@ namespace MoShiro.Backend.Api;
[JsonSerializable(typeof(AboutDto))]
[JsonSerializable(typeof(NodeBuildDto))]
[JsonSerializable(typeof(NodeBinaryDto))]
[JsonSerializable(typeof(NodeOwnerDto))]
[JsonSerializable(typeof(BuildInfoDto))]
[JsonSerializable(typeof(BackendUpdateDto))]
[JsonSerializable(typeof(ConfirmBackendUpdateRequest))]
@@ -27,10 +27,12 @@ public sealed partial class AgentService
private readonly TrustService _trust;
private readonly BinaryBindingService _bindings;
private readonly NodeOwnerService _owners;
public AgentService(BinaryBindingService bindings, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings, NodeInfoService nodes, LinkService links, TrustService trust)
public AgentService(BinaryBindingService bindings, 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;
_nodes = nodes;
@@ -151,6 +153,7 @@ public sealed partial class AgentService
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);
await AfterAgentSetChangedAsync(ct);
@@ -69,9 +69,11 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
private readonly ReleaseService _releases;
private readonly ScheduleService _schedule;
private readonly IProbeStore _probes;
private readonly NodeOwnerService _owners;
public MoShiroNodeEventSink(IAgentStore agents, AgentRuntime runtime, IEventStore events, LiveHub live, AgentService service, NodeInfoService nodes, ReleaseService releases, ScheduleService schedule, IProbeStore probes)
public MoShiroNodeEventSink(IAgentStore agents, AgentRuntime runtime, IEventStore events, LiveHub live, AgentService service, NodeInfoService nodes, ReleaseService releases, ScheduleService schedule, IProbeStore probes, NodeOwnerService owners)
{
_owners = owners;
_releases = releases;
_schedule = schedule;
_probes = probes;
@@ -148,6 +150,7 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
{
ProbeHelloMeta? meta = raw.Deserialize(MoShiroJsonContext.Default.ProbeHelloMeta);
await _nodes.SetPlatformAsync(id.Value, NodeKinds.Probe, meta?.Platform, ct);
_owners.Report(id.Value, meta?.Owners);
if (meta?.Ipv6 is { } ipv6)
{
await _probes.SetIpv6DiagnosticsAsync(id.Value, JsonSerializer.Serialize(ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic), ct);
@@ -229,6 +232,7 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
}
}
_owners.Report(id.Value, meta.Owners);
AgentRecord? before = await _agents.GetAsync(id, ct);
string? limitsJson = meta.LocalLimits is null ? null : JsonSerializer.Serialize(meta.LocalLimits, LocalLimitsJson.Default.LocalLimits);
string? ipv6Json = meta.Ipv6 is null ? null : JsonSerializer.Serialize(meta.Ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic);
@@ -0,0 +1,68 @@
using System.Collections.Concurrent;
namespace MoShiro.Backend.Application;
/// <summary>节点的一个属主:它报上来的 passkey 凭证 id,以及面板上对应的 passkey 名称和账户(对不上任何已注册的 passkey 时为空)。</summary>
public sealed record NodeOwner(string CredentialId, string? PasskeyName, string? Username);
/// <summary>
/// 节点的属主:Agent 和 Probe 握手时报上来的、安装时固定在节点本地的 passkey 凭证 id。
/// <b>属主的强制在节点本地</b>(没有属主的节点不运行,见 <c>OwnerGuard</c>),面板这边只是顺着凭证 id 反查出它绑定在哪个账户的哪把 passkey 上——
/// 以后给节点更多的接口和操作(extension 会话、加密内容)时,据此判断谁有资格用。上报的只是自述,真正的授权由节点自己对 passkey 签名的校验把关。
/// 在内存里:节点每次连上来都会重新报。
/// </summary>
public sealed class NodeOwnerService
{
private const int MaxOwners = 16;
private const int MaxCredentialIdLength = 256;
private readonly ConcurrentDictionary<string, string[]> _reported = new(StringComparer.Ordinal);
private readonly IAuthStore _auth;
public NodeOwnerService(IAuthStore auth)
{
_auth = auth;
}
public void Report(string nodeId, IReadOnlyList<string>? credentialIds)
{
string[] clean = credentialIds is null ? [] : [.. credentialIds.Where(id => id.Length is > 0 and <= MaxCredentialIdLength).Distinct(StringComparer.Ordinal).Take(MaxOwners)];
if (clean.Length == 0)
{
_reported.TryRemove(nodeId, out _);
}
else
{
_reported[nodeId] = clean;
}
}
public void Forget(string nodeId) => _reported.TryRemove(nodeId, out _);
/// <summary>每个节点的属主(只含报过的节点)。</summary>
public async ValueTask<IReadOnlyDictionary<string, IReadOnlyList<NodeOwner>>> ListAsync(CancellationToken ct)
{
var result = new Dictionary<string, IReadOnlyList<NodeOwner>>(StringComparer.Ordinal);
var users = new Dictionary<string, string?>(StringComparer.Ordinal);
foreach ((string nodeId, string[] ids) in _reported)
{
var owners = new List<NodeOwner>();
foreach (string id in ids)
{
PasskeyRecord? passkey = await _auth.FindPasskeyAsync(id, ct);
string? username = null;
if (passkey is not null && !users.TryGetValue(passkey.UserId, out username))
{
username = (await _auth.GetUserAsync(passkey.UserId, ct))?.Username;
users[passkey.UserId] = username;
}
owners.Add(new NodeOwner(id, passkey?.Name, username));
}
result[nodeId] = owners;
}
return result;
}
}
@@ -30,10 +30,12 @@ public sealed partial class ProbeService
private readonly LinkService _links;
private readonly BinaryBindingService _bindings;
private readonly NodeOwnerService _owners;
public ProbeService(BinaryBindingService bindings, IProbeStore probes, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TimeProvider time, NodeInfoService nodes, LinkService links)
public ProbeService(BinaryBindingService bindings, NodeOwnerService owners, IProbeStore probes, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TimeProvider time, NodeInfoService nodes, LinkService links)
{
_bindings = bindings;
_owners = owners;
_links = links;
_nodes = nodes;
_probes = probes;
@@ -98,6 +100,7 @@ public sealed partial class ProbeService
{
await _nodes.ForgetAsync(id, ct);
await _bindings.ForgetAsync(id, ct);
_owners.Forget(id);
await _links.ForgetNodeAsync(id, ct);
_gateway.NotifyAllTaskSetsChanged();
}
@@ -39,6 +39,7 @@ public static class ApplicationServiceCollectionExtensions
services.AddSingleton<AuditService>();
services.AddSingleton<ServerIdentityService>();
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>();