From 359313a57bdd96864e96493f3e2bbffc2d0d12fc Mon Sep 17 00:00:00 2001 From: JustMe Date: Wed, 7 Oct 2026 22:12:15 +0800 Subject: [PATCH] feat: record who created join codes and who added each node [CI] --- .../Fleet.Server.Abstractions/NodeRecords.cs | 6 ++++-- .../FleetEnrollmentService.cs | 3 ++- backend/openapi/v1.json | 18 +++++++++++++++++ .../Admin/AgentEndpoints.cs | 2 +- .../src/MoShiro.Backend.Api/Common/Mappers.cs | 6 +++--- .../Contracts/AdminDtos.cs | 7 ++++--- .../Admin/JoinTokenService.cs | 4 ++-- .../Auth/AuthModels.cs | 3 +++ .../Models/AgentModels.cs | 8 +++++--- .../Ports/StorePorts.cs | 2 +- .../MoShiro.Backend.Storage.Sqlite/Schema.cs | 13 +++++++++++- .../SqliteAgentStore.cs | 17 ++++++++-------- .../SqliteConfigStores.cs | 20 +++++++++---------- .../MoShiro.Backend.Tests/InstallTests.cs | 7 +++++++ .../MoShiro.Backend.Tests/StorageTests.cs | 4 ++-- web/admin/src/pages/AgentDetail.tsx | 6 ++++++ web/admin/src/pages/Agents.tsx | 2 ++ web/admin/src/pages/Probes.tsx | 1 + web/shared/api/schema.ts | 3 +++ 19 files changed, 95 insertions(+), 37 deletions(-) diff --git a/backend/fleet/src/Fleet.Server.Abstractions/NodeRecords.cs b/backend/fleet/src/Fleet.Server.Abstractions/NodeRecords.cs index e792a2f..d5db797 100644 --- a/backend/fleet/src/Fleet.Server.Abstractions/NodeRecords.cs +++ b/backend/fleet/src/Fleet.Server.Abstractions/NodeRecords.cs @@ -33,7 +33,8 @@ public sealed record NewNode( string? NodeVersion, long NowMs, string Kind = "", - byte[]? TlsPublicKey = null); + byte[]? TlsPublicKey = null, + string? AddedBy = null); public sealed record JoinTokenRecord( string Id, @@ -46,6 +47,7 @@ public sealed record JoinTokenRecord( long ExpiresAtMs, long? RevokedAtMs, long CreatedAtMs, - string Kind = ""); + string Kind = "", + string? CreatedBy = null); public sealed record Presence(bool Online, long AtMs, string? NodeVersion, IReadOnlyList Capabilities, string? HelloMetaJson); diff --git a/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentService.cs b/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentService.cs index 64f03a6..bf50fb0 100644 --- a/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentService.cs +++ b/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentService.cs @@ -80,7 +80,8 @@ public sealed partial class FleetEnrollmentService request.NodeVersion, nowMs, token.Kind, - tlsPublicKey); + tlsPublicKey, + token.CreatedBy); Result created = await _store.CreateNodeAsync(node, ct); if (!created.TryGet(out NodeRecord? record, out FleetError? error)) diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index 439e5b5..878072d 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -4812,6 +4812,12 @@ "ipv6_host_auto": { "type": "boolean", "default": true + }, + "added_by": { + "type": [ + "null", + "string" + ] } } }, @@ -6204,6 +6210,12 @@ "role": { "type": "string", "default": "agent" + }, + "created_by": { + "type": [ + "null", + "string" + ] } } }, @@ -7721,6 +7733,12 @@ "$ref": "#/components/schemas/Ipv6DiagnosticDto" } ] + }, + "added_by": { + "type": [ + "null", + "string" + ] } } }, diff --git a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs index 669d2ac..b0c22b0 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs @@ -133,7 +133,7 @@ public static class AgentEndpoints { string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}"; Result result = await tokens.CreateAsync( - new Application.CreateJoinTokenRequest(request.Note, request.Labels, request.AutoApprove ?? true, request.MaxUses ?? 1, request.ExpiresInHours ?? 24, request.Role ?? JoinTokenService.AgentRole), baseUrl, http.RequestAborted); + new Application.CreateJoinTokenRequest(request.Note, request.Labels, request.AutoApprove ?? true, request.MaxUses ?? 1, request.ExpiresInHours ?? 24, request.Role ?? JoinTokenService.AgentRole, http.GetPrincipal().Label), baseUrl, http.RequestAborted); if (!result.TryGet(out CreatedJoinToken? created, out FleetError? error)) { return Problems.From(http, error); diff --git a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs index 8826c67..131affe 100644 --- a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs +++ b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs @@ -41,7 +41,7 @@ public static class Mappers public static ProbeDto ToDto(ProbeView v) => new( v.Probe.Id, v.Probe.Name, Time(v.Probe.CreatedAtMs), Time(v.Probe.RevokedAtMs), v.Revoked, v.Online, - [.. v.Connections.Select(c => new ProbeConnectionDto(c.AgentId.Value, c.AgentName, c.AgentOnline, c.Version, c.Remote, Time(c.SinceMs), c.IpVersion))], ProbeIpv6Of(v.Probe)); + [.. v.Connections.Select(c => new ProbeConnectionDto(c.AgentId.Value, c.AgentName, c.AgentOnline, c.Version, c.Remote, Time(c.SinceMs), c.IpVersion))], ProbeIpv6Of(v.Probe), v.Probe.AddedBy); public static SeriesDto ToDto(SeriesResult r, bool live) => new( @@ -103,7 +103,7 @@ public static class Mappers : null; return new AgentDto( a.Id.Value, a.Name, StatusName(a.Status), view.Online, view.ProbeConnected, [.. view.Probes.Select(ToDto)], view.Host is null ? null : ToDto(view.Host), a.PublicHost, a.ProbeTcpPort, a.ProbeUdpPorts, a.Labels, a.PublicVisible, - a.Kinds, a.AgentVersion, limits, a.TaskVersion, a.AppliedTaskVersion, rejected, heartbeat, view.EndpointChanges, Time(a.CreatedAtMs), Time(a.LastSeenAtMs), a.Ipv6Host, a.Ipv6Supported, Ipv6Of(view), !a.PublicHostManual, !a.Ipv6HostManual); + a.Kinds, a.AgentVersion, limits, a.TaskVersion, a.AppliedTaskVersion, rejected, heartbeat, view.EndpointChanges, Time(a.CreatedAtMs), Time(a.LastSeenAtMs), a.Ipv6Host, a.Ipv6Supported, Ipv6Of(view), !a.PublicHostManual, !a.Ipv6HostManual, a.AddedBy); } public static Ipv6DiagnosticDto Ipv6Of(AgentView view) @@ -154,7 +154,7 @@ public static class Mappers new(t.Id.Value, t.Scope, t.AgentId?.Value, t.BaseTaskId?.Value, t.Kind, t.Target, t.Params, t.Schedule, t.Enabled, Time(t.UpdatedAtMs)); public static JoinTokenDto ToDto(JoinTokenInfo t) => - new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), JoinTokenService.RoleOf(t.Kind)); + new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), JoinTokenService.RoleOf(t.Kind), t.CreatedBy); public static PasskeyDto ToDto(PasskeyRecord p) => new(p.Id, p.Name, Time(p.CreatedAtMs), Time(p.LastUsedAtMs), p.Transports); diff --git a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs index c5f2980..a95568e 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs @@ -54,7 +54,8 @@ public sealed record AgentDto( bool Ipv6Supported = false, Ipv6DiagnosticDto? Ipv6 = null, bool PublicHostAuto = true, - bool Ipv6HostAuto = true); + bool Ipv6HostAuto = true, + string? AddedBy = null); public sealed record Ipv6DiagnosticDto(bool? AddressConfigured, bool SocketSupported, bool? Listening, bool? HasGlobalAddress, bool? HasDefaultRoute, string State, string NetworkScope = "unknown", bool? HostHasGlobalAddress = null, bool? HostHasDefaultRoute = null); @@ -64,7 +65,7 @@ public sealed record Ipv6DiagnosticDto(bool? AddressConfigured, bool SocketSuppo public sealed record AgentPatchRequest(string? Name, IReadOnlyList? Labels, string? PublicHost, bool? PublicVisible, string? Ipv6Host = null, bool? PublicHostAuto = null, bool? Ipv6HostAuto = null); /// agent 或 probe:用这个令牌注册出来的是哪种节点。 -public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt, string Role = "agent"); +public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt, string Role = "agent", string? CreatedBy = null); public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours, string? Role = null); @@ -79,7 +80,7 @@ public sealed record ConnectedProbeDto(string ProbeId, string? Version, string? /// 某个 Probe 现在连着的一个 Agent。 public sealed record ProbeConnectionDto(string AgentId, string AgentName, bool AgentOnline, string? Version, string? Remote, DateTimeOffset ConnectedSince, string IpVersion = "ipv4"); -public sealed record ProbeDto(string Id, string Name, DateTimeOffset CreatedAt, DateTimeOffset? RevokedAt, bool Revoked, bool Online, IReadOnlyList Connections, Ipv6DiagnosticDto? Ipv6 = null); +public sealed record ProbeDto(string Id, string Name, DateTimeOffset CreatedAt, DateTimeOffset? RevokedAt, bool Revoked, bool Online, IReadOnlyList Connections, Ipv6DiagnosticDto? Ipv6 = null, string? AddedBy = null); /// 一对(Probe,Agent)的线路开关:linked 是最终结果,override 是手动设置(null = 跟随自动连接),connected 是现在有没有连着。 public sealed record LinkDto(string ProbeId, string ProbeName, string AgentId, string AgentName, bool Linked, bool? Override, bool Connected); diff --git a/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs b/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs index bf60a76..f9c2f34 100644 --- a/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs +++ b/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs @@ -3,7 +3,7 @@ using Fleet.Core; namespace MoShiro.Backend.Application; /// agent(默认)或 probe:用这个令牌注册出来的是哪种节点。 -public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours, string Role = JoinTokenService.AgentRole); +public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours, string Role = JoinTokenService.AgentRole, string? CreatedBy = null); /// 各个系统上的一行安装命令:Linux 用 bash、macOS 用 zsh、Windows 用 PowerShell。命令里只有一次性代码,节点名字默认取主机名;Agent 和 Probe 是同一份脚本,角色由代码决定。 public sealed record InstallCommands(string Linux, string Macos, string Windows); @@ -72,7 +72,7 @@ public sealed class JoinTokenService long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); JoinTokenInfo info = await _store.CreateAsync( Ulid.NewPrefixed("jtk", _time), TokenHash.Sha256(plaintext), request.Note, request.Labels ?? [], request.AutoApprove, KindOf(request.Role), - request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, ct); + request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, request.CreatedBy, ct); string baseUrl = BaseUrl(requestBaseUrl); string query = $"{baseUrl}/install?code={plaintext}"; diff --git a/backend/src/MoShiro.Backend.Application/Auth/AuthModels.cs b/backend/src/MoShiro.Backend.Application/Auth/AuthModels.cs index 60d3f4c..c6a0620 100644 --- a/backend/src/MoShiro.Backend.Application/Auth/AuthModels.cs +++ b/backend/src/MoShiro.Backend.Application/Auth/AuthModels.cs @@ -15,6 +15,9 @@ public sealed record Principal(PrincipalKind Kind, IReadOnlySet Capabili /// 会话是怎么登录的():只有 passkey 登录的会话以后能提权。API key 为空。 public string AuthMethod { get; init; } = ""; + /// 审计和归属里显示的名字:用户名,或者 key:令牌名。 + public string Label => Kind == PrincipalKind.Token && UserId is null ? "key:" + (Username ?? TokenId) : Username ?? "anonymous"; + public static Principal Anonymous { get; } = new(PrincipalKind.Anonymous, new HashSet(), null, null, null, null); public bool Has(string capability) => capability == Capability.Authenticated ? Kind != PrincipalKind.Anonymous : Capabilities.Contains(capability); diff --git a/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs b/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs index 25a38af..33ef79e 100644 --- a/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs +++ b/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs @@ -29,7 +29,8 @@ public sealed record AgentRecord( bool PublicHostManual = false, bool Ipv6HostManual = false, IReadOnlyList? ReportedAddresses = null, - byte[]? TlsPublicKey = null); + byte[]? TlsPublicKey = null, + string? AddedBy = null); /// 管理员可以修改的字段(merge-patch 语义:为 null 的字段不改)。 public sealed class AgentPatch @@ -76,10 +77,11 @@ public sealed record JoinTokenInfo( long ExpiresAtMs, long? RevokedAtMs, long CreatedAtMs, - string Kind = ""); + string Kind = "", + string? CreatedBy = null); /// /// 是连 Backend 的身份公钥, 是对等面(连 Agent)的公钥,两把是节点自己生成的独立密钥; /// Agent 的 peers 里放的是后者。 /// -public sealed record ProbeRecord(string Id, string Name, byte[] PublicKey, long CreatedAtMs, long? RevokedAtMs, string? Ipv6DiagnosticsJson = null, byte[]? PeerPublicKey = null); +public sealed record ProbeRecord(string Id, string Name, byte[] PublicKey, long CreatedAtMs, long? RevokedAtMs, string? Ipv6DiagnosticsJson = null, byte[]? PeerPublicKey = null, string? AddedBy = null); diff --git a/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs b/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs index ce18a42..ba1c7ef 100644 --- a/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs +++ b/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs @@ -54,7 +54,7 @@ public interface IGeoLookup public interface IJoinTokenStore { - ValueTask CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, CancellationToken ct); + ValueTask CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, string? createdBy, CancellationToken ct); ValueTask> ListAsync(CancellationToken ct); diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs index b8c1d26..2745ce7 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs @@ -5,7 +5,7 @@ namespace MoShiro.Backend.Storage.Sqlite; /// 表结构。版本记录在 PRAGMA user_version;以后的迁移是按版本号追加的内置 SQL。 internal static class Schema { - public const int CurrentVersion = 12; + public const int CurrentVersion = 13; public static void Migrate(SqliteConnection connection) { @@ -183,6 +183,17 @@ internal static class Schema connection.Exec("PRAGMA user_version = 12"); transaction.Commit(); } + + if (version < 13) + { + // 归属:加入码是谁创建的,节点是用哪个加入码(也就是谁)加进来的;审计日志 + using SqliteTransaction transaction = connection.BeginTransaction(); + connection.Exec("ALTER TABLE join_tokens ADD COLUMN created_by TEXT"); + connection.Exec("ALTER TABLE agents ADD COLUMN added_by TEXT"); + connection.Exec("ALTER TABLE probes ADD COLUMN added_by TEXT"); + connection.Exec("PRAGMA user_version = 13"); + transaction.Commit(); + } } /// diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs index 2b4e11f..6f13a7f 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs @@ -14,7 +14,7 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore { private const string AgentColumns = "id, name, status, pubkey, tls_spki, public_host, probe_tcp_port, probe_udp_ports, labels, public_visible, kinds, agent_version, local_limits, " + - "task_version, applied_task_version, rejected, created_at, last_seen_at, row_version, ipv6_host, ipv6_supported, ipv6_diagnostics, public_host_manual, ipv6_host_manual, reported_addresses, tls_pubkey"; + "task_version, applied_task_version, rejected, created_at, last_seen_at, row_version, ipv6_host, ipv6_supported, ipv6_diagnostics, public_host_manual, ipv6_host_manual, reported_addresses, tls_pubkey, added_by"; private readonly SqliteDatabase _db; @@ -157,8 +157,8 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore byte[] hash = tokenSha256.ToArray(); return _db.ReadAsync( c => c.QueryOne( - "SELECT id, token_sha256, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind FROM join_tokens WHERE token_sha256 = @h", - r => new JoinTokenRecord(r.GetString(0), r.Bytes(1), r.NullableString(2), StorageJson.Strings(r.GetString(3)), r.GetInt64(4) != 0, r.GetInt32(5), r.GetInt32(6), r.GetInt64(7), r.NullableLong(8), r.GetInt64(9), r.GetString(10)), + "SELECT id, token_sha256, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by FROM join_tokens WHERE token_sha256 = @h", + r => new JoinTokenRecord(r.GetString(0), r.Bytes(1), r.NullableString(2), StorageJson.Strings(r.GetString(3)), r.GetInt64(4) != 0, r.GetInt32(5), r.GetInt32(6), r.GetInt64(7), r.NullableLong(8), r.GetInt64(9), r.GetString(10), r.NullableString(11)), ("@h", hash)), ct); } @@ -184,9 +184,9 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore } c.Exec( - "INSERT INTO agents (id, name, status, pubkey, tls_spki, labels, meta, agent_version, created_at) VALUES (@id, @name, @status, @pubkey, @spki, @labels, @meta, @version, @now)", + "INSERT INTO agents (id, name, status, pubkey, tls_spki, labels, meta, agent_version, created_at, added_by) VALUES (@id, @name, @status, @pubkey, @spki, @labels, @meta, @version, @now, @by)", ("@id", node.Id.Value), ("@name", node.Name), ("@status", StatusText(node.Status)), ("@pubkey", node.PublicKey), ("@spki", node.TlsSpki), - ("@labels", StorageJson.ToJson(node.Labels)), ("@meta", node.MetaJson), ("@version", node.NodeVersion), ("@now", node.NowMs)); + ("@labels", StorageJson.ToJson(node.Labels)), ("@meta", node.MetaJson), ("@version", node.NodeVersion), ("@now", node.NowMs), ("@by", node.AddedBy)); return Result.Ok(ToNode(GetInternal(c, node.Id)!, node.MetaJson)); }, ct); @@ -204,8 +204,8 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore } c.Exec( - "INSERT INTO probes (id, name, pubkey, peer_pubkey, created_at) VALUES (@id, @name, @pubkey, @peer, @now)", - ("@id", node.Id.Value), ("@name", node.Name), ("@pubkey", node.PublicKey), ("@peer", peerKey), ("@now", node.NowMs)); + "INSERT INTO probes (id, name, pubkey, peer_pubkey, created_at, added_by) VALUES (@id, @name, @pubkey, @peer, @now, @by)", + ("@id", node.Id.Value), ("@name", node.Name), ("@pubkey", node.PublicKey), ("@peer", peerKey), ("@now", node.NowMs), ("@by", node.AddedBy)); return Result.Ok(new NodeRecord(node.Id, node.Name, NodeStatus.Active, node.PublicKey, node.TlsSpki, node.Labels, node.MetaJson, node.NodeVersion, node.NowMs, null)); } @@ -267,7 +267,8 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore r.GetInt64(22) != 0, r.GetInt64(23) != 0, StorageJson.Strings(r.GetString(24)), - r.NullableBytes(25)); + r.NullableBytes(25), + r.NullableString(26)); private static NodeRecord ToNode(AgentRecord a, string? metaJson) => new(a.Id, a.Name, a.Status, a.PublicKey, a.TlsSpki, a.Labels, metaJson, a.AgentVersion, a.CreatedAtMs, a.LastSeenAtMs); diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs index 40cf6b3..1244c2d 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs @@ -15,28 +15,28 @@ public sealed class SqliteJoinTokenStore : IJoinTokenStore _db = db; } - public ValueTask CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, CancellationToken ct) => + public ValueTask CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, string? createdBy, CancellationToken ct) => _db.WriteAsync(c => { c.Exec( - "INSERT INTO join_tokens (id, token_sha256, note, labels, auto_approve, kind, max_uses, expires_at, created_at) VALUES (@id, @h, @note, @labels, @auto, @kind, @max, @exp, @now)", + "INSERT INTO join_tokens (id, token_sha256, note, labels, auto_approve, kind, max_uses, expires_at, created_at, created_by) VALUES (@id, @h, @note, @labels, @auto, @kind, @max, @exp, @now, @by)", ("@id", id), ("@h", tokenSha256), ("@note", note), ("@labels", StorageJson.ToJson(labels)), ("@auto", autoApprove ? 1 : 0), ("@kind", kind), - ("@max", maxUses), ("@exp", expiresAtMs), ("@now", nowMs)); - return new JoinTokenInfo(id, note, labels, autoApprove, maxUses, 0, expiresAtMs, null, nowMs, kind); + ("@max", maxUses), ("@exp", expiresAtMs), ("@now", nowMs), ("@by", createdBy)); + return new JoinTokenInfo(id, note, labels, autoApprove, maxUses, 0, expiresAtMs, null, nowMs, kind, createdBy); }, ct); public ValueTask> ListAsync(CancellationToken ct) => _db.ReadAsync>( c => c.Query( - "SELECT id, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind FROM join_tokens ORDER BY created_at DESC, id", - r => new JoinTokenInfo(r.GetString(0), r.NullableString(1), StorageJson.Strings(r.GetString(2)), r.GetInt64(3) != 0, r.GetInt32(4), r.GetInt32(5), r.GetInt64(6), r.NullableLong(7), r.GetInt64(8), r.GetString(9))), + "SELECT id, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by FROM join_tokens ORDER BY created_at DESC, id", + r => new JoinTokenInfo(r.GetString(0), r.NullableString(1), StorageJson.Strings(r.GetString(2)), r.GetInt64(3) != 0, r.GetInt32(4), r.GetInt32(5), r.GetInt64(6), r.NullableLong(7), r.GetInt64(8), r.GetString(9), r.NullableString(10))), ct); public ValueTask FindAsync(byte[] tokenSha256, CancellationToken ct) => _db.ReadAsync( c => c.QueryOne( - "SELECT id, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind FROM join_tokens WHERE token_sha256 = @h", - r => new JoinTokenInfo(r.GetString(0), r.NullableString(1), StorageJson.Strings(r.GetString(2)), r.GetInt64(3) != 0, r.GetInt32(4), r.GetInt32(5), r.GetInt64(6), r.NullableLong(7), r.GetInt64(8), r.GetString(9)), + "SELECT id, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by FROM join_tokens WHERE token_sha256 = @h", + r => new JoinTokenInfo(r.GetString(0), r.NullableString(1), StorageJson.Strings(r.GetString(2)), r.GetInt64(3) != 0, r.GetInt32(4), r.GetInt32(5), r.GetInt64(6), r.NullableLong(7), r.GetInt64(8), r.GetString(9), r.NullableString(10)), ("@h", tokenSha256)), ct); @@ -58,12 +58,12 @@ public sealed class SqliteProbeStore : IProbeStore public ValueTask> ListAsync(CancellationToken ct) => _db.ReadAsync>( - c => c.Query("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics, peer_pubkey FROM probes ORDER BY created_at, id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5), r.NullableBytes(6))), + c => c.Query("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics, peer_pubkey, added_by FROM probes ORDER BY created_at, id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5), r.NullableBytes(6), r.NullableString(7))), ct); public ValueTask GetAsync(string id, CancellationToken ct) => _db.ReadAsync( - c => c.QueryOne("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics, peer_pubkey FROM probes WHERE id = @id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5), r.NullableBytes(6)), ("@id", id)), + c => c.QueryOne("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics, peer_pubkey, added_by FROM probes WHERE id = @id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5), r.NullableBytes(6), r.NullableString(7)), ("@id", id)), ct); public ValueTask SetIpv6DiagnosticsAsync(string id, string diagnosticsJson, CancellationToken ct) => diff --git a/backend/tests/MoShiro.Backend.Tests/InstallTests.cs b/backend/tests/MoShiro.Backend.Tests/InstallTests.cs index e7c21c6..ddcb629 100644 --- a/backend/tests/MoShiro.Backend.Tests/InstallTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/InstallTests.cs @@ -56,6 +56,13 @@ public sealed class InstallTests Assert.DoesNotContain("@@", powershell, StringComparison.Ordinal); } + [Fact] + public async Task Codes_RecordWhoCreatedThem() + { + JsonNode created = await CreateAsync("agent"); + Assert.StartsWith("key:", created["token"]!["created_by"]!.GetValue(), StringComparison.Ordinal); // Admin 客户端是 API key + } + [Fact] public async Task Script_IsNotServedForUnknownRevokedOrUsedUpCodes() { diff --git a/backend/tests/MoShiro.Backend.Tests/StorageTests.cs b/backend/tests/MoShiro.Backend.Tests/StorageTests.cs index fa02e7f..61ac795 100644 --- a/backend/tests/MoShiro.Backend.Tests/StorageTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/StorageTests.cs @@ -302,7 +302,7 @@ public sealed class StorageTests : IDisposable var tokens = new SqliteJoinTokenStore(_db); var fleet = new SqliteAgentStore(_db); byte[] hash = TokenHash.Sha256("jt_race"); - JoinTokenInfo created = await tokens.CreateAsync("jtk_1", hash, "note", ["a"], true, "", maxUses: 3, expiresAtMs: 10_000, nowMs: 1_000, default); + JoinTokenInfo created = await tokens.CreateAsync("jtk_1", hash, "note", ["a"], true, "", maxUses: 3, expiresAtMs: 10_000, nowMs: 1_000, createdBy: "alice", default); Assert.Equal(0, created.Uses); bool[] results = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => Task.Run(async () => await fleet.TryConsumeJoinTokenAsync("jtk_1", 2_000, default)))); @@ -310,7 +310,7 @@ public sealed class StorageTests : IDisposable Assert.Equal(3, (await fleet.FindJoinTokenAsync(hash, default))!.Uses); Assert.False(await fleet.TryConsumeJoinTokenAsync("jtk_1", 2_000, default)); - JoinTokenInfo expiring = await tokens.CreateAsync("jtk_2", TokenHash.Sha256("jt_exp"), null, [], true, "", 5, expiresAtMs: 5_000, nowMs: 1_000, default); + JoinTokenInfo expiring = await tokens.CreateAsync("jtk_2", TokenHash.Sha256("jt_exp"), null, [], true, "", 5, expiresAtMs: 5_000, nowMs: 1_000, createdBy: null, default); Assert.False(await fleet.TryConsumeJoinTokenAsync(expiring.Id, 5_000, default), "expired at the boundary"); Assert.True(await tokens.RevokeAsync("jtk_2", 3_000, default)); Assert.False(await tokens.RevokeAsync("jtk_2", 3_000, default)); diff --git a/web/admin/src/pages/AgentDetail.tsx b/web/admin/src/pages/AgentDetail.tsx index 0ebdc4c..aa2d444 100644 --- a/web/admin/src/pages/AgentDetail.tsx +++ b/web/admin/src/pages/AgentDetail.tsx @@ -264,6 +264,12 @@ export function AgentDetail() {
{a.public_visible ? '是' : '否'}
注册时间
{fmtTime(a.created_at)}
+ {a.added_by ? ( + <> +
添加者
+
{a.added_by}
+ + ) : null}
最后在线
{fmtTime(a.last_seen_at)}
标签
diff --git a/web/admin/src/pages/Agents.tsx b/web/admin/src/pages/Agents.tsx index b8b7673..7246b45 100644 --- a/web/admin/src/pages/Agents.tsx +++ b/web/admin/src/pages/Agents.tsx @@ -195,6 +195,7 @@ export function Agents() { 备注 + 创建者 使用 注册后 过期 @@ -209,6 +210,7 @@ export function Agents() { return ( {t.note || —} + {t.created_by ?? '—'} {t.uses} / {t.max_uses} diff --git a/web/admin/src/pages/Probes.tsx b/web/admin/src/pages/Probes.tsx index eb31244..d089e0b 100644 --- a/web/admin/src/pages/Probes.tsx +++ b/web/admin/src/pages/Probes.tsx @@ -256,6 +256,7 @@ export function Probes() {

创建于 {fmtTime(p.created_at)} + {p.added_by ? `,由 ${p.added_by} 添加` : ''} {p.revoked_at ? `,吊销于 ${fmtTime(p.revoked_at)}` : ''}

{p.connections.length > 0 ? ( diff --git a/web/shared/api/schema.ts b/web/shared/api/schema.ts index 980e4df..dec44f8 100644 --- a/web/shared/api/schema.ts +++ b/web/shared/api/schema.ts @@ -1153,6 +1153,7 @@ export interface components { public_host_auto: boolean; /** @default true */ ipv6_host_auto: boolean; + added_by?: null | string; }; AgentIpv6Dto: { agent_id: string; @@ -1466,6 +1467,7 @@ export interface components { created_at: unknown; /** @default agent */ role: string; + created_by?: null | string; }; JsonElement: unknown; KindDto: { @@ -1792,6 +1794,7 @@ export interface components { online: boolean; connections: components["schemas"]["ProbeConnectionDto"][]; ipv6?: null | components["schemas"]["Ipv6DiagnosticDto"]; + added_by?: null | string; }; ProblemDto: { type: string;