223 lines
13 KiB
C#
223 lines
13 KiB
C#
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>Agent 管理:列表、修改、审批、启停、接管、删除;加入令牌;Probe 身份。</summary>
|
|
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<PageDto<AgentDto>>().ProducesProblems(401, 403);
|
|
group.MapGet("/agents/{id}", GetAsync)
|
|
.Api("agents_get", "One agent", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404);
|
|
group.MapPatch("/agents/{id}", PatchAsync)
|
|
.Accepts<AgentPatchRequest>("application/merge-patch+json", "application/json")
|
|
.Api("agents_update", "Update name, labels, public host or visibility (merge-patch, If-Match supported)", AgentsTag)
|
|
.Returns<AgentDto>().ProducesProblems(400, 401, 403, 404, 409, 412);
|
|
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<AgentDto>().ProducesProblems(401, 403, 404, 409);
|
|
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<AgentDto>().ProducesProblems(401, 403, 404, 409);
|
|
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<AgentDto>().ProducesProblems(401, 403, 404, 409);
|
|
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<AgentDto>().ProducesProblems(401, 403, 404);
|
|
group.MapDelete("/agents/{id}", DeleteAsync)
|
|
.Api("agents_delete", "Delete an agent; history expires with the retention policy", AgentsTag)
|
|
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
|
|
|
|
group.MapGet("/join-tokens", ListTokensAsync)
|
|
.Api("join_tokens_list", "Join tokens", TokensTag).Returns<PageDto<JoinTokenDto>>().ProducesProblems(401, 403);
|
|
group.MapPost("/join-tokens", CreateTokenAsync)
|
|
.Api("join_tokens_create", "Create a join token; the plaintext is returned only once", TokensTag)
|
|
.Returns<CreatedJoinTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403);
|
|
group.MapDelete("/join-tokens/{id}", RevokeTokenAsync)
|
|
.Api("join_tokens_revoke", "Revoke a join token; with purge=true delete the record for good", TokensTag)
|
|
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
|
|
|
|
group.MapGet("/probes", ListProbesAsync)
|
|
.Api("probes_list", "All probes, with the agents each one is connected to", ProbeTag).Returns<PageDto<ProbeDto>>().ProducesProblems(401, 403);
|
|
group.MapPost("/probes", CreateProbeAsync)
|
|
.Api("probes_create", "Create a probe identity; the private key is returned only once", ProbeTag)
|
|
.Returns<ProbeIdentityDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409);
|
|
group.MapGet("/probes/{id}", GetProbeAsync)
|
|
.Api("probes_get", "One probe", ProbeTag).Returns<ProbeDto>().ProducesProblems(401, 403, 404);
|
|
group.MapPatch("/probes/{id}", PatchProbeAsync)
|
|
.Api("probes_update", "Rename a probe", ProbeTag).Returns<ProbeDto>().ProducesProblems(400, 401, 403, 404, 409);
|
|
group.MapPost("/probes/{id}/rotate", RotateProbeAsync)
|
|
.Api("probes_rotate", "Give a probe a new key (same id): the old key stops working and its sessions are dropped; the new probe.json is returned only once", ProbeTag)
|
|
.Returns<ProbeIdentityDto>().ProducesProblems(401, 403, 404, 409);
|
|
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<ProbeDto>().ProducesProblems(401, 403, 404);
|
|
group.MapDelete("/probes/{id}", DeleteProbeAsync)
|
|
.Api("probes_delete", "Delete a probe and its lines; stored data expires with the retention policy", ProbeTag)
|
|
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
|
|
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<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 404, 409);
|
|
}
|
|
|
|
private static async Task<IResult> ListAsync(AgentService agents, CancellationToken ct) =>
|
|
TypedResults.Json(new PageDto<AgentDto>([.. (await agents.ListAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoAgentDto);
|
|
|
|
private static async Task<IResult> 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<IResult> PatchAsync(HttpContext http, AgentService agents, AgentPatchRequest request, string id, CancellationToken ct)
|
|
{
|
|
Result<AgentView> result = await agents.PatchAsync(
|
|
new NodeId(id), new AgentPatch { Name = request.Name, Labels = request.Labels, PublicHost = request.PublicHost, Ipv6Host = request.Ipv6Host, 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<IResult> TransitionAsync(HttpContext http, string id, Func<NodeId, CancellationToken, ValueTask<Result<AgentView>>> action)
|
|
{
|
|
Result<AgentView> 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<IResult> DeleteAsync(AgentService agents, string id, CancellationToken ct)
|
|
{
|
|
await agents.DeleteAsync(new NodeId(id), ct);
|
|
return TypedResults.NoContent();
|
|
}
|
|
|
|
private static async Task<IResult> ListTokensAsync(JoinTokenService tokens, CancellationToken ct) =>
|
|
TypedResults.Json(new PageDto<JoinTokenDto>([.. (await tokens.ListAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoJoinTokenDto);
|
|
|
|
private static async Task<IResult> CreateTokenAsync(HttpContext http, JoinTokenService tokens, CreateJoinTokenRequest request, BackendOptions options)
|
|
{
|
|
string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}";
|
|
Result<CreatedJoinToken> result = await tokens.CreateAsync(
|
|
new Application.CreateJoinTokenRequest(request.Note, request.Labels, request.AutoApprove ?? true, request.MaxUses ?? 1, request.ExpiresInHours ?? 24), baseUrl, 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<IResult> 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<IResult> ListProbesAsync(ProbeService probes, CancellationToken ct) =>
|
|
TypedResults.Json(new PageDto<ProbeDto>([.. (await probes.ListAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoProbeDto);
|
|
|
|
private static async Task<IResult> CreateProbeAsync(HttpContext http, ProbeService probes, BackendOptions options, CreateProbeRequest? request, CancellationToken ct)
|
|
{
|
|
Result<ProbeIdentityFile> result = await probes.CreateAsync(request?.Name, PanelUrl(http, options), ct);
|
|
if (!result.TryGet(out ProbeIdentityFile? file, out FleetError? error))
|
|
{
|
|
return Problems.From(http, error);
|
|
}
|
|
|
|
http.Response.Headers.CacheControl = "no-store";
|
|
http.Response.Headers.Location = "/api/admin/probes/" + file.ProbeId.Value;
|
|
return TypedResults.Json(ToIdentityDto(file), ApiJsonContext.Default.ProbeIdentityDto, statusCode: StatusCodes.Status201Created);
|
|
}
|
|
|
|
private static async Task<IResult> 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<IResult> 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<ProbeView> 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<IResult> RotateProbeAsync(HttpContext http, ProbeService probes, BackendOptions options, string id, CancellationToken ct)
|
|
{
|
|
Result<ProbeIdentityFile> result = await probes.RotateAsync(id, PanelUrl(http, options), ct);
|
|
if (!result.TryGet(out ProbeIdentityFile? file, out FleetError? error))
|
|
{
|
|
return Problems.From(http, error);
|
|
}
|
|
|
|
http.Response.Headers.CacheControl = "no-store";
|
|
return TypedResults.Json(ToIdentityDto(file), ApiJsonContext.Default.ProbeIdentityDto);
|
|
}
|
|
|
|
private static async Task<IResult> RevokeProbeAsync(HttpContext http, ProbeService probes, string id, CancellationToken ct)
|
|
{
|
|
Result<ProbeView> 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<IResult> DeleteProbeAsync(ProbeService probes, string id, CancellationToken ct)
|
|
{
|
|
await probes.DeleteAsync(id, ct);
|
|
return TypedResults.NoContent();
|
|
}
|
|
|
|
private static async Task<IResult> DisconnectProbeAsync(HttpContext http, ProbeService probes, string id, string probe_id, CancellationToken ct)
|
|
{
|
|
Result<bool> 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");
|
|
}
|
|
|
|
/// <summary>Probe 要连的面板地址:配置的公开地址,没有就用这次请求的地址。</summary>
|
|
private static string PanelUrl(HttpContext http, BackendOptions options) => (options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}").TrimEnd('/');
|
|
|
|
private static ProbeIdentityDto ToIdentityDto(ProbeIdentityFile file) =>
|
|
new(file.ProbeId.Value, file.PrivateKey, file.Server, new ProbeLimitsDto(file.Limits.MaxTestMbps, file.Limits.MaxTotalPps));
|
|
}
|