Replace Probe with Agent across node runtime, Fleet contracts, backend state, API and storage. Report incoming sessions and outgoing link health through each Agent uplink, and retain directed link controls and retry. Update protocol fixtures and end-to-end coverage for the unified model; accept requested cancellation during the Fleet timeout test cleanup.
159 lines
8.6 KiB
C#
159 lines
8.6 KiB
C#
using Fleet.Core;
|
|
using Fleet.Server;
|
|
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>fleet.auto_link</c>(不监听的节点自动拨所有监听节点);
|
|
/// 这里看每一对的结果,并且手动强制开、关或取消手动设置。把一个节点拖到另一个监听节点上建立线路,就是 <c>PUT</c> 一个 <c>enabled: true</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 possible dialer/listener pair: whether it is linked, the manual override and whether it is connected; plus the nodes and which of them listen", Tag).Returns<LinkMatrixDto>().ProducesProblems(401, 403)
|
|
.Requires(Capability.FleetRead);
|
|
group.MapPut("/links/{dialer_id}/{listener_id}", SetAsync)
|
|
.Api("links_set", "Connect a node to a listening node (enabled: true), force the link off (false), or give it back to the automatic setting (null)", Tag).Returns<LinkDto>().ProducesProblems(400, 401, 403, 404, 409, 422)
|
|
.Requires(Capability.FleetWrite);
|
|
group.MapPost("/links/{dialer_id}/{listener_id}/retry", RetryAsync)
|
|
.Api("links_retry", "Enable and immediately retry the dialer-to-listener connection", Tag).Returns<LinkRetryDto>().ProducesProblems(401, 403, 404, 409, 422)
|
|
.Requires(Capability.FleetWrite);
|
|
}
|
|
|
|
private static async Task<IResult> ListAsync(AgentService agents, LinkService links, LinkRuntime runtime, TimeProvider time, CancellationToken ct)
|
|
{
|
|
LinkSnapshot snapshot = await links.SnapshotAsync(ct);
|
|
AgentView[] nodes = [.. (await agents.ListAsync(ct)).Where(n => n.Agent.Status == NodeStatus.Active).OrderBy(n => n.Agent.Name, StringComparer.Ordinal)];
|
|
var items = new List<LinkDto>();
|
|
foreach (AgentView dialer in nodes)
|
|
{
|
|
foreach (AgentView listener in nodes.Where(n => n.Agent.Listens && n.Agent.Id != dialer.Agent.Id))
|
|
{
|
|
items.Add(ToDto(snapshot, dialer, listener, runtime, time));
|
|
}
|
|
}
|
|
|
|
LinkNodeDto[] nodeItems = [.. nodes.Select(n => new LinkNodeDto(n.Agent.Id.Value, n.Agent.Name, n.Agent.Listens, n.Online, n.RuntimeStatus))];
|
|
return TypedResults.Json(new LinkMatrixDto(snapshot.Auto, nodeItems, items), ApiJsonContext.Default.LinkMatrixDto);
|
|
}
|
|
|
|
private static async Task<IResult> SetAsync(HttpContext http, AgentService agents, LinkService links, LinkRuntime runtime, TimeProvider time, string dialer_id, string listener_id, SetLinkRequest request, CancellationToken ct)
|
|
{
|
|
(AgentView? dialer, AgentView? listener, IResult? problem) = await ResolveAsync(http, agents, dialer_id, listener_id, request.Enabled == true, ct);
|
|
if (problem is not null)
|
|
{
|
|
return problem;
|
|
}
|
|
|
|
await links.SetAsync(dialer_id, listener_id, request.Enabled, ct);
|
|
await agents.AfterAgentSetChangedAsync(ct); // 重新下发:拨号方的拨号目标、监听方允许的拨号方
|
|
if (request.Enabled == true)
|
|
{
|
|
await agents.NotifyLinkRetryAsync(dialer!.Agent.Id, listener!.Agent.Id, ct);
|
|
}
|
|
|
|
return TypedResults.Json(ToDto(await links.SnapshotAsync(ct), dialer!, listener!, runtime, time), ApiJsonContext.Default.LinkDto);
|
|
}
|
|
|
|
private static async Task<IResult> RetryAsync(HttpContext http, AgentService agents, LinkService links, LinkRuntime runtime, TimeProvider time, string dialer_id, string listener_id, CancellationToken ct)
|
|
{
|
|
(AgentView? dialer, AgentView? listener, IResult? problem) = await ResolveAsync(http, agents, dialer_id, listener_id, requireListening: true, ct);
|
|
if (problem is not null)
|
|
{
|
|
return problem;
|
|
}
|
|
|
|
await links.SetAsync(dialer_id, listener_id, true, ct);
|
|
await agents.AfterAgentSetChangedAsync(ct);
|
|
bool queued = await agents.NotifyLinkRetryAsync(dialer!.Agent.Id, listener!.Agent.Id, ct);
|
|
LinkDto link = ToDto(await links.SnapshotAsync(ct), dialer, listener, runtime, time);
|
|
return TypedResults.Json(new LinkRetryDto(queued, link), ApiJsonContext.Default.LinkRetryDto, statusCode: StatusCodes.Status202Accepted);
|
|
}
|
|
|
|
/// <summary>两端都得是存在且激活的节点,不能连自己;开线路时监听方必须在监听。</summary>
|
|
private static async Task<(AgentView? Dialer, AgentView? Listener, IResult? Problem)> ResolveAsync(HttpContext http, AgentService agents, string dialerId, string listenerId, bool requireListening, CancellationToken ct)
|
|
{
|
|
if (dialerId == listenerId)
|
|
{
|
|
return (null, null, Problems.Create(http, MoShiroErrors.SelfLink, "a node cannot connect to itself"));
|
|
}
|
|
|
|
if (await agents.GetAsync(new NodeId(dialerId), ct) is not { } dialer)
|
|
{
|
|
return (null, null, Problems.Create(http, MoShiroErrors.AgentNotFound, "dialer not found"));
|
|
}
|
|
|
|
if (await agents.GetAsync(new NodeId(listenerId), ct) is not { } listener)
|
|
{
|
|
return (null, null, Problems.Create(http, MoShiroErrors.AgentNotFound, "listener not found"));
|
|
}
|
|
|
|
if (requireListening && !listener.Agent.Listens)
|
|
{
|
|
return (null, null, Problems.Create(http, MoShiroErrors.NotListening, "the listener does not accept connections: only nodes that listen can be connected to"));
|
|
}
|
|
|
|
return (dialer, listener, null);
|
|
}
|
|
|
|
private static LinkDto ToDto(LinkSnapshot snapshot, AgentView dialer, AgentView listener, LinkRuntime runtime, TimeProvider time)
|
|
{
|
|
bool linked = snapshot.IsLinked(dialer.Agent, listener.Agent);
|
|
ReportedLink? reported = runtime.Get(dialer.Agent.Id.Value, listener.Agent.Id.Value, IpVersions.V4);
|
|
LinkHealth? health = reported?.Health;
|
|
bool fresh = reported is not null && time.GetUtcNow().ToUnixTimeMilliseconds() - reported.AtMs <= 30_000;
|
|
string state;
|
|
string? reason = null;
|
|
if (!linked) { state = "disabled"; reason = "线路策略已关闭"; }
|
|
else if (dialer.RuntimeStatus != NodeRuntimeStatus.Running) { state = "dialer_unavailable"; reason = $"拨号节点状态:{dialer.RuntimeStatus}"; }
|
|
else if (listener.RuntimeStatus != NodeRuntimeStatus.Running) { state = "listener_unavailable"; reason = $"监听节点状态:{listener.RuntimeStatus}"; }
|
|
else if (health is null || !fresh)
|
|
{
|
|
state = "waiting_report";
|
|
reason = health is null ? "拨号节点尚未上报此线路的状态" : "拨号节点的线路状态上报已过期";
|
|
}
|
|
else if (health.Control.State == ControlState.Backoff)
|
|
{
|
|
state = "control_down";
|
|
reason = health.Control.LastError is { } fault ? $"与监听节点失联:{fault}" : "与监听节点失联,等待自动重试";
|
|
}
|
|
else if (health.Control.State != ControlState.Up || listener.Dialers.All(d => d.Id != dialer.Agent.Id.Value))
|
|
{
|
|
state = "control_connecting";
|
|
reason = "正在建立到监听节点的控制连接";
|
|
}
|
|
else if (health.Task.Quic.State == QuicState.Ready)
|
|
{
|
|
state = "connected";
|
|
}
|
|
else if (health.Task.Quic.State == QuicState.Retrying)
|
|
{
|
|
state = "task_retrying";
|
|
reason = health.Task.Quic.LastError is { } fault ? $"控制连接正常,QUIC 数据通道重试中:{fault}" : "控制连接正常,QUIC 数据通道重试中";
|
|
}
|
|
else
|
|
{
|
|
state = "task_connecting";
|
|
reason = "控制连接正常,正在建立 QUIC 数据通道";
|
|
}
|
|
|
|
bool inBackoff = health?.Control.State == ControlState.Backoff;
|
|
return new LinkDto(dialer.Agent.Id.Value, dialer.Agent.Name, listener.Agent.Id.Value, listener.Agent.Name, linked,
|
|
snapshot.OverrideOf(dialer.Agent.Id.Value, listener.Agent.Id.Value), state == "connected", dialer.RuntimeStatus, listener.RuntimeStatus,
|
|
state, reason, (inBackoff ? health!.Control.LastError : health?.Task.Quic.LastError)?.ToString(),
|
|
health is null ? 0 : inBackoff ? health.Control.RetryAttempt : health.Task.Quic.RetryAttempt,
|
|
health is null ? null : inBackoff ? health.Control.RetryInMs : health.Task.Quic.RetryInMs);
|
|
}
|
|
}
|
|
|
|
public sealed record LinkRetryDto(bool Queued, LinkDto Link);
|