using Fleet.Core; using Fleet.Server; using Microsoft.Data.Sqlite; using MoShiro.Backend.Application; using MoShiro.Protocol; namespace MoShiro.Backend.Storage.Sqlite; /// /// Agent 表同时是 Fleet 的节点表:实现 (MoShiro 的视图)和 (注册与认证)。 /// 加入令牌的校验也在这里,和 MoShiro 的其他数据放在同一个 moshiro.db 里,由同一个单写入者写入。 /// public sealed class SqliteAgentStore : IAgentStore, IFleetStore { private const string AgentColumns = "id, name, status, pubkey, tls_spki, public_host, tcp_port, quic_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, added_by, listens"; private readonly SqliteDatabase _db; public SqliteAgentStore(SqliteDatabase db) { _db = db; } // ───────────────────────── IAgentStore ───────────────────────── public ValueTask> ListAsync(CancellationToken ct) => _db.ReadAsync>(c => c.Query($"SELECT {AgentColumns} FROM agents ORDER BY created_at, id", ReadAgent), ct); public ValueTask GetAsync(NodeId id, CancellationToken ct) => _db.ReadAsync(c => GetInternal(c, id), ct); public ValueTask ApplyEnrollmentAsync(NodeId id, AgentEnrollment enrollment, CancellationToken ct) => _db.ExecuteAsync(c => { c.Exec( "UPDATE agents SET public_host = CASE WHEN public_host = '' THEN @host ELSE public_host END, tcp_port = @tcp, quic_ports = @quic, listens = @listens, " + "kinds = CASE WHEN kinds = '[]' THEN @kinds ELSE kinds END, row_version = row_version + 1 WHERE id = @id", ("@id", id.Value), ("@host", enrollment.PublicHost), ("@tcp", enrollment.TcpPort), ("@listens", enrollment.TcpPort > 0 ? 1 : 0), ("@quic", StorageJson.ToJson(enrollment.QuicPorts)), ("@kinds", StorageJson.ToJson(enrollment.Kinds))); }, ct); public ValueTask> PatchAsync(NodeId id, AgentPatch patch, long? expectedRowVersion, CancellationToken ct) => _db.WriteAsync(c => { AgentRecord? current = GetInternal(c, id); if (current is null) { return Result.Fail(MoShiroErrors.AgentNotFound, "agent not found"); } if (expectedRowVersion is { } expected && expected != current.RowVersion) { return Result.Fail(MoShiroErrors.PreconditionFailed, "the agent was modified by someone else"); } if (patch.Name is { } name && name != current.Name && current.Status != NodeStatus.Pending && NameTaken(c, name, id)) { return Result.Fail(MoShiroErrors.NameConflict, "another agent already uses this name"); } // 手填就标记为 manual(自动识别不再覆盖);撤销手填回到自动,值留到下一次识别 bool hostAuto = patch.PublicHostAuto == true; bool v6Auto = patch.Ipv6HostAuto == true; c.Exec( "UPDATE agents SET name = @name, labels = @labels, public_host = @host, ipv6_host = @v6, public_host_manual = @hostManual, ipv6_host_manual = @v6Manual, " + "public_visible = @visible, row_version = row_version + 1 WHERE id = @id", ("@id", id.Value), ("@name", patch.Name ?? current.Name), ("@labels", StorageJson.ToJson(patch.Labels ?? current.Labels)), ("@host", hostAuto ? current.PublicHost : patch.PublicHost ?? current.PublicHost), ("@v6", v6Auto ? current.Ipv6Host : patch.Ipv6Host ?? current.Ipv6Host), ("@hostManual", hostAuto ? 0 : patch.PublicHost is not null || current.PublicHostManual ? 1 : 0), ("@v6Manual", v6Auto ? 0 : patch.Ipv6Host is not null || current.Ipv6HostManual ? 1 : 0), ("@visible", (patch.PublicVisible ?? current.PublicVisible) ? 1 : 0)); return Result.Ok(GetInternal(c, id)!); }, ct); public ValueTask> SetStatusAsync(NodeId id, NodeStatus status, CancellationToken ct) => _db.WriteAsync(c => { AgentRecord? current = GetInternal(c, id); if (current is null) { return Result.Fail(MoShiroErrors.AgentNotFound, "agent not found"); } if (status != NodeStatus.Pending && NameTaken(c, current.Name, id)) { return Result.Fail(MoShiroErrors.NameConflict, "another agent already uses this name; rename it or take over the existing node"); } c.Exec("UPDATE agents SET status = @status, row_version = row_version + 1 WHERE id = @id", ("@id", id.Value), ("@status", StatusText(status))); return Result.Ok(GetInternal(c, id)!); }, ct); public ValueTask> TakeoverAsync(NodeId pendingId, CancellationToken ct) => _db.WriteAsync(c => { AgentRecord? pending = GetInternal(c, pendingId); if (pending is null || pending.Status != NodeStatus.Pending) { return Result.Fail(MoShiroErrors.AgentNotFound, "no pending agent with this id"); } string? existingId = c.Scalar("SELECT id FROM agents WHERE name = @name AND status <> 'pending' AND id <> @id", ("@name", pending.Name), ("@id", pendingId.Value)); if (existingId is null) { return Result.Fail(MoShiroErrors.AgentNotFound, "there is no established agent with this name to take over"); } // 旧记录换成新公钥,历史数据保留;待审批记录删除 c.Exec("DELETE FROM agents WHERE id = @id", ("@id", pendingId.Value)); c.Exec( "UPDATE agents SET pubkey = @pubkey, tls_spki = @spki, tls_pubkey = @peer, status = 'active', task_hash = NULL, row_version = row_version + 1 WHERE id = @id", ("@id", existingId), ("@pubkey", pending.PublicKey), ("@spki", pending.TlsSpki), ("@peer", pending.TlsPublicKey)); return Result.Ok(GetInternal(c, new NodeId(existingId))!); }, ct); public ValueTask DeleteAsync(NodeId id, CancellationToken ct) => _db.ExecuteAsync(c => c.Exec("DELETE FROM agents WHERE id = @id", ("@id", id.Value)), ct); public ValueTask ApplyHelloAsync(NodeId id, AgentHello hello, CancellationToken ct) => _db.ExecuteAsync(c => { ListenInfo? listen = hello.Listen; c.Exec( "UPDATE agents SET kinds = @kinds, agent_version = @version, local_limits = COALESCE(@limits, local_limits), listens = @listens, " + "tcp_port = COALESCE(@tcp, tcp_port), quic_ports = COALESCE(@quic, quic_ports), ipv6_supported = @v6, ipv6_diagnostics = @diag, " + "reported_addresses = COALESCE(@addr, reported_addresses), row_version = row_version + 1 WHERE id = @id", ("@id", id.Value), ("@kinds", StorageJson.ToJson(hello.Kinds)), ("@version", hello.AgentVersion), ("@limits", hello.LocalLimitsJson), ("@listens", listen is null ? 0 : 1), ("@tcp", listen?.TcpPort), ("@quic", listen is null ? null : StorageJson.ToJson(listen.QuicPorts)), ("@v6", listen?.Ipv6Supported == true ? 1 : 0), ("@diag", hello.Ipv6DiagnosticsJson), ("@addr", listen?.Addresses is null ? null : StorageJson.ToJson(listen.Addresses))); }, ct); public ValueTask ApplyAutoAddressesAsync(NodeId id, string? publicHost, string? ipv6Host, CancellationToken ct) => _db.ExecuteAsync(c => { c.Exec( "UPDATE agents SET public_host = CASE WHEN public_host_manual = 0 AND @host IS NOT NULL THEN @host ELSE public_host END, " + "ipv6_host = CASE WHEN ipv6_host_manual = 0 AND @v6 IS NOT NULL THEN @v6 ELSE ipv6_host END, row_version = row_version + 1 " + "WHERE id = @id AND ((public_host_manual = 0 AND @host IS NOT NULL AND public_host <> @host) OR (ipv6_host_manual = 0 AND @v6 IS NOT NULL AND ipv6_host <> @v6))", ("@id", id.Value), ("@host", publicHost), ("@v6", ipv6Host)); }, ct); public ValueTask SetIpv6DiagnosticsAsync(NodeId id, string diagnosticsJson, CancellationToken ct) => _db.ExecuteAsync(c => c.Exec("UPDATE agents SET ipv6_diagnostics = @diag WHERE id = @id", ("@diag", diagnosticsJson), ("@id", id.Value)), ct); public ValueTask SetTaskVersionAsync(NodeId id, long version, string taskHash, CancellationToken ct) => _db.ExecuteAsync(c => c.Exec("UPDATE agents SET task_version = @v, task_hash = @h WHERE id = @id", ("@id", id.Value), ("@v", version), ("@h", taskHash)), ct); public ValueTask GetTaskHashAsync(NodeId id, CancellationToken ct) => _db.ReadAsync(c => c.Scalar("SELECT task_hash FROM agents WHERE id = @id", ("@id", id.Value)), ct); public ValueTask SetAppliedTaskStateAsync(NodeId id, long appliedVersion, string? rejectedJson, CancellationToken ct) => _db.ExecuteAsync(c => c.Exec("UPDATE agents SET applied_task_version = @v, rejected = @r WHERE id = @id", ("@id", id.Value), ("@v", appliedVersion), ("@r", rejectedJson)), ct); // ───────────────────────── IFleetStore ───────────────────────── public ValueTask FindJoinTokenAsync(ReadOnlyMemory tokenSha256, CancellationToken ct) { 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, 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); } public ValueTask TryConsumeJoinTokenAsync(string tokenId, long nowMs, CancellationToken ct) => _db.WriteAsync( c => c.Exec( "UPDATE join_tokens SET uses = uses + 1 WHERE id = @id AND revoked_at IS NULL AND expires_at > @now AND uses < max_uses", ("@id", tokenId), ("@now", nowMs)) == 1, ct); public ValueTask> CreateNodeAsync(NewNode node, CancellationToken ct) => _db.WriteAsync(c => { if (node.Status != NodeStatus.Pending && NameTaken(c, node.Name, node.Id)) { return Result.Fail(FleetErrors.NameConflict, "a node with this name already exists"); } c.Exec( "INSERT INTO agents (id, name, status, pubkey, tls_spki, tls_pubkey, labels, meta, agent_version, created_at, added_by) VALUES (@id, @name, @status, @pubkey, @spki, @peer, @labels, @meta, @version, @now, @by)", ("@id", node.Id.Value), ("@name", node.Name), ("@status", StatusText(node.Status)), ("@pubkey", node.PublicKey), ("@spki", node.TlsSpki), ("@peer", node.TlsPublicKey), ("@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); ValueTask IFleetStore.GetNodeAsync(NodeId id, CancellationToken ct) => _db.ReadAsync(c => GetInternal(c, id) is { } agent ? ToNode(agent, null) : null, ct); public ValueTask FindEstablishedNodeByNameAsync(string name, CancellationToken ct) => _db.ReadAsync( c => c.QueryOne($"SELECT {AgentColumns} FROM agents WHERE name = @name AND status <> 'pending'", ReadAgent, ("@name", name)) is { } agent ? ToNode(agent, null) : null, ct); public ValueTask UpdatePresenceAsync(NodeId id, Presence presence, CancellationToken ct) => _db.ExecuteAsync( c => c.Exec( "UPDATE agents SET last_seen_at = @at, agent_version = COALESCE(@version, agent_version) WHERE id = @id", ("@id", id.Value), ("@at", presence.AtMs), ("@version", presence.NodeVersion)), ct); // ───────────────────────── 内部 ───────────────────────── private static AgentRecord? GetInternal(SqliteConnection c, NodeId id) => c.QueryOne($"SELECT {AgentColumns} FROM agents WHERE id = @id", ReadAgent, ("@id", id.Value)); private static bool NameTaken(SqliteConnection c, string name, NodeId except) => c.Scalar("SELECT COUNT(*) FROM agents WHERE name = @name AND status <> 'pending' AND id <> @id", ("@name", name), ("@id", except.Value)) > 0; private static AgentRecord ReadAgent(SqliteDataReader r) => new( new NodeId(r.GetString(0)), r.GetString(1), ParseStatus(r.GetString(2)), r.Bytes(3), r.Bytes(4), r.GetString(5), r.GetInt32(6), StorageJson.Ints(r.GetString(7)), StorageJson.Strings(r.GetString(8)), r.GetInt64(9) != 0, StorageJson.Strings(r.GetString(10)), r.NullableString(11), r.NullableString(12), r.GetInt64(13), r.GetInt64(14), r.NullableString(15), r.GetInt64(16), r.NullableLong(17), r.GetInt64(18), r.GetString(19), r.GetInt64(20) != 0, r.NullableString(21), r.GetInt64(22) != 0, r.GetInt64(23) != 0, StorageJson.Strings(r.GetString(24)), r.NullableBytes(25), r.NullableString(26), r.GetInt64(27) != 0); 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); private static string StatusText(NodeStatus status) => status switch { NodeStatus.Active => "active", NodeStatus.Disabled => "disabled", _ => "pending", }; private static NodeStatus ParseStatus(string text) => text switch { "active" => NodeStatus.Active, "disabled" => NodeStatus.Disabled, _ => NodeStatus.Pending, }; }