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,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");
}
}