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;
///
/// 线路开关:哪些(拨号方,监听方)要连。自动连接的总开关是设置 fleet.auto_link(不监听的节点自动拨所有监听节点);
/// 这里看每一对的结果,并且手动强制开、关或取消手动设置。把一个节点拖到另一个监听节点上建立线路,就是 PUT 一个 enabled: true。
///
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().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().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().ProducesProblems(401, 403, 404, 409, 422)
.Requires(Capability.FleetWrite);
}
private static async Task 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();
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 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 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);
}
/// 两端都得是存在且激活的节点,不能连自己;开线路时监听方必须在监听。
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);