diff --git a/backend/agent/src/MoShiro.Agent/AgentApp.cs b/backend/agent/src/MoShiro.Agent/AgentApp.cs index 83c674d..9433a5f 100644 --- a/backend/agent/src/MoShiro.Agent/AgentApp.cs +++ b/backend/agent/src/MoShiro.Agent/AgentApp.cs @@ -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()], diff --git a/backend/agent/src/MoShiro.Agent/AgentConfig.cs b/backend/agent/src/MoShiro.Agent/AgentConfig.cs index 9032de6..e27cabe 100644 --- a/backend/agent/src/MoShiro.Agent/AgentConfig.cs +++ b/backend/agent/src/MoShiro.Agent/AgentConfig.cs @@ -45,6 +45,10 @@ public sealed class FleetSection [JsonPropertyName("identity_dir")] public string IdentityDir { get; set; } = "/etc/moshiro"; + + /// 节点自己要求有属主(passkey):没有就不运行。见 OwnerGuard。 + [JsonPropertyName("require_owner")] + public bool RequireOwner { get; set; } = true; } public sealed class ListenSection diff --git a/backend/agent/src/MoShiro.Agent/Program.cs b/backend/agent/src/MoShiro.Agent/Program.cs index 493a37b..11fe862 100644 --- a/backend/agent/src/MoShiro.Agent/Program.cs +++ b/backend/agent/src/MoShiro.Agent/Program.cs @@ -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) { diff --git a/backend/fleet/src/Fleet.Hosting/OwnerGuard.cs b/backend/fleet/src/Fleet.Hosting/OwnerGuard.cs new file mode 100644 index 0000000..157622a --- /dev/null +++ b/backend/fleet/src/Fleet.Hosting/OwnerGuard.cs @@ -0,0 +1,48 @@ +using Fleet.Core; + +namespace Fleet.Hosting; + +/// +/// 节点自己要求有属主(passkey):Agent 和 Probe 共用这一份,启动时检查。 +/// +/// 属主是安装命令带来、注册时固定在 trust.json 里的 passkey 凭证 id(必须是操作员集合里的凭证)。节点据此知道自己绑定在哪个 passkey 上, +/// 才敢把更多的接口和操作交给它(extension 会话、加密内容都只认属主的 passkey);没有属主的节点没有人能授权它的敏感能力,所以不运行。 +/// 这是节点本地的强制,不依赖面板:面板被攻破也改不了节点已经固定的属主。 +/// +/// +public static class OwnerGuard +{ + /// 设为 1 放行没有属主的节点(只给没有信任根的老面板)。 + public const string AllowEnvironmentVariable = "MOSHIRO_ALLOW_NO_OWNER"; + + /// 没问题返回 null;否则是要告诉运维的原因。 + 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; + } + + /// 读身份目录里固定的信任根,检查属主;不行就记一条错误并返回 false(调用方应当退出)。 + 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; + } +} diff --git a/backend/fleet/tests/Fleet.Tests/OwnerGuardTests.cs b/backend/fleet/tests/Fleet.Tests/OwnerGuardTests.cs new file mode 100644 index 0000000..8cb373b --- /dev/null +++ b/backend/fleet/tests/Fleet.Tests/OwnerGuardTests.cs @@ -0,0 +1,70 @@ +using Fleet.Core; +using Fleet.Hosting; +using Fleet.Identity; + +namespace Fleet.Tests; + +/// 节点自己要求有属主(passkey):Agent 和 Probe 共用这一份,没有属主就不运行。 +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)); + } +} diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index 060bc21..185acd9 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -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", diff --git a/backend/probe/src/MoShiro.Probe/ProbeApp.cs b/backend/probe/src/MoShiro.Probe/ProbeApp.cs index 2bd1e75..c9d1309 100644 --- a/backend/probe/src/MoShiro.Probe/ProbeApp.cs +++ b/backend/probe/src/MoShiro.Probe/ProbeApp.cs @@ -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.StateType, diff --git a/backend/probe/src/MoShiro.Probe/ProbeConfig.cs b/backend/probe/src/MoShiro.Probe/ProbeConfig.cs index 4f4d6a2..262c33b 100644 --- a/backend/probe/src/MoShiro.Probe/ProbeConfig.cs +++ b/backend/probe/src/MoShiro.Probe/ProbeConfig.cs @@ -29,6 +29,10 @@ public sealed class ProbeFleetSection /// fleet-enroll 写出的身份目录:node.json、node.key(连面板的密钥)、tls.pfx(对等面的密钥)。 [JsonPropertyName("identity_dir")] public string IdentityDir { get; set; } = "/etc/moshiro/probe-id"; + + /// 节点自己要求有属主(passkey):没有就不运行。见 OwnerGuard(Agent 和 Probe 共用)。 + [JsonPropertyName("require_owner")] + public bool RequireOwner { get; set; } = true; } public sealed class KnownAgent diff --git a/backend/probe/src/MoShiro.Probe/Program.cs b/backend/probe/src/MoShiro.Probe/Program.cs index 6f375b3..85a0464 100644 --- a/backend/probe/src/MoShiro.Probe/Program.cs +++ b/backend/probe/src/MoShiro.Probe/Program.cs @@ -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) { diff --git a/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs b/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs index 43c7b34..c063c88 100644 --- a/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs +++ b/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs @@ -306,6 +306,10 @@ public sealed class ProbeHeartbeatExtra /// Probe 的 fleet.hello 附加字段:系统、架构和构建信息(只用于展示和更新)。 public sealed class ProbeHelloMeta { + /// 这个 Probe 的属主:安装时固定在节点本地的 passkey 凭证 id。面板据此知道它绑定在哪个账户的哪把 passkey 上。 + [JsonPropertyName("owners")] + public List? Owners { get; set; } + [JsonPropertyName("ipv6")] public Ipv6Diagnostic? Ipv6 { get; set; } @@ -316,6 +320,10 @@ public sealed class ProbeHelloMeta /// fleet.hello 的附加字段。 public sealed class AgentHelloMeta { + /// 这个 Agent 的属主:安装时固定在节点本地的 passkey 凭证 id。面板据此知道它绑定在哪个账户的哪把 passkey 上。 + [JsonPropertyName("owners")] + public List? Owners { get; set; } + [JsonPropertyName("ipv6")] public Ipv6Diagnostic? Ipv6 { get; set; } diff --git a/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs index 92b1785..499aae4 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs @@ -73,13 +73,14 @@ public static class BuildEndpoints return await PublishReleaseAsync(http, releases, request); } - private static async Task GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, BinaryBindingService bindings, CancellationToken ct) + private static async Task GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, BinaryBindingService bindings, NodeOwnerService owners, CancellationToken ct) { + IReadOnlyDictionary> ownerOf = await owners.ListAsync(ct); Dictionary bound = (await bindings.ListAsync(ct)).ToDictionary(b => b.NodeId, StringComparer.Ordinal); var items = (await nodes.ListAsync(ct)) .OrderBy(n => n.Kind, StringComparer.Ordinal) .ThenBy(n => n.NodeId, StringComparer.Ordinal) - .Select(n => new NodeBuildDto(n.NodeId, n.Kind, n.Os, n.OsName, n.Arch, n.Version, n.Tag, n.Commit, n.BuiltAt, ToDto(releases.StatusOf(new NodeId(n.NodeId))), bound.TryGetValue(n.NodeId, out NodeBinding? binding) ? ToDto(binding) : null)) + .Select(n => new NodeBuildDto(n.NodeId, n.Kind, n.Os, n.OsName, n.Arch, n.Version, n.Tag, n.Commit, n.BuiltAt, ToDto(releases.StatusOf(new NodeId(n.NodeId))), bound.TryGetValue(n.NodeId, out NodeBinding? binding) ? ToDto(binding) : null, ownerOf.TryGetValue(n.NodeId, out IReadOnlyList? list) ? [.. list.Select(o => new NodeOwnerDto(o.CredentialId, o.PasskeyName, o.Username))] : null)) .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); diff --git a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs index dc12c2c..e9279c9 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs @@ -181,7 +181,10 @@ public sealed record RecomputeDto(string State, IReadOnlyList Metrics, l /// 节点二进制的绑定:第一次握手成功时绑定的 SHA-256。Signed 表示它出现在一份 CI 签名的清单里(否则只是首次握手时记下的);Violation 是最近一次不符的原因,下一次握手符合了就清掉。 public sealed record NodeBinaryDto(string Sha256, bool Signed, long Seq, DateTimeOffset BoundAt, string? Violation, DateTimeOffset? ViolationAt); -public sealed record NodeBuildDto(string Id, string Kind, string? Os, string? OsName, string? Arch, string? Version, string? Tag, string? Commit, string? BuiltAt, NodeUpdateDto? Update = null, NodeBinaryDto? Binary = null); +/// 节点的一个属主:节点报上来的(安装时固定在节点本地的)passkey 凭证 id,以及它在面板上对应的 passkey 名称和账户(对不上任何已注册的 passkey 时为空)。 +public sealed record NodeOwnerDto(string CredentialId, string? Passkey, string? Account); + +public sealed record NodeBuildDto(string Id, string Kind, string? Os, string? OsName, string? Arch, string? Version, string? Tag, string? Commit, string? BuiltAt, NodeUpdateDto? Update = null, NodeBinaryDto? Binary = null, IReadOnlyList? Owners = null); /// 节点宿主机上的更新器报告的状态(up_to_date、available、applying、failed、rolled_back、invalid);没装更新器的节点没有这个字段。 public sealed record NodeUpdateDto(string State, string? CurrentDigest, string? AvailableTag, string? AvailableVersion, string? Error, DateTimeOffset? At); diff --git a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs index 334e91c..da9e61b 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs @@ -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))] diff --git a/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs b/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs index 925236d..9418a53 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs @@ -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); diff --git a/backend/src/MoShiro.Backend.Application/Agents/MoShiroFleetHooks.cs b/backend/src/MoShiro.Backend.Application/Agents/MoShiroFleetHooks.cs index fa507d3..769a26e 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/MoShiroFleetHooks.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/MoShiroFleetHooks.cs @@ -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); diff --git a/backend/src/MoShiro.Backend.Application/Agents/NodeOwnerService.cs b/backend/src/MoShiro.Backend.Application/Agents/NodeOwnerService.cs new file mode 100644 index 0000000..a49d10c --- /dev/null +++ b/backend/src/MoShiro.Backend.Application/Agents/NodeOwnerService.cs @@ -0,0 +1,68 @@ +using System.Collections.Concurrent; + +namespace MoShiro.Backend.Application; + +/// 节点的一个属主:它报上来的 passkey 凭证 id,以及面板上对应的 passkey 名称和账户(对不上任何已注册的 passkey 时为空)。 +public sealed record NodeOwner(string CredentialId, string? PasskeyName, string? Username); + +/// +/// 节点的属主:Agent 和 Probe 握手时报上来的、安装时固定在节点本地的 passkey 凭证 id。 +/// 属主的强制在节点本地(没有属主的节点不运行,见 OwnerGuard),面板这边只是顺着凭证 id 反查出它绑定在哪个账户的哪把 passkey 上—— +/// 以后给节点更多的接口和操作(extension 会话、加密内容)时,据此判断谁有资格用。上报的只是自述,真正的授权由节点自己对 passkey 签名的校验把关。 +/// 在内存里:节点每次连上来都会重新报。 +/// +public sealed class NodeOwnerService +{ + private const int MaxOwners = 16; + private const int MaxCredentialIdLength = 256; + + private readonly ConcurrentDictionary _reported = new(StringComparer.Ordinal); + private readonly IAuthStore _auth; + + public NodeOwnerService(IAuthStore auth) + { + _auth = auth; + } + + public void Report(string nodeId, IReadOnlyList? 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 _); + + /// 每个节点的属主(只含报过的节点)。 + public async ValueTask>> ListAsync(CancellationToken ct) + { + var result = new Dictionary>(StringComparer.Ordinal); + var users = new Dictionary(StringComparer.Ordinal); + foreach ((string nodeId, string[] ids) in _reported) + { + var owners = new List(); + 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; + } +} diff --git a/backend/src/MoShiro.Backend.Application/Agents/ProbeService.cs b/backend/src/MoShiro.Backend.Application/Agents/ProbeService.cs index cc9853a..0d945f6 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/ProbeService.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/ProbeService.cs @@ -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(); } diff --git a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs index d8f69ec..b31ac09 100644 --- a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs +++ b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs @@ -39,6 +39,7 @@ public static class ApplicationServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); diff --git a/backend/tests/MoShiro.Backend.Tests/TrustTests.cs b/backend/tests/MoShiro.Backend.Tests/TrustTests.cs index 4f7d1dc..6a5e845 100644 --- a/backend/tests/MoShiro.Backend.Tests/TrustTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/TrustTests.cs @@ -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() == 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()); + Assert.False(string.IsNullOrEmpty(owner["passkey"]?.GetValue())); + Assert.False(string.IsNullOrEmpty(owner["account"]?.GetValue())); + } + [Fact] public async Task TheProposal_ListsEveryNewPasskey_SoTheSignerSeesWhatTheyAreAuthorizing() { diff --git a/docs/access.md b/docs/access.md index 2ed7332..f170aba 100644 --- a/docs/access.md +++ b/docs/access.md @@ -284,11 +284,12 @@ ### 8.0 范围:这一节先固定协议和要求,extension 本身先不做 - **web ssh、文件、抓包等 extension 现在都不实现**,Agent 和 Probe 一样(Probe 是节点,同样要在本地执行下面的要求)。这里只把「谁来强制」和「怎么证明」定下来,以后实现时不改信任链,也不用重装节点。 -- **已经固定在节点本地的字段**:`controllers`(允许授权本节点 extension 会话的 passkey 凭证 id,必须是操作员集合里的凭证)由安装命令带来,节点注册时保存在 `trust.json`,Backend 之后改不了;从集合里去掉的凭证不再有效。**每个 Agent 和 Probe 都必须有属主,至少一把**:属主是节点敏感内容(extension 会话、加密数据)绑定的人——这些内容只有属主的 passkey 能授权、解密,没有属主的节点没有人能用它的敏感能力,也就不该存在。接收者名单(`recipients`)等做加密时再加。节点还没有 extension,所以现在只是被固定和保存,不起别的作用。 +- **已经固定在节点本地的字段**:`controllers`(允许授权本节点 extension 会话的 passkey 凭证 id,必须是操作员集合里的凭证)由安装命令带来,节点注册时保存在 `trust.json`,Backend 之后改不了;从集合里去掉的凭证不再有效。**每个 Agent 和 Probe 都必须有属主,至少一把——而且这是节点自己的要求,不是 Backend 的要求**:节点启动时检查本地固定的属主,没有(或者属主的 passkey 已经不在操作员集合里)就不运行(Agent 和 Probe 共用 `Fleet.Hosting` 里的 `OwnerGuard`;配置 `fleet.require_owner`,`MOSHIRO_ALLOW_NO_OWNER=1` 放行)。面板被攻破也改不了节点本地的属主。属主是节点敏感内容(extension 会话、加密数据)绑定的人——这些内容只有属主的 passkey 能授权、解密,没有属主的节点没有人能用它的敏感能力,也就不该存在。接收者名单(`recipients`)等做加密时再加。节点还没有 extension,所以现在只是被固定和保存,不起别的作用。 ### 8.1 添加节点时选属主 -- API:`POST /api/admin/join-tokens` 的 `controllers: [credential_id…]`:不给就是操作员集合里的全部凭证;给了就必须非空,且每个 id 都是操作员集合里的凭证。**还没有建立信任根、或者一把属主都没有,都不能创建加入码**(400,字段 `controllers`)。`MoShiro__RequireOwner=false` 可以关掉(只给没有信任根的老部署)。 +- API:`POST /api/admin/join-tokens` 的 `controllers: [credential_id…]`:不给就是操作员集合里的全部凭证;给了就必须非空,且每个 id 都是操作员集合里的凭证。还没有建立信任根、或者一把属主都没有时,面板**提前拒绝**创建加入码(400,字段 `controllers`)——这只是尽早失败,免得装出一个起不来的节点;真正的强制在节点(上面)。`MoShiro__RequireOwner=false` 可以关掉这个提前检查(只给没有信任根的老部署)。 +- **逆推出绑定关系**:节点握手时把本地固定的属主(passkey 凭证 id)报给面板(hello 的 `owners`),面板顺着凭证 id 反查出它绑定在**哪个账户的哪把 passkey** 上,显示在「系统 → 版本」的「属主」列(对不上任何已注册 passkey 的显示「未知凭证」)。上报只是自述,真正的授权由节点自己校验 passkey 签名;以后给节点更多的接口和操作(extension 会话、加密内容)时,据此决定谁有资格用,不会越权到别人的节点。 - 界面:「添加 Agent / Probe」对话框里一个「属主(passkey)」多选列表,**必选**(一把都不选时创建按钮不可用);只是选,不用触碰。选定的写进安装命令,节点装上时固定。 - 安装端也强制:`install.sh`、`install.ps1`、容器入口缺了 `MOSHIRO_GENESIS` 或 `MOSHIRO_CONTROLLERS` 就不装(只有没有信任根的老面板才用 `MOSHIRO_ALLOW_NO_OWNER=1`);`fleet-enroll --genesis` 不带 `--controllers` 也拒绝。 - 设计意图(还没做):属主的 passkey 是敏感内容的钥匙。节点和 Probe 这一侧要在宿主侧拿到属主 passkey 的证明,才可以解密、才可以开 extension 会话;Backend 在这里只是搬运,见第 8.2、8.4 节。 diff --git a/web/admin/src/pages/System.tsx b/web/admin/src/pages/System.tsx index e84f8fd..2464533 100644 --- a/web/admin/src/pages/System.tsx +++ b/web/admin/src/pages/System.tsx @@ -1,7 +1,7 @@ import { Download, KeyRound, Plus, Trash2 } from 'lucide-react' import { useEffect, useMemo, useState, type ReactNode } from 'react' import { api } from '@shared/api/client' -import type { AgentDto, ApiTokenDto, BuildInfoDto, NodeBinaryDto, CapabilityCatalogDto, MeDto, PasskeyDto, NodeUpdateDto, ReleaseDto, CreatedApiTokenDto, AuditDto, EventDto, Page, ProbeDto, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types' +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 { fmtBuiltAt, frontendBuild } from '@shared/buildInfo' import { fmtNum, fmtTime } from '@shared/format' import { translateSetting } from '@shared/i18n' @@ -192,6 +192,7 @@ function BuildRow({ name, kind, version, tag, commit, builtAt, platform, update {platform || dash} {update ?? dash} {dash} + {dash} ) } @@ -210,6 +211,23 @@ const UPDATE_STATE: Record未报告 + return ( + + {owners.map((o) => ( + + {o.passkey ? `${o.passkey}${o.account ? ` · ${o.account}` : ''}` : '未知凭证'} + + ))} + + ) +} + function BinaryCell({ binary, busy, onAccept }: { binary?: NodeBinaryDto | null; busy: boolean; onAccept: () => void }) { if (!binary) return 未绑定 const short = binary.sha256.slice(0, 10) @@ -293,6 +311,7 @@ function Versions() { 系统 更新 二进制 + 属主 @@ -317,6 +336,9 @@ function Versions() { void run(async () => { await api.del(`/api/admin/nodes/${n.id}/binary`); info.reload() }, '已清除绑定,节点下一次连接时重新绑定')} /> + + + ))} diff --git a/web/shared/api/schema.ts b/web/shared/api/schema.ts index 90c4d80..98e9fac 100644 --- a/web/shared/api/schema.ts +++ b/web/shared/api/schema.ts @@ -1720,6 +1720,7 @@ export interface components { built_at: null | string; update?: null | components["schemas"]["NodeUpdateDto"]; binary?: null | components["schemas"]["NodeBinaryDto"]; + owners?: null | components["schemas"]["NodeOwnerDto"][]; }; NodeInfoDto: { id: string; @@ -1733,6 +1734,11 @@ export interface components { version?: null | string; tag?: null | string; }; + NodeOwnerDto: { + credential_id: string; + passkey: null | string; + account: null | string; + }; NodeUpdateDto: { state: string; current_digest: null | string; diff --git a/web/shared/api/types.ts b/web/shared/api/types.ts index ed42e81..721db0f 100644 --- a/web/shared/api/types.ts +++ b/web/shared/api/types.ts @@ -20,6 +20,7 @@ export type BackendUpdateDto = Narrow export type NodeBuildDto = Narrow export type NodeUpdateDto = Narrow export type NodeBinaryDto = Narrow +export type NodeOwnerDto = Narrow export type ReleaseDto = Narrow export type LinkDto = Narrow export type LinkMatrixDto = Narrow