refactor!: rename the project from Monitor to MoShiro [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful

Namespaces, assemblies, solution, binaries, images, systemd units, config paths, environment variables, protocol methods and the Linux user are all renamed. Breaking: existing databases, node identities and installed units must be recreated.
This commit is contained in:
justme committed 2026-10-07 02:03:25 +08:00
1 parent 27ac7fb787
commit 2e146d4da7
309 files changed
+1565 -1572

No files matched your search

@@ -0,0 +1,222 @@
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, 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));
}
@@ -0,0 +1,91 @@
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>登录、退出、当前身份。登录接口限流,失败一律返回 401,不区分“用户不存在”和“密码错误”。</summary>
public static class AuthEndpoints
{
private const string Tag = "admin-auth";
/// <summary>登录不需要认证:挂在没有守卫的分组上。</summary>
public static void MapLogin(RouteGroupBuilder open)
{
open.MapPost("/auth/login", LoginAsync)
.Api("auth_login", "Sign in with a username and password", Tag)
.Returns<LoginResponse>().ProducesProblems(400, 401, 429);
}
public static void MapSession(RouteGroupBuilder guarded)
{
guarded.MapPost("/auth/logout", LogoutAsync)
.Api("auth_logout", "Sign out and clear the session cookie", Tag)
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401);
guarded.MapGet("/auth/me", Me)
.Api("auth_me", "The current identity", Tag)
.Returns<MeDto>().ProducesProblems(401);
}
private static async Task<IResult> LoginAsync(HttpContext http, LoginRequest request, AuthService auth, LoginRateLimiter limiter, BackendOptions options)
{
string source = http.Connection.RemoteIpAddress?.ToString() ?? "unknown";
if (!limiter.TryAcquire(source, out int retryAfter))
{
return Problems.RetryAfter(http, new FleetError(MoShiroErrors.TooManyRequests, "too many sign-in attempts"), retryAfter);
}
Result<LoginResult> result = await auth.LoginAsync(request.Username ?? "", request.Password ?? "", http.RequestAborted);
if (!result.TryGet(out LoginResult? login, out FleetError? error))
{
return Problems.From(http, error);
}
// 跨域的前端(return_session)不用 Cookie:会话令牌放在响应里,之后用 Bearer 带上
bool returnSession = request.ReturnSession == true;
if (!returnSession)
{
http.Response.Cookies.Append(
PrincipalResolver.SessionCookie,
login.SessionToken,
new CookieOptions
{
HttpOnly = true,
Secure = options.SecureCookies,
SameSite = SameSiteMode.Strict,
Path = "/",
Expires = DateTimeOffset.FromUnixTimeMilliseconds(login.ExpiresAtMs),
});
}
http.Response.Headers.CacheControl = "no-store";
return TypedResults.Json(new LoginResponse(login.UserId, login.Username, login.CsrfToken, Mappers.Time(login.ExpiresAtMs), returnSession ? login.SessionToken : null), ApiJsonContext.Default.LoginResponse);
}
private static async Task<IResult> LogoutAsync(HttpContext http, AuthService auth)
{
string? bearer = PrincipalResolver.BearerOf(http);
if (bearer is not null)
{
await auth.LogoutAsync(bearer, http.RequestAborted); // 登录时 return_session 拿到的会话令牌;API 令牌不是会话,这里什么也不会发生
}
else if (http.Request.Cookies.TryGetValue(PrincipalResolver.SessionCookie, out string? session))
{
await auth.LogoutAsync(session, http.RequestAborted);
}
http.Response.Cookies.Delete(PrincipalResolver.SessionCookie, new CookieOptions { Path = "/" });
return TypedResults.NoContent();
}
private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult<MeDto> Me(HttpContext http)
{
Principal principal = http.GetPrincipal();
return TypedResults.Json(
new MeDto(principal.Kind.ToString().ToLowerInvariant(), principal.Scope.ToString().ToLowerInvariant(), principal.UserId, principal.Username, principal.CsrfToken),
ApiJsonContext.Default.MeDto);
}
}
@@ -0,0 +1,63 @@
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);
group.MapGet("/releases", ListReleasesAsync)
.Api("releases_list", "The latest signed release manifest of each component", Tag).Returns<PageDto<ReleaseDto>>().ProducesProblems(401, 403);
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);
}
/// <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);
}
private static async Task<IResult> GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, CancellationToken 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)))))
.ToList();
http.Response.Headers.CacheControl = "no-store";
return TypedResults.Json(new BuildInfoDto(NodeEndpoints.Backend(withCommit: true), items), 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);
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);
}
@@ -0,0 +1,105 @@
using System.Text;
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>原始数据检索与下载、事件、指标重算进度。</summary>
public static class DataEndpoints
{
private const string Tag = "admin-data";
public static void Map(RouteGroupBuilder group)
{
group.MapGet("/raw/chunks", ListChunksAsync)
.Api("raw_chunks_list", "Raw chunk metadata", Tag).Returns<PageDto<RawChunkMetaDto>>().ProducesProblems(400, 401, 403);
group.MapGet("/raw/chunks/{chunk_id}", DownloadChunkAsync)
.Api("raw_chunks_get", "Download a raw chunk (application/vnd.moshiro.rawchunk)", Tag)
.Produces(StatusCodes.Status200OK, contentType: "application/vnd.moshiro.rawchunk").ProducesProblems(401, 403, 404, 410);
group.MapGet("/events", ListEventsAsync)
.Api("events_list", "Agent events", Tag).Returns<PageDto<EventDto>>().ProducesProblems(400, 401, 403);
group.MapGet("/analysis/recompute", RecomputeAsync)
.Api("analysis_recompute_get", "Progress of the metric backfill", Tag).Returns<RecomputeDto>().ProducesProblems(401, 403);
}
private static async Task<IResult> ListChunksAsync(
HttpContext http, RawBrowserService raw, string? agent_id, string? kind, string? test_id, DateTimeOffset? from, DateTimeOffset? to, int? limit, string? cursor)
{
int size = Math.Clamp(limit ?? 50, 1, 500);
long? after = null;
if (cursor is not null)
{
if (!long.TryParse(cursor, out long parsed))
{
return Problems.From(http, ValidationError.Single("cursor", "is not a valid cursor"));
}
after = parsed;
}
var query = new RawQuery(
agent_id is null ? null : new NodeId(agent_id), null, kind, test_id is null ? null : new TestId(test_id),
from?.ToUnixTimeMilliseconds(), to?.ToUnixTimeMilliseconds(), after, size + 1);
IReadOnlyList<RawChunkMeta> metas = await raw.ListAsync(query, http.RequestAborted);
bool more = metas.Count > size;
IReadOnlyList<RawChunkMeta> page = [.. metas.Take(size)];
// 游标按 start_ts 前进:下一页从最后一项的 start_ts + 1 开始(同一毫秒的多个块在同一页内给出)
string? next = more ? (page[^1].StartTs + 1).ToString(System.Globalization.CultureInfo.InvariantCulture) : null;
return TypedResults.Json(new PageDto<RawChunkMetaDto>([.. page.Select(Mappers.ToDto)], next), ApiJsonContext.Default.PageDtoRawChunkMetaDto);
}
private static async Task<IResult> DownloadChunkAsync(HttpContext http, RawBrowserService raw, string chunk_id)
{
if (!Guid.TryParse(chunk_id, out Guid id))
{
return Problems.Create(http, MoShiroErrors.ChunkNotFound, "chunk not found");
}
Result<(RawChunkMeta Meta, byte[] Bytes)> result = await raw.GetAsync(new ChunkId(id), http.RequestAborted);
if (!result.TryGet(out (RawChunkMeta Meta, byte[] Bytes) found, out FleetError? error))
{
return Problems.From(http, error);
}
http.Response.Headers.CacheControl = "private, max-age=3600";
return TypedResults.File(found.Bytes, "application/vnd.moshiro.rawchunk", chunk_id + ".msrc");
}
private static async Task<IResult> ListEventsAsync(
HttpContext http, EventService events, TimeProvider time, string? agent_id, string? kind, DateTimeOffset? from, DateTimeOffset? to, int? limit, string? cursor)
{
long now = time.GetUtcNow().ToUnixTimeMilliseconds();
long toMs = to?.ToUnixTimeMilliseconds() ?? now + 1;
long fromMs = from?.ToUnixTimeMilliseconds() ?? toMs - Steps.Day;
long? before = null;
if (cursor is not null)
{
if (!long.TryParse(cursor, out long parsed))
{
return Problems.From(http, ValidationError.Single("cursor", "is not a valid cursor"));
}
before = parsed;
}
int size = Math.Clamp(limit ?? 50, 1, 500);
IReadOnlyList<EventRecord> rows = await events.ListAsync(agent_id is null ? null : new NodeId(agent_id), kind, fromMs, toMs, before, size + 1, http.RequestAborted);
bool more = rows.Count > size;
IReadOnlyList<EventRecord> page = [.. rows.Take(size)];
string? next = more ? page[^1].Id.ToString(System.Globalization.CultureInfo.InvariantCulture) : null;
return TypedResults.Json(new PageDto<EventDto>([.. page.Select(Mappers.ToDto)], next), ApiJsonContext.Default.PageDtoEventDto);
}
private static async Task<IResult> RecomputeAsync(OpsService ops, CancellationToken ct)
{
RecomputeProgress p = ops.RecomputeProgress;
return TypedResults.Json(
new RecomputeDto(p.State, p.Metrics, p.WindowsDone, p.WindowsTotal, Mappers.Time(p.FromMs), Mappers.Time(p.ToMs), await ops.QueueLengthAsync(ct)),
ApiJsonContext.Default.RecomputeDto);
}
}
@@ -0,0 +1,59 @@
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>线路开关:哪些(Probe,Agent)要连。自动连接的总开关是设置 <c>fleet.auto_link</c>;这里看每一对的结果,并且手动强制开、关或取消手动设置。</summary>
public static class LinkEndpoints
{
private const string Tag = "admin-links";
public static void Map(RouteGroupBuilder group)
{
group.MapGet("/links", ListAsync)
.Api("links_list", "Every probe/agent pair: whether it is linked, the manual override and whether it is connected", Tag).Returns<LinkMatrixDto>().ProducesProblems(401, 403);
group.MapPut("/links/{probe_id}/{agent_id}", SetAsync)
.Api("links_set", "Force a link on or off, or give it back to the automatic setting (enabled: null)", Tag).Returns<LinkDto>().ProducesProblems(400, 401, 403, 404);
}
private static async Task<IResult> ListAsync(AgentService agents, ProbeService probes, LinkService links, CancellationToken ct)
{
LinkSnapshot snapshot = await links.SnapshotAsync(ct);
IReadOnlyList<AgentView> agentViews = await agents.ListAsync(ct);
var items = new List<LinkDto>();
foreach (ProbeView probe in (await probes.ListAsync(ct)).Where(p => !p.Revoked).OrderBy(p => p.Probe.Name, StringComparer.Ordinal))
{
foreach (AgentView agent in agentViews.OrderBy(a => a.Agent.Name, StringComparer.Ordinal))
{
items.Add(ToDto(snapshot, probe, agent));
}
}
return TypedResults.Json(new LinkMatrixDto(snapshot.Auto, items), ApiJsonContext.Default.LinkMatrixDto);
}
private static async Task<IResult> SetAsync(HttpContext http, AgentService agents, ProbeService probes, LinkService links, string probe_id, string agent_id, SetLinkRequest request, CancellationToken ct)
{
if (await probes.GetAsync(probe_id, ct) is not { Revoked: false } probe)
{
return Problems.Create(http, MoShiroErrors.ProbeNotFound, "probe not found");
}
if (await agents.GetAsync(new NodeId(agent_id), ct) is not { } agent)
{
return Problems.Create(http, MoShiroErrors.AgentNotFound, "agent not found");
}
await links.SetAsync(probe_id, agent_id, request.Enabled, ct);
await agents.AfterAgentSetChangedAsync(ct); // 重新下发:Probe 的 Agent 列表、Agent 允许的 Probe
return TypedResults.Json(ToDto(await links.SnapshotAsync(ct), probe, agent), ApiJsonContext.Default.LinkDto);
}
private static LinkDto ToDto(LinkSnapshot snapshot, ProbeView probe, AgentView agent) =>
new(probe.Probe.Id, probe.Probe.Name, agent.Agent.Id.Value, agent.Agent.Name, snapshot.IsLinked(probe.Probe.Id, agent.Agent.Id.Value), snapshot.OverrideOf(probe.Probe.Id, agent.Agent.Id.Value),
agent.Probes.Any(p => p.Id == probe.Probe.Id));
}
@@ -0,0 +1,99 @@
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>API 令牌、站点设置与保留期、用户。令牌明文只在创建的响应里出现一次。</summary>
public static class SystemEndpoints
{
private const string TokensTag = "admin-api-tokens";
private const string SettingsTag = "admin-settings";
private const string UsersTag = "admin-users";
public static void Map(RouteGroupBuilder group)
{
group.MapGet("/api-tokens", ListTokensAsync)
.Api("api_tokens_list", "API tokens", TokensTag).Returns<PageDto<ApiTokenDto>>().ProducesProblems(401, 403);
group.MapPost("/api-tokens", CreateTokenAsync)
.Api("api_tokens_create", "Create an API token (scope read or admin); the plaintext is returned only once", TokensTag)
.Returns<CreatedApiTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403);
group.MapDelete("/api-tokens/{id}", RevokeTokenAsync)
.Api("api_tokens_revoke", "Revoke an API token", TokensTag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
group.MapGet("/settings", ListSettingsAsync)
.Api("settings_list", "Site settings and retention periods", SettingsTag).Returns<PageDto<SettingDto>>().ProducesProblems(401, 403);
group.MapPatch("/settings", PatchSettingsAsync)
.Accepts<Dictionary<string, JsonElement>>("application/merge-patch+json", "application/json")
.Api("settings_update", "Change settings (merge-patch)", SettingsTag).Returns<PageDto<SettingDto>>().ProducesProblems(400, 401, 403);
group.MapGet("/users", ListUsersAsync)
.Api("users_list", "Panel users", UsersTag).Returns<PageDto<UserDto>>().ProducesProblems(401, 403);
group.MapPost("/users", CreateUserAsync)
.Api("users_create", "Create a panel user", UsersTag).Returns<UserDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409);
group.MapPatch("/users/{id}", ChangePasswordAsync)
.Api("users_update", "Change a user's password", UsersTag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(400, 401, 403);
}
private static async Task<IResult> ListTokensAsync(AuthService auth, CancellationToken ct) =>
TypedResults.Json(new PageDto<ApiTokenDto>([.. (await auth.ListApiTokensAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoApiTokenDto);
private static async Task<IResult> CreateTokenAsync(HttpContext http, AuthService auth, CreateApiTokenRequest request)
{
Result<CreatedApiToken> result = await auth.CreateApiTokenAsync(request.Name ?? "", request.Scope ?? "", http.RequestAborted);
if (!result.TryGet(out CreatedApiToken? created, out FleetError? error))
{
return Problems.From(http, error);
}
http.Response.Headers.CacheControl = "no-store";
return TypedResults.Json(new CreatedApiTokenDto(Mappers.ToDto(created.Record), created.Plaintext), ApiJsonContext.Default.CreatedApiTokenDto, statusCode: StatusCodes.Status201Created);
}
private static async Task<IResult> RevokeTokenAsync(AuthService auth, string id, CancellationToken ct)
{
await auth.RevokeApiTokenAsync(id, ct);
return TypedResults.NoContent();
}
private static async Task<IResult> ListSettingsAsync(SettingsService settings, CancellationToken ct) =>
TypedResults.Json(new PageDto<SettingDto>([.. (await settings.ListAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoSettingDto);
private static async Task<IResult> PatchSettingsAsync(HttpContext http, SettingsService settings, AgentService agents, Dictionary<string, JsonElement> changes)
{
Result<IReadOnlyList<SettingView>> result = await settings.PatchAsync(changes, http.RequestAborted);
if (result.IsSuccess && changes.ContainsKey(SettingsService.AutoLink))
{
await agents.AfterAgentSetChangedAsync(http.RequestAborted); // 自动连接开关变了:重新下发各 Probe 的 Agent 列表和各 Agent 允许的 Probe
}
return result.TryGet(out IReadOnlyList<SettingView>? views, out FleetError? error)
? TypedResults.Json(new PageDto<SettingDto>([.. views.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoSettingDto)
: Problems.From(http, error);
}
private static async Task<IResult> ListUsersAsync(AuthService auth, CancellationToken ct) =>
TypedResults.Json(new PageDto<UserDto>([.. (await auth.ListUsersAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoUserDto);
private static async Task<IResult> CreateUserAsync(HttpContext http, AuthService auth, CreateUserRequest request)
{
Result<UserRecord> result = await auth.CreateUserAsync(request.Username ?? "", request.Password ?? "", http.RequestAborted);
if (!result.TryGet(out UserRecord? user, out FleetError? error))
{
return Problems.From(http, error);
}
http.Response.Headers.Location = "/api/admin/users";
return TypedResults.Json(Mappers.ToDto(user), ApiJsonContext.Default.UserDto, statusCode: StatusCodes.Status201Created);
}
private static async Task<IResult> ChangePasswordAsync(HttpContext http, AuthService auth, ChangePasswordRequest request, string id)
{
Result<bool> result = await auth.ChangePasswordAsync(id, request.Password ?? "", http.RequestAborted);
return result.IsSuccess ? TypedResults.NoContent() : Problems.From(http, result.Error);
}
}
@@ -0,0 +1,149 @@
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 的覆盖、目标任务)与按需测试。参数按 Kind 的 ParamSpec 校验,并检查带宽预算。</summary>
public static class TaskEndpoints
{
private const string TasksTag = "admin-tasks";
private const string TestsTag = "admin-tests";
public static void Map(RouteGroupBuilder group)
{
group.MapGet("/tasks", ListAsync)
.Api("tasks_list", "Tasks: line templates, per-agent overrides and target tasks", TasksTag).Returns<PageDto<TaskDto>>().ProducesProblems(401, 403);
group.MapPost("/tasks", CreateAsync)
.Api("tasks_create", "Create a task; params are validated against the kind", TasksTag)
.Returns<TaskDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 404);
group.MapGet("/tasks/{id}", GetAsync)
.Api("tasks_get", "One task", TasksTag).Returns<TaskDto>().ProducesProblems(401, 403, 404);
group.MapPatch("/tasks/{id}", PatchAsync)
.Accepts<PatchTaskRequest>("application/merge-patch+json", "application/json")
.Api("tasks_update", "Change params, schedule, target or enabled (merge-patch, If-Match supported)", TasksTag)
.Returns<TaskDto>().ProducesProblems(400, 401, 403, 404, 412);
group.MapDelete("/tasks/{id}", DeleteAsync)
.Api("tasks_delete", "Delete a task", TasksTag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
group.MapPost("/lines/{line_id}/tests", StartTestAsync)
.Api("tests_start", "Start an on-demand test on a line", TestsTag)
.Returns<TestDto>(StatusCodes.Status202Accepted).ProducesProblems(400, 401, 403, 404, 409);
group.MapGet("/lines/{line_id}/tests", ListTestsAsync)
.Api("tests_list", "Recent tests of a line", TestsTag).Returns<PageDto<TestDto>>().ProducesProblems(401, 403);
group.MapPost("/tests/{id}/cancel", CancelAsync)
.Api("tests_cancel", "Cancel a running test", TestsTag).Returns<TestDto>().ProducesProblems(401, 403, 404);
}
private static async Task<IResult> ListAsync(TaskService tasks, string? agent_id, string? scope, CancellationToken ct)
{
IEnumerable<TaskRecord> all = await tasks.ListAsync(ct);
if (agent_id is not null)
{
all = all.Where(t => t.AgentId?.Value == agent_id);
}
if (scope is not null)
{
all = all.Where(t => t.Scope == scope);
}
return TypedResults.Json(new PageDto<TaskDto>([.. all.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoTaskDto);
}
private static async Task<IResult> CreateAsync(HttpContext http, TaskService tasks, CreateTaskRequest request, CancellationToken ct)
{
Result<TaskRecord> result = await tasks.CreateAsync(
new TaskInput(
request.Scope ?? "", request.Kind ?? "", request.Target, request.Params, request.Schedule, request.Enabled,
request.AgentId is null ? null : new NodeId(request.AgentId), request.BaseTaskId is null ? null : new TaskId(request.BaseTaskId)),
ct);
if (!result.TryGet(out TaskRecord? task, out FleetError? error))
{
return Problems.From(http, error);
}
http.Response.Headers.Location = "/api/admin/tasks/" + task.Id.Value;
Concurrency.SetETag(http, task.RowVersion);
return TypedResults.Json(Mappers.ToDto(task), ApiJsonContext.Default.TaskDto, statusCode: StatusCodes.Status201Created);
}
private static async Task<IResult> GetAsync(HttpContext http, TaskService tasks, string id, CancellationToken ct)
{
TaskRecord? task = await tasks.GetAsync(new TaskId(id), ct);
if (task is null)
{
return Problems.Create(http, MoShiroErrors.TaskNotFound, "task not found");
}
Concurrency.SetETag(http, task.RowVersion);
return TypedResults.Json(Mappers.ToDto(task), ApiJsonContext.Default.TaskDto);
}
private static async Task<IResult> PatchAsync(HttpContext http, TaskService tasks, PatchTaskRequest request, string id, CancellationToken ct)
{
Result<TaskRecord> result = await tasks.UpdateAsync(new TaskId(id), new TaskPatch(request.Params, request.Schedule, request.Enabled, request.Target), Concurrency.ExpectedRowVersion(http), ct);
if (!result.TryGet(out TaskRecord? task, out FleetError? error))
{
return Problems.From(http, error);
}
Concurrency.SetETag(http, task.RowVersion);
return TypedResults.Json(Mappers.ToDto(task), ApiJsonContext.Default.TaskDto);
}
private static async Task<IResult> DeleteAsync(TaskService tasks, string id, CancellationToken ct)
{
await tasks.DeleteAsync(new TaskId(id), ct);
return TypedResults.NoContent();
}
private static async Task<IResult> StartTestAsync(HttpContext http, TestService tests, LineService lines, StartTestRequestDto request, string line_id, CancellationToken ct)
{
Result<LineView> found = await lines.ResolveAsync(line_id, ct);
if (!found.TryGet(out LineView? line, out FleetError? lineError))
{
return Problems.From(http, lineError);
}
Result<TestRecord> result = await tests.StartAsync(line.Key.Agent, line.Key.Probe, new StartTestRequest(request.Plan ?? "", request.Params), "api", ct);
if (!result.TryGet(out TestRecord? test, out FleetError? error))
{
return Problems.From(http, error);
}
http.Response.Headers.Location = "/api/public/tests/" + test.Id.Value;
return TypedResults.Json(Mappers.ToDto(test), ApiJsonContext.Default.TestDto, statusCode: StatusCodes.Status202Accepted);
}
private static async Task<IResult> ListTestsAsync(HttpContext http, TestService tests, LineService lines, string line_id, int? limit, CancellationToken ct)
{
// 只给 Agent id(并且有多个 Probe)时列出这个 Agent 上所有 Probe 的测试
NodeId agent;
ProbeId? probe = null;
if (LineKey.TryParse(line_id, out LineKey key))
{
agent = key.Agent;
probe = key.Probe;
}
else
{
agent = new NodeId(line_id);
}
return TypedResults.Json(
new PageDto<TestDto>([.. (await tests.ListAsync(agent, probe, Math.Clamp(limit ?? 50, 1, 500), ct)).Select(Mappers.ToDto)], null),
ApiJsonContext.Default.PageDtoTestDto);
}
private static async Task<IResult> CancelAsync(HttpContext http, TestService tests, string id, CancellationToken ct)
{
Result<TestRecord> result = await tests.CancelAsync(new TestId(id), ct);
return result.TryGet(out TestRecord? test, out FleetError? error)
? TypedResults.Json(Mappers.ToDto(test), ApiJsonContext.Default.TestDto)
: Problems.From(http, error);
}
}
@@ -0,0 +1,135 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Json;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using MoShiro.Backend.Application;
namespace MoShiro.Backend.Api;
/// <summary>
/// Api 层的入口:服务注册、中间件、端点映射。新增资源时在 <see cref="MapMoShiroApi"/> 里加一行。
/// Api 只调用 Application;错误码到 HTTP 状态的映射只在这一层做一次(<see cref="ErrorCodeHttpMap"/>)。
/// </summary>
public static class ApiModule
{
public static IServiceCollection AddMoShiroApi(this IServiceCollection services)
{
services.AddSingleton<SseLimiter>();
services.Configure<JsonOptions>(options =>
{
JsonSerializerOptions json = options.SerializerOptions;
json.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
json.DictionaryKeyPolicy = null;
json.DefaultIgnoreCondition = JsonIgnoreCondition.Never;
json.Converters.Add(new Rfc3339Converter());
json.TypeInfoResolverChain.Insert(0, ApiJsonContext.Default);
});
// 框架自己产生的错误(请求体格式不对、路由不存在、未处理的异常)也统一成 problem+json,并带上 code 和 trace_id
services.AddProblemDetails(options => options.CustomizeProblemDetails = context =>
{
int status = context.ProblemDetails.Status ?? context.HttpContext.Response.StatusCode;
context.ProblemDetails.Extensions["code"] = DefaultCode(status);
context.ProblemDetails.Extensions["trace_id"] = System.Diagnostics.Activity.Current?.Id ?? context.HttpContext.TraceIdentifier;
if (status >= 500)
{
context.ProblemDetails.Detail = null;
}
});
services.AddOpenApi("v1");
return services;
}
/// <summary>
/// 跨域(可选):只对 <c>/api/public</c> 和 <c>/api/admin</c> 生效,只放行名单里的来源,且不带凭据(Cookie 不会跨域),
/// 所以跨域的前端只能用 Bearer 令牌。需要放在认证和路由之前。
/// </summary>
public static IApplicationBuilder UseMoShiroCors(this IApplicationBuilder app, string? origins)
{
string[] allowed = (origins ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (allowed.Length == 0)
{
return app;
}
bool any = allowed.Contains("*");
return app.Use(async (http, next) =>
{
string? origin = http.Request.Headers.Origin;
if (origin is null || !http.Request.Path.StartsWithSegments("/api") || !(any || allowed.Contains(origin.TrimEnd('/'), StringComparer.OrdinalIgnoreCase)))
{
await next(http);
return;
}
http.Response.Headers.AccessControlAllowOrigin = any ? "*" : origin;
http.Response.Headers.Vary = "Origin";
http.Response.Headers.AccessControlExposeHeaders = "Retry-After";
if (HttpMethods.IsOptions(http.Request.Method) && http.Request.Headers.ContainsKey("Access-Control-Request-Method"))
{
http.Response.Headers.AccessControlAllowMethods = "GET, POST, PATCH, PUT, DELETE, OPTIONS";
http.Response.Headers.AccessControlAllowHeaders = "Authorization, Content-Type, X-CSRF-Token";
http.Response.Headers.AccessControlMaxAge = "600";
http.Response.StatusCode = StatusCodes.Status204NoContent;
return;
}
await next(http);
});
}
/// <summary>认证中间件:解析 Bearer 令牌或会话 Cookie。需要放在路由之后、端点之前。</summary>
public static IApplicationBuilder UseMoShiroApi(this IApplicationBuilder app)
{
app.UseExceptionHandler();
app.UseStatusCodePages();
app.Use(PrincipalResolver.ResolveAsync);
return app;
}
public static IEndpointRouteBuilder MapMoShiroApi(this IEndpointRouteBuilder app)
{
RouteGroupBuilder publicGroup = app.MapGroup("/api/public").AddEndpointFilter(PublicGuard.InvokeAsync);
QueryEndpoints.Map(publicGroup);
LiveEndpoints.Map(publicGroup);
HostEndpoints.Map(publicGroup);
NodeEndpoints.Map(publicGroup);
CatalogEndpoints.Map(publicGroup);
RouteGroupBuilder admin = app.MapGroup("/api/admin");
AuthEndpoints.MapLogin(admin);
BuildEndpoints.MapPublish(admin.MapGroup(string.Empty).AddEndpointFilter(ReleaseGuard.InvokeAsync));
RouteGroupBuilder guarded = admin.MapGroup(string.Empty).AddEndpointFilter(AdminGuard.InvokeAsync);
AuthEndpoints.MapSession(guarded);
AgentEndpoints.Map(guarded);
TaskEndpoints.Map(guarded);
DataEndpoints.Map(guarded);
SystemEndpoints.Map(guarded);
BuildEndpoints.Map(guarded);
LinkEndpoints.Map(guarded);
OpsEndpoints.Map(app);
app.MapOpenApi("/openapi/v1.json");
return app;
}
private static string DefaultCode(int status) => status switch
{
400 => "validation_failed",
401 => "unauthorized",
403 => "forbidden",
404 => "not_found",
405 => "method_not_allowed",
413 => "payload_too_large",
415 => "unsupported_media_type",
429 => "too_many_requests",
503 => "storage_unavailable",
_ => "internal_error",
};
}
@@ -0,0 +1,25 @@
using System.Globalization;
using Microsoft.AspNetCore.Http;
namespace MoShiro.Backend.Api;
/// <summary>并发控制:配置类资源返回 ETag(行版本),PATCH/DELETE 支持 If-Match,不匹配时返回 412。</summary>
internal static class Concurrency
{
public static string ETag(long rowVersion) => "\"" + rowVersion.ToString(CultureInfo.InvariantCulture) + "\"";
public static void SetETag(HttpContext http, long rowVersion) => http.Response.Headers.ETag = ETag(rowVersion);
/// <summary>没有 If-Match(或 <c>*</c>)按“最后写入者获胜”处理,返回 null;写成不合法的值返回 -1(一定不匹配)。</summary>
public static long? ExpectedRowVersion(HttpContext http)
{
string? header = http.Request.Headers.IfMatch;
if (string.IsNullOrWhiteSpace(header) || header.Trim() == "*")
{
return null;
}
string trimmed = header.Trim().Trim('"');
return long.TryParse(trimmed, NumberStyles.None, CultureInfo.InvariantCulture, out long version) ? version : -1;
}
}
@@ -0,0 +1,25 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
namespace MoShiro.Backend.Api;
public static class EndpointConventions
{
/// <summary>每个端点都必须有 operationId(snake_case,资源_动作)、summary 和 tag。</summary>
public static RouteHandlerBuilder Api(this RouteHandlerBuilder builder, string operationId, string summary, string tag) =>
builder.WithName(operationId).WithSummary(summary).WithTags(tag);
public static RouteHandlerBuilder Returns<T>(this RouteHandlerBuilder builder, int status = StatusCodes.Status200OK) =>
builder.Produces<T>(status);
/// <summary>登记该端点可能返回的错误状态(problem+json),让 OpenAPI 文档完整。</summary>
public static RouteHandlerBuilder ProducesProblems(this RouteHandlerBuilder builder, params int[] statuses)
{
foreach (int status in statuses)
{
builder.Produces<ProblemDto>(status, Problems.ContentType);
}
return builder;
}
}
@@ -0,0 +1,106 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using MoShiro.Backend.Application;
using MoShiro.Protocol;
namespace MoShiro.Backend.Api;
public static class HttpContextPrincipalExtensions
{
private const string Key = "moshiro.principal";
public static Principal GetPrincipal(this HttpContext http) =>
http.Items.TryGetValue(Key, out object? value) && value is Principal principal ? principal : Principal.Anonymous;
public static void SetPrincipal(this HttpContext http, Principal principal) => http.Items[Key] = principal;
}
/// <summary>
/// 认证:<c>Authorization: Bearer &lt;api_token&gt;</c> 或会话 Cookie。作为中间件运行,只解析身份;授权由各层的守卫决定。
/// </summary>
public static class PrincipalResolver
{
public const string SessionCookie = "moshiro_session";
public const string CsrfHeader = "X-CSRF-Token";
public static string? BearerOf(HttpContext http)
{
string? header = http.Request.Headers.Authorization;
return header is not null && header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) ? header["Bearer ".Length..].Trim() : null;
}
public static async Task ResolveAsync(HttpContext http, Func<Task> next)
{
AuthService auth = http.RequestServices.GetRequiredService<AuthService>();
Principal? principal = null;
string? bearer = BearerOf(http);
if (bearer is not null)
{
principal = await auth.AuthenticateTokenAsync(bearer, http.RequestAborted);
if (principal is null && await auth.AuthenticateSessionAsync(bearer, http.RequestAborted) is { } viaSession)
{
// 登录时用 return_session 拿到的会话令牌:和 API 令牌一样走 Bearer,不是 Cookie,所以不需要 CSRF
principal = viaSession with { Kind = PrincipalKind.Token, CsrfToken = null };
}
}
else if (http.Request.Cookies.TryGetValue(SessionCookie, out string? session) && !string.IsNullOrEmpty(session))
{
principal = await auth.AuthenticateSessionAsync(session, http.RequestAborted);
}
http.SetPrincipal(principal ?? Principal.Anonymous);
await next();
}
}
/// <summary>管理层守卫:需要 admin 作用域;Cookie 会话的非 GET 请求必须带 <c>X-CSRF-Token</c>。</summary>
public static class AdminGuard
{
public static async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
HttpContext http = context.HttpContext;
Principal principal = http.GetPrincipal();
if (!principal.IsAdmin)
{
return principal.Scope != ApiScope.None
? Problems.Create(http, MoShiroErrors.Forbidden, "the token scope does not allow this operation")
: Problems.Create(http, MoShiroErrors.Unauthorized, "authentication is required");
}
if (principal.Kind == PrincipalKind.Session && !HttpMethods.IsGet(http.Request.Method) && !HttpMethods.IsHead(http.Request.Method) && !HttpMethods.IsOptions(http.Request.Method))
{
string? csrf = http.Request.Headers[PrincipalResolver.CsrfHeader];
if (csrf is null || principal.CsrfToken is null || !AuthService.FixedTimeEquals(csrf, principal.CsrfToken))
{
return Problems.Create(http, MoShiroErrors.CsrfFailed, "a valid X-CSRF-Token header is required");
}
}
return await next(context);
}
}
/// <summary>登记发布清单的守卫:管理员(会话要带 CSRF)或 release 令牌;release 令牌只能做这一件事。</summary>
public static class ReleaseGuard
{
public static async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) =>
context.HttpContext.GetPrincipal().Scope == ApiScope.Release ? await next(context) : await AdminGuard.InvokeAsync(context, next);
}
/// <summary>展示层守卫:站点公开时匿名可读;私有时需要登录或只读令牌。</summary>
public static class PublicGuard
{
public static async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
HttpContext http = context.HttpContext;
if (!http.GetPrincipal().CanRead && !await http.RequestServices.GetRequiredService<SettingsService>().IsSitePublicAsync(http.RequestAborted))
{
return Problems.Create(http, MoShiroErrors.SitePrivate, "this site is private; sign in or use a read-only token");
}
return await next(context);
}
/// <summary>被隐藏(public_visible = false)的线路只对已认证的访问者可见。</summary>
public static bool CanSeeHidden(HttpContext http) => http.GetPrincipal().CanRead;
}
@@ -0,0 +1,160 @@
using System.Text.Json;
using Fleet.Server;
using MoShiro.Backend.Analysis;
using MoShiro.Backend.Application;
using MoShiro.Protocol;
namespace MoShiro.Backend.Api;
/// <summary>应用层模型 → REST DTO。public 层的 DTO 只暴露可以公开的字段。</summary>
public static class Mappers
{
public static DateTimeOffset Time(long ms) => DateTimeOffset.FromUnixTimeMilliseconds(ms);
public static DateTimeOffset? Time(long? ms) => ms is { } v ? DateTimeOffset.FromUnixTimeMilliseconds(v) : null;
public static string StatusName(NodeStatus status) => status switch
{
NodeStatus.Active => "active",
NodeStatus.Disabled => "disabled",
_ => "pending",
};
public static TestSummaryDto? Summary(TestRecord? test) =>
test is null ? null : new TestSummaryDto(test.Id.Value, test.Plan, test.Status, Time(test.RequestedAtMs), Time(test.FinishedAtMs));
public static SummaryDto ToDto(SummaryResult summary) =>
new(summary.Values, summary.Samples.ToDictionary(s => s.Key, s => checked((long)s.Value)), summary.Coverage);
public static EventDto ToDto(EventRecord e) => new(e.Id, e.AgentId?.Value, e.Kind, Time(e.AtMs), e.Detail);
public static OverviewLineDto ToDto(OverviewLine line) =>
new(
line.Line.Key.Id, line.Line.Agent.Agent.Id.Value, line.Line.Probe.Id, line.Line.Probe.Name, line.Line.Agent.Agent.Name, line.Line.Agent.Agent.Labels,
line.Line.Agent.Online, line.Line.Connected, line.Line.Agent.EndpointChanges, ToDto(line.Summary), Summary(line.LastTest));
public static HostDto ToDto(HostSnapshot h) =>
new(h.CpuPercent, h.MemUsed, h.MemTotal, h.DiskUsed, h.DiskTotal, h.NetRxTotal, h.NetTxTotal, h.NetRxBps, h.NetTxBps, h.NetLinkMbps, h.UptimeSeconds, Time(h.AtUtcMs));
public static ConnectedProbeDto ToDto(ConnectedProbeInfo p) => new(p.Id, p.Version, p.Remote, Time(p.SinceMs));
public static ProbeDto ToDto(ProbeView v) =>
new(
v.Probe.Id, v.Probe.Name, Time(v.Probe.CreatedAtMs), Time(v.Probe.RevokedAtMs), v.Revoked, v.Online,
[.. v.Connections.Select(c => new ProbeConnectionDto(c.AgentId.Value, c.AgentName, c.AgentOnline, c.Version, c.Remote, Time(c.SinceMs)))]);
public static SeriesDto ToDto(SeriesResult r, bool live) =>
new(
r.Line.Id, r.Line.Agent.Value, r.Line.Probe.Value, Time(r.Range.FromMs), Time(r.Range.ToMs), Steps.Format(r.Range.StepMs), r.Plan.Name, live && r.Range.IsRelative, r.Plan.Degraded,
r.T, r.Values, ToDto(r.Summary), new EdgePartialDto(r.Range.FirstPartial, r.Range.LastPartial),
[.. r.Gaps.Select(g => new GapDto(Time(g.FromMs), Time(g.ToMs), g.Reason))], [.. r.Events.Select(ToDto)], r.Versions);
public static TestDto ToDto(TestDetail detail)
{
TestRecord t = detail.Test;
return new TestDto(
t.Id.Value, t.AgentId.Value, t.ProbeId.Value, t.Plan, detail.PhaseKinds, t.Status, t.Phase, t.ErrorCode, t.Trigger,
Time(t.RequestedAtMs), Time(t.StartedAtMs), Time(t.FinishedAtMs), detail.Result is null ? null : ToDto(detail.Result));
}
public static TestDto ToDto(TestRecord t) =>
new(t.Id.Value, t.AgentId.Value, t.ProbeId.Value, t.Plan, [.. (Plans.Find(t.Plan)?.Phases ?? []).Select(p => p.Kind)], t.Status, t.Phase, t.ErrorCode, t.Trigger, Time(t.RequestedAtMs), Time(t.StartedAtMs), Time(t.FinishedAtMs), null);
public static TestResultDto ToDto(TestResultView r) =>
new(
r.Metrics,
r.Scores.ToDictionary(s => s.Key, s => new ScoreDto(s.Value.Score, s.Value.Version, s.Value.Estimated, [.. s.Value.Dimensions.Select(d => new DimensionDto(d.Id, d.Label, d.Weight, d.Value, d.Score))], s.Value.Caveats)),
r.Series,
[.. r.Steps.Select(s => new TestStepDto(s.Index, s.TargetMbps, s.LossPct, s.JitterP95Ms, s.DeliveredMbps))],
r.Caveats);
public static AgentDto ToDto(AgentView view)
{
AgentRecord a = view.Agent;
LocalLimitsDto? limits = null;
if (a.LocalLimitsJson is { } json)
{
try
{
LocalLimits? parsed = JsonSerializer.Deserialize(json, ApiJsonContext.Default.LocalLimits);
limits = parsed is null ? null : new LocalLimitsDto(parsed.MaxTotalMbps, parsed.MaxTotalPps, parsed.AllowedTargetCidrs, parsed.MaxConcurrentTests);
}
catch (JsonException)
{
// 无法解析就当作没有上报
}
}
IReadOnlyList<RejectedDto> rejected = [];
if (a.RejectedJson is { } rejectedJson)
{
try
{
rejected = [.. (JsonSerializer.Deserialize(rejectedJson, ApiJsonContext.Default.ListRejectedItem) ?? []).Select(r => new RejectedDto(r.Id, r.Reason))];
}
catch (JsonException)
{
// 同上
}
}
HeartbeatDto? heartbeat = view.Heartbeat is { } h
? new HeartbeatDto(h.Sessions, h.PpsIn, h.PpsOut, h.DroppedInvalid, h.DroppedRateLimited, h.QueueBytes, Time(view.HeartbeatAtMs ?? 0))
: null;
return new AgentDto(
a.Id.Value, a.Name, StatusName(a.Status), view.Online, view.ProbeConnected, [.. view.Probes.Select(ToDto)], view.Host is null ? null : ToDto(view.Host), a.PublicHost, a.ProbeTcpPort, a.ProbeUdpPorts, a.Labels, a.PublicVisible,
a.Kinds, a.AgentVersion, limits, a.TaskVersion, a.AppliedTaskVersion, rejected, heartbeat, view.EndpointChanges, Time(a.CreatedAtMs), Time(a.LastSeenAtMs));
}
public static TaskDto ToDto(TaskRecord t) =>
new(t.Id.Value, t.Scope, t.AgentId?.Value, t.BaseTaskId?.Value, t.Kind, t.Target, t.Params, t.Schedule, t.Enabled, Time(t.UpdatedAtMs));
public static JoinTokenDto ToDto(JoinTokenInfo t) =>
new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs));
public static ApiTokenDto ToDto(ApiTokenRecord t) => new(t.Id, t.Name, t.Scope, Time(t.CreatedAtMs), Time(t.LastUsedAtMs), Time(t.RevokedAtMs));
public static RawChunkMetaDto ToDto(RawChunkMeta m) =>
new(m.ChunkId.ToString(), m.AgentId.Value, m.Series, m.Kind, m.TaskId?.Value, m.TestId?.Value, m.Phase, Time(m.StartTs), m.DurationMs, m.Partial, m.Length);
public static SettingDto ToDto(SettingView s) => new(s.Key, s.Type, s.Value, s.Default, s.Description);
public static UserDto ToDto(UserRecord u) => new(u.Id, u.Username, Time(u.CreatedAtMs));
// ── 目录 ──
public static KindDto ToDto(KindSpec k) =>
new(
k.Id, k.Title, k.Description,
[.. Enum.GetValues<Scope>().Where(s => k.Scopes.HasFlag(s)).Select(s => s.ToString().ToLowerInvariant())],
new BlocksDto(k.Blocks.Transport, k.Blocks.Pacer, k.Blocks.Payload, k.Blocks.Behavior, k.Blocks.Samplers),
[.. k.Params.Select(ToDto)],
[.. k.Streams.Select(s => new StreamDto(s.Name, [.. s.Columns.Select(c => new ColumnDto(c.Name, RawChunkCodec.TypeName(c.Type), c.Enc.ToString().ToLowerInvariant(), c.Nullable, RoleName(c.Role)))]))]);
public static ParamDto ToDto(ParamSpec p) => p switch
{
IntParam i => new ParamDto(i.Name, "int", i.DefaultValue, i.Min, i.Max, i.Unit, null, i.Description),
NumberParam n => new ParamDto(n.Name, "number", n.DefaultValue, n.Min, n.Max, n.Unit, null, n.Description),
BoolParam b => new ParamDto(b.Name, "bool", b.DefaultValue, null, null, null, null, b.Description),
EnumParam e => new ParamDto(e.Name, "enum", e.DefaultValue, null, null, null, e.Values, e.Description),
_ => new ParamDto(p.Name, "unknown", p.DefaultValue, null, null, null, null, p.Description),
};
public static PlanDto ToDto(PlanSpec p) =>
new(p.Id, p.Title, p.Description, [.. p.Phases.Select(ph => new PhaseDto(ph.Kind, ph.DurationSeconds, JsonSerializer.SerializeToElement(ph.Overrides.ToDictionary(o => o.Key, o => o.Value), ApiJsonContext.Default.SettingsPatch)))]);
public static MetricDto ToDto(MetricDef def, MetricExport export) =>
new(
export.Id, def.Id, def.Kind.ToString().ToLowerInvariant(), Metric.StatName(export.Stat), def.Label, def.Description,
export.Unit.ToString().ToLowerInvariant(), def.Better.ToString().ToLowerInvariant(), export.Good, export.Poor, def.Fingerprint, def.From);
public static ModelDto ToDto(IServiceModel m) => new(m.Id, m.Title, m.Version, m.Needs);
private static string? RoleName(ColumnRole role) => role switch
{
ColumnRole.TsProbe => "ts_probe",
ColumnRole.TsAgent => "ts_agent",
ColumnRole.TsLocal => "ts_local",
_ => null,
};
}
@@ -0,0 +1,95 @@
using System.Diagnostics;
using Fleet.Core;
using Microsoft.AspNetCore.Http;
using MoShiro.Backend.Application;
using MoShiro.Protocol;
namespace MoShiro.Backend.Api;
/// <summary>错误码到 HTTP 状态的映射:只在 Api 层做一次。</summary>
public static class ErrorCodeHttpMap
{
// 必须声明在 s_map 之前:静态字段按文本顺序初始化,BuildMap 会用到它
public static readonly ErrorCode NotImplemented = new("not_implemented");
private static readonly Dictionary<string, int> s_map = BuildMap();
public static int StatusOf(ErrorCode code) => s_map.GetValueOrDefault(code.Value, StatusCodes.Status500InternalServerError);
public static string TitleOf(int status) => status switch
{
400 => "Validation failed",
401 => "Unauthorized",
403 => "Forbidden",
404 => "Not found",
409 => "Conflict",
410 => "Gone",
412 => "Precondition failed",
413 => "Payload too large",
422 => "Unprocessable request",
429 => "Too many requests",
501 => "Not implemented",
503 => "Service unavailable",
_ => "Internal error",
};
private static Dictionary<string, int> BuildMap()
{
var map = new Dictionary<string, int>(StringComparer.Ordinal);
void Add(int status, params ErrorCode[] codes)
{
foreach (ErrorCode code in codes)
{
map[code.Value] = status;
}
}
Add(400, MoShiroErrors.ValidationFailed, MoShiroErrors.BudgetExceeded, FleetErrors.InvalidParams);
Add(401, MoShiroErrors.Unauthorized, MoShiroErrors.CsrfFailed, FleetErrors.Unauthorized);
Add(403, MoShiroErrors.Forbidden, MoShiroErrors.SitePrivate, FleetErrors.Forbidden, FleetErrors.ProofInvalid);
Add(
404, MoShiroErrors.NotFound, MoShiroErrors.AgentNotFound, MoShiroErrors.ProbeNotFound, MoShiroErrors.TaskNotFound, MoShiroErrors.TestNotFound, MoShiroErrors.ChunkNotFound,
MoShiroErrors.TokenNotFound, MoShiroErrors.UnknownKind, MoShiroErrors.UnknownPlan, FleetErrors.NotFound);
Add(409, MoShiroErrors.NameConflict, MoShiroErrors.ProbeRevoked, MoShiroErrors.ProbeOffline, MoShiroErrors.TestBusy, MoShiroErrors.AgentNotActive, MoShiroErrors.AgentOffline, FleetErrors.NameConflict, FleetErrors.NodeOffline);
Add(410, MoShiroErrors.RawExpired, MoShiroErrors.JoinTokenExpired, FleetErrors.JoinTokenExpired);
Add(412, MoShiroErrors.PreconditionFailed);
Add(413, MoShiroErrors.PayloadTooLarge);
Add(422, MoShiroErrors.ProbeRequired, MoShiroErrors.RangeTooLarge, MoShiroErrors.InvalidStep, MoShiroErrors.InvalidRange, MoShiroErrors.UnknownMetric, MoShiroErrors.UnknownPreset, MoShiroErrors.TooManyMetrics, FleetErrors.Unsupported);
Add(429, MoShiroErrors.TooManyRequests, MoShiroErrors.TooManyStreams);
Add(503, MoShiroErrors.StorageUnavailable, FleetErrors.Timeout);
map[NotImplemented.Value] = 501;
return map;
}
}
/// <summary>所有 4xx/5xx 响应都使用 problem+json;5xx 不泄露异常信息,只提供 trace_id。</summary>
public static class Problems
{
public const string ContentType = "application/problem+json";
public static IResult From(HttpContext http, FleetError error)
{
int status = ErrorCodeHttpMap.StatusOf(error.Code);
IReadOnlyDictionary<string, string[]>? fields = error is ValidationError v
? v.Fields.ToDictionary(f => f.Key, f => new[] { f.Value })
: null;
return Create(http, status, error.Code.Value, status >= 500 ? null : error.Message, fields);
}
public static IResult Create(HttpContext http, ErrorCode code, string? detail) =>
Create(http, ErrorCodeHttpMap.StatusOf(code), code.Value, detail, null);
public static IResult Create(HttpContext http, int status, string code, string? detail, IReadOnlyDictionary<string, string[]>? errors = null)
{
string? trace = Activity.Current?.Id ?? http.TraceIdentifier;
var problem = new ProblemDto("about:blank", ErrorCodeHttpMap.TitleOf(status), status, code, detail, trace, errors);
return TypedResults.Json(problem, ApiJsonContext.Default.ProblemDto, ContentType, status);
}
public static IResult RetryAfter(HttpContext http, FleetError error, int seconds)
{
http.Response.Headers.RetryAfter = seconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
return From(http, error);
}
}
@@ -0,0 +1,95 @@
using System.Security.Cryptography;
using System.Text;
using Fleet.Core;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using MoShiro.Backend.Application;
using MoShiro.Protocol;
namespace MoShiro.Backend.Api;
internal static class QueryHelpers
{
/// <summary>查询的统一超时(默认 10 s):超时返回 503,而不是让请求一直挂着。</summary>
public static async Task<IResult> GuardedAsync(HttpContext http, Func<CancellationToken, Task<IResult>> work)
{
BackendOptions options = http.RequestServices.GetRequiredService<BackendOptions>();
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(http.RequestAborted);
timeout.CancelAfter(options.QueryTimeout);
try
{
return await work(timeout.Token);
}
catch (OperationCanceledException) when (!http.RequestAborted.IsCancellationRequested)
{
return Problems.Create(http, MoShiroErrors.StorageUnavailable, null);
}
}
/// <summary>
/// 缓存策略:已经结束的绝对区间(<c>to</c> 早于当前时间 − 2 分钟)可以永久缓存,并带 ETag;相对时间段不缓存。
/// 返回非空时表示命中 If-None-Match,直接返回 304。
/// </summary>
public static IResult? ApplyCaching(HttpContext http, QueryRange range, long nowMs, string material)
{
bool closed = !range.IsRelative && range.RequestedToMs < nowMs - (2 * Steps.Minute);
if (!closed)
{
http.Response.Headers.CacheControl = "no-store";
return null;
}
string etag = ETagOf(http.Request.Path + http.Request.QueryString.Value + material);
http.Response.Headers.CacheControl = "public, max-age=31536000, immutable";
http.Response.Headers.ETag = etag;
return MatchesIfNoneMatch(http, etag) ? TypedResults.StatusCode(StatusCodes.Status304NotModified) : null;
}
/// <summary>目录:<c>max-age=300</c>,并带 ETag。</summary>
public static IResult? ApplyCatalogCaching(HttpContext http, string material)
{
string etag = ETagOf(http.Request.Path + material);
http.Response.Headers.CacheControl = "public, max-age=300";
http.Response.Headers.ETag = etag;
return MatchesIfNoneMatch(http, etag) ? TypedResults.StatusCode(StatusCodes.Status304NotModified) : null;
}
public static string ETagOf(string material) =>
"\"" + Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(material)))[..32] + "\"";
private static bool MatchesIfNoneMatch(HttpContext http, string etag)
{
foreach (string? candidate in http.Request.Headers.IfNoneMatch)
{
if (candidate is not null && candidate.Split(',', StringSplitOptions.TrimEntries).Contains(etag))
{
return true;
}
}
return false;
}
/// <summary>线路 id(<c>agent~probe</c>,或只有一个 Probe 时的 Agent id)→ 可以展示的线路。否则 404 / 422(不暴露被隐藏的线路存在与否)。</summary>
public static async ValueTask<Result<LineView>> FindVisibleLineAsync(HttpContext http, LineService lines, string id, CancellationToken ct)
{
Result<LineView> found = await lines.ResolveAsync(id, ct);
if (!found.TryGet(out LineView? line, out FleetError? error))
{
return Result<LineView>.Fail(error);
}
return line.Agent.Agent.Status == Fleet.Server.NodeStatus.Active && (line.Agent.Agent.PublicVisible || PublicGuard.CanSeeHidden(http)) && line.Probe.RevokedAtMs is null
? found
: Result<LineView>.Fail(MoShiroErrors.AgentNotFound, "line not found");
}
/// <summary>找到可以展示的线路:已激活,并且(对外可见,或者访问者已认证)。否则 404(不暴露被隐藏的线路存在与否)。</summary>
public static async ValueTask<Result<AgentView>> FindVisibleAgentAsync(HttpContext http, AgentService agents, string id, CancellationToken ct)
{
AgentView? view = await agents.GetAsync(new NodeId(id), ct);
return view is { Agent.Status: Fleet.Server.NodeStatus.Active } && (view.Agent.PublicVisible || PublicGuard.CanSeeHidden(http))
? Result<AgentView>.Ok(view)
: Result<AgentView>.Fail(MoShiroErrors.AgentNotFound, "agent not found");
}
}
@@ -0,0 +1,144 @@
using System.Text.Json;
namespace MoShiro.Backend.Api;
/// <summary><c>return_session</c>:跨域的前端用——不设 Cookie,把会话令牌放在响应里,之后用 <c>Authorization: Bearer</c> 带上。</summary>
public sealed record LoginRequest(string Username, string Password, bool? ReturnSession);
public sealed record LoginResponse(string UserId, string Username, string CsrfToken, DateTimeOffset ExpiresAt, string? SessionToken);
public sealed record MeDto(string Kind, string Scope, string? UserId, string? Username, string? CsrfToken);
public sealed record LocalLimitsDto(double MaxTotalMbps, long MaxTotalPps, IReadOnlyList<string> AllowedTargetCidrs, int MaxConcurrentTests);
public sealed record RejectedDto(string Id, string Reason);
public sealed record HeartbeatDto(int Sessions, long PpsIn, long PpsOut, long DroppedInvalid, long DroppedRateLimited, long QueueBytes, DateTimeOffset At);
public sealed record AgentDto(
string Id,
string Name,
string Status,
bool Online,
bool ProbeConnected,
IReadOnlyList<ConnectedProbeDto> Probes,
HostDto? Host,
string PublicHost,
int ProbeTcpPort,
IReadOnlyList<int> ProbeUdpPorts,
IReadOnlyList<string> Labels,
bool PublicVisible,
IReadOnlyList<string> Kinds,
string? AgentVersion,
LocalLimitsDto? LocalLimits,
long TaskVersion,
long AppliedTaskVersion,
IReadOnlyList<RejectedDto> Rejected,
HeartbeatDto? Heartbeat,
int EndpointChanges,
DateTimeOffset CreatedAt,
DateTimeOffset? LastSeenAt);
/// <summary>merge-patch:缺省或为 null 的字段不修改。</summary>
public sealed record AgentPatchRequest(string? Name, IReadOnlyList<string>? Labels, string? PublicHost, bool? PublicVisible);
public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList<string> Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt);
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours);
/// <summary>令牌明文只在创建的响应里出现一次。</summary>
public sealed record InstallCommandsDto(string Linux, string Macos, string Windows);
public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands);
/// <summary>Agent 眼中连着的一个 Probe(Agent 上报)。</summary>
public sealed record ConnectedProbeDto(string ProbeId, string? Version, string? Remote, DateTimeOffset ConnectedSince);
/// <summary>某个 Probe 现在连着的一个 Agent。</summary>
public sealed record ProbeConnectionDto(string AgentId, string AgentName, bool AgentOnline, string? Version, string? Remote, DateTimeOffset ConnectedSince);
public sealed record ProbeDto(string Id, string Name, DateTimeOffset CreatedAt, DateTimeOffset? RevokedAt, bool Revoked, bool Online, IReadOnlyList<ProbeConnectionDto> Connections);
/// <summary>一对(Probe,Agent)的线路开关:<c>linked</c> 是最终结果,<c>override</c> 是手动设置(<c>null</c> = 跟随自动连接),<c>connected</c> 是现在有没有连着。</summary>
public sealed record LinkDto(string ProbeId, string ProbeName, string AgentId, string AgentName, bool Linked, bool? Override, bool Connected);
public sealed record LinkMatrixDto(bool AutoLink, IReadOnlyList<LinkDto> Items);
public sealed record SetLinkRequest(bool? Enabled);
public sealed record ProbeLimitsDto(double MaxTestMbps, long MaxTotalPps);
/// <summary><c>probe.json</c>:含私钥,只在生成时返回一次。</summary>
public sealed record ProbeIdentityDto(string ProbeId, string PrivateKey, string Server, ProbeLimitsDto Limits);
public sealed record CreateProbeRequest(string? Name);
public sealed record PatchProbeRequest(string? Name);
public sealed record TaskDto(
string Id,
string Scope,
string? AgentId,
string? BaseTaskId,
string Kind,
string? Target,
IReadOnlyDictionary<string, JsonElement> Params,
string Schedule,
bool Enabled,
DateTimeOffset UpdatedAt);
public sealed record CreateTaskRequest(
string Scope,
string Kind,
string? Target,
Dictionary<string, JsonElement>? Params,
string? Schedule,
bool? Enabled,
string? AgentId,
string? BaseTaskId);
public sealed record PatchTaskRequest(Dictionary<string, JsonElement>? Params, string? Schedule, bool? Enabled, string? Target);
public sealed record StartTestRequestDto(string Plan, Dictionary<string, JsonElement>? Params);
public sealed record RawChunkMetaDto(
string ChunkId,
string AgentId,
string Series,
string Kind,
string? TaskId,
string? TestId,
int? Phase,
DateTimeOffset Start,
int DurationMs,
bool Partial,
int Bytes);
public sealed record ApiTokenDto(string Id, string Name, string Scope, DateTimeOffset CreatedAt, DateTimeOffset? LastUsedAt, DateTimeOffset? RevokedAt);
public sealed record CreateApiTokenRequest(string Name, string Scope);
public sealed record CreatedApiTokenDto(ApiTokenDto Token, string Plaintext);
public sealed record SettingDto(string Key, string Type, string Value, string Default, string Description);
public sealed record UserDto(string Id, string Username, DateTimeOffset CreatedAt);
public sealed record CreateUserRequest(string Username, string Password);
public sealed record ChangePasswordRequest(string Password);
public sealed record RecomputeDto(string State, IReadOnlyList<string> Metrics, long WindowsDone, long WindowsTotal, DateTimeOffset? From, DateTimeOffset? To, int QueueLength);
/// <summary>管理端看的构建信息:每个节点自报的版本、tag、提交和构建时间,以及系统。</summary>
public sealed record NodeBuildDto(string Id, string Kind, string? Os, string? OsName, string? Arch, string? Version, string? Tag, string? Commit, string? BuiltAt, NodeUpdateDto? Update = null);
/// <summary>节点宿主机上的更新器报告的状态(<c>up_to_date</c>、<c>available</c>、<c>applying</c>、<c>failed</c>、<c>rolled_back</c>、<c>invalid</c>);没装更新器的节点没有这个字段。</summary>
public sealed record NodeUpdateDto(string State, string? CurrentDigest, string? AvailableTag, string? AvailableVersion, string? Error, DateTimeOffset? At);
/// <summary>面板登记的最新发布清单(CI 签名的;验签在节点上)。</summary>
public sealed record ReleaseDto(string Component, long Seq, string Tag, string Version, string Digest);
public sealed record PublishReleaseRequest(string? Manifest, string? Signature);
public sealed record BuildInfoDto(BuildDto Backend, IReadOnlyList<NodeBuildDto> Nodes);
@@ -0,0 +1,85 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace MoShiro.Backend.Api;
/// <summary>
/// REST 的 JSON 源生成上下文(snake_case,字段始终存在、未知值写 null,枚举与时间的格式见数据表示规则)。
/// 新增 DTO 时在这里登记一行,不使用反射。
/// </summary>
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower,
DictionaryKeyPolicy = JsonKnownNamingPolicy.Unspecified,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
Converters = [typeof(Rfc3339Converter)],
NumberHandling = JsonNumberHandling.Strict)]
[JsonSerializable(typeof(ProblemDto))]
[JsonSerializable(typeof(HealthDto))]
[JsonSerializable(typeof(IdDto))]
[JsonSerializable(typeof(OverviewDto))]
[JsonSerializable(typeof(SeriesDto))]
[JsonSerializable(typeof(CompareDto))]
[JsonSerializable(typeof(DistributionDto))]
[JsonSerializable(typeof(HeatmapDto))]
[JsonSerializable(typeof(LineDto))]
[JsonSerializable(typeof(TestDto))]
[JsonSerializable(typeof(PageDto<TestDto>))]
[JsonSerializable(typeof(PageDto<KindDto>))]
[JsonSerializable(typeof(PageDto<PlanDto>))]
[JsonSerializable(typeof(PageDto<MetricDto>))]
[JsonSerializable(typeof(PageDto<PresetDto>))]
[JsonSerializable(typeof(PageDto<ModelDto>))]
[JsonSerializable(typeof(LiveNodeDto))]
[JsonSerializable(typeof(LiveTestDto))]
[JsonSerializable(typeof(LoginRequest))]
[JsonSerializable(typeof(LoginResponse))]
[JsonSerializable(typeof(MeDto))]
[JsonSerializable(typeof(AgentDto))]
[JsonSerializable(typeof(PageDto<AgentDto>))]
[JsonSerializable(typeof(AgentPatchRequest))]
[JsonSerializable(typeof(LinkDto))]
[JsonSerializable(typeof(LinkMatrixDto))]
[JsonSerializable(typeof(SetLinkRequest))]
[JsonSerializable(typeof(BuildDto))]
[JsonSerializable(typeof(ReleaseDto))]
[JsonSerializable(typeof(PageDto<ReleaseDto>))]
[JsonSerializable(typeof(PublishReleaseRequest))]
[JsonSerializable(typeof(AboutDto))]
[JsonSerializable(typeof(NodeBuildDto))]
[JsonSerializable(typeof(BuildInfoDto))]
[JsonSerializable(typeof(NodeInfoDto))]
[JsonSerializable(typeof(PageDto<NodeInfoDto>))]
[JsonSerializable(typeof(JoinTokenDto))]
[JsonSerializable(typeof(PageDto<JoinTokenDto>))]
[JsonSerializable(typeof(CreateJoinTokenRequest))]
[JsonSerializable(typeof(CreatedJoinTokenDto))]
[JsonSerializable(typeof(ProbeIdentityDto))]
[JsonSerializable(typeof(CreateProbeRequest))]
[JsonSerializable(typeof(PatchProbeRequest))]
[JsonSerializable(typeof(ProbeDto))]
[JsonSerializable(typeof(PageDto<HostItemDto>))]
[JsonSerializable(typeof(HostSeriesDto))]
[JsonSerializable(typeof(LiveHostDto))]
[JsonSerializable(typeof(PageDto<ProbeDto>))]
[JsonSerializable(typeof(TaskDto))]
[JsonSerializable(typeof(PageDto<TaskDto>))]
[JsonSerializable(typeof(CreateTaskRequest))]
[JsonSerializable(typeof(PatchTaskRequest))]
[JsonSerializable(typeof(StartTestRequestDto))]
[JsonSerializable(typeof(RawChunkMetaDto))]
[JsonSerializable(typeof(PageDto<RawChunkMetaDto>))]
[JsonSerializable(typeof(PageDto<EventDto>))]
[JsonSerializable(typeof(ApiTokenDto))]
[JsonSerializable(typeof(PageDto<ApiTokenDto>))]
[JsonSerializable(typeof(CreateApiTokenRequest))]
[JsonSerializable(typeof(CreatedApiTokenDto))]
[JsonSerializable(typeof(PageDto<SettingDto>))]
[JsonSerializable(typeof(Dictionary<string, JsonElement>), TypeInfoPropertyName = "SettingsPatch")]
[JsonSerializable(typeof(PageDto<UserDto>))]
[JsonSerializable(typeof(UserDto))]
[JsonSerializable(typeof(CreateUserRequest))]
[JsonSerializable(typeof(ChangePasswordRequest))]
[JsonSerializable(typeof(RecomputeDto))]
[JsonSerializable(typeof(MoShiro.Protocol.LocalLimits))]
[JsonSerializable(typeof(List<Fleet.Rpc.RejectedItem>))]
public sealed partial class ApiJsonContext : JsonSerializerContext;
@@ -0,0 +1,33 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace MoShiro.Backend.Api;
/// <summary>时间点统一使用 RFC 3339 UTC,以 Z 结尾,精确到毫秒(唯一例外:列式时序里的时间轴 <c>t</c> 是 Unix 毫秒)。</summary>
public sealed class Rfc3339Converter : JsonConverter<DateTimeOffset>
{
private const string Format = "yyyy-MM-dd'T'HH:mm:ss.fff'Z'";
public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
DateTimeOffset.Parse(reader.GetString() ?? throw new JsonException("timestamp must be a string"), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal);
public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options) =>
writer.WriteStringValue(value.UtcDateTime.ToString(Format, CultureInfo.InvariantCulture));
}
/// <summary>RFC 9457 problem+json,附加 <c>code</c>(客户端判断的唯一依据)、<c>trace_id</c> 和 400 时的逐字段 <c>errors</c>。</summary>
public sealed record ProblemDto(
string Type,
string Title,
int Status,
string Code,
string? Detail,
string? TraceId,
IReadOnlyDictionary<string, string[]>? Errors);
public sealed record PageDto<T>(IReadOnlyList<T> Items, string? NextCursor);
public sealed record IdDto(string Id);
public sealed record HealthDto(string Status, bool Database, bool RawDirectory);
@@ -0,0 +1,185 @@
namespace MoShiro.Backend.Api;
/// <summary>汇总:整个时间段由累加器精确合并得出的值(不是各点的平均)。</summary>
public sealed record SummaryDto(IReadOnlyDictionary<string, double?> Metrics, IReadOnlyDictionary<string, long> Samples, double Coverage);
public sealed record EdgePartialDto(bool First, bool Last);
public sealed record GapDto(DateTimeOffset From, DateTimeOffset To, string Reason);
public sealed record EventDto(long Id, string? AgentId, string Kind, DateTimeOffset At, string? Detail);
public sealed record TestSummaryDto(string Id, string Plan, string Status, DateTimeOffset RequestedAt, DateTimeOffset? FinishedAt);
public sealed record OverviewLineDto(
string Id,
string AgentId,
string ProbeId,
string ProbeName,
string Name,
IReadOnlyList<string> Labels,
bool Online,
bool ProbeConnected,
int EndpointChanges,
SummaryDto Summary,
TestSummaryDto? LastTest);
public sealed record OverviewDto(string Range, DateTimeOffset From, DateTimeOffset To, string Step, string Source, bool Degraded, IReadOnlyList<OverviewLineDto> Lines);
/// <summary>列式响应:时间轴 <c>t</c>(Unix 毫秒)只出现一次,<c>values</c> 里每个数组的长度都与 <c>t</c> 相同;没有数据的桶是 null。</summary>
public sealed record SeriesDto(
string LineId,
string AgentId,
string ProbeId,
DateTimeOffset From,
DateTimeOffset To,
string Step,
string Source,
bool Live,
bool Degraded,
IReadOnlyList<long> T,
IReadOnlyDictionary<string, double?[]> Values,
SummaryDto Summary,
EdgePartialDto EdgePartial,
IReadOnlyList<GapDto> Gaps,
IReadOnlyList<EventDto> Events,
IReadOnlyDictionary<string, string> Versions);
public sealed record CompareDto(
string Metric,
DateTimeOffset From,
DateTimeOffset To,
string Step,
string Source,
bool Degraded,
IReadOnlyList<long> T,
IReadOnlyDictionary<string, double?[]> Values,
IReadOnlyDictionary<string, double?> Summary);
public sealed record DistributionBucketDto(double Le, long Count);
public sealed record DistributionDto(string Metric, string Unit, IReadOnlyList<DistributionBucketDto> Buckets, double? P50, double? P95, double? P99, long Count);
public sealed record HeatmapDto(
string Metric,
string Step,
string Source,
bool Degraded,
IReadOnlyList<long> T,
IReadOnlyList<double> Edges,
IReadOnlyList<IReadOnlyList<long>> Counts);
/// <summary>一条线路 = 一对(Agent,Probe);<c>id</c> 是 <c>&lt;agent_id&gt;~&lt;probe_id&gt;</c>。</summary>
public sealed record LineDto(
string Id,
string AgentId,
string ProbeId,
string ProbeName,
string Name,
IReadOnlyList<string> Labels,
bool Online,
bool ProbeConnected,
DateTimeOffset? DataSince,
IReadOnlyList<string> Kinds,
TestSummaryDto? LastTest);
public sealed record DimensionDto(string Id, string Label, double Weight, double? Value, double? Score);
public sealed record ScoreDto(double? Score, int Version, bool Estimated, IReadOnlyList<DimensionDto> Dimensions, IReadOnlyList<string> Caveats);
public sealed record TestStepDto(int Index, double TargetMbps, double LossPct, double? JitterP95Ms, double DeliveredMbps);
public sealed record TestResultDto(
IReadOnlyDictionary<string, double> Metrics,
IReadOnlyDictionary<string, ScoreDto> Scores,
IReadOnlyDictionary<string, double?[]> Series,
IReadOnlyList<TestStepDto> Steps,
IReadOnlyList<string> Caveats);
public sealed record TestDto(
string Id,
string AgentId,
string ProbeId,
string Plan,
IReadOnlyList<string> Phases,
string Status,
int? Phase,
string? ErrorCode,
string Trigger,
DateTimeOffset RequestedAt,
DateTimeOffset? StartedAt,
DateTimeOffset? FinishedAt,
TestResultDto? Result);
// ── 目录(由代码注册表生成) ──────────────────────────────────────────
public sealed record ParamDto(string Name, string Type, System.Text.Json.JsonElement Default, double? Min, double? Max, string? Unit, IReadOnlyList<string>? Values, string Description);
public sealed record BlocksDto(string Transport, string Pacer, string Payload, string Behavior, IReadOnlyList<string> Samplers);
public sealed record ColumnDto(string Name, string Type, string Encoding, bool Nullable, string? Role);
public sealed record StreamDto(string Name, IReadOnlyList<ColumnDto> Columns);
public sealed record KindDto(string Id, string Title, string Description, IReadOnlyList<string> Scopes, BlocksDto Blocks, IReadOnlyList<ParamDto> Params, IReadOnlyList<StreamDto> Streams);
public sealed record PhaseDto(string Kind, int DurationS, System.Text.Json.JsonElement Params);
public sealed record PlanDto(string Id, string Title, string Description, IReadOnlyList<PhaseDto> Phases);
public sealed record MetricDto(
string Id,
string DefinitionId,
string Accumulator,
string Stat,
string Label,
string Description,
string Unit,
string Better,
double? Good,
double? Poor,
string Version,
IReadOnlyList<string> From);
public sealed record PresetDto(string Id, string Label, IReadOnlyList<string> Metrics);
public sealed record ModelDto(string Id, string Title, int Version, IReadOnlyList<string> Needs);
public sealed record LiveNodeDto(string Kind, string Id, bool Online, DateTimeOffset At);
public sealed record LiveTestDto(string TestId, string Status, int? Phase, int Phases);
/// <summary>
/// 主机资源占用(展示页能拿到的全部内容):只有汇总的数字,<b>没有 CPU 型号、主机名、网卡名,也没有任何 IP 地址</b>。
/// 这些字段 Agent 根本不采集,所以即使这里写漏了也泄漏不出去。
/// </summary>
public sealed record HostDto(
double? CpuPercent,
long? MemUsed,
long? MemTotal,
long? DiskUsed,
long? DiskTotal,
long? NetRxTotal,
long? NetTxTotal,
long? NetRxBps,
long? NetTxBps,
long? NetLinkMbps,
long? UptimeSeconds,
DateTimeOffset At);
/// <summary>一个 Agent 的最新主机状态;<c>stale</c> 表示超过 15 秒没有更新(离线,或者 Agent 关闭了上报)。</summary>
/// <summary>节点的展示信息:只有系统、架构、国家代码、运营商,没有任何地址;站点打开「显示构建信息」之后还有版本和 tag。</summary>
public sealed record NodeInfoDto(string Id, string Kind, string? Os, string? OsName, string? Arch, string? Country, string? IspKey, string? IspName, string? Version = null, string? Tag = null);
/// <summary>一个程序的构建信息:版本、tag、提交(短)和构建时间(UTC,ISO 8601)。没设置的是 <c>null</c>。</summary>
public sealed record BuildDto(string Version, string? Tag, string? Commit, string? BuiltAt);
/// <summary>公开的「关于」:站点没有打开「显示构建信息」时只有 <c>show_build: false</c>。</summary>
public sealed record AboutDto(bool ShowBuild, BuildDto? Backend);
public sealed record HostItemDto(string AgentId, string Name, bool Online, bool Stale, DateTimeOffset? LastSeenAt, HostDto? Host);
public sealed record LiveHostDto(string AgentId, bool Online, HostDto Host);
/// <summary>主机资源占用的历史(列式,按步长取平均)。</summary>
public sealed record HostSeriesDto(string AgentId, DateTimeOffset From, DateTimeOffset To, string Step, IReadOnlyList<long> T, IReadOnlyDictionary<string, double?[]> Values);
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Library</OutputType>
<IsAotCompatible>true</IsAotCompatible>
<EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<ProjectReference Include="..\MoShiro.Backend.Application\MoShiro.Backend.Application.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,53 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using MoShiro.Backend.Application;
using MoShiro.Protocol;
namespace MoShiro.Backend.Api;
/// <summary>运维端点:<c>/healthz</c>、<c>/metrics</c>(admin 令牌)、安装脚本。</summary>
public static class OpsEndpoints
{
private const string Tag = "ops";
public static void Map(IEndpointRouteBuilder app)
{
app.MapGet("/healthz", HealthAsync)
.Api("health_get", "SQLite and the raw data directory are both writable", Tag)
.Returns<HealthDto>().ProducesProblems(503);
app.MapGet("/metrics", MetricsAsync)
.Api("metrics_get", "Prometheus metrics (admin token)", Tag)
.Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(401, 403);
app.MapGet("/install.sh", (HttpContext http) => NotShipped(http))
.Api("install_sh", "Installer for Linux nodes", Tag).ProducesProblems(501);
app.MapGet("/install.ps1", (HttpContext http) => NotShipped(http))
.Api("install_ps1", "Installer for Windows nodes", Tag).ProducesProblems(501);
}
private static async Task<IResult> HealthAsync(HttpContext http, OpsService ops)
{
StorageHealth health = await ops.HealthAsync(http.RequestAborted);
bool ok = health.Database && health.RawDirectory;
HealthDto dto = new(ok ? "ok" : "unavailable", health.Database, health.RawDirectory);
http.Response.Headers.CacheControl = "no-store";
return TypedResults.Json(dto, ApiJsonContext.Default.HealthDto, statusCode: ok ? StatusCodes.Status200OK : StatusCodes.Status503ServiceUnavailable);
}
private static async Task<IResult> MetricsAsync(HttpContext http, OpsService ops)
{
Principal principal = http.GetPrincipal();
if (!principal.IsAdmin)
{
return principal.CanRead
? Problems.Create(http, MoShiroErrors.Forbidden, "an admin token is required")
: Problems.Create(http, MoShiroErrors.Unauthorized, "an admin token is required");
}
return TypedResults.Text(await ops.RenderPrometheusAsync(http.RequestAborted), "text/plain; version=0.0.4");
}
/// <summary>安装脚本需要下载节点程序的发布物,那属于打包与部署(M6),目前还没有。</summary>
private static IResult NotShipped(HttpContext http) =>
Problems.Create(http, StatusCodes.Status501NotImplemented, ErrorCodeHttpMap.NotImplemented.Value, "the node installer ships with packaging (M6)");
}
@@ -0,0 +1,52 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using MoShiro.Backend.Analysis;
using MoShiro.Protocol;
namespace MoShiro.Backend.Api;
/// <summary>目录:由代码里的注册表生成。新增 Kind、方案、指标、分组、模型后,目录自动更新,前端不需要改。</summary>
public static class CatalogEndpoints
{
private const string Tag = "catalog";
public static void Map(RouteGroupBuilder group)
{
RouteGroupBuilder catalog = group.MapGroup("/catalog");
catalog.MapGet("/kinds", Kinds)
.Api("catalog_kinds", "Measurement kinds with their building blocks and parameter specs", Tag).Returns<PageDto<KindDto>>().ProducesProblems(403);
catalog.MapGet("/plans", Plans)
.Api("catalog_plans", "On-demand test plans", Tag).Returns<PageDto<PlanDto>>().ProducesProblems(403);
catalog.MapGet("/metrics", Metrics)
.Api("catalog_metrics", "Metric ids with unit, label, direction, thresholds and version", Tag).Returns<PageDto<MetricDto>>().ProducesProblems(403);
catalog.MapGet("/presets", Presets)
.Api("catalog_presets", "Metric groups the front end can display", Tag).Returns<PageDto<PresetDto>>().ProducesProblems(403);
catalog.MapGet("/models", Models)
.Api("catalog_models", "Service models and the metrics they need", Tag).Returns<PageDto<ModelDto>>().ProducesProblems(403);
}
private static IResult Kinds(HttpContext http) =>
QueryHelpers.ApplyCatalogCaching(http, string.Join(',', MoShiro.Protocol.Kinds.All.Select(k => k.Id)))
?? TypedResults.Json(new PageDto<KindDto>([.. MoShiro.Protocol.Kinds.All.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoKindDto);
private static IResult Plans(HttpContext http) =>
QueryHelpers.ApplyCatalogCaching(http, string.Join(',', MoShiro.Backend.Application.Plans.All.Select(p => p.Id)))
?? TypedResults.Json(new PageDto<PlanDto>([.. MoShiro.Backend.Application.Plans.All.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoPlanDto);
private static IResult Metrics(HttpContext http) =>
QueryHelpers.ApplyCatalogCaching(http, string.Join(',', MetricRegistry.All.Select(m => m.Fingerprint)))
?? TypedResults.Json(
new PageDto<MetricDto>([.. MetricRegistry.AllExports().Select(e => Mappers.ToDto(e.Def, e.Export))], null),
ApiJsonContext.Default.PageDtoMetricDto);
private static IResult Presets(HttpContext http) =>
QueryHelpers.ApplyCatalogCaching(http, string.Join(',', MetricRegistry.Presets.Select(p => p.Id + string.Join('|', p.MetricIds))))
?? TypedResults.Json(
new PageDto<PresetDto>([.. MetricRegistry.Presets.Select(p => new PresetDto(p.Id, p.Label, p.MetricIds))], null),
ApiJsonContext.Default.PageDtoPresetDto);
private static IResult Models(HttpContext http) =>
QueryHelpers.ApplyCatalogCaching(http, string.Join(',', ModelRegistry.All.Select(m => m.Id + m.Version)))
?? TypedResults.Json(new PageDto<ModelDto>([.. ModelRegistry.All.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoModelDto);
}
@@ -0,0 +1,97 @@
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 的主机资源占用:最新值(实时的部分走 SSE 的 <c>hosts</c> 主题)和历史曲线。只有汇总的数字,没有型号和 IP。</summary>
public static class HostEndpoints
{
private const string Tag = "public";
public static void Map(RouteGroupBuilder group)
{
group.MapGet("/hosts", ListAsync)
.Api("hosts_list", "Latest host resource usage of every visible agent (no hardware model, no addresses)", Tag)
.Returns<PageDto<HostItemDto>>().ProducesProblems(403);
group.MapGet("/agents/{agent_id}/host", HistoryAsync)
.Api("hosts_history", "Host resource usage history of one agent (columnar, averaged per step)", Tag)
.Returns<HostSeriesDto>().ProducesProblems(403, 404, 422);
}
private static async Task<IResult> ListAsync(HttpContext http, AgentService agents, HostStatusService hosts, CancellationToken ct)
{
var items = new List<HostItemDto>();
foreach (AgentView view in await agents.ListAsync(ct))
{
if (view.Agent.Status != Fleet.Server.NodeStatus.Active || !(view.Agent.PublicVisible || PublicGuard.CanSeeHidden(http)))
{
continue;
}
(HostSnapshot? host, bool stale) = hosts.Latest(view.Agent.Id);
items.Add(new HostItemDto(view.Agent.Id.Value, view.Agent.Name, view.Online, stale, Mappers.Time(view.Agent.LastSeenAtMs), host is null ? null : Mappers.ToDto(host)));
}
return TypedResults.Json(new PageDto<HostItemDto>(items, null), ApiJsonContext.Default.PageDtoHostItemDto);
}
private static async Task<IResult> HistoryAsync(HttpContext http, AgentService agents, HostStatusService hosts, TimeProvider time, string agent_id, string? range, CancellationToken ct)
{
AgentView? view = await agents.GetAsync(new NodeId(agent_id), ct);
if (view is not { Agent.Status: Fleet.Server.NodeStatus.Active } || !(view.Agent.PublicVisible || PublicGuard.CanSeeHidden(http)))
{
return Problems.Create(http, MoShiroErrors.AgentNotFound, "agent not found");
}
if (!RangeResolver.TryParseSpan(range ?? "1h", out long span) || span < Steps.Hour || span > 30 * Steps.Day)
{
return Problems.From(http, new FleetError(MoShiroErrors.InvalidRange, "range must look like 1h, 6h, 24h or 7d (between 1 hour and 30 days)"));
}
long now = time.GetUtcNow().ToUnixTimeMilliseconds();
long from = now - span;
IReadOnlyList<HostSample> samples = await hosts.HistoryAsync(view.Agent.Id, from, now + 60_000, ct);
// 最多约 300 个点:按分钟的整数倍取步长,每个步长内取平均
long stepMs = Math.Max(60_000, (long)Math.Ceiling(span / 300.0 / 60_000) * 60_000);
long first = from - (from % stepMs);
int points = (int)(((now - first) / stepMs) + 1);
var t = new long[points];
for (int i = 0; i < points; i++)
{
t[i] = first + (i * stepMs);
}
double?[] Average(Func<HostSample, double?> pick)
{
var sum = new double[points];
var count = new int[points];
foreach (HostSample s in samples)
{
int index = (int)((s.AtMs - first) / stepMs);
if (index >= 0 && index < points && pick(s) is { } v)
{
sum[index] += v;
count[index]++;
}
}
return [.. Enumerable.Range(0, points).Select(i => count[i] == 0 ? (double?)null : Math.Round(sum[i] / count[i], 2))];
}
static double? Percent(long? used, long? total) => used is { } u && total is { } tot && tot > 0 ? 100.0 * u / tot : null;
var values = new Dictionary<string, double?[]>(StringComparer.Ordinal)
{
["cpu"] = Average(s => s.Cpu),
["mem"] = Average(s => Percent(s.MemUsed, s.MemTotal)),
["disk"] = Average(s => Percent(s.DiskUsed, s.DiskTotal)),
["rx_bps"] = Average(s => s.NetRxBps),
["tx_bps"] = Average(s => s.NetTxBps),
};
return TypedResults.Json(new HostSeriesDto(view.Agent.Id.Value, Mappers.Time(first), Mappers.Time(now), Steps.Format(stepMs), t, values), ApiJsonContext.Default.HostSeriesDto);
}
}
@@ -0,0 +1,367 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using Fleet.Core;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Primitives;
using MoShiro.Backend.Application;
using MoShiro.Protocol;
namespace MoShiro.Backend.Api;
/// <summary>
/// 实时推送(SSE):先推送当前快照,再推送增量(不补发历史)。SSE 只负责“更快地获得变化”,
/// 推送的内容都能由对应的 REST 查询重建。实时延迟取决于原始块的切块节奏(10 s)加上处理时间,典型值 10~12 s。
/// </summary>
public static class LiveEndpoints
{
private static readonly TimeSpan s_tick = TimeSpan.FromMilliseconds(500);
private static readonly TimeSpan s_debounce = TimeSpan.FromSeconds(1);
private static readonly TimeSpan s_heartbeat = TimeSpan.FromSeconds(15);
private static readonly TimeSpan s_periodicRefresh = TimeSpan.FromSeconds(10);
public static void Map(RouteGroupBuilder group)
{
group.MapGet("/live", StreamAsync)
.Api("live_stream", "Server-sent events: nodes, overview, series and test topics", "public")
.Produces(StatusCodes.Status200OK, contentType: "text/event-stream")
.ProducesProblems(400, 403, 404, 422, 429);
}
private static async Task<IResult> StreamAsync(
HttpContext http, AgentService agents, LineService lineService, TestService tests, QueryService query, LiveHub hub, SseLimiter limiter,
BackendOptions options, BackendCounters counters, TimeProvider time, IHostApplicationLifetime lifetime)
{
StringValues raw = http.Request.Query["topics"];
Result<IReadOnlyList<LiveTopic>> parsed = LiveTopic.ParseAll(raw.Where(v => v is not null)!, options.MaxSseTopics);
if (!parsed.TryGet(out IReadOnlyList<LiveTopic>? topics, out FleetError? error))
{
return Problems.From(http, error);
}
// 订阅的线路必须存在并且对访问者可见
var lineKeys = new Dictionary<string, LineKey>(StringComparer.Ordinal);
foreach (LiveTopic topic in topics.Where(t => t.Line is not null))
{
Result<LineView> found = await QueryHelpers.FindVisibleLineAsync(http, lineService, topic.Line!, http.RequestAborted);
if (!found.TryGet(out LineView? resolved, out FleetError? lineError))
{
return Problems.From(http, lineError);
}
lineKeys[topic.Raw] = resolved.Key;
}
foreach (LiveTopic topic in topics.Where(t => t.Preset is not null))
{
Result<MetricSelection> selection = MetricSelection.Resolve(null, topic.Preset, options.MaxMetricsPerQuery);
if (!selection.IsSuccess)
{
return Problems.From(http, selection.Error);
}
}
string client = http.Connection.RemoteIpAddress?.ToString() ?? "unknown";
if (!limiter.TryAcquire(client))
{
return Problems.Create(http, MoShiroErrors.TooManyStreams, $"at most {options.MaxSsePerClient} live connections per client");
}
counters.SseOpened();
try
{
http.Response.ContentType = "text/event-stream";
http.Response.Headers.CacheControl = "no-store";
http.Response.Headers["X-Accel-Buffering"] = "no";
await http.Response.Body.FlushAsync(http.RequestAborted);
var session = new LiveSession(http, agents, lineService, tests, query, hub, options, time, topics, lineKeys);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(http.RequestAborted, lifetime.ApplicationStopping);
await session.RunAsync(linked.Token);
}
catch (OperationCanceledException)
{
// 客户端断开或服务正在停止
}
finally
{
counters.SseClosed();
limiter.Release(client);
}
return TypedResults.Empty;
}
private sealed class LiveSession
{
private readonly HttpContext _http;
private readonly AgentService _agents;
private readonly LineService _lines;
private readonly IReadOnlyDictionary<string, LineKey> _lineKeys;
private readonly TestService _tests;
private readonly QueryService _query;
private readonly LiveHub _hub;
private readonly BackendOptions _options;
private readonly TimeProvider _time;
private readonly IReadOnlyList<LiveTopic> _topics;
private readonly object _gate = new();
private readonly Dictionary<string, long> _lastSent = new(StringComparer.Ordinal);
private readonly HashSet<(NodeId Agent, string Series)> _dirtyLines = [];
private readonly HashSet<TestId> _dirtyTests = [];
private readonly Queue<NodeSignal> _nodeSignals = new();
private readonly Dictionary<NodeId, HostSignal> _hostSignals = [];
private long _pushAtMs;
private long _lastWriteMs;
private long _lastPeriodicMs;
public LiveSession(HttpContext http, AgentService agents, LineService lines, TestService tests, QueryService query, LiveHub hub, BackendOptions options, TimeProvider time, IReadOnlyList<LiveTopic> topics, IReadOnlyDictionary<string, LineKey> lineKeys)
{
_http = http;
_agents = agents;
_lines = lines;
_lineKeys = lineKeys;
_tests = tests;
_query = query;
_hub = hub;
_options = options;
_time = time;
_topics = topics;
}
public async Task RunAsync(CancellationToken ct)
{
using LiveSubscription subscription = _hub.Subscribe();
Task drain = DrainAsync(subscription, ct);
long now = Now();
_lastWriteMs = now;
_lastPeriodicMs = now;
await PushSnapshotAsync(ct);
using var timer = new PeriodicTimer(s_tick, _time);
while (await timer.WaitForNextTickAsync(ct))
{
await FlushPendingAsync(ct);
}
await drain;
}
private async Task DrainAsync(LiveSubscription subscription, CancellationToken ct)
{
try
{
await foreach (LiveSignal signal in subscription.Reader.ReadAllAsync(ct))
{
lock (_gate)
{
switch (signal)
{
case NodeSignal node when _topics.Any(t => t.Kind == LiveTopicKind.Nodes):
_nodeSignals.Enqueue(node);
break;
case HostSignal host when _topics.Any(t => t.Kind == LiveTopicKind.Hosts):
_hostSignals[host.AgentId] = host;
break;
case ChunkSignal chunk:
_dirtyLines.Add((chunk.AgentId, chunk.Series));
_pushAtMs = _pushAtMs == 0 ? Now() + (long)s_debounce.TotalMilliseconds : _pushAtMs;
break;
case TestSignal test when _topics.Any(t => t.Test == test.TestId):
_dirtyTests.Add(test.TestId);
break;
}
}
}
}
catch (OperationCanceledException)
{
// 连接结束
}
}
private async Task FlushPendingAsync(CancellationToken ct)
{
long now = Now();
NodeSignal[] nodes;
HostSignal[] hostSignals;
TestId[] testIds;
(NodeId Agent, string Series)[] dirtyLines = [];
bool refreshAll = now - _lastPeriodicMs >= (long)s_periodicRefresh.TotalMilliseconds;
lock (_gate)
{
nodes = [.. _nodeSignals];
_nodeSignals.Clear();
hostSignals = [.. _hostSignals.Values];
_hostSignals.Clear();
testIds = [.. _dirtyTests];
_dirtyTests.Clear();
if (_pushAtMs != 0 && now >= _pushAtMs)
{
dirtyLines = [.. _dirtyLines];
_dirtyLines.Clear();
_pushAtMs = 0;
}
}
foreach (HostSignal host in hostSignals)
{
AgentView? view = await _agents.GetAsync(host.AgentId, ct);
if (view is not null && IsVisible(view) && view.Agent.Status == Fleet.Server.NodeStatus.Active)
{
await WriteAsync("host", new LiveHostDto(host.AgentId.Value, view.Online, Mappers.ToDto(host.Host)), ApiJsonContext.Default.LiveHostDto, ct);
}
}
foreach (NodeSignal node in nodes)
{
AgentView? view = await _agents.GetAsync(new NodeId(node.Id), ct);
if (view is not null && IsVisible(view))
{
await WriteAsync("node", new LiveNodeDto(node.Kind, node.Id, node.Online, Mappers.Time(node.AtMs)), ApiJsonContext.Default.LiveNodeDto, ct);
}
}
foreach (TestId testId in testIds)
{
await PushTestAsync(testId, ct);
}
if (dirtyLines.Length > 0)
{
foreach (LiveTopic topic in _topics.Where(t => t.Kind == LiveTopicKind.Series && dirtyLines.Contains((_lineKeys[t.Raw].Agent, _lineKeys[t.Raw].Series))))
{
await PushSeriesAsync(topic, ct);
}
foreach (LiveTopic topic in _topics.Where(t => t.Kind == LiveTopicKind.Overview))
{
await PushOverviewAsync(topic, ct);
}
_lastPeriodicMs = now;
}
else if (refreshAll)
{
// 没有新块也定期刷新(例如线路断了之后汇总需要更新)
foreach (LiveTopic topic in _topics.Where(t => t.Kind is LiveTopicKind.Overview or LiveTopicKind.Series))
{
await (topic.Kind == LiveTopicKind.Overview ? PushOverviewAsync(topic, ct) : PushSeriesAsync(topic, ct));
}
_lastPeriodicMs = now;
}
if (Now() - _lastWriteMs >= (long)s_heartbeat.TotalMilliseconds)
{
await WriteRawAsync(": keepalive\n\n", ct);
}
}
private async Task PushSnapshotAsync(CancellationToken ct)
{
foreach (LiveTopic topic in _topics)
{
switch (topic.Kind)
{
case LiveTopicKind.Nodes:
foreach (AgentView view in (await _agents.ListAsync(ct)).Where(v => v.Agent.Status == Fleet.Server.NodeStatus.Active && IsVisible(v)))
{
await WriteAsync("node", new LiveNodeDto("agent", view.Agent.Id.Value, view.Online, Mappers.Time(Now())), ApiJsonContext.Default.LiveNodeDto, ct);
}
break;
case LiveTopicKind.Hosts:
foreach (AgentView view in (await _agents.ListAsync(ct)).Where(v => v.Agent.Status == Fleet.Server.NodeStatus.Active && IsVisible(v) && v.Host is not null))
{
await WriteAsync("host", new LiveHostDto(view.Agent.Id.Value, view.Online, Mappers.ToDto(view.Host!)), ApiJsonContext.Default.LiveHostDto, ct);
}
break;
case LiveTopicKind.Overview:
await PushOverviewAsync(topic, ct);
break;
case LiveTopicKind.Series:
await PushSeriesAsync(topic, ct);
break;
case LiveTopicKind.Test:
await PushTestAsync(topic.Test!.Value, ct);
break;
}
}
}
private async Task PushOverviewAsync(LiveTopic topic, CancellationToken ct)
{
MetricSelection selection = MetricSelection.Resolve(null, topic.Preset, _options.MaxMetricsPerQuery).Value;
IReadOnlyList<LineView> visible = await _lines.ListAsync(a => a.Agent.Status == Fleet.Server.NodeStatus.Active && IsVisible(a), ct);
Result<OverviewResult> result = await _query.OverviewAsync(visible, new RangeRequest(topic.Range, null, null, null, null), selection, ct);
if (result.TryGet(out OverviewResult? overview, out _))
{
var dto = new OverviewDto(
topic.Range!, Mappers.Time(overview.Range.FromMs), Mappers.Time(overview.Range.ToMs), Steps.Format(overview.Range.StepMs),
overview.Plan.Name, overview.Plan.Degraded, [.. overview.Lines.Select(Mappers.ToDto)]);
await WriteAsync("overview", dto, ApiJsonContext.Default.OverviewDto, ct);
}
}
private async Task PushSeriesAsync(LiveTopic topic, CancellationToken ct)
{
MetricSelection selection = MetricSelection.Resolve(null, topic.Preset, _options.MaxMetricsPerQuery).Value;
string step = Steps.Format(topic.StepMs);
long now = Now();
long from = _lastSent.TryGetValue(topic.Raw, out long last) ? last : now - 20_000;
Result<SeriesResult> latest = await _query.SeriesAsync(_lineKeys[topic.Raw], new RangeRequest(null, DateTimeOffset.FromUnixTimeMilliseconds(from), null, step, null), selection, ct);
Result<SeriesResult> summary = await _query.SeriesAsync(_lineKeys[topic.Raw], new RangeRequest("15m", null, null, step, null), selection, ct);
if (!latest.TryGet(out SeriesResult? series, out _) || !summary.TryGet(out SeriesResult? whole, out _))
{
return;
}
if (series.T.Count > 0)
{
_lastSent[topic.Raw] = series.T[^1];
}
SeriesDto dto = Mappers.ToDto(series, live: true) with { Summary = Mappers.ToDto(whole.Summary) };
await WriteAsync("series", dto, ApiJsonContext.Default.SeriesDto, ct);
}
private async Task PushTestAsync(TestId testId, CancellationToken ct)
{
TestDetail? detail = await _tests.GetAsync(testId, ct);
if (detail is null)
{
return;
}
AgentView? view = await _agents.GetAsync(detail.Test.AgentId, ct);
if (view is null || !IsVisible(view))
{
return;
}
await WriteAsync("test", new LiveTestDto(testId.Value, detail.Test.Status, detail.Test.Phase, detail.Test.PhaseCount), ApiJsonContext.Default.LiveTestDto, ct);
}
private bool IsVisible(AgentView view) => view.Agent.PublicVisible || PublicGuard.CanSeeHidden(_http);
private async Task WriteAsync<T>(string name, T payload, JsonTypeInfo<T> typeInfo, CancellationToken ct)
{
string json = JsonSerializer.Serialize(payload, typeInfo);
await WriteRawAsync($"event: {name}\ndata: {json}\n\n", ct);
}
private async Task WriteRawAsync(string text, CancellationToken ct)
{
await _http.Response.Body.WriteAsync(Encoding.UTF8.GetBytes(text), ct);
await _http.Response.Body.FlushAsync(ct);
_lastWriteMs = Now();
}
private long Now() => _time.GetUtcNow().ToUnixTimeMilliseconds();
}
}
@@ -0,0 +1,136 @@
using System.Collections.Concurrent;
using Fleet.Core;
using MoShiro.Backend.Application;
using MoShiro.Protocol;
namespace MoShiro.Backend.Api;
internal enum LiveTopicKind
{
Nodes,
Hosts,
Overview,
Series,
Test,
}
/// <summary>
/// 订阅主题:<c>nodes</c>、<c>hosts</c>(Agent 主机资源占用的实时推送)、<c>overview:{range}:{preset}</c>、<c>series:{line_id}:{step}:{preset}</c>(步长 ≤ 1m)、<c>test:{test_id}</c>。
/// preset 可以是逗号分隔的多个分组,所以不含冒号的片段会并入前一个主题的 preset。
/// </summary>
internal sealed record LiveTopic(LiveTopicKind Kind, string Raw, string? Range, long StepMs, string? Preset, string? Line, TestId? Test)
{
public static Result<IReadOnlyList<LiveTopic>> ParseAll(IEnumerable<string> values, int maxTopics)
{
var tokens = new List<string>();
foreach (string value in values)
{
foreach (string token in value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (!token.Contains(':', StringComparison.Ordinal) && token is not ("nodes" or "hosts") && tokens.Count > 0
&& (tokens[^1].StartsWith("overview:", StringComparison.Ordinal) || tokens[^1].StartsWith("series:", StringComparison.Ordinal)))
{
tokens[^1] += "," + token;
}
else
{
tokens.Add(token);
}
}
}
if (tokens.Count == 0)
{
return ValidationError.Fail<IReadOnlyList<LiveTopic>>("topics", "at least one topic is required");
}
if (tokens.Count > maxTopics)
{
return Result<IReadOnlyList<LiveTopic>>.Fail(MoShiroErrors.TooManyStreams, $"at most {maxTopics} topics per connection");
}
var topics = new List<LiveTopic>();
foreach (string token in tokens.Distinct(StringComparer.Ordinal))
{
Result<LiveTopic> parsed = Parse(token);
if (!parsed.TryGet(out LiveTopic? topic, out FleetError? error))
{
return Result<IReadOnlyList<LiveTopic>>.Fail(error);
}
topics.Add(topic);
}
return Result<IReadOnlyList<LiveTopic>>.Ok(topics);
}
private static Result<LiveTopic> Parse(string token)
{
string[] parts = token.Split(':', 4);
switch (parts[0])
{
case "hosts" when parts.Length == 1:
return Result<LiveTopic>.Ok(new LiveTopic(LiveTopicKind.Hosts, token, null, 0, null, null, null));
case "nodes" when parts.Length == 1:
return Result<LiveTopic>.Ok(new LiveTopic(LiveTopicKind.Nodes, token, null, 0, null, null, null));
case "overview" when parts.Length == 3:
if (!RangeResolver.TryParseSpan(parts[1], out _))
{
return ValidationError.Fail<LiveTopic>("topics", $"{token}: range must look like 5m, 6h or 7d");
}
return Result<LiveTopic>.Ok(new LiveTopic(LiveTopicKind.Overview, token, parts[1], 0, parts[2], null, null));
case "series" when parts.Length == 4:
if (!Steps.TryParse(parts[2], out long step) || step > Steps.Minute)
{
return Result<LiveTopic>.Fail(MoShiroErrors.InvalidStep, $"{token}: series topics support steps up to 1m; refetch coarser steps over REST");
}
return Result<LiveTopic>.Ok(new LiveTopic(LiveTopicKind.Series, token, null, step, parts[3], parts[1], null));
case "test" when parts.Length == 2:
return Result<LiveTopic>.Ok(new LiveTopic(LiveTopicKind.Test, token, null, 0, null, null, new TestId(parts[1])));
default:
return ValidationError.Fail<LiveTopic>("topics", $"unknown topic {token}");
}
}
}
/// <summary>每个客户端最多同时 4 个 SSE 连接。</summary>
public sealed class SseLimiter
{
private readonly ConcurrentDictionary<string, int> _open = new();
private readonly BackendOptions _options;
public SseLimiter(BackendOptions options)
{
_options = options;
}
public bool TryAcquire(string client)
{
while (true)
{
int current = _open.GetOrAdd(client, 0);
if (current >= _options.MaxSsePerClient)
{
return false;
}
if (_open.TryUpdate(client, current + 1, current))
{
return true;
}
}
}
public void Release(string client)
{
while (_open.TryGetValue(client, out int current))
{
if (current <= 1 ? _open.TryRemove(new KeyValuePair<string, int>(client, current)) : _open.TryUpdate(client, current - 1, current))
{
return;
}
}
}
}
@@ -0,0 +1,69 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using MoShiro.Backend.Application;
namespace MoShiro.Backend.Api;
/// <summary>
/// 节点的展示信息:系统、架构、国家、运营商。<b>只有这几项,没有任何 IP 地址、城市、ASN、经纬度</b>——
/// 这些在 Backend 里只在查询的那一刻用到,存起来的记录里也没有,所以访客(和任何别的接口)都拿不到。
/// </summary>
public static class NodeEndpoints
{
private const string Tag = "public-nodes";
public static void Map(RouteGroupBuilder group)
{
group.MapGet("/nodes", ListAsync)
.Api("nodes_list", "Display info of the visible agents and probes: system, architecture, country and ISP (never an address)", Tag)
.Returns<PageDto<NodeInfoDto>>().ProducesProblems(403);
group.MapGet("/about", AboutAsync)
.Api("about_get", "The backend's version, tag and build time, if the site shows build info", Tag)
.Returns<AboutDto>().ProducesProblems(403);
}
/// <summary>这个后端自己的构建信息;没有(本地构建)的字段是 <c>null</c>。</summary>
internal static BuildDto Backend(bool withCommit) =>
new(MoShiro.Protocol.BuildInfo.Version, Empty(MoShiro.Protocol.BuildInfo.Tag), withCommit ? Empty(MoShiro.Protocol.BuildInfo.Commit) : null, Empty(MoShiro.Protocol.BuildInfo.BuiltAt));
private static string? Empty(string value) => value.Length == 0 ? null : value;
private static async Task<IResult> AboutAsync(HttpContext http, SettingsService settings)
{
bool show = await settings.IsShowBuildAsync(http.RequestAborted);
http.Response.Headers.CacheControl = "no-store";
return TypedResults.Json(new AboutDto(show, show ? Backend(withCommit: false) : null), ApiJsonContext.Default.AboutDto);
}
private static async Task<IResult> ListAsync(HttpContext http, AgentService agents, ProbeService probes, NodeInfoService nodes, SettingsService settings, CancellationToken ct)
{
// 和别的公开接口一样:看得见的 Agent(隐藏的只有登录后才看得见)和没有被吊销的 Probe
var visible = new HashSet<string>(StringComparer.Ordinal);
foreach (AgentView view in await agents.ListAsync(ct))
{
if (view.Agent.Status == Fleet.Server.NodeStatus.Active && (view.Agent.PublicVisible || PublicGuard.CanSeeHidden(http)))
{
visible.Add(view.Agent.Id.Value);
}
}
foreach (ProbeView probe in await probes.ListAsync(ct))
{
if (!probe.Revoked)
{
visible.Add(probe.Probe.Id);
}
}
// 版本和 tag 只在站点打开「显示构建信息」时才给访客;关着就是 null
bool showBuild = await settings.IsShowBuildAsync(ct);
var items = (await nodes.ListAsync(ct))
.Where(n => visible.Contains(n.NodeId))
.OrderBy(n => n.NodeId, StringComparer.Ordinal)
.Select(n => new NodeInfoDto(n.NodeId, n.Kind, n.Os, n.OsName, n.Arch, n.Country, n.IspKey, n.IspName, showBuild ? n.Version : null, showBuild ? n.Tag : null))
.ToList();
http.Response.Headers.CacheControl = "no-store";
return TypedResults.Json(new PageDto<NodeInfoDto>(items, null), ApiJsonContext.Default.PageDtoNodeInfoDto);
}
}
@@ -0,0 +1,244 @@
using Fleet.Core;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using MoShiro.Backend.Analysis;
using MoShiro.Backend.Application;
using MoShiro.Protocol;
namespace MoShiro.Backend.Api;
/// <summary>展示数据的查询端点:参数解析、校验与缓存头,业务都在 Application.Query。</summary>
public static class QueryEndpoints
{
private const string Tag = "public";
private const string DefaultOverviewPreset = "udp,tcp,scores";
private const string DefaultSeriesPreset = "udp,tcp";
public static void Map(RouteGroupBuilder group)
{
group.MapGet("/overview", OverviewAsync)
.Api("overview_get", "Ranking of all lines over a time range", Tag)
.Returns<OverviewDto>().ProducesProblems(403, 422, 429, 503);
group.MapGet("/lines/{line_id}", LineAsync)
.Api("lines_get", "One line", Tag)
.Returns<LineDto>().ProducesProblems(403, 404);
group.MapGet("/lines/{line_id}/series", SeriesAsync)
.Api("lines_series", "Time series of one line (columnar)", Tag)
.Returns<SeriesDto>().ProducesProblems(403, 404, 422, 429, 503);
group.MapGet("/compare", CompareAsync)
.Api("lines_compare", "One metric across several lines, aligned on one time axis", Tag)
.Returns<CompareDto>().ProducesProblems(403, 404, 422, 429, 503);
group.MapGet("/lines/{line_id}/distribution", DistributionAsync)
.Api("lines_distribution", "Histogram and CDF of a histogram metric", Tag)
.Returns<DistributionDto>().ProducesProblems(403, 404, 422, 429, 503);
group.MapGet("/lines/{line_id}/heatmap", HeatmapAsync)
.Api("lines_heatmap", "Time by latency heat map", Tag)
.Returns<HeatmapDto>().ProducesProblems(403, 404, 422, 429, 503);
group.MapGet("/tests/{test_id}", TestAsync)
.Api("tests_get", "A test with its result", Tag)
.Returns<TestDto>().ProducesProblems(403, 404);
}
private static Task<IResult> OverviewAsync(
HttpContext http, LineService lineService, QueryService query, BackendOptions options, TimeProvider time,
string? range, DateTimeOffset? from, DateTimeOffset? to, string? preset, string? metrics, string? sort, string? order) =>
QueryHelpers.GuardedAsync(http, async ct =>
{
Result<MetricSelection> selection = MetricSelection.Resolve(metrics, preset ?? (metrics is null ? DefaultOverviewPreset : null), options.MaxMetricsPerQuery);
if (!selection.TryGet(out MetricSelection? sel, out FleetError? error))
{
return Problems.From(http, error);
}
IReadOnlyList<LineView> visible = await lineService.ListAsync(a => a.Agent.Status == Fleet.Server.NodeStatus.Active && (a.Agent.PublicVisible || PublicGuard.CanSeeHidden(http)), ct);
Result<OverviewResult> result = await query.OverviewAsync(visible, new RangeRequest(range, from, to, null, null), sel, ct);
if (!result.TryGet(out OverviewResult? overview, out error))
{
return Problems.From(http, error);
}
IEnumerable<OverviewLine> lines = Sort(overview.Lines, sort, order, sel);
IResult? cached = QueryHelpers.ApplyCaching(http, overview.Range, time.GetUtcNow().ToUnixTimeMilliseconds(), overview.Plan.Name + string.Join(',', sel.AllIds));
if (cached is not null)
{
return cached;
}
var dto = new OverviewDto(
range ?? "custom", Mappers.Time(overview.Range.FromMs), Mappers.Time(overview.Range.ToMs), Steps.Format(overview.Range.StepMs),
overview.Plan.Name, overview.Plan.Degraded, [.. lines.Select(Mappers.ToDto)]);
return TypedResults.Json(dto, ApiJsonContext.Default.OverviewDto);
});
private static async Task<IResult> LineAsync(HttpContext http, LineService lines, TestService tests, QueryService query, string line_id, CancellationToken ct)
{
Result<LineView> found = await QueryHelpers.FindVisibleLineAsync(http, lines, line_id, ct);
if (!found.TryGet(out LineView? line, out FleetError? error))
{
return Problems.From(http, error);
}
AgentView view = line.Agent;
IReadOnlyList<TestRecord> recent = await tests.ListAsync(view.Agent.Id, new ProbeId(line.Probe.Id), 1, ct);
TestRecord? last = recent.Count > 0 ? recent[0] : null;
return TypedResults.Json(
new LineDto(
line.Key.Id, view.Agent.Id.Value, line.Probe.Id, line.Probe.Name, view.Agent.Name, view.Agent.Labels, view.Online, line.Connected,
Mappers.Time(await query.EarliestAsync(line.Key, ct)), view.Agent.Kinds, Mappers.Summary(last)),
ApiJsonContext.Default.LineDto);
}
private static Task<IResult> SeriesAsync(
HttpContext http, LineService lines, QueryService query, BackendOptions options, TimeProvider time,
string line_id, string? range, DateTimeOffset? from, DateTimeOffset? to, string? step, string? tz, string? preset, string? metrics, string? anchor) =>
QueryHelpers.GuardedAsync(http, async ct =>
{
Result<LineView> found = await QueryHelpers.FindVisibleLineAsync(http, lines, line_id, ct);
if (!found.TryGet(out LineView? view, out FleetError? error))
{
return Problems.From(http, error);
}
Result<MetricSelection> selection = MetricSelection.Resolve(metrics, preset ?? (metrics is null ? DefaultSeriesPreset : null), options.MaxMetricsPerQuery);
if (!selection.TryGet(out MetricSelection? sel, out error))
{
return Problems.From(http, error);
}
Result<SeriesResult> result = await query.SeriesAsync(view.Key, new RangeRequest(range, from, to, step, tz, anchor), sel, ct);
if (!result.TryGet(out SeriesResult? series, out error))
{
return Problems.From(http, error);
}
IResult? cached = QueryHelpers.ApplyCaching(http, series.Range, time.GetUtcNow().ToUnixTimeMilliseconds(), series.Plan.Name + string.Join(',', series.Versions.Values));
return cached ?? TypedResults.Json(Mappers.ToDto(series, live: true), ApiJsonContext.Default.SeriesDto);
});
private static Task<IResult> CompareAsync(
HttpContext http, LineService lineService, QueryService query, TimeProvider time,
[FromQuery(Name = "lines")] string? lineIds, string metric, string? range, DateTimeOffset? from, DateTimeOffset? to, string? step) =>
QueryHelpers.GuardedAsync(http, async ct =>
{
string? list = lineIds;
if (string.IsNullOrWhiteSpace(list))
{
return Problems.From(http, ValidationError.Single("lines", "is required"));
}
var ids = new List<LineKey>();
foreach (string id in list.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Distinct())
{
Result<LineView> found = await QueryHelpers.FindVisibleLineAsync(http, lineService, id, ct);
if (!found.TryGet(out LineView? view, out FleetError? findError))
{
return Problems.From(http, findError);
}
ids.Add(view.Key);
}
Result<CompareResult> result = await query.CompareAsync(ids, metric, new RangeRequest(range, from, to, step, null), ct);
if (!result.TryGet(out CompareResult? compare, out FleetError? error))
{
return Problems.From(http, error);
}
IResult? cached = QueryHelpers.ApplyCaching(http, compare.Range, time.GetUtcNow().ToUnixTimeMilliseconds(), compare.Plan.Name + metric);
return cached ?? TypedResults.Json(
new CompareDto(
compare.Metric, Mappers.Time(compare.Range.FromMs), Mappers.Time(compare.Range.ToMs), Steps.Format(compare.Range.StepMs), compare.Plan.Name, compare.Plan.Degraded,
compare.T, compare.Values, compare.Summary),
ApiJsonContext.Default.CompareDto);
});
private static Task<IResult> DistributionAsync(
HttpContext http, LineService lines, QueryService query, TimeProvider time,
string line_id, string metric, string? range, DateTimeOffset? from, DateTimeOffset? to) =>
QueryHelpers.GuardedAsync(http, async ct =>
{
Result<LineView> found = await QueryHelpers.FindVisibleLineAsync(http, lines, line_id, ct);
if (!found.TryGet(out LineView? view, out FleetError? error))
{
return Problems.From(http, error);
}
var request = new RangeRequest(range, from, to, null, null);
Result<DistributionResult> result = await query.DistributionAsync(view.Key, metric, request, ct);
if (!result.TryGet(out DistributionResult? distribution, out error))
{
return Problems.From(http, error);
}
Result<(QueryRange Range, SourcePlan Plan)> planned = query.Plan(request, "15m");
IResult? cached = planned.IsSuccess ? QueryHelpers.ApplyCaching(http, planned.Value.Range, time.GetUtcNow().ToUnixTimeMilliseconds(), metric) : null;
return cached ?? TypedResults.Json(
new DistributionDto(
distribution.Metric, distribution.Unit, [.. distribution.Buckets.Select(b => new DistributionBucketDto(b.Le, checked((long)b.Count)))],
distribution.P50, distribution.P95, distribution.P99, checked((long)distribution.Count)),
ApiJsonContext.Default.DistributionDto);
});
private static Task<IResult> HeatmapAsync(
HttpContext http, LineService lines, QueryService query, TimeProvider time,
string line_id, string metric, string? range, DateTimeOffset? from, DateTimeOffset? to, string? step) =>
QueryHelpers.GuardedAsync(http, async ct =>
{
Result<LineView> found = await QueryHelpers.FindVisibleLineAsync(http, lines, line_id, ct);
if (!found.TryGet(out LineView? view, out FleetError? error))
{
return Problems.From(http, error);
}
Result<HeatmapResult> result = await query.HeatmapAsync(view.Key, metric, new RangeRequest(range, from, to, step, null), ct);
if (!result.TryGet(out HeatmapResult? heatmap, out error))
{
return Problems.From(http, error);
}
IResult? cached = QueryHelpers.ApplyCaching(http, heatmap.Range, time.GetUtcNow().ToUnixTimeMilliseconds(), heatmap.Plan.Name + metric);
return cached ?? TypedResults.Json(
new HeatmapDto(
metric, Steps.Format(heatmap.Range.StepMs), heatmap.Plan.Name, heatmap.Plan.Degraded, heatmap.T, heatmap.Edges,
[.. heatmap.Counts.Select(row => (IReadOnlyList<long>)[.. row.Select(c => checked((long)c))])]),
ApiJsonContext.Default.HeatmapDto);
});
private static async Task<IResult> TestAsync(HttpContext http, TestService tests, AgentService agents, string test_id, CancellationToken ct)
{
TestDetail? detail = await tests.GetAsync(new TestId(test_id), ct);
if (detail is null)
{
return Problems.Create(http, MoShiroErrors.TestNotFound, "test not found");
}
Result<AgentView> visible = await QueryHelpers.FindVisibleAgentAsync(http, agents, detail.Test.AgentId.Value, ct);
if (!visible.IsSuccess)
{
return Problems.Create(http, MoShiroErrors.TestNotFound, "test not found");
}
return TypedResults.Json(Mappers.ToDto(detail), ApiJsonContext.Default.TestDto);
}
private static IEnumerable<OverviewLine> Sort(IReadOnlyList<OverviewLine> lines, string? sort, string? order, MetricSelection selection)
{
string? key = sort ?? selection.ScoreModelIds.Select(m => "score." + m).FirstOrDefault();
if (key is null)
{
return lines.OrderBy(l => l.Line.Agent.Agent.Name, StringComparer.Ordinal);
}
bool descending = order is not null
? order.Equals("desc", StringComparison.OrdinalIgnoreCase)
: key.StartsWith("score.", StringComparison.Ordinal) || (MetricRegistry.TryFindExport(key, out MetricDef def, out _) && def.Better == Better.Higher);
IEnumerable<OverviewLine> withValue = lines.Where(l => l.Summary.Values.GetValueOrDefault(key) is not null);
IEnumerable<OverviewLine> without = lines.Where(l => l.Summary.Values.GetValueOrDefault(key) is null);
IOrderedEnumerable<OverviewLine> ordered = descending
? withValue.OrderByDescending(l => l.Summary.Values[key])
: withValue.OrderBy(l => l.Summary.Values[key]);
return ordered.ThenBy(l => l.Line.Agent.Agent.Name, StringComparer.Ordinal).Concat(without.OrderBy(l => l.Line.Agent.Agent.Name, StringComparer.Ordinal));
}
}