refactor!: rename the project from Monitor to MoShiro [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
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:
1 parent
27ac7fb787
commit
2e146d4da7
309 files changed
+1565
-1572
No files matched your search
@@ -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 <api_token></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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user