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();
}