155 lines
10 KiB
C#
155 lines
10 KiB
C#
using System.Text.Json;
|
|
using Fleet.Core;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Routing;
|
|
using MoShiro.Backend.Application;
|
|
using MoShiro.Protocol;
|
|
|
|
namespace MoShiro.Backend.Api;
|
|
|
|
/// <summary>构建信息与更新:后端和每个节点的版本、tag、构建时间,CI 签名的发布清单,以及「更新这个节点」的请求。更新本身由节点宿主机上的更新器验签并执行。</summary>
|
|
public static class BuildEndpoints
|
|
{
|
|
private const string Tag = "admin-build";
|
|
|
|
public static void Map(RouteGroupBuilder group)
|
|
{
|
|
group.MapGet("/build-info", GetAsync)
|
|
.Api("build_info_get", "Version, tag, commit, build time and update state of the backend and every agent and probe", Tag)
|
|
.Returns<BuildInfoDto>().ProducesProblems(401, 403)
|
|
.Requires(Capability.UpdateRead);
|
|
group.MapGet("/releases", ListReleasesAsync)
|
|
.Api("releases_list", "The latest signed release manifest of each component", Tag).Returns<PageDto<ReleaseDto>>().ProducesProblems(401, 403)
|
|
.Requires(Capability.UpdateRead);
|
|
group.MapPost("/nodes/{id}/update", RequestUpdateAsync)
|
|
.Api("nodes_update", "Ask an online agent or probe to install the release it has been offered", Tag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 409)
|
|
.Requires(Capability.UpdateConfirm);
|
|
group.MapDelete("/nodes/{id}/binary", ClearBinaryAsync)
|
|
.Api("nodes_binary_clear", "Accept the node's current binary: clear its binding so the next handshake binds it again", Tag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403)
|
|
.Requires(Capability.FleetWrite);
|
|
group.MapGet("/backend/update", BackendUpdateAsync)
|
|
.Api("backend_update_get", "Current channel and host-verified backend update", Tag).Returns<BackendUpdateDto>().ProducesProblems(401, 403)
|
|
.Requires(Capability.UpdateRead);
|
|
group.MapPost("/backend/update", ConfirmBackendUpdateAsync)
|
|
.Api("backend_update_confirm", "Confirm one verified backend release before the host restarts it", Tag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(400, 401, 403, 412)
|
|
.Requires(Capability.UpdateConfirm);
|
|
group.MapGet("/backend/update/live", LiveBackendUpdateAsync)
|
|
.Api("backend_update_live", "Server-sent backend update notifications", Tag).Produces(StatusCodes.Status200OK, contentType: "text/event-stream").ProducesProblems(401, 403)
|
|
.Requires(Capability.UpdateRead);
|
|
}
|
|
|
|
/// <summary>登记发布清单:挂在 <see cref="ReleaseGuard"/> 下,除了管理员,CI 用的 <c>release</c> 令牌也能调用(它只能做这一件事)。</summary>
|
|
public static void MapPublish(RouteGroupBuilder group)
|
|
{
|
|
group.MapPost("/releases", PublishReleaseAsync)
|
|
.Api("releases_publish", "Register a CI-signed release manifest; online nodes get it right away (their updaters verify the signature). Admin or release tokens", Tag)
|
|
.Returns<ReleaseDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409)
|
|
.Requires(Capability.ReleasePublish);
|
|
}
|
|
|
|
/// <summary>
|
|
/// CI 登记清单的入口,<b>不需要令牌</b>:请求里的清单必须带有效的 CI 发布签名(用 Backend 内嵌的发布公钥验)才收下。
|
|
/// 这样登记不依赖面板里的任何凭证(清库、重装、还没有 passkey 的时候照常工作),也没有一把能被偷的长期令牌;
|
|
/// 旧清单重放由 <c>seq</c> 递增挡住,节点的更新器还会用本机公钥再验一次。
|
|
/// </summary>
|
|
public static void MapOffer(IEndpointRouteBuilder app)
|
|
{
|
|
app.MapPost("/api/release/offer", OfferReleaseAsync)
|
|
.Api("release_offer", "Register a release manifest that carries a valid CI signature (no token needed)", "release")
|
|
.Returns<ReleaseDto>(StatusCodes.Status201Created).ProducesProblems(400, 403, 409);
|
|
}
|
|
|
|
private static async Task<IResult> OfferReleaseAsync(HttpContext http, ReleaseService releases, PublishReleaseRequest request)
|
|
{
|
|
string manifest = request.Manifest ?? "";
|
|
string signature = request.Signature ?? "";
|
|
if (manifest.Length is 0 or > ReleaseService.MaxManifestBytes || signature.Length is 0 or > ReleaseService.MaxSignatureBytes
|
|
|| !ReleaseSignature.Verify(InstallScripts.ReleaseKey(), manifest, signature))
|
|
{
|
|
return Problems.Create(http, MoShiroErrors.Forbidden, "the manifest does not carry a valid release signature");
|
|
}
|
|
|
|
return await PublishReleaseAsync(http, releases, request);
|
|
}
|
|
|
|
private static async Task<IResult> GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, BinaryBindingService bindings, NodeOwnerService owners, CancellationToken ct)
|
|
{
|
|
IReadOnlyDictionary<string, IReadOnlyList<NodeOwner>> ownerOf = await owners.ListAsync(ct);
|
|
Dictionary<string, NodeBinding> bound = (await bindings.ListAsync(ct)).ToDictionary(b => b.NodeId, StringComparer.Ordinal);
|
|
var items = (await nodes.ListAsync(ct))
|
|
.OrderBy(n => n.Kind, StringComparer.Ordinal)
|
|
.ThenBy(n => n.NodeId, StringComparer.Ordinal)
|
|
.Select(n => new NodeBuildDto(n.NodeId, n.Kind, n.Os, n.OsName, n.Arch, n.Version, n.Tag, n.Commit, n.BuiltAt, ToDto(releases.StatusOf(new NodeId(n.NodeId))), bound.TryGetValue(n.NodeId, out NodeBinding? binding) ? ToDto(binding) : null, ownerOf.TryGetValue(n.NodeId, out IReadOnlyList<NodeOwner>? list) ? [.. list.Select(o => new NodeOwnerDto(o.CredentialId, o.PasskeyName, o.Username))] : null))
|
|
.ToList();
|
|
http.Response.Headers.CacheControl = "no-store";
|
|
return TypedResults.Json(new BuildInfoDto(NodeEndpoints.Backend(withCommit: true), items, ToDto(await backendUpdates.GetAsync(ct))), ApiJsonContext.Default.BuildInfoDto);
|
|
}
|
|
|
|
private static async Task<IResult> ListReleasesAsync(ReleaseService releases, CancellationToken ct) =>
|
|
TypedResults.Json(new PageDto<ReleaseDto>([.. (await releases.ListAsync(ct)).Select(ToDto)], null), ApiJsonContext.Default.PageDtoReleaseDto);
|
|
|
|
private static async Task<IResult> PublishReleaseAsync(HttpContext http, ReleaseService releases, PublishReleaseRequest request)
|
|
{
|
|
Result<ReleaseOffer> result = await releases.PublishAsync(request.Manifest ?? "", request.Signature ?? "", http.RequestAborted);
|
|
return result.TryGet(out ReleaseOffer? offer, out FleetError? error)
|
|
? TypedResults.Json(ToDto(offer), ApiJsonContext.Default.ReleaseDto, statusCode: StatusCodes.Status201Created)
|
|
: Problems.From(http, error);
|
|
}
|
|
|
|
private static async Task<IResult> RequestUpdateAsync(HttpContext http, ReleaseService releases, string id, CancellationToken ct) =>
|
|
await releases.RequestUpdateAsync(new NodeId(id), ct) ? TypedResults.NoContent() : Problems.Create(http, MoShiroErrors.AgentOffline, "the node is not connected");
|
|
|
|
private static ReleaseDto ToDto(ReleaseOffer offer) => new(offer.Component, offer.Seq, offer.Tag, offer.Version, offer.Digest, offer.Channel);
|
|
|
|
private static BackendUpdateDto ToDto(BackendUpdateView view) =>
|
|
new(view.Channel, view.Enabled, view.State, view.CurrentTag, view.CurrentDigest, view.Available is { } release ? ToDto(release) : null, view.Error);
|
|
|
|
private static async Task<IResult> BackendUpdateAsync(BackendUpdates updates, CancellationToken ct) =>
|
|
TypedResults.Json(ToDto(await updates.GetAsync(ct)), ApiJsonContext.Default.BackendUpdateDto);
|
|
|
|
private static async Task<IResult> ConfirmBackendUpdateAsync(HttpContext http, BackendUpdates updates, ConfirmBackendUpdateRequest request, CancellationToken ct)
|
|
{
|
|
Result<bool> result = await updates.ConfirmAsync(request.Seq, ct);
|
|
return result.IsSuccess ? TypedResults.NoContent() : Problems.From(http, result.Error);
|
|
}
|
|
|
|
private static async Task LiveBackendUpdateAsync(HttpContext http, BackendUpdates updates, TimeProvider time, CancellationToken ct)
|
|
{
|
|
http.Response.ContentType = "text/event-stream";
|
|
http.Response.Headers.CacheControl = "no-store";
|
|
http.Response.Headers["X-Accel-Buffering"] = "no";
|
|
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(2), time);
|
|
string? previous = null;
|
|
try
|
|
{
|
|
do
|
|
{
|
|
string json = JsonSerializer.Serialize(ToDto(await updates.GetAsync(ct)), ApiJsonContext.Default.BackendUpdateDto);
|
|
await http.Response.WriteAsync(json != previous ? "event: backend_update\ndata: " + json + "\n\n" : ": heartbeat\n\n", ct);
|
|
await http.Response.Body.FlushAsync(ct);
|
|
previous = json;
|
|
}
|
|
while (await timer.WaitForNextTickAsync(ct));
|
|
}
|
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
|
{
|
|
// 浏览器断开或后端正在升级重启
|
|
}
|
|
}
|
|
|
|
private static NodeBinaryDto ToDto(NodeBinding b) =>
|
|
new(b.Sha256, b.Signed, b.Seq, DateTimeOffset.FromUnixTimeMilliseconds(b.BoundAtMs), b.Violation, b.ViolationAtMs is { } at ? DateTimeOffset.FromUnixTimeMilliseconds(at) : null);
|
|
|
|
/// <summary>管理员确认新的二进制是自己要的:清除绑定,节点下一次握手重新绑定(没有绑定可清也返回 204,幂等)。</summary>
|
|
private static async Task<IResult> ClearBinaryAsync(HttpContext http, BinaryBindingService bindings, string id)
|
|
{
|
|
http.SetDetail("binary binding cleared");
|
|
await bindings.ClearAsync(id, http.RequestAborted);
|
|
return TypedResults.NoContent();
|
|
}
|
|
|
|
private static NodeUpdateDto? ToDto(UpdateStatus? status) =>
|
|
status is null ? null : new NodeUpdateDto(status.State, status.Current, status.Available?.Tag, status.Available?.Version, status.Error, status.At > 0 ? DateTimeOffset.FromUnixTimeSeconds(status.At) : null);
|
|
}
|