Files
MoShiro/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs
T
justme ad5efc62ff agent: unify listening and dialing with directed mutual measurements
Replace Probe with Agent across node runtime, Fleet contracts, backend state, API and storage. Report incoming sessions and outgoing link health through each Agent uplink, and retain directed link controls and retry.

Update protocol fixtures and end-to-end coverage for the unified model; accept requested cancellation during the Fleet timeout test cleanup.
2026-10-21 11:55:36 +08:00

246 lines
11 KiB
C#

using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.RegularExpressions;
using Fleet.Core;
using MoShiro.Protocol;
namespace MoShiro.Backend.Application;
/// <summary>面板记下的、CI 签名的发布清单(只留每个组件最新的一份)。<c>Manifest</c> 是签名对象的原始文本,面板一个字节也不改。</summary>
public sealed record ReleaseOffer(string Component, long Seq, string Tag, string Version, string Digest, string Manifest, string Signature, string Channel = "release");
/// <summary>
/// 发布清单的分发:CI 签名之后推给面板,面板存下来并原样转给在线的节点(节点连上时再补发一次)。
/// 登记入口(<c>/api/release/offer</c>)只收带有效 CI 签名的清单;面板<b>不决定拉什么</b>:节点宿主机上的更新器用本机的发布公钥验签,并且只认本机配置的镜像仓库;
/// 所以面板即使被攻破,也推不出一个能被装上的假镜像。面板只负责「通知」和「请求更新」,状态由节点随心跳报回来。
/// </summary>
public sealed partial class ReleaseService : IDisposable
{
public const int MaxManifestBytes = 4096;
public const int MaxSignatureBytes = 512;
public const string BackendComponent = "backend";
public const string AgentComponent = "agent";
private static readonly string[] s_components = [AgentComponent, BackendComponent];
private readonly ISettingsStore _settings;
private readonly IAgentGateway _gateway;
private readonly BackendOptions _options;
private readonly ReleaseKeyService _keys;
private readonly ReleaseFeed _feed;
private readonly ConcurrentDictionary<string, long> _feedChecked = new();
private readonly ConcurrentDictionary<string, UpdateStatus> _status = new();
public ReleaseService(ISettingsStore settings, IAgentGateway gateway, BackendOptions options, ReleaseKeyService keys, ReleaseFeed feed)
{
_keys = keys;
_feed = feed;
_settings = settings;
_gateway = gateway;
_options = options;
}
internal SemaphoreSlim BackendGate { get; } = new(1, 1);
public void Dispose() => BackendGate.Dispose();
/// <summary>登记一份新清单并推给在线的同类节点。格式对、带 CI 发布密钥的有效签名、<c>seq</c> 比现有的新才收下;所有登记入口(CI、镜像仓库、管理员)都走这里,所以没有不验签的路径。节点的更新器之后还会用本机公钥再验一次。</summary>
public async ValueTask<Result<ReleaseOffer>> PublishAsync(string manifest, string signature, CancellationToken ct)
{
if (!TryParse(manifest, signature, out ReleaseOffer? offer, out string? problem))
{
return ValidationError.Fail<ReleaseOffer>("manifest", problem);
}
if (!await _keys.VerifyAsync(manifest, signature, ct))
{
return Result<ReleaseOffer>.Fail(MoShiroErrors.Forbidden, "the manifest does not carry a valid release signature");
}
await BackendGate.WaitAsync(ct);
try
{
bool backend = offer.Component == BackendComponent;
ReleaseOffer? current = backend ? await GetBackendAsync(offer.Channel, ct) : await GetAsync(offer.Component, ct);
if (current is not null && offer.Seq <= current.Seq)
{
return Result<ReleaseOffer>.Fail(MoShiroErrors.NameConflict, $"a release with seq {current.Seq} or newer is already registered");
}
string componentKey = backend ? BackendComponent + "." + offer.Channel : offer.Component;
await _settings.SetAsync(Key(componentKey, "manifest"), offer.Manifest, ct);
await _settings.SetAsync(Key(componentKey, "signature"), offer.Signature, ct);
if (!backend)
{
foreach (NodeId node in OnlineOf(offer.Component))
{
await _gateway.NotifyUpdateOfferAsync(node, offer.Manifest, offer.Signature, ct);
}
}
return Result<ReleaseOffer>.Ok(offer);
}
finally
{
BackendGate.Release();
}
}
/// <summary>
/// 这个组件的清单;面板里没有(清过库、CI 登记那一步网络失败)或者已经放了一阵,就去镜像仓库里取一次最新的:
/// 取回来的清单要用 CI 的发布公钥验过签才登记,所以仓库被改也只会被拒绝。取不到就用手里有的。
/// </summary>
public async ValueTask<ReleaseOffer?> GetOrFetchAsync(string component, CancellationToken ct)
{
string channel = BackendUpdates.ChannelOf(_options);
ReleaseOffer? stored = component == BackendComponent ? await GetBackendAsync(channel, ct) : await GetAsync(component, ct);
long now = Environment.TickCount64;
long wait = stored is null ? 30_000 : 240_000; // 没有就 30 秒试一次,已经有了就隔 4 分钟看一眼有没有更新的(后台每 5 分钟问一次)
if (_feedChecked.TryGetValue(component, out long last) && now - last < wait)
{
return stored;
}
_feedChecked[component] = now;
if (await _feed.FetchAsync(component, channel, ct) is { } fetched && await _keys.VerifyAsync(fetched.Manifest, fetched.Signature, ct))
{
Result<ReleaseOffer> published = await PublishAsync(fetched.Manifest, fetched.Signature, ct);
if (published.IsSuccess)
{
return published.Value;
}
}
return stored;
}
/// <summary>不再信任某把密钥之后(删除自有发布密钥):已登记的清单里,现在验不过的丢掉,免得继续分发。</summary>
public async ValueTask PurgeUnverifiedAsync(CancellationToken ct)
{
foreach (string key in new[] { AgentComponent, BackendComponent + ".dev", BackendComponent + ".release" })
{
string? manifest = await _settings.GetAsync(Key(key, "manifest"), ct);
string? signature = await _settings.GetAsync(Key(key, "signature"), ct);
if (!string.IsNullOrEmpty(manifest) && !string.IsNullOrEmpty(signature) && !await _keys.VerifyAsync(manifest, signature, ct))
{
await _settings.SetAsync(Key(key, "manifest"), string.Empty, ct);
await _settings.SetAsync(Key(key, "signature"), string.Empty, ct);
}
}
}
public async ValueTask<ReleaseOffer?> GetAsync(string component, CancellationToken ct)
{
string? manifest = await _settings.GetAsync(Key(component, "manifest"), ct);
string? signature = await _settings.GetAsync(Key(component, "signature"), ct);
return manifest is not null && signature is not null && TryParse(manifest, signature, out ReleaseOffer? offer, out _) && offer.Component == component ? offer : null;
}
public async ValueTask<IReadOnlyList<ReleaseOffer>> ListAsync(CancellationToken ct)
{
var list = new List<ReleaseOffer>();
foreach (string component in s_components)
{
ReleaseOffer? offer = component == BackendComponent ? await GetBackendAsync(BackendUpdates.ChannelOf(_options), ct) : await GetAsync(component, ct);
if (offer is not null)
{
list.Add(offer);
}
}
return list;
}
public async ValueTask<ReleaseOffer?> GetBackendAsync(string channel, CancellationToken ct)
{
string key = BackendComponent + "." + channel;
string? manifest = await _settings.GetAsync(Key(key, "manifest"), ct);
string? signature = await _settings.GetAsync(Key(key, "signature"), ct);
return manifest is not null && signature is not null && TryParse(manifest, signature, out ReleaseOffer? offer, out _)
&& offer.Component == BackendComponent && offer.Channel == channel ? offer : null;
}
/// <summary>节点刚连上:把它这个组件最新的清单补发一份(节点离线时发布的也不会错过)。</summary>
public async ValueTask OfferToNodeAsync(NodeId node, CancellationToken ct)
{
if (ComponentOf(node) is { } component && await GetAsync(component, ct) is { } offer)
{
await _gateway.NotifyUpdateOfferAsync(node, offer.Manifest, offer.Signature, ct);
}
}
/// <summary>请求某个节点更新到它手里那份(验过签的)清单。节点不在线返回 false。</summary>
public ValueTask<bool> RequestUpdateAsync(NodeId node, CancellationToken ct) => _gateway.NotifyUpdateApplyAsync(node, ct);
public void RecordStatus(NodeId node, UpdateStatus? status)
{
if (status is null)
{
_status.TryRemove(node.Value, out _);
}
else
{
_status[node.Value] = status;
}
}
public UpdateStatus? StatusOf(NodeId node) => _status.TryGetValue(node.Value, out UpdateStatus? status) ? status : null;
private IEnumerable<NodeId> OnlineOf(string component) => _gateway.OnlineNodes.Where(n => ComponentOf(n) == component);
internal static string? ComponentOf(NodeId node) =>
node.Value.StartsWith(AgentIds.Prefix, StringComparison.Ordinal) ? AgentComponent : null;
private static string Key(string component, string part) => $"release.{component}.{part}";
private static bool TryParse(string manifest, string signature, [NotNullWhen(true)] out ReleaseOffer? offer, [NotNullWhen(false)] out string? problem)
{
offer = null;
problem = null;
if (manifest.Length is 0 or > MaxManifestBytes || signature.Length is 0 or > MaxSignatureBytes || !Base64().IsMatch(signature))
{
problem = "manifest or signature has an unexpected size or encoding";
return false;
}
try
{
using JsonDocument document = JsonDocument.Parse(manifest);
JsonElement root = document.RootElement;
string component = Text(root, "component");
string digest = Text(root, "digest");
string channel = Text(root, "channel");
if (channel.Length == 0)
{
channel = "release"; // 老节点的清单没有 channel,保留原来的兼容性
}
if (root.ValueKind != JsonValueKind.Object || Array.IndexOf(s_components, component) < 0
|| channel is not ("dev" or "release")
|| !root.TryGetProperty("seq", out JsonElement seq) || !seq.TryGetInt64(out long seqValue) || seqValue <= 0
|| !Digest().IsMatch(digest) || Text(root, "image").Length is 0 or > 200)
{
problem = "manifest needs a known component, a positive seq, an image and a sha256 digest";
return false;
}
offer = new ReleaseOffer(component, seqValue, Text(root, "tag"), Text(root, "version"), digest, manifest, signature, channel);
return true;
}
catch (JsonException)
{
problem = "manifest is not valid JSON";
return false;
}
}
private static string Text(JsonElement root, string name) =>
root.ValueKind == JsonValueKind.Object && root.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.String ? value.GetString() ?? "" : "";
[GeneratedRegex("^[A-Za-z0-9+/=]+$")]
private static partial Regex Base64();
[GeneratedRegex("^sha256:[0-9a-f]{64}$")]
private static partial Regex Digest();
}