using System.Text; using System.Text.Json; using System.Text.RegularExpressions; namespace MoShiro.Backend.Application; /// /// 镜像仓库里的签名清单:CI 每次构建都把签名清单放在 <命名空间>/moshiro-<组件>-manifest:<通道> 这个没有文件层的小镜像的标签里 /// (org.moshiro.manifest 是 base64 的清单,org.moshiro.signature 是签名)。面板没有登记过这个组件的清单时(清过库、CI 登记那一步网络失败), /// 直接按 OCI 分发协议匿名读取它——只读两个小 JSON,不拉任何镜像层。取回来的只是候选:调用方要用 CI 的发布公钥验过签才采用,所以仓库被改也只会验签失败。 /// public sealed partial class ReleaseFeed : IDisposable { private const int MaxBytes = 1 << 20; private readonly BackendOptions _options; private readonly HttpClient _http; public ReleaseFeed(BackendOptions options) { _options = options; _http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; } public void Dispose() => _http.Dispose(); /// 取不到(仓库不可读、没有这个标签、格式不对)返回 null;不抛异常。 public async ValueTask<(string Manifest, string Signature)?> FetchAsync(string component, string channel, CancellationToken ct) { string registry = (_options.NodeImageRegistry ?? "").Trim().TrimEnd('/'); if (registry.Length == 0) { return null; } string baseUrl = registry.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || registry.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ? registry : "https://" + registry; string repository = $"{(channel == "dev" ? "moshiro-dev" : "moshiro")}/moshiro-{component}-manifest"; try { string manifestJson = await GetAsync($"{baseUrl}/v2/{repository}/manifests/{channel}", "application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json", ct); using JsonDocument manifest = JsonDocument.Parse(manifestJson); string? digest = manifest.RootElement.GetProperty("config").GetProperty("digest").GetString(); if (digest is null || !Digest().IsMatch(digest)) { return null; } string configJson = await GetAsync($"{baseUrl}/v2/{repository}/blobs/{digest}", "application/json", ct); using JsonDocument config = JsonDocument.Parse(configJson); JsonElement labels = config.RootElement.GetProperty("config").GetProperty("Labels"); string? encoded = labels.GetProperty("org.moshiro.manifest").GetString(); string? signature = labels.GetProperty("org.moshiro.signature").GetString(); return encoded is null || signature is null ? null : (Encoding.UTF8.GetString(Convert.FromBase64String(encoded)), signature); } catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException or KeyNotFoundException or InvalidOperationException or FormatException) { return null; } } private async Task GetAsync(string url, string accept, CancellationToken ct) { using var request = new HttpRequestMessage(HttpMethod.Get, url); request.Headers.TryAddWithoutValidation("Accept", accept); using HttpResponseMessage response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct); response.EnsureSuccessStatusCode(); if (response.Content.Headers.ContentLength is > MaxBytes) { throw new InvalidOperationException("the registry response is too large"); } await using Stream stream = await response.Content.ReadAsStreamAsync(ct); using var limited = new MemoryStream(); byte[] buffer = new byte[8192]; int read; while ((read = await stream.ReadAsync(buffer, ct)) > 0) { limited.Write(buffer, 0, read); if (limited.Length > MaxBytes) { throw new InvalidOperationException("the registry response is too large"); } } return Encoding.UTF8.GetString(limited.ToArray()); } [GeneratedRegex("^sha256:[0-9a-f]{64}$")] private static partial Regex Digest(); }