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; /// 构建信息与更新:后端和每个节点的版本、tag、构建时间,CI 签名的发布清单,以及「更新这个节点」的请求。更新本身由节点宿主机上的更新器验签并执行。 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().ProducesProblems(401, 403) .Requires(Capability.UpdateRead); group.MapGet("/releases", ListReleasesAsync) .Api("releases_list", "The latest signed release manifest of each component", Tag).Returns>().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(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 409) .Requires(Capability.UpdateConfirm); group.MapGet("/backend/update", BackendUpdateAsync) .Api("backend_update_get", "Current channel and host-verified backend update", Tag).Returns().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(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); } /// /// CI 登记清单的入口,不需要令牌:请求里的清单必须带有效的 CI 发布签名(用 Backend 内嵌的发布公钥验)才收下。 /// 这样登记不依赖面板里的任何凭证(清库、重装、还没有 passkey 的时候照常工作),也没有一把能被偷的长期令牌; /// 旧清单重放由 seq 递增挡住,节点的更新器还会用本机公钥再验一次。 /// 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(StatusCodes.Status201Created).ProducesProblems(400, 403, 409); } private static async Task OfferReleaseAsync(HttpContext http, ReleaseService releases, ReleaseKeyService keys, 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 || !await keys.VerifyAsync(manifest, signature, http.RequestAborted)) { 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 GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, NodeOwnerService owners, CancellationToken ct) { IReadOnlyDictionary> ownerOf = await owners.ListAsync(ct); 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))), ownerOf.TryGetValue(n.NodeId, out IReadOnlyList? 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 ListReleasesAsync(ReleaseService releases, CancellationToken ct) => TypedResults.Json(new PageDto([.. (await releases.ListAsync(ct)).Select(ToDto)], null), ApiJsonContext.Default.PageDtoReleaseDto); private static async Task PublishReleaseAsync(HttpContext http, ReleaseService releases, PublishReleaseRequest request) { Result 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 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 BackendUpdateAsync(BackendUpdates updates, CancellationToken ct) => TypedResults.Json(ToDto(await updates.GetAsync(ct)), ApiJsonContext.Default.BackendUpdateDto); private static async Task ConfirmBackendUpdateAsync(HttpContext http, BackendUpdates updates, ConfirmBackendUpdateRequest request, CancellationToken ct) { Result 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 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); }