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; /// 面板记下的、CI 签名的发布清单(只留每个组件最新的一份)。Manifest 是签名对象的原始文本,面板一个字节也不改。 public sealed record ReleaseOffer(string Component, long Seq, string Tag, string Version, string Digest, string Manifest, string Signature, string Channel = "release"); /// /// 发布清单的分发:CI 签名之后推给面板,面板存下来并原样转给在线的 Agent、Probe(节点连上时再补发一次)。 /// 面板不验签、也不决定拉什么:节点宿主机上的更新器用本机的发布公钥验签,并且只认本机配置的镜像仓库; /// 所以面板即使被攻破,也推不出一个能被装上的假镜像。面板只负责「通知」和「请求更新」,状态由节点随心跳报回来。 /// public sealed partial class ReleaseService : IDisposable { public const int MaxManifestBytes = 4096; public const int MaxSignatureBytes = 512; public const string BackendComponent = "backend"; private static readonly string[] s_components = [NodeKinds.Agent, NodeKinds.Probe, BackendComponent]; private readonly ISettingsStore _settings; private readonly IAgentGateway _gateway; private readonly IBackendUpdateFiles _backendFiles; private readonly BackendOptions _options; private readonly ConcurrentDictionary _status = new(); public ReleaseService(ISettingsStore settings, IAgentGateway gateway, IBackendUpdateFiles backendFiles, BackendOptions options) { _settings = settings; _gateway = gateway; _backendFiles = backendFiles; _options = options; } internal SemaphoreSlim BackendGate { get; } = new(1, 1); public void Dispose() => BackendGate.Dispose(); /// 登记一份新清单并推给在线的同类节点。只要格式对、seq 比现有的新就收下(签名由节点验证)。 public async ValueTask> PublishAsync(string manifest, string signature, CancellationToken ct) { if (!TryParse(manifest, signature, out ReleaseOffer? offer, out string? problem)) { return ValidationError.Fail("manifest", problem); } 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.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) { if (offer.Channel == BackendUpdates.ChannelOf(_options)) { await _backendFiles.WriteOfferAsync(offer.Manifest, offer.Signature, ct); } } else { foreach (NodeId node in OnlineOf(offer.Component)) { await _gateway.NotifyUpdateOfferAsync(node, offer.Manifest, offer.Signature, ct); } } return Result.Ok(offer); } finally { BackendGate.Release(); } } public async ValueTask 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> ListAsync(CancellationToken ct) { var list = new List(); 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 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; } /// 节点刚连上:把它这个组件最新的清单补发一份(节点离线时发布的也不会错过)。 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); } } /// 请求某个节点更新到它手里那份(验过签的)清单。节点不在线返回 false。 public ValueTask 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; public void Forget(NodeId node) => _status.TryRemove(node.Value, out _); private IEnumerable OnlineOf(string component) => _gateway.OnlineNodes.Where(n => ComponentOf(n) == component); private static string? ComponentOf(NodeId node) => node.Value.StartsWith(AgentIds.Prefix, StringComparison.Ordinal) ? NodeKinds.Agent : node.Value.StartsWith(ProbeId.Prefix, StringComparison.Ordinal) ? NodeKinds.Probe : 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(); }