feat: bind each node to the binary it runs at its first handshake; refuse changes that are not in a CI-signed manifest [CI]
ci/woodpecker/push/main-amd64-build Pipeline failed
ci/woodpecker/push/main-amd64-build Pipeline failed
This commit is contained in:
1 parent
117d34a2af
commit
4841f7b15b
40 files changed
+1011
-21
No files matched your search
@@ -25,6 +25,9 @@ public static class BuildEndpoints
|
||||
group.MapPost("/nodes/{id}/update", RequestUpdateAsync)
|
||||
.Api("nodes_update", "Ask an online agent or probe to install the release it has been offered", Tag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 409)
|
||||
.Requires(Capability.UpdateConfirm);
|
||||
group.MapDelete("/nodes/{id}/binary", ClearBinaryAsync)
|
||||
.Api("nodes_binary_clear", "Accept the node's current binary: clear its binding so the next handshake binds it again", Tag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403)
|
||||
.Requires(Capability.FleetWrite);
|
||||
group.MapGet("/backend/update", BackendUpdateAsync)
|
||||
.Api("backend_update_get", "Current channel and host-verified backend update", Tag).Returns<BackendUpdateDto>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.UpdateRead);
|
||||
@@ -70,12 +73,13 @@ public static class BuildEndpoints
|
||||
return await PublishReleaseAsync(http, releases, request);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, CancellationToken ct)
|
||||
private static async Task<IResult> GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, BinaryBindingService bindings, CancellationToken ct)
|
||||
{
|
||||
Dictionary<string, NodeBinding> bound = (await bindings.ListAsync(ct)).ToDictionary(b => b.NodeId, StringComparer.Ordinal);
|
||||
var items = (await nodes.ListAsync(ct))
|
||||
.OrderBy(n => n.Kind, StringComparer.Ordinal)
|
||||
.ThenBy(n => n.NodeId, StringComparer.Ordinal)
|
||||
.Select(n => new NodeBuildDto(n.NodeId, n.Kind, n.Os, n.OsName, n.Arch, n.Version, n.Tag, n.Commit, n.BuiltAt, ToDto(releases.StatusOf(new NodeId(n.NodeId)))))
|
||||
.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))
|
||||
.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);
|
||||
@@ -133,6 +137,17 @@ public static class BuildEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
private static NodeBinaryDto ToDto(NodeBinding b) =>
|
||||
new(b.Sha256, b.Signed, b.Seq, DateTimeOffset.FromUnixTimeMilliseconds(b.BoundAtMs), b.Violation, b.ViolationAtMs is { } at ? DateTimeOffset.FromUnixTimeMilliseconds(at) : null);
|
||||
|
||||
/// <summary>管理员确认新的二进制是自己要的:清除绑定,节点下一次握手重新绑定(没有绑定可清也返回 204,幂等)。</summary>
|
||||
private static async Task<IResult> ClearBinaryAsync(HttpContext http, BinaryBindingService bindings, string id)
|
||||
{
|
||||
http.SetDetail("binary binding cleared");
|
||||
await bindings.ClearAsync(id, http.RequestAborted);
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
|
||||
private static NodeUpdateDto? ToDto(UpdateStatus? status) =>
|
||||
status is null ? null : new NodeUpdateDto(status.State, status.Current, status.Available?.Tag, status.Available?.Version, status.Error, status.At > 0 ? DateTimeOffset.FromUnixTimeSeconds(status.At) : null);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ public static class ApiModule
|
||||
public static IServiceCollection AddMoShiroApi(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<SseLimiter>();
|
||||
services.AddSingleton<MoShiro.Backend.Application.IReleaseKey, EmbeddedReleaseKey>();
|
||||
services.AddSingleton<ClientAddressResolver>();
|
||||
services.Configure<JsonOptions>(options =>
|
||||
{
|
||||
|
||||
@@ -178,7 +178,10 @@ public sealed record ChangePasswordRequest(string Password);
|
||||
public sealed record RecomputeDto(string State, IReadOnlyList<string> Metrics, long WindowsDone, long WindowsTotal, DateTimeOffset? From, DateTimeOffset? To, int QueueLength);
|
||||
|
||||
/// <summary>管理端看的构建信息:每个节点自报的版本、tag、提交和构建时间,以及系统。</summary>
|
||||
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);
|
||||
/// <summary>节点二进制的绑定:第一次握手成功时绑定的 SHA-256。<c>Signed</c> 表示它出现在一份 CI 签名的清单里(否则只是首次握手时记下的);<c>Violation</c> 是最近一次不符的原因,下一次握手符合了就清掉。</summary>
|
||||
public sealed record NodeBinaryDto(string Sha256, bool Signed, long Seq, DateTimeOffset BoundAt, string? Violation, DateTimeOffset? ViolationAt);
|
||||
|
||||
public sealed record NodeBuildDto(string Id, string Kind, string? Os, string? OsName, string? Arch, string? Version, string? Tag, string? Commit, string? BuiltAt, NodeUpdateDto? Update = null, NodeBinaryDto? Binary = null);
|
||||
|
||||
/// <summary>节点宿主机上的更新器报告的状态(<c>up_to_date</c>、<c>available</c>、<c>applying</c>、<c>failed</c>、<c>rolled_back</c>、<c>invalid</c>);没装更新器的节点没有这个字段。</summary>
|
||||
public sealed record NodeUpdateDto(string State, string? CurrentDigest, string? AvailableTag, string? AvailableVersion, string? Error, DateTimeOffset? At);
|
||||
|
||||
@@ -47,6 +47,7 @@ namespace MoShiro.Backend.Api;
|
||||
[JsonSerializable(typeof(PublishReleaseRequest))]
|
||||
[JsonSerializable(typeof(AboutDto))]
|
||||
[JsonSerializable(typeof(NodeBuildDto))]
|
||||
[JsonSerializable(typeof(NodeBinaryDto))]
|
||||
[JsonSerializable(typeof(BuildInfoDto))]
|
||||
[JsonSerializable(typeof(BackendUpdateDto))]
|
||||
[JsonSerializable(typeof(ConfirmBackendUpdateRequest))]
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using MoShiro.Backend.Application;
|
||||
|
||||
namespace MoShiro.Backend.Api;
|
||||
|
||||
/// <summary>内嵌在程序里的 CI 发布公钥(<c>packaging/update/release-key.pub.pem</c>)。</summary>
|
||||
public sealed class EmbeddedReleaseKey : IReleaseKey
|
||||
{
|
||||
public string Pem => InstallScripts.ReleaseKey();
|
||||
}
|
||||
@@ -26,8 +26,11 @@ public sealed partial class AgentService
|
||||
private readonly LinkService _links;
|
||||
private readonly TrustService _trust;
|
||||
|
||||
public AgentService(IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings, NodeInfoService nodes, LinkService links, TrustService trust)
|
||||
private readonly BinaryBindingService _bindings;
|
||||
|
||||
public AgentService(BinaryBindingService bindings, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings, NodeInfoService nodes, LinkService links, TrustService trust)
|
||||
{
|
||||
_bindings = bindings;
|
||||
_trust = trust;
|
||||
_links = links;
|
||||
_nodes = nodes;
|
||||
@@ -147,6 +150,7 @@ public sealed partial class AgentService
|
||||
await _gateway.DisconnectAsync(id, "deleted", ct);
|
||||
await _agents.DeleteAsync(id, ct);
|
||||
await _nodes.ForgetAsync(id.Value, ct);
|
||||
await _bindings.ForgetAsync(id.Value, ct);
|
||||
await _links.ForgetNodeAsync(id.Value, ct);
|
||||
_runtime.Remove(id);
|
||||
await AfterAgentSetChangedAsync(ct);
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using Fleet.Server;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>CI 发布公钥(PEM):验节点握手时带来的签名清单。由 Api 层提供(它就是内嵌在程序里的那一把)。</summary>
|
||||
public interface IReleaseKey
|
||||
{
|
||||
string Pem { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 节点二进制的绑定:节点第一次握手成功的那一刻,把它正在运行的二进制的 SHA-256 和这个节点绑在一起;之后哈希对不上,就不让它继续运行。
|
||||
/// <para>
|
||||
/// 合法的升级:新哈希出现在一份 <b>CI 签名的清单</b>里(节点握手时带着宿主机更新器验过的清单,这里用内嵌的 CI 公钥再验一遍,
|
||||
/// 所以不依赖面板有没有收到过这次发布),并且清单的 seq 不低于绑定时的——不许回退到更旧的版本。其余情况(文件被换掉、降级、没人签过的二进制、
|
||||
/// 手工替换了二进制的非容器节点)都算不符;管理员确认过之后可以清除绑定,下一次握手重新绑定。
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 边界:哈希是节点自己报的。一个被换进来、故意撒谎的恶意二进制可以照抄原来的哈希;这一层抓的是文件被换、版本被改、降级和起了不对的二进制。
|
||||
/// 容器节点要堵住撒谎,需要宿主机更新器背书(容器之外),见 docs/access.md。
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class BinaryBindingService : IBinaryAttestor
|
||||
{
|
||||
public const string Enforce = "enforce";
|
||||
public const string Warn = "warn";
|
||||
public const string Off = "off";
|
||||
|
||||
private readonly INodeBindingStore _store;
|
||||
private readonly IReleaseKey _key;
|
||||
private readonly BackendOptions _options;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public BinaryBindingService(INodeBindingStore store, IReleaseKey key, BackendOptions options, TimeProvider time)
|
||||
{
|
||||
_store = store;
|
||||
_key = key;
|
||||
_options = options;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<NodeBinding>> ListAsync(CancellationToken ct) => _store.ListAsync(ct);
|
||||
|
||||
/// <summary>管理员确认新的二进制是自己要的(比如手工升级了非容器节点):清除绑定,下一次握手重新绑定。</summary>
|
||||
public ValueTask<bool> ClearAsync(string nodeId, CancellationToken ct) => _store.DeleteAsync(nodeId, ct);
|
||||
|
||||
/// <summary>节点删除之后顺手清掉它的绑定。</summary>
|
||||
public async ValueTask ForgetAsync(string nodeId, CancellationToken ct) => await _store.DeleteAsync(nodeId, ct);
|
||||
|
||||
public async ValueTask<BinaryDecision> CheckAsync(NodeRecord node, BinaryEvidence evidence, CancellationToken ct)
|
||||
{
|
||||
string policy = _options.BinaryPolicy;
|
||||
if (policy == Off || ReleaseService.ComponentOf(node.Id) is not { } component)
|
||||
{
|
||||
return new BinaryDecision(true);
|
||||
}
|
||||
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
NodeBinding? binding = await _store.GetAsync(node.Id.Value, ct);
|
||||
|
||||
if (evidence.Sha256 is null)
|
||||
{
|
||||
// 旧节点不上报:还没绑定过就放行(未绑定);绑定过的不许退回去,否则换个旧版本就绕过了
|
||||
return binding is null ? new BinaryDecision(true) : await ViolationAsync(binding, "the node stopped reporting its binary hash", policy, now, ct);
|
||||
}
|
||||
|
||||
string hash = evidence.Sha256;
|
||||
if (!IsHash(hash))
|
||||
{
|
||||
return new BinaryDecision(policy != Enforce, "the reported binary hash is malformed");
|
||||
}
|
||||
|
||||
Official? official = VerifyManifest(evidence, component);
|
||||
bool listed = official is not null && official.Binaries.Contains(hash);
|
||||
|
||||
if (binding is null)
|
||||
{
|
||||
if (official is not null && !listed)
|
||||
{
|
||||
// 带着一份有效的签名清单,自己的哈希却不在里面:它不是这份清单对应的官方二进制
|
||||
return new BinaryDecision(policy != Enforce, "this binary is not in the CI-signed manifest it was installed from");
|
||||
}
|
||||
|
||||
await _store.UpsertAsync(new NodeBinding(node.Id.Value, hash, official?.Seq ?? 0, listed, now, null, null), ct);
|
||||
return new BinaryDecision(true);
|
||||
}
|
||||
|
||||
if (binding.Sha256 == hash)
|
||||
{
|
||||
if (listed && (official!.Seq > binding.Seq || !binding.Signed || binding.Violation is not null))
|
||||
{
|
||||
await _store.UpsertAsync(binding with { Seq = Math.Max(binding.Seq, official!.Seq), Signed = true, Violation = null, ViolationAtMs = null }, ct);
|
||||
}
|
||||
else if (binding.Violation is not null)
|
||||
{
|
||||
await _store.UpsertAsync(binding with { Violation = null, ViolationAtMs = null }, ct);
|
||||
}
|
||||
|
||||
return new BinaryDecision(true);
|
||||
}
|
||||
|
||||
// 哈希变了:必须是一份不低于绑定时 seq 的 CI 签名清单里的官方二进制(合法升级),否则不符
|
||||
if (listed && official!.Seq >= binding.Seq)
|
||||
{
|
||||
await _store.UpsertAsync(new NodeBinding(node.Id.Value, hash, official.Seq, true, now, null, null), ct);
|
||||
return new BinaryDecision(true);
|
||||
}
|
||||
|
||||
return await ViolationAsync(binding, $"the running binary changed (bound {Short(binding.Sha256)}, now {Short(hash)}) and is not in a newer CI-signed manifest", policy, now, ct);
|
||||
}
|
||||
|
||||
private async ValueTask<BinaryDecision> ViolationAsync(NodeBinding binding, string reason, string policy, long now, CancellationToken ct)
|
||||
{
|
||||
await _store.UpsertAsync(binding with { Violation = reason, ViolationAtMs = now }, ct);
|
||||
return new BinaryDecision(policy != Enforce, reason);
|
||||
}
|
||||
|
||||
private sealed record Official(long Seq, HashSet<string> Binaries);
|
||||
|
||||
/// <summary>用内嵌的 CI 公钥验清单;验过且是这个组件的才返回它列出的官方二进制哈希。</summary>
|
||||
private Official? VerifyManifest(BinaryEvidence evidence, string component)
|
||||
{
|
||||
if (evidence.Manifest is not { Length: > 0 and <= ReleaseService.MaxManifestBytes } manifest
|
||||
|| evidence.Signature is not { Length: > 0 and <= ReleaseService.MaxSignatureBytes } signature
|
||||
|| !ReleaseSignature.Verify(_key.Pem, manifest, signature))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(manifest);
|
||||
JsonElement root = document.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object
|
||||
|| !root.TryGetProperty("component", out JsonElement c) || c.GetString() != component
|
||||
|| !root.TryGetProperty("seq", out JsonElement s) || !s.TryGetInt64(out long seq) || seq <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var binaries = new HashSet<string>(StringComparer.Ordinal);
|
||||
if (root.TryGetProperty("binaries", out JsonElement list) && list.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
foreach (string item in (list.GetString() ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (IsHash(item))
|
||||
{
|
||||
binaries.Add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Official(seq, binaries);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsHash(string value) => value.Length == 64 && value.All(ch => ch is (>= '0' and <= '9') or (>= 'a' and <= 'f'));
|
||||
|
||||
private static string Short(string hash) => hash.Length > 12 ? hash[..12] : hash;
|
||||
}
|
||||
@@ -29,8 +29,11 @@ public sealed partial class ProbeService
|
||||
private readonly NodeInfoService _nodes;
|
||||
private readonly LinkService _links;
|
||||
|
||||
public ProbeService(IProbeStore probes, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TimeProvider time, NodeInfoService nodes, LinkService links)
|
||||
private readonly BinaryBindingService _bindings;
|
||||
|
||||
public ProbeService(BinaryBindingService bindings, IProbeStore probes, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TimeProvider time, NodeInfoService nodes, LinkService links)
|
||||
{
|
||||
_bindings = bindings;
|
||||
_links = links;
|
||||
_nodes = nodes;
|
||||
_probes = probes;
|
||||
@@ -94,6 +97,7 @@ public sealed partial class ProbeService
|
||||
if (deleted)
|
||||
{
|
||||
await _nodes.ForgetAsync(id, ct);
|
||||
await _bindings.ForgetAsync(id, ct);
|
||||
await _links.ForgetNodeAsync(id, ct);
|
||||
_gateway.NotifyAllTaskSetsChanged();
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ public static class ApplicationServiceCollectionExtensions
|
||||
services.AddSingleton<PasskeyService>();
|
||||
services.AddSingleton<AuditService>();
|
||||
services.AddSingleton<ServerIdentityService>();
|
||||
services.AddSingleton<BinaryBindingService>();
|
||||
services.AddSingleton<Fleet.Server.IBinaryAttestor>(sp => sp.GetRequiredService<BinaryBindingService>());
|
||||
services.AddSingleton<Fleet.Server.IServerIdentity>(sp => sp.GetRequiredService<ServerIdentityService>());
|
||||
services.AddSingleton<TrustService>();
|
||||
services.AddSingleton<Fleet.Server.IOperatorTrustSource>(sp => sp.GetRequiredService<TrustService>());
|
||||
|
||||
@@ -21,6 +21,11 @@ public sealed class BackendOptions
|
||||
/// <summary>宿主机更新器的只读状态目录;留空使用数据目录下的 update-status/。</summary>
|
||||
public string? BackendUpdateStatusDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 节点二进制绑定的策略:<c>enforce</c>(默认)二进制和绑定不符、又不在 CI 签名的更新清单里就拒绝连接;<c>warn</c> 只记录不拒绝;<c>off</c> 不检查。
|
||||
/// </summary>
|
||||
public string BinaryPolicy { get; set; } = "enforce";
|
||||
|
||||
/// <summary>面板的对外地址(用于生成安装命令)。</summary>
|
||||
public string? PublicUrl { get; set; }
|
||||
|
||||
|
||||
@@ -144,6 +144,20 @@ public interface IEventStore
|
||||
ValueTask<int> DeleteOlderThanAsync(long cutoffMs, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>节点的二进制绑定:第一次握手成功时绑定的 SHA-256,对应的签名清单 seq(没有签名清单时为 0),以及最近一次不符的原因。</summary>
|
||||
public sealed record NodeBinding(string NodeId, string Sha256, long Seq, bool Signed, long BoundAtMs, string? Violation, long? ViolationAtMs);
|
||||
|
||||
public interface INodeBindingStore
|
||||
{
|
||||
ValueTask<NodeBinding?> GetAsync(string nodeId, CancellationToken ct);
|
||||
|
||||
ValueTask<IReadOnlyList<NodeBinding>> ListAsync(CancellationToken ct);
|
||||
|
||||
ValueTask UpsertAsync(NodeBinding binding, CancellationToken ct);
|
||||
|
||||
ValueTask<bool> DeleteAsync(string nodeId, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>操作员集合的链(passkey 签的版本,按版本号递增)。</summary>
|
||||
public interface IOperatorSetStore
|
||||
{
|
||||
|
||||
@@ -145,7 +145,7 @@ public sealed partial class ReleaseService : IDisposable
|
||||
|
||||
private IEnumerable<NodeId> OnlineOf(string component) => _gateway.OnlineNodes.Where(n => ComponentOf(n) == component);
|
||||
|
||||
private static string? ComponentOf(NodeId node) =>
|
||||
internal static string? ComponentOf(NodeId node) =>
|
||||
node.Value.StartsWith(AgentIds.Prefix, StringComparison.Ordinal) ? NodeKinds.Agent
|
||||
: node.Value.StartsWith(ProbeId.Prefix, StringComparison.Ordinal) ? NodeKinds.Probe
|
||||
: null;
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace MoShiro.Backend.Api;
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 发布清单的签名校验:用 Backend 内嵌的 CI 发布公钥(<c>packaging/update/release-key.pub.pem</c>)验 ECDSA P-256 / SHA-256(DER)签名,签名对象是清单的原始字节。
|
||||
@@ -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 = 13;
|
||||
public const int CurrentVersion = 14;
|
||||
|
||||
public static void Migrate(SqliteConnection connection)
|
||||
{
|
||||
@@ -221,6 +221,26 @@ internal static class Schema
|
||||
connection.Exec("PRAGMA user_version = 13");
|
||||
transaction.Commit();
|
||||
}
|
||||
|
||||
if (version < 14)
|
||||
{
|
||||
// 节点的二进制绑定:第一次握手成功时绑定正在运行的二进制的 SHA-256
|
||||
using SqliteTransaction transaction = connection.BeginTransaction();
|
||||
connection.Exec(
|
||||
"""
|
||||
CREATE TABLE node_bindings (
|
||||
node_id TEXT PRIMARY KEY,
|
||||
sha256 TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
signed INTEGER NOT NULL,
|
||||
bound_at INTEGER NOT NULL,
|
||||
violation TEXT,
|
||||
violation_at INTEGER
|
||||
)
|
||||
""");
|
||||
connection.Exec("PRAGMA user_version = 14");
|
||||
transaction.Commit();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
using MoShiro.Backend.Application;
|
||||
|
||||
namespace MoShiro.Backend.Storage.Sqlite;
|
||||
|
||||
public sealed class SqliteNodeBindingStore : INodeBindingStore
|
||||
{
|
||||
private const string Columns = "node_id, sha256, seq, signed, bound_at, violation, violation_at";
|
||||
|
||||
private readonly SqliteDatabase _db;
|
||||
|
||||
public SqliteNodeBindingStore(SqliteDatabase db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public ValueTask<NodeBinding?> GetAsync(string nodeId, CancellationToken ct) =>
|
||||
_db.ReadAsync<NodeBinding?>(c => c.QueryOne($"SELECT {Columns} FROM node_bindings WHERE node_id = @id", Read, ("@id", nodeId)), ct);
|
||||
|
||||
public ValueTask<IReadOnlyList<NodeBinding>> ListAsync(CancellationToken ct) =>
|
||||
_db.ReadAsync<IReadOnlyList<NodeBinding>>(c => c.Query($"SELECT {Columns} FROM node_bindings ORDER BY node_id", Read), ct);
|
||||
|
||||
public async ValueTask UpsertAsync(NodeBinding binding, CancellationToken ct) =>
|
||||
await _db.WriteAsync(
|
||||
c => c.Exec(
|
||||
$"INSERT INTO node_bindings ({Columns}) VALUES (@id, @sha, @seq, @signed, @at, @violation, @violation_at) "
|
||||
+ "ON CONFLICT(node_id) DO UPDATE SET sha256 = excluded.sha256, seq = excluded.seq, signed = excluded.signed, bound_at = excluded.bound_at, violation = excluded.violation, violation_at = excluded.violation_at",
|
||||
("@id", binding.NodeId), ("@sha", binding.Sha256), ("@seq", binding.Seq), ("@signed", binding.Signed ? 1 : 0), ("@at", binding.BoundAtMs), ("@violation", binding.Violation), ("@violation_at", binding.ViolationAtMs)),
|
||||
ct);
|
||||
|
||||
public ValueTask<bool> DeleteAsync(string nodeId, CancellationToken ct) =>
|
||||
_db.WriteAsync(c => c.Exec("DELETE FROM node_bindings WHERE node_id = @id", ("@id", nodeId)) > 0, ct);
|
||||
|
||||
private static NodeBinding Read(Microsoft.Data.Sqlite.SqliteDataReader r) =>
|
||||
new(r.GetString(0), r.GetString(1), r.GetInt64(2), r.GetInt64(3) != 0, r.GetInt64(4), r.NullableString(5), r.NullableLong(6));
|
||||
}
|
||||
@@ -24,6 +24,7 @@ public static class StorageServiceCollectionExtensions
|
||||
services.AddSingleton<IAuthStore, SqliteAuthStore>();
|
||||
services.AddSingleton<IAuditStore, SqliteAuditStore>();
|
||||
services.AddSingleton<IOperatorSetStore, SqliteOperatorSetStore>();
|
||||
services.AddSingleton<INodeBindingStore, SqliteNodeBindingStore>();
|
||||
services.AddSingleton<IRawStore, SqliteRawStore>();
|
||||
services.AddSingleton<IMetricStore, SqliteMetricStore>();
|
||||
services.AddSingleton<IStorageHealth, SqliteStorageHealth>();
|
||||
|
||||
Reference in new issue
Block a user