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:
1 parent
8c37db1577
commit
2ae63653c4
23 files changed
+328
-10
No files matched your search
@@ -304,6 +304,7 @@ public sealed class AgentApp : IDisposable
|
||||
{
|
||||
var meta = new AgentHelloMeta
|
||||
{
|
||||
Owners = [.. _trust.Controllers],
|
||||
Ipv6 = Ipv6Diagnostics.Sample(_ipv6Supported, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")),
|
||||
Ipv6Supported = _ipv6Supported,
|
||||
Addresses = [.. LocalAddresses.Sample()],
|
||||
|
||||
@@ -45,6 +45,10 @@ public sealed class FleetSection
|
||||
|
||||
[JsonPropertyName("identity_dir")]
|
||||
public string IdentityDir { get; set; } = "/etc/moshiro";
|
||||
|
||||
/// <summary>节点自己要求有属主(passkey):没有就不运行。见 <c>OwnerGuard</c>。</summary>
|
||||
[JsonPropertyName("require_owner")]
|
||||
public bool RequireOwner { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed class ListenSection
|
||||
|
||||
@@ -28,6 +28,11 @@ try
|
||||
config = NodeConfig.Load(configPath, AgentConfigContext.Default.AgentConfig);
|
||||
restart = new RestartController(RestartSchedule.Resolve(config.RestartAt));
|
||||
identity = LoadedNodeIdentity.Load(config.Fleet.IdentityDir);
|
||||
if (!OwnerGuard.Check(config.Fleet.IdentityDir, config.Fleet.RequireOwner, log))
|
||||
{
|
||||
identity.Dispose();
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or System.Text.Json.JsonException or System.Security.Cryptography.CryptographicException)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using Fleet.Core;
|
||||
|
||||
namespace Fleet.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// 节点自己要求有属主(passkey):Agent 和 Probe 共用这一份,启动时检查。
|
||||
/// <para>
|
||||
/// 属主是安装命令带来、注册时固定在 <c>trust.json</c> 里的 passkey 凭证 id(必须是操作员集合里的凭证)。节点据此知道自己绑定在哪个 passkey 上,
|
||||
/// 才敢把更多的接口和操作交给它(extension 会话、加密内容都只认属主的 passkey);没有属主的节点没有人能授权它的敏感能力,所以不运行。
|
||||
/// 这是节点本地的强制,不依赖面板:面板被攻破也改不了节点已经固定的属主。
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class OwnerGuard
|
||||
{
|
||||
/// <summary>设为 <c>1</c> 放行没有属主的节点(只给没有信任根的老面板)。</summary>
|
||||
public const string AllowEnvironmentVariable = "MOSHIRO_ALLOW_NO_OWNER";
|
||||
|
||||
/// <summary>没问题返回 null;否则是要告诉运维的原因。</summary>
|
||||
public static string? Problem(TrustAnchor trust, bool require)
|
||||
{
|
||||
if (!require || Environment.GetEnvironmentVariable(AllowEnvironmentVariable) == "1")
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!trust.Pinned)
|
||||
{
|
||||
return "this node has no owner: it was not enrolled with a trust root and owner passkeys; enroll it again with the command from the panel (or set " + AllowEnvironmentVariable + "=1 for a panel without a trust root)";
|
||||
}
|
||||
|
||||
return trust.Controllers.Count == 0
|
||||
? "none of this node's owner passkeys is in the operator set any more; enroll it again with a command from the panel"
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>读身份目录里固定的信任根,检查属主;不行就记一条错误并返回 false(调用方应当退出)。</summary>
|
||||
public static bool Check(string identityDirectory, bool require, INodeLog log)
|
||||
{
|
||||
TrustAnchor trust = TrustAnchor.Load(identityDirectory, log);
|
||||
if (Problem(trust, require) is { } problem)
|
||||
{
|
||||
log.Error("startup failed", ("err", problem));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using Fleet.Core;
|
||||
using Fleet.Hosting;
|
||||
using Fleet.Identity;
|
||||
|
||||
namespace Fleet.Tests;
|
||||
|
||||
/// <summary>节点自己要求有属主(passkey):Agent 和 Probe 共用这一份,没有属主就不运行。</summary>
|
||||
public sealed class OwnerGuardTests : IDisposable
|
||||
{
|
||||
private readonly TestAuthenticator _alice = new();
|
||||
private readonly string _directory = Path.Combine(Path.GetTempPath(), "moshiro-owner-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public OwnerGuardTests()
|
||||
{
|
||||
Directory.CreateDirectory(_directory);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_alice.Dispose();
|
||||
Directory.Delete(_directory, recursive: true);
|
||||
}
|
||||
|
||||
private void Pin(params string[] controllers)
|
||||
{
|
||||
var set = new OperatorSet(1, OperatorTrust.Genesis, TestAuthenticator.RpId, [TestAuthenticator.Origin], [_alice.Key]);
|
||||
SignedStatement genesis = _alice.Sign(OperatorTrust.Encode(set));
|
||||
TrustAnchor.Pin(_directory, OperatorTrust.Fingerprint(genesis.Statement), [genesis.ToDto()], controllers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ANodeEnrolledWithoutATrustRoot_HasNoOwner_AndDoesNotRun()
|
||||
{
|
||||
TrustAnchor none = TrustAnchor.Load(_directory, NullNodeLog.Instance);
|
||||
|
||||
Assert.NotNull(OwnerGuard.Problem(none, require: true));
|
||||
Assert.False(OwnerGuard.Check(_directory, require: true, NullNodeLog.Instance));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ANodePinnedToAnOwnerPasskey_Runs()
|
||||
{
|
||||
Pin(_alice.CredentialId);
|
||||
|
||||
TrustAnchor pinned = TrustAnchor.Load(_directory, NullNodeLog.Instance);
|
||||
|
||||
Assert.Null(OwnerGuard.Problem(pinned, require: true));
|
||||
Assert.True(OwnerGuard.Check(_directory, require: true, NullNodeLog.Instance));
|
||||
Assert.Equal([_alice.CredentialId], pinned.Controllers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APinnedNodeWhoseOwnerIsNoLongerInTheOperatorSet_DoesNotRun()
|
||||
{
|
||||
Pin("some-passkey-that-is-not-in-the-set");
|
||||
|
||||
TrustAnchor pinned = TrustAnchor.Load(_directory, NullNodeLog.Instance);
|
||||
|
||||
Assert.Empty(pinned.Controllers);
|
||||
Assert.NotNull(OwnerGuard.Problem(pinned, require: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheRequirementCanBeTurnedOff_ForAPanelWithoutATrustRoot()
|
||||
{
|
||||
TrustAnchor none = TrustAnchor.Load(_directory, NullNodeLog.Instance);
|
||||
|
||||
Assert.Null(OwnerGuard.Problem(none, require: false));
|
||||
}
|
||||
}
|
||||
@@ -7169,6 +7169,15 @@
|
||||
"$ref": "#/components/schemas/NodeBinaryDto"
|
||||
}
|
||||
]
|
||||
},
|
||||
"owners": {
|
||||
"type": [
|
||||
"null",
|
||||
"array"
|
||||
],
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NodeOwnerDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -7241,6 +7250,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"NodeOwnerDto": {
|
||||
"required": [
|
||||
"credential_id",
|
||||
"passkey",
|
||||
"account"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"credential_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"passkey": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"account": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"NodeUpdateDto": {
|
||||
"required": [
|
||||
"state",
|
||||
|
||||
@@ -44,7 +44,7 @@ public sealed class ProbeApp : IDisposable
|
||||
NodeVersion = Version,
|
||||
Capabilities = [MoShiroCapabilities.Base],
|
||||
HeartbeatExtra = () => JsonSerializer.SerializeToElement(new ProbeHeartbeatExtra { Update = update.ReadStatus(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHeartbeatExtra),
|
||||
HelloMeta = () => JsonSerializer.SerializeToElement(new ProbeHelloMeta { Platform = PlatformDetector.Detect(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHelloMeta),
|
||||
HelloMeta = () => JsonSerializer.SerializeToElement(new ProbeHelloMeta { Owners = [.. trust.Controllers], Platform = PlatformDetector.Detect(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHelloMeta),
|
||||
};
|
||||
var list = new StateHandler<ProbeAgentList>(
|
||||
ProbeAgentList.StateType,
|
||||
|
||||
@@ -29,6 +29,10 @@ public sealed class ProbeFleetSection
|
||||
/// <summary><c>fleet-enroll</c> 写出的身份目录:<c>node.json</c>、<c>node.key</c>(连面板的密钥)、<c>tls.pfx</c>(对等面的密钥)。</summary>
|
||||
[JsonPropertyName("identity_dir")]
|
||||
public string IdentityDir { get; set; } = "/etc/moshiro/probe-id";
|
||||
|
||||
/// <summary>节点自己要求有属主(passkey):没有就不运行。见 <c>OwnerGuard</c>(Agent 和 Probe 共用)。</summary>
|
||||
[JsonPropertyName("require_owner")]
|
||||
public bool RequireOwner { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed class KnownAgent
|
||||
|
||||
@@ -38,6 +38,12 @@ try
|
||||
identity.Dispose();
|
||||
throw new InvalidOperationException($"the identity in {file.Fleet.IdentityDir} is not a probe's: enroll with a probe join token");
|
||||
}
|
||||
|
||||
if (!OwnerGuard.Check(file.Fleet.IdentityDir, file.Fleet.RequireOwner, log))
|
||||
{
|
||||
identity.Dispose();
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or System.Text.Json.JsonException or System.Security.Cryptography.CryptographicException)
|
||||
{
|
||||
|
||||
@@ -306,6 +306,10 @@ public sealed class ProbeHeartbeatExtra
|
||||
/// <summary>Probe 的 <c>fleet.hello</c> 附加字段:系统、架构和构建信息(只用于展示和更新)。</summary>
|
||||
public sealed class ProbeHelloMeta
|
||||
{
|
||||
/// <summary>这个 Probe 的属主:安装时固定在节点本地的 passkey 凭证 id。面板据此知道它绑定在哪个账户的哪把 passkey 上。</summary>
|
||||
[JsonPropertyName("owners")]
|
||||
public List<string>? Owners { get; set; }
|
||||
|
||||
[JsonPropertyName("ipv6")]
|
||||
public Ipv6Diagnostic? Ipv6 { get; set; }
|
||||
|
||||
@@ -316,6 +320,10 @@ public sealed class ProbeHelloMeta
|
||||
/// <summary><c>fleet.hello</c> 的附加字段。</summary>
|
||||
public sealed class AgentHelloMeta
|
||||
{
|
||||
/// <summary>这个 Agent 的属主:安装时固定在节点本地的 passkey 凭证 id。面板据此知道它绑定在哪个账户的哪把 passkey 上。</summary>
|
||||
[JsonPropertyName("owners")]
|
||||
public List<string>? Owners { get; set; }
|
||||
|
||||
[JsonPropertyName("ipv6")]
|
||||
public Ipv6Diagnostic? Ipv6 { get; set; }
|
||||
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -250,6 +250,30 @@ public sealed class TrustTests
|
||||
Assert.Equal(HttpStatusCode.Created, code.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task APinnedAgent_ReportsItsOwner_AndThePanelFindsTheAccountAndPasskey()
|
||||
{
|
||||
// 宿主(属主)要求在节点这一侧:节点把安装时固定的属主报上来,面板顺着凭证 id 反查出它绑定在哪个账户的哪把 passkey 上
|
||||
Operator op = await OperatorAsync();
|
||||
(string code, _, _) = await CreateCodeAsync(role: "agent", controllers: [op.Passkey.Id]);
|
||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend, pin: (code, op.Genesis, [op.Passkey.Id]));
|
||||
|
||||
JsonNode? owner = null;
|
||||
await BackendFixture.EventuallyAsync(
|
||||
async () =>
|
||||
{
|
||||
JsonNode info = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/build-info");
|
||||
owner = info["nodes"]!.AsArray().FirstOrDefault(n => n!["id"]!.GetValue<string>() == agent.Id)?["owners"]?.AsArray().FirstOrDefault();
|
||||
return owner is not null;
|
||||
},
|
||||
TimeSpan.FromSeconds(30),
|
||||
"the agent reports its owner in hello");
|
||||
|
||||
Assert.Equal(op.Passkey.Id, owner!["credential_id"]!.GetValue<string>());
|
||||
Assert.False(string.IsNullOrEmpty(owner["passkey"]?.GetValue<string>()));
|
||||
Assert.False(string.IsNullOrEmpty(owner["account"]?.GetValue<string>()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheProposal_ListsEveryNewPasskey_SoTheSignerSeesWhatTheyAreAuthorizing()
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user