backend: require confirmation for signed channel updates [CI]
ci/woodpecker/pr/verify Pipeline failed
ci/woodpecker/pr/web-build unknown status

This commit is contained in:
justme committed 2026-10-07 12:57:20 +08:00
1 parent 7160de2a36
commit 84f9d211bc
62 files changed
+1717 -70

No files matched your search

@@ -0,0 +1,89 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
using MoShiro.Backend.Application;
using MoShiro.Protocol;
namespace MoShiro.Backend.Storage.Sqlite;
/// <summary>Backend 到宿主机的文件信箱。状态卷只读挂载,业务进程改写不了验签和安装结果。</summary>
public sealed class BackendUpdateFiles : IBackendUpdateFiles
{
private readonly string _requests;
private readonly string _status;
public BackendUpdateFiles(BackendOptions options)
{
_requests = Path.GetFullPath(options.BackendUpdateDirectory ?? Path.Combine(options.DataDirectory, "update"));
_status = Path.GetFullPath(options.BackendUpdateStatusDirectory ?? Path.Combine(options.DataDirectory, "update-status"));
}
public async ValueTask WriteOfferAsync(string manifest, string signature, CancellationToken ct)
{
await WriteAsync("offer.manifest", manifest, ct);
await WriteAsync("offer.sig", signature, ct);
}
public async ValueTask<UpdateStatus?> ReadStatusAsync(CancellationToken ct)
{
string? json = await ReadAsync(Path.Combine(_status, "status.json"), 4096, ct);
if (json is null)
{
return null;
}
try
{
return JsonSerializer.Deserialize(json, MoShiroJsonContext.Default.UpdateStatus);
}
catch (JsonException)
{
return null;
}
}
public async ValueTask<long?> ReadRequestAsync(CancellationToken ct)
{
string? request = await ReadAsync(Path.Combine(_requests, "apply.request"), 64, ct);
return long.TryParse(request, NumberStyles.None, CultureInfo.InvariantCulture, out long seq) ? seq : null;
}
public ValueTask RequestAsync(long seq, CancellationToken ct) => WriteAsync("apply.request", seq.ToString(CultureInfo.InvariantCulture), ct);
private async ValueTask WriteAsync(string name, string value, CancellationToken ct)
{
Directory.CreateDirectory(_requests);
string path = Path.Combine(_requests, name);
string temporary = Path.Combine(_requests, $".{name}.{Guid.NewGuid():N}");
try
{
await File.WriteAllTextAsync(temporary, value, new UTF8Encoding(false), ct);
File.Move(temporary, path, overwrite: true);
}
finally
{
if (File.Exists(temporary))
{
File.Delete(temporary);
}
}
}
private static async ValueTask<string?> ReadAsync(string path, int limit, CancellationToken ct)
{
try
{
var file = new FileInfo(path);
if (!file.Exists || file.LinkTarget is not null || file.Length > limit)
{
return null;
}
return await File.ReadAllTextAsync(path, ct);
}
catch (IOException)
{
return null; // 更新器正做原子替换,下一次读取再试
}
}
}
@@ -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 = 6;
public const int CurrentVersion = 7;
public static void Migrate(SqliteConnection connection)
{
@@ -101,6 +101,15 @@ internal static class Schema
connection.Exec("PRAGMA user_version = 6");
transaction.Commit();
}
if (version < 7)
{
using SqliteTransaction transaction = connection.BeginTransaction();
connection.Exec("ALTER TABLE agents ADD COLUMN ipv6_diagnostics TEXT");
connection.Exec("ALTER TABLE probes ADD COLUMN ipv6_diagnostics TEXT");
connection.Exec("PRAGMA user_version = 7");
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";
"task_version, applied_task_version, rejected, created_at, last_seen_at, row_version, ipv6_host, ipv6_supported, ipv6_diagnostics";
private readonly SqliteDatabase _db;
@@ -117,9 +117,9 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore
{
c.Exec(
"UPDATE agents SET kinds = @kinds, agent_version = @version, local_limits = COALESCE(@limits, local_limits), tls_spki = COALESCE(@spki, tls_spki), " +
"probe_tcp_port = COALESCE(@tcp, probe_tcp_port), probe_udp_ports = COALESCE(@udp, probe_udp_ports), ipv6_supported = @v6, row_version = row_version + 1 WHERE id = @id",
"probe_tcp_port = COALESCE(@tcp, probe_tcp_port), probe_udp_ports = COALESCE(@udp, probe_udp_ports), ipv6_supported = @v6, ipv6_diagnostics = @diag, row_version = row_version + 1 WHERE id = @id",
("@id", id.Value), ("@kinds", StorageJson.ToJson(hello.Kinds)), ("@version", hello.AgentVersion), ("@limits", hello.LocalLimitsJson),
("@spki", hello.TlsSpki), ("@tcp", hello.ProbeTcpPort), ("@udp", hello.ProbeUdpPorts is null ? null : StorageJson.ToJson(hello.ProbeUdpPorts)), ("@v6", hello.Ipv6Supported ? 1 : 0));
("@spki", hello.TlsSpki), ("@tcp", hello.ProbeTcpPort), ("@udp", hello.ProbeUdpPorts is null ? null : StorageJson.ToJson(hello.ProbeUdpPorts)), ("@v6", hello.Ipv6Supported ? 1 : 0), ("@diag", hello.Ipv6DiagnosticsJson));
}, ct);
public ValueTask SetTaskVersionAsync(NodeId id, long version, string taskHash, CancellationToken ct) =>
@@ -219,7 +219,8 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore
r.NullableLong(17),
r.GetInt64(18),
r.GetString(19),
r.GetInt64(20) != 0);
r.GetInt64(20) != 0,
r.NullableString(21));
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);
@@ -50,14 +50,17 @@ 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 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))),
c => c.Query("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics 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))),
ct);
public ValueTask<ProbeRecord?> GetAsync(string id, CancellationToken ct) =>
_db.ReadAsync(
c => c.QueryOne("SELECT id, name, pubkey, created_at, revoked_at FROM probes WHERE id = @id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4)), ("@id", id)),
c => c.QueryOne("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics 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)), ("@id", id)),
ct);
public ValueTask SetIpv6DiagnosticsAsync(string id, string diagnosticsJson, CancellationToken ct) =>
_db.ExecuteAsync(c => c.Exec("UPDATE probes SET ipv6_diagnostics = @diag WHERE id = @id", ("@diag", diagnosticsJson), ("@id", id)), ct);
public ValueTask<bool> RevokeAsync(string id, long nowMs, CancellationToken ct) =>
_db.WriteAsync(c => c.Exec("UPDATE probes SET revoked_at = @now WHERE id = @id AND revoked_at IS NULL", ("@id", id), ("@now", nowMs)) == 1, ct);
@@ -25,6 +25,7 @@ public static class StorageServiceCollectionExtensions
services.AddSingleton<IRawStore, SqliteRawStore>();
services.AddSingleton<IMetricStore, SqliteMetricStore>();
services.AddSingleton<IStorageHealth, SqliteStorageHealth>();
services.AddSingleton<IBackendUpdateFiles, BackendUpdateFiles>();
return services;
}
}