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 签名之后推给面板,面板存下来并原样转给在线的节点(节点连上时再补发一次)。 /// 登记入口(/api/release/offer)只收带有效 CI 签名的清单;面板不决定拉什么:节点宿主机上的更新器用本机的发布公钥验签,并且只认本机配置的镜像仓库; /// 所以面板即使被攻破,也推不出一个能被装上的假镜像。面板只负责「通知」和「请求更新」,状态由节点随心跳报回来。 /// 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 _feedChecked = new(); private readonly ConcurrentDictionary _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(); /// 登记一份新清单并推给在线的同类节点。格式对、带 CI 发布密钥的有效签名、seq 比现有的新才收下;所有登记入口(CI、镜像仓库、管理员)都走这里,所以没有不验签的路径。节点的更新器之后还会用本机公钥再验一次。 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); } if (!await _keys.VerifyAsync(manifest, signature, ct)) { return Result.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.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.Ok(offer); } finally { BackendGate.Release(); } } /// /// 这个组件的清单;面板里没有(清过库、CI 登记那一步网络失败)或者已经放了一阵,就去镜像仓库里取一次最新的: /// 取回来的清单要用 CI 的发布公钥验过签才登记,所以仓库被改也只会被拒绝。取不到就用手里有的。 /// public async ValueTask 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 published = await PublishAsync(fetched.Manifest, fetched.Signature, ct); if (published.IsSuccess) { return published.Value; } } return stored; } /// 不再信任某把密钥之后(删除自有发布密钥):已登记的清单里,现在验不过的丢掉,免得继续分发。 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 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; private IEnumerable 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(); }