feat: record who created join codes and who added each node [CI]

This commit is contained in:
justme committed 2026-10-07 22:12:15 +08:00
1 parent 6385c1b9a3
commit 359313a57b
19 files changed
+95 -37

No files matched your search

@@ -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<string> Capabilities, string? HelloMetaJson);
@@ -80,7 +80,8 @@ public sealed partial class FleetEnrollmentService
request.NodeVersion,
nowMs,
token.Kind,
tlsPublicKey);
tlsPublicKey,
token.CreatedBy);
Result<NodeRecord> created = await _store.CreateNodeAsync(node, ct);
if (!created.TryGet(out NodeRecord? record, out FleetError? error))
+18
View File
@@ -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"
]
}
}
},
@@ -133,7 +133,7 @@ public static class AgentEndpoints
{
string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}";
Result<CreatedJoinToken> 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);
@@ -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);
@@ -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<string>? Labels, string? PublicHost, bool? PublicVisible, string? Ipv6Host = null, bool? PublicHostAuto = null, bool? Ipv6HostAuto = null);
/// <param name="Role"><c>agent</c> 或 <c>probe</c>:用这个令牌注册出来的是哪种节点。</param>
public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList<string> 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<string> 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<string>? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours, string? Role = null);
@@ -79,7 +80,7 @@ public sealed record ConnectedProbeDto(string ProbeId, string? Version, string?
/// <summary>某个 Probe 现在连着的一个 Agent。</summary>
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<ProbeConnectionDto> Connections, Ipv6DiagnosticDto? Ipv6 = null);
public sealed record ProbeDto(string Id, string Name, DateTimeOffset CreatedAt, DateTimeOffset? RevokedAt, bool Revoked, bool Online, IReadOnlyList<ProbeConnectionDto> Connections, Ipv6DiagnosticDto? Ipv6 = null, string? AddedBy = null);
/// <summary>一对(Probe,Agent)的线路开关:<c>linked</c> 是最终结果,<c>override</c> 是手动设置(<c>null</c> = 跟随自动连接),<c>connected</c> 是现在有没有连着。</summary>
public sealed record LinkDto(string ProbeId, string ProbeName, string AgentId, string AgentName, bool Linked, bool? Override, bool Connected);
@@ -3,7 +3,7 @@ using Fleet.Core;
namespace MoShiro.Backend.Application;
/// <param name="Role"><c>agent</c>(默认)或 <c>probe</c>:用这个令牌注册出来的是哪种节点。</param>
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours, string Role = JoinTokenService.AgentRole);
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours, string Role = JoinTokenService.AgentRole, string? CreatedBy = null);
/// <summary>各个系统上的一行安装命令:Linux 用 bash、macOS 用 zsh、Windows 用 PowerShell。命令里只有一次性代码,节点名字默认取主机名;Agent 和 Probe 是同一份脚本,角色由代码决定。</summary>
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}";
@@ -15,6 +15,9 @@ public sealed record Principal(PrincipalKind Kind, IReadOnlySet<string> Capabili
/// <summary>会话是怎么登录的(<see cref="AuthMethods"/>):只有 passkey 登录的会话以后能提权。API key 为空。</summary>
public string AuthMethod { get; init; } = "";
/// <summary>审计和归属里显示的名字:用户名,或者 <c>key:令牌名</c>。</summary>
public string Label => Kind == PrincipalKind.Token && UserId is null ? "key:" + (Username ?? TokenId) : Username ?? "anonymous";
public static Principal Anonymous { get; } = new(PrincipalKind.Anonymous, new HashSet<string>(), null, null, null, null);
public bool Has(string capability) => capability == Capability.Authenticated ? Kind != PrincipalKind.Anonymous : Capabilities.Contains(capability);
@@ -29,7 +29,8 @@ public sealed record AgentRecord(
bool PublicHostManual = false,
bool Ipv6HostManual = false,
IReadOnlyList<string>? ReportedAddresses = null,
byte[]? TlsPublicKey = null);
byte[]? TlsPublicKey = null,
string? AddedBy = null);
/// <summary>管理员可以修改的字段(merge-patch 语义:为 null 的字段不改)。</summary>
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);
/// <summary>
/// <see cref="PublicKey"/> 是连 Backend 的身份公钥,<see cref="PeerPublicKey"/> 是对等面(连 Agent)的公钥,两把是节点自己生成的独立密钥;
/// Agent 的 <c>peers</c> 里放的是后者。
/// </summary>
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);
@@ -54,7 +54,7 @@ public interface IGeoLookup
public interface IJoinTokenStore
{
ValueTask<JoinTokenInfo> CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList<string> labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, CancellationToken ct);
ValueTask<JoinTokenInfo> CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList<string> labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, string? createdBy, CancellationToken ct);
ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct);
@@ -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 = 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();
}
}
/// <summary>
@@ -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<NodeRecord>.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<NodeRecord>.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);
@@ -15,28 +15,28 @@ public sealed class SqliteJoinTokenStore : IJoinTokenStore
_db = db;
}
public ValueTask<JoinTokenInfo> CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList<string> labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, CancellationToken ct) =>
public ValueTask<JoinTokenInfo> CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList<string> 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<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct) =>
_db.ReadAsync<IReadOnlyList<JoinTokenInfo>>(
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<JoinTokenInfo?> 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<IReadOnlyList<ProbeRecord>> ListAsync(CancellationToken ct) =>
_db.ReadAsync<IReadOnlyList<ProbeRecord>>(
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<ProbeRecord?> 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) =>
@@ -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<string>(), StringComparison.Ordinal); // Admin 客户端是 API key
}
[Fact]
public async Task Script_IsNotServedForUnknownRevokedOrUsedUpCodes()
{
@@ -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));
+6
View File
@@ -264,6 +264,12 @@ export function AgentDetail() {
<dd>{a.public_visible ? '是' : '否'}</dd>
<dt>注册时间</dt>
<dd>{fmtTime(a.created_at)}</dd>
{a.added_by ? (
<>
<dt>添加者</dt>
<dd>{a.added_by}</dd>
</>
) : null}
<dt>最后在线</dt>
<dd>{fmtTime(a.last_seen_at)}</dd>
<dt>标签</dt>
+2
View File
@@ -195,6 +195,7 @@ export function Agents() {
<thead>
<tr>
<th>备注</th>
<th>创建者</th>
<th>使用</th>
<th>注册后</th>
<th>过期</th>
@@ -209,6 +210,7 @@ export function Agents() {
return (
<tr key={t.id}>
<td>{t.note || <span className="faint">—</span>}</td>
<td className="muted">{t.created_by ?? '—'}</td>
<td className="num">
{t.uses} / {t.max_uses}
</td>
+1
View File
@@ -256,6 +256,7 @@ export function Probes() {
</div>
<p className="faint" style={{ marginTop: 4 }}>
创建于 {fmtTime(p.created_at)}
{p.added_by ? `,由 ${p.added_by} 添加` : ''}
{p.revoked_at ? `,吊销于 ${fmtTime(p.revoked_at)}` : ''}
</p>
{p.connections.length > 0 ? (
+3
View File
@@ -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;