using Fleet.Core; using Fleet.Identity; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using MoShiro.Backend.Application; using MoShiro.Protocol; namespace MoShiro.Backend.Api; /// Agent 管理:列表、修改、审批、启停、接管、删除;加入令牌;Probe 身份。 public static class AgentEndpoints { private const string AgentsTag = "admin-agents"; private const string TokensTag = "admin-join-tokens"; private const string ProbeTag = "admin-probe"; public static void Map(RouteGroupBuilder group) { group.MapGet("/agents", ListAsync) .Api("agents_list", "All agents including pending and disabled ones", AgentsTag).Returns>().ProducesProblems(401, 403) .Requires(Capability.FleetRead); group.MapGet("/agents/{id}", GetAsync) .Api("agents_get", "One agent", AgentsTag).Returns().ProducesProblems(401, 403, 404) .Requires(Capability.FleetRead); group.MapPatch("/agents/{id}", PatchAsync) .Accepts("application/merge-patch+json", "application/json") .Api("agents_update", "Update name, labels, public host or visibility (merge-patch, If-Match supported)", AgentsTag) .Returns().ProducesProblems(400, 401, 403, 404, 409, 412) .Requires(Capability.FleetWrite); group.MapPost("/agents/{id}/approve", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.ApproveAsync(nodeId, ct))) .Api("agents_approve", "Approve a pending agent", AgentsTag).Returns().ProducesProblems(401, 403, 404, 409) .Requires(Capability.FleetEnroll); group.MapPost("/agents/{id}/disable", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.DisableAsync(nodeId, ct))) .Api("agents_disable", "Disable an agent and drop its connection", AgentsTag).Returns().ProducesProblems(401, 403, 404, 409) .Requires(Capability.FleetWrite); group.MapPost("/agents/{id}/enable", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.EnableAsync(nodeId, ct))) .Api("agents_enable", "Enable a disabled agent", AgentsTag).Returns().ProducesProblems(401, 403, 404, 409) .Requires(Capability.FleetWrite); group.MapPost("/agents/{id}/takeover", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.TakeoverAsync(nodeId, ct))) .Api("agents_takeover", "Replace the existing node of the same name with this pending one, keeping history", AgentsTag) .Returns().ProducesProblems(401, 403, 404) .Requires(Capability.FleetEnroll); group.MapDelete("/agents/{id}", DeleteAsync) .Api("agents_delete", "Delete an agent; history expires with the retention policy", AgentsTag) .Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403) .Requires(Capability.FleetRevoke); group.MapGet("/join-tokens", ListTokensAsync) .Api("join_tokens_list", "Join tokens", TokensTag).Returns>().ProducesProblems(401, 403) .Requires(Capability.FleetRead); group.MapPost("/join-tokens", CreateTokenAsync) .Api("join_tokens_create", "Create a join token for an agent or a probe (role); the plaintext is returned only once", TokensTag) .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403) .Requires(Capability.FleetEnroll); group.MapDelete("/join-tokens/{id}", RevokeTokenAsync) .Api("join_tokens_revoke", "Revoke a join token; with purge=true delete the record for good", TokensTag) .Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403) .Requires(Capability.FleetEnroll); group.MapGet("/probes", ListProbesAsync) .Api("probes_list", "All probes, with the agents each one is connected to", ProbeTag).Returns>().ProducesProblems(401, 403) .Requires(Capability.FleetRead); group.MapGet("/probes/{id}", GetProbeAsync) .Api("probes_get", "One probe", ProbeTag).Returns().ProducesProblems(401, 403, 404) .Requires(Capability.FleetRead); group.MapPatch("/probes/{id}", PatchProbeAsync) .Api("probes_update", "Rename a probe", ProbeTag).Returns().ProducesProblems(400, 401, 403, 404, 409) .Requires(Capability.FleetWrite); group.MapPost("/probes/{id}/revoke", RevokeProbeAsync) .Api("probes_revoke", "Revoke a probe: every agent drops its session and refuses it; history stays until it expires", ProbeTag) .Returns().ProducesProblems(401, 403, 404) .Requires(Capability.FleetRevoke); group.MapDelete("/probes/{id}", DeleteProbeAsync) .Api("probes_delete", "Delete a probe and its lines; stored data expires with the retention policy", ProbeTag) .Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403) .Requires(Capability.FleetRevoke); group.MapPost("/agents/{id}/probes/{probe_id}/disconnect", DisconnectProbeAsync) .Api("agents_probe_disconnect", "Make an agent drop one probe's session (the probe reconnects unless it is revoked)", AgentsTag) .Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 404, 409) .Requires(Capability.FleetWrite); } private static async Task ListAsync(AgentService agents, CancellationToken ct) => TypedResults.Json(new PageDto([.. (await agents.ListAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoAgentDto); private static async Task GetAsync(HttpContext http, AgentService agents, string id, CancellationToken ct) { AgentView? view = await agents.GetAsync(new NodeId(id), ct); if (view is null) { return Problems.Create(http, MoShiroErrors.AgentNotFound, "agent not found"); } Concurrency.SetETag(http, view.Agent.RowVersion); return TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.AgentDto); } private static async Task PatchAsync(HttpContext http, AgentService agents, AgentPatchRequest request, string id, CancellationToken ct) { Result result = await agents.PatchAsync( new NodeId(id), new AgentPatch { Name = request.Name, Labels = request.Labels, PublicHost = request.PublicHost, Ipv6Host = request.Ipv6Host, PublicHostAuto = request.PublicHostAuto, Ipv6HostAuto = request.Ipv6HostAuto, PublicVisible = request.PublicVisible }, Concurrency.ExpectedRowVersion(http), ct); if (!result.TryGet(out AgentView? view, out FleetError? error)) { return Problems.From(http, error); } Concurrency.SetETag(http, view.Agent.RowVersion); return TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.AgentDto); } private static async Task TransitionAsync(HttpContext http, string id, Func>> action) { Result result = await action(new NodeId(id), http.RequestAborted); if (!result.TryGet(out AgentView? view, out FleetError? error)) { return Problems.From(http, error); } Concurrency.SetETag(http, view.Agent.RowVersion); return TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.AgentDto); } private static async Task DeleteAsync(AgentService agents, string id, CancellationToken ct) { await agents.DeleteAsync(new NodeId(id), ct); return TypedResults.NoContent(); } private static async Task ListTokensAsync(JoinTokenService tokens, CancellationToken ct) => TypedResults.Json(new PageDto([.. (await tokens.ListAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoJoinTokenDto); private static async Task CreateTokenAsync(HttpContext http, JoinTokenService tokens, TrustService trust, CreateJoinTokenRequest request, BackendOptions options) { string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}"; // 信任根建立之后,命令里带上创世指纹和属主;属主默认是操作员集合里的全部 passkey,也可以只选其中几把 OperatorSet? set = await trust.CurrentAsync(http.RequestAborted); string? genesis = set is null ? null : (await trust.StateAsync(AuthEndpoints.PasskeyContextOf(http, options), http.RequestAborted)).Genesis; Result owner = OwnerPolicy.Resolve(options.RequireOwner, set, request.Controllers); if (!owner.TryGet(out string[]? controllers, out FleetError? ownerError)) { return Problems.From(http, ownerError); } var plan = new InstallPlan(genesis, controllers, InstallScripts.Sha256(InstallScripts.Sh), InstallScripts.Sha256(InstallScripts.Ps1)); http.SetDetail($"{request.Role ?? JoinTokenService.AgentRole}: {request.Note}"); Result result = await tokens.CreateAsync( new Application.CreateJoinTokenRequest(request.Note, request.Labels, request.AutoApprove ?? true, request.MaxUses ?? 1, request.ExpiresInHours ?? 24, request.Role ?? JoinTokenService.AgentRole, http.GetPrincipal().Label), baseUrl, plan, http.RequestAborted); if (!result.TryGet(out CreatedJoinToken? created, out FleetError? error)) { return Problems.From(http, error); } http.Response.Headers.CacheControl = "no-store"; http.Response.Headers.Location = "/api/admin/join-tokens"; return TypedResults.Json(new CreatedJoinTokenDto(Mappers.ToDto(created.Info), created.Plaintext, new InstallCommandsDto(created.InstallCommands.Linux, created.InstallCommands.Macos, created.InstallCommands.Windows)), ApiJsonContext.Default.CreatedJoinTokenDto, statusCode: StatusCodes.Status201Created); } private static async Task RevokeTokenAsync(JoinTokenService tokens, string id, bool? purge, CancellationToken ct) { if (purge == true) { await tokens.DeleteAsync(id, ct); } else { await tokens.RevokeAsync(id, ct); } return TypedResults.NoContent(); } private static async Task ListProbesAsync(ProbeService probes, CancellationToken ct) => TypedResults.Json(new PageDto([.. (await probes.ListAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoProbeDto); private static async Task GetProbeAsync(HttpContext http, ProbeService probes, string id, CancellationToken ct) => await probes.GetAsync(id, ct) is { } view ? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto) : Problems.Create(http, MoShiroErrors.ProbeNotFound, "probe not found"); private static async Task PatchProbeAsync(HttpContext http, ProbeService probes, PatchProbeRequest request, string id, CancellationToken ct) { if (request.Name is null) { return Problems.From(http, ValidationError.Single("name", "is required")); } Result result = await probes.RenameAsync(id, request.Name, ct); return result.TryGet(out ProbeView? view, out FleetError? error) ? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto) : Problems.From(http, error); } private static async Task RevokeProbeAsync(HttpContext http, ProbeService probes, string id, CancellationToken ct) { Result result = await probes.RevokeAsync(id, ct); return result.TryGet(out ProbeView? view, out FleetError? error) ? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto) : Problems.From(http, error); } private static async Task DeleteProbeAsync(ProbeService probes, string id, CancellationToken ct) { await probes.DeleteAsync(id, ct); return TypedResults.NoContent(); } private static async Task DisconnectProbeAsync(HttpContext http, ProbeService probes, string id, string probe_id, CancellationToken ct) { Result result = await probes.DisconnectAsync(probe_id, new NodeId(id), ct); if (!result.TryGet(out bool dropped, out FleetError? error)) { return Problems.From(http, error); } return dropped ? TypedResults.NoContent() : Problems.Create(http, MoShiroErrors.ProbeOffline, "this probe has no session with this agent"); } }