agent: unify listening and dialing with directed mutual measurements
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.
This commit is contained in:
1 parent
d5ebf29e57
commit
ad5efc62ff
206 files changed
+4202
-4964
No files matched your search
@@ -8,27 +8,26 @@ using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Api;
|
||||
|
||||
/// <summary>Agent 管理:列表、修改、审批、启停、接管、删除;加入令牌;Probe 身份。</summary>
|
||||
/// <summary>节点管理:列表、修改、审批、启停、接管、删除;加入令牌。</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)
|
||||
.Api("agents_list", "All nodes (agents) including pending and disabled ones; each says whether it listens, which dialers are connected to it and which listeners it dials", AgentsTag).Returns<PageDto<AgentDto>>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.FleetRead);
|
||||
group.MapGet("/agents/{id}", GetAsync)
|
||||
.Api("agents_get", "One agent", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404)
|
||||
.Requires(Capability.FleetRead);
|
||||
group.MapGet("/nodes/{id}/logs", ListNodeLogsAsync)
|
||||
.Api("node_runtime_logs", "Recent Agent or Probe runtime logs", "admin-nodes").Returns<NodeRuntimeLogsDto>().ProducesProblems(401, 403)
|
||||
.Api("node_runtime_logs", "Recent runtime logs of a node", "admin-nodes").Returns<NodeRuntimeLogsDto>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.FleetRead);
|
||||
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)
|
||||
.Api("agents_update", "Update name, labels, public host, ISP or visibility (merge-patch, If-Match supported)", AgentsTag)
|
||||
.Returns<AgentDto>().ProducesProblems(400, 401, 403, 404, 409, 412)
|
||||
.Requires(Capability.FleetWrite);
|
||||
group.MapPost("/agents/{id}/approve", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.ApproveAsync(nodeId, ct)))
|
||||
@@ -53,7 +52,7 @@ public static class AgentEndpoints
|
||||
.Api("join_tokens_list", "Join tokens", TokensTag).Returns<PageDto<JoinTokenDto>>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.FleetRead);
|
||||
group.MapPost("/join-tokens", CreateTokenAsync)
|
||||
.Api("join_tokens_create", "Create a join token for an agent or a probe (role); the plaintext is returned only once", TokensTag)
|
||||
.Api("join_tokens_create", "Create a join token for an agent; the plaintext is returned only once", TokensTag)
|
||||
.Returns<CreatedJoinTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403)
|
||||
.Requires(Capability.FleetEnroll);
|
||||
group.MapDelete("/join-tokens/{id}", RevokeTokenAsync)
|
||||
@@ -61,25 +60,8 @@ public static class AgentEndpoints
|
||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403)
|
||||
.Requires(Capability.FleetEnroll);
|
||||
|
||||
group.MapGet("/probes", ListProbesAsync)
|
||||
.Api("probes_list", "All probes, with the agents each one is connected to", ProbeTag).Returns<PageDto<ProbeDto>>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.FleetRead);
|
||||
group.MapGet("/probes/{id}", GetProbeAsync)
|
||||
.Api("probes_get", "One probe", ProbeTag).Returns<ProbeDto>().ProducesProblems(401, 403, 404)
|
||||
.Requires(Capability.FleetRead);
|
||||
group.MapPatch("/probes/{id}", PatchProbeAsync)
|
||||
.Api("probes_update", "Rename a probe", ProbeTag).Returns<ProbeDto>().ProducesProblems(400, 401, 403, 404, 409)
|
||||
.Requires(Capability.FleetWrite);
|
||||
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)
|
||||
.Requires(Capability.FleetRevoke);
|
||||
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)
|
||||
.Requires(Capability.FleetRevoke);
|
||||
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)
|
||||
group.MapPost("/agents/{id}/dialers/{dialer_id}/disconnect", DisconnectDialerAsync)
|
||||
.Api("agents_dialer_disconnect", "Make a listening node drop one dialer's session (the dialer reconnects unless it is disabled)", AgentsTag)
|
||||
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 404, 409)
|
||||
.Requires(Capability.FleetWrite);
|
||||
}
|
||||
@@ -107,8 +89,13 @@ public static class AgentEndpoints
|
||||
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)
|
||||
private static async Task<IResult> PatchAsync(HttpContext http, AgentService agents, NodeInfoService nodes, AgentPatchRequest request, string id, CancellationToken ct)
|
||||
{
|
||||
if (request.Isp is not null && request.Isp != "auto" && IspClassifier.NameOf(request.Isp) is null)
|
||||
{
|
||||
return Problems.From(http, ValidationError.Single("isp", "must be telecom, unicom, mobile, broadnet, other or auto"));
|
||||
}
|
||||
|
||||
Result<AgentView> result = await agents.PatchAsync(
|
||||
new NodeId(id), new AgentPatch { Name = request.Name, Labels = request.Labels, PublicHost = request.PublicHost, Ipv6Host = request.Ipv6Host, PublicHostAuto = request.PublicHostAuto, Ipv6HostAuto = request.Ipv6HostAuto, PublicVisible = request.PublicVisible }, Concurrency.ExpectedRowVersion(http), ct);
|
||||
if (!result.TryGet(out AgentView? view, out FleetError? error))
|
||||
@@ -116,6 +103,11 @@ public static class AgentEndpoints
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
if (request.Isp is not null)
|
||||
{
|
||||
await nodes.SetIspAsync(id, request.Isp == "auto" ? null : request.Isp, ct);
|
||||
}
|
||||
|
||||
Concurrency.SetETag(http, view.Agent.RowVersion);
|
||||
return TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.AgentDto);
|
||||
}
|
||||
@@ -182,62 +174,14 @@ public static class AgentEndpoints
|
||||
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> 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, NodeInfoService nodes, PatchProbeRequest request, string id, CancellationToken ct)
|
||||
private static async Task<IResult> DisconnectDialerAsync(HttpContext http, AgentService agents, string id, string dialer_id, CancellationToken ct)
|
||||
{
|
||||
if (request.Name is null && request.Isp is null)
|
||||
{
|
||||
return Problems.From(http, ValidationError.Single("name", "name or isp is required"));
|
||||
}
|
||||
|
||||
if (request.Isp is not null && request.Isp != "auto" && IspClassifier.NameOf(request.Isp) is null)
|
||||
{
|
||||
return Problems.From(http, ValidationError.Single("isp", "must be telecom, unicom, mobile, broadnet, other or auto"));
|
||||
}
|
||||
|
||||
Result<ProbeView> result = request.Name is null
|
||||
? (await probes.GetAsync(id, ct) is { } existing ? Result<ProbeView>.Ok(existing) : Result<ProbeView>.Fail(MoShiroErrors.ProbeNotFound, "probe not found"))
|
||||
: await probes.RenameAsync(id, request.Name, ct);
|
||||
if (!result.TryGet(out ProbeView? view, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
if (request.Isp is not null)
|
||||
{
|
||||
await nodes.SetIspAsync(id, NodeKinds.Probe, request.Isp == "auto" ? null : request.Isp, ct);
|
||||
}
|
||||
|
||||
return TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto);
|
||||
}
|
||||
|
||||
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);
|
||||
Result<bool> result = await agents.DisconnectDialerAsync(new NodeId(id), new NodeId(dialer_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");
|
||||
return dropped ? TypedResults.NoContent() : Problems.Create(http, MoShiroErrors.DialerOffline, "this dialer has no session with this node");
|
||||
}
|
||||
}
|
||||
@@ -16,14 +16,14 @@ public static class BuildEndpoints
|
||||
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)
|
||||
.Api("build_info_get", "Version, tag, commit, build time and update state of the backend and every node", Tag)
|
||||
.Returns<BuildInfoDto>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.UpdateRead);
|
||||
group.MapGet("/releases", ListReleasesAsync)
|
||||
.Api("releases_list", "The latest signed release manifest of each component", Tag).Returns<PageDto<ReleaseDto>>().ProducesProblems(401, 403)
|
||||
.Requires(Capability.UpdateRead);
|
||||
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)
|
||||
.Api("nodes_update", "Ask an online node to install the release it has been offered", Tag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 409)
|
||||
.Requires(Capability.UpdateConfirm);
|
||||
group.MapGet("/backend/update", BackendUpdateAsync)
|
||||
.Api("backend_update_get", "Current channel and host-verified backend update", Tag).Returns<BackendUpdateDto>().ProducesProblems(401, 403)
|
||||
@@ -65,9 +65,8 @@ public static class BuildEndpoints
|
||||
{
|
||||
IReadOnlyDictionary<string, IReadOnlyList<NodeOwner>> ownerOf = await owners.ListAsync(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))), ownerOf.TryGetValue(n.NodeId, out IReadOnlyList<NodeOwner>? list) ? [.. list.Select(o => new NodeOwnerDto(o.CredentialId, o.PasskeyName, o.Username))] : null))
|
||||
.OrderBy(n => n.NodeId, StringComparer.Ordinal)
|
||||
.Select(n => new NodeBuildDto(n.NodeId, n.Os, n.OsName, n.Arch, n.Version, n.Tag, n.Commit, n.BuiltAt, ToDto(releases.StatusOf(new NodeId(n.NodeId))), ownerOf.TryGetValue(n.NodeId, out IReadOnlyList<NodeOwner>? list) ? [.. list.Select(o => new NodeOwnerDto(o.CredentialId, o.PasskeyName, o.Username))] : null))
|
||||
.ToList();
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
return TypedResults.Json(new BuildInfoDto(NodeEndpoints.Backend(withCommit: true), items, ToDto(await backendUpdates.GetAsync(ct))), ApiJsonContext.Default.BuildInfoDto);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Fleet.Core;
|
||||
using Fleet.Server;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
@@ -7,7 +8,10 @@ using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Api;
|
||||
|
||||
/// <summary>线路开关:哪些(Probe,Agent)要连。自动连接的总开关是设置 <c>fleet.auto_link</c>;这里看每一对的结果,并且手动强制开、关或取消手动设置。</summary>
|
||||
/// <summary>
|
||||
/// 线路开关:哪些(拨号方,监听方)要连。自动连接的总开关是设置 <c>fleet.auto_link</c>(不监听的节点自动拨所有监听节点);
|
||||
/// 这里看每一对的结果,并且手动强制开、关或取消手动设置。把一个节点拖到另一个监听节点上建立线路,就是 <c>PUT</c> 一个 <c>enabled: true</c>。
|
||||
/// </summary>
|
||||
public static class LinkEndpoints
|
||||
{
|
||||
private const string Tag = "admin-links";
|
||||
@@ -15,110 +19,139 @@ public static class LinkEndpoints
|
||||
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)
|
||||
.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/{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)
|
||||
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/{probe_id}/{agent_id}/retry", RetryAsync)
|
||||
.Api("links_retry", "Enable and immediately retry the Probe-to-Agent connection", Tag).Returns<LinkRetryDto>().ProducesProblems(401, 403, 404)
|
||||
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, ProbeService probes, LinkService links, ProbeQuicRuntime quic, TimeProvider time, CancellationToken ct)
|
||||
private static async Task<IResult> ListAsync(AgentService agents, LinkService links, LinkRuntime runtime, TimeProvider time, CancellationToken ct)
|
||||
{
|
||||
LinkSnapshot snapshot = await links.SnapshotAsync(ct);
|
||||
IReadOnlyList<AgentView> agentViews = await agents.ListAsync(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 (ProbeView probe in (await probes.ListAsync(ct)).Where(p => !p.Revoked).OrderBy(p => p.Probe.Name, StringComparer.Ordinal))
|
||||
foreach (AgentView dialer in nodes)
|
||||
{
|
||||
foreach (AgentView agent in agentViews.OrderBy(a => a.Agent.Name, StringComparer.Ordinal))
|
||||
foreach (AgentView listener in nodes.Where(n => n.Agent.Listens && n.Agent.Id != dialer.Agent.Id))
|
||||
{
|
||||
items.Add(ToDto(snapshot, probe, agent, quic, time));
|
||||
items.Add(ToDto(snapshot, dialer, listener, runtime, time));
|
||||
}
|
||||
}
|
||||
|
||||
return TypedResults.Json(new LinkMatrixDto(snapshot.Auto, items), ApiJsonContext.Default.LinkMatrixDto);
|
||||
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, ProbeService probes, LinkService links, ProbeQuicRuntime quic, TimeProvider time, string probe_id, string agent_id, SetLinkRequest request, CancellationToken ct)
|
||||
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)
|
||||
{
|
||||
if (await probes.GetAsync(probe_id, ct) is not { Revoked: false } probe)
|
||||
(AgentView? dialer, AgentView? listener, IResult? problem) = await ResolveAsync(http, agents, dialer_id, listener_id, request.Enabled == true, ct);
|
||||
if (problem is not null)
|
||||
{
|
||||
return Problems.Create(http, MoShiroErrors.ProbeNotFound, "probe not found");
|
||||
return problem;
|
||||
}
|
||||
|
||||
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
|
||||
await links.SetAsync(dialer_id, listener_id, request.Enabled, ct);
|
||||
await agents.AfterAgentSetChangedAsync(ct); // 重新下发:拨号方的拨号目标、监听方允许的拨号方
|
||||
if (request.Enabled == true)
|
||||
{
|
||||
await agents.NotifyProbeLinkRetryAsync(new NodeId(probe_id), agent.Agent.Id, ct);
|
||||
await agents.NotifyLinkRetryAsync(dialer!.Agent.Id, listener!.Agent.Id, ct);
|
||||
}
|
||||
return TypedResults.Json(ToDto(await links.SnapshotAsync(ct), probe, agent, quic, time), ApiJsonContext.Default.LinkDto);
|
||||
|
||||
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, ProbeService probes, LinkService links, ProbeQuicRuntime quic, TimeProvider time, string probe_id, string agent_id, CancellationToken ct)
|
||||
private static async Task<IResult> RetryAsync(HttpContext http, AgentService agents, LinkService links, LinkRuntime runtime, TimeProvider time, string dialer_id, string listener_id, CancellationToken ct)
|
||||
{
|
||||
if (await probes.GetAsync(probe_id, ct) is not { Revoked: false } probe)
|
||||
(AgentView? dialer, AgentView? listener, IResult? problem) = await ResolveAsync(http, agents, dialer_id, listener_id, requireListening: true, ct);
|
||||
if (problem is not null)
|
||||
{
|
||||
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");
|
||||
return problem;
|
||||
}
|
||||
|
||||
await links.SetAsync(probe_id, agent_id, true, ct);
|
||||
await links.SetAsync(dialer_id, listener_id, true, ct);
|
||||
await agents.AfterAgentSetChangedAsync(ct);
|
||||
bool queued = await agents.NotifyProbeLinkRetryAsync(new NodeId(probe_id), agent.Agent.Id, ct);
|
||||
LinkDto link = ToDto(await links.SnapshotAsync(ct), probe, agent, quic, time);
|
||||
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 LinkDto ToDto(LinkSnapshot snapshot, ProbeView probe, AgentView agent, ProbeQuicRuntime quicRuntime, TimeProvider time)
|
||||
/// <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)
|
||||
{
|
||||
bool linked = snapshot.IsLinked(probe.Probe.Id, agent.Agent.Id.Value);
|
||||
ProbeQuicLinkSnapshot? quicSnapshot = quicRuntime.Get(probe.Probe.Id, agent.Agent.Id.Value, IpVersions.V4);
|
||||
ProbeQuicLinkHealth? health = quicSnapshot?.Health;
|
||||
bool fresh = quicSnapshot is not null && time.GetUtcNow().ToUnixTimeMilliseconds() - quicSnapshot.AtMs <= 30_000;
|
||||
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 (probe.RuntimeStatus != NodeRuntimeStatus.Running) { state = "probe_unavailable"; reason = $"Probe 状态:{probe.RuntimeStatus}"; }
|
||||
else if (agent.RuntimeStatus != NodeRuntimeStatus.Running) { state = "agent_unavailable"; reason = $"Agent 状态:{agent.RuntimeStatus}"; }
|
||||
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 ? "Probe 尚未上报此线路的 QUIC 拨号状态" : "Probe 的线路状态上报已过期";
|
||||
reason = health is null ? "拨号节点尚未上报此线路的状态" : "拨号节点的线路状态上报已过期";
|
||||
}
|
||||
else if (health.Connected && agent.Probes.Any(p => p.Id == probe.Probe.Id))
|
||||
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.State == ProbeLinkAttemptState.RetryWait)
|
||||
else if (health.Task.Quic.State == QuicState.Retrying)
|
||||
{
|
||||
state = "retry_wait";
|
||||
reason = health.LastError is { Length: > 0 } error ? $"QUIC 连接失败:{error}" : "QUIC 连接失败,等待自动重试";
|
||||
}
|
||||
else if (agent.Probes.All(p => p.Id != probe.Probe.Id))
|
||||
{
|
||||
state = "control_disconnected";
|
||||
reason = health.LastError ?? "Probe 尚未建立到 Agent 的测量控制会话";
|
||||
state = "task_retrying";
|
||||
reason = health.Task.Quic.LastError is { } fault ? $"控制连接正常,QUIC 数据通道重试中:{fault}" : "控制连接正常,QUIC 数据通道重试中";
|
||||
}
|
||||
else
|
||||
{
|
||||
state = "quic_connecting";
|
||||
reason = health.LastError ?? "控制会话已建立,正在建立 QUIC 测量会话";
|
||||
state = "task_connecting";
|
||||
reason = "控制连接正常,正在建立 QUIC 数据通道";
|
||||
}
|
||||
bool connected = state == "connected";
|
||||
return new LinkDto(probe.Probe.Id, probe.Probe.Name, agent.Agent.Id.Value, agent.Agent.Name, linked,
|
||||
snapshot.OverrideOf(probe.Probe.Id, agent.Agent.Id.Value), connected, probe.RuntimeStatus, agent.RuntimeStatus,
|
||||
state, reason, health?.LastError, health?.RetryAttempt ?? 0, health?.RetryInMs);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ public static class SystemEndpoints
|
||||
|
||||
if (result.IsSuccess && changes.ContainsKey(SettingsService.AutoLink))
|
||||
{
|
||||
await agents.AfterAgentSetChangedAsync(http.RequestAborted); // 自动连接开关变了:重新下发各 Probe 的 Agent 列表和各 Agent 允许的 Probe
|
||||
await agents.AfterAgentSetChangedAsync(http.RequestAborted); // 自动连接开关变了:重新下发各 拨号方 的 Agent 列表和各 Agent 允许的 拨号方
|
||||
}
|
||||
|
||||
return result.TryGet(out IReadOnlyList<SettingView>? views, out FleetError? error)
|
||||
|
||||
@@ -117,7 +117,7 @@ public static class TaskEndpoints
|
||||
return Problems.From(http, lineError);
|
||||
}
|
||||
|
||||
Result<TestRecord> result = await tests.StartAsync(line.Key.Agent, line.Key.Probe, new StartTestRequest(request.Plan ?? "", request.Params), "api", line.IpVersion, ct);
|
||||
Result<TestRecord> result = await tests.StartAsync(line.Key.Listener, line.Key.Dialer, new StartTestRequest(request.Plan ?? "", request.Params), "api", line.IpVersion, ct);
|
||||
if (!result.TryGet(out TestRecord? test, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
@@ -129,14 +129,14 @@ public static class TaskEndpoints
|
||||
|
||||
private static async Task<IResult> ListTestsAsync(HttpContext http, TestService tests, LineService lines, string line_id, int? limit, CancellationToken ct)
|
||||
{
|
||||
// 只给 Agent id(并且有多个 Probe)时列出这个 Agent 上所有 Probe 的测试
|
||||
// 只给监听方 id(并且有多条线路)时列出这个节点上所有线路的测试
|
||||
NodeId agent;
|
||||
ProbeId? probe = null;
|
||||
NodeId? dialer = null;
|
||||
string? ipVersion = null;
|
||||
if (LineKey.TryParse(line_id, out LineKey key))
|
||||
{
|
||||
agent = key.Agent;
|
||||
probe = key.Probe;
|
||||
agent = key.Listener;
|
||||
dialer = key.Dialer;
|
||||
ipVersion = key.IpVersion;
|
||||
}
|
||||
else
|
||||
@@ -145,7 +145,7 @@ public static class TaskEndpoints
|
||||
}
|
||||
|
||||
return TypedResults.Json(
|
||||
new PageDto<TestDto>([.. (await tests.ListAsync(agent, probe, Math.Clamp(limit ?? 50, 1, 500), ipVersion, ct)).Select(Mappers.ToDto)], null),
|
||||
new PageDto<TestDto>([.. (await tests.ListAsync(agent, dialer, Math.Clamp(limit ?? 50, 1, 500), ipVersion, ct)).Select(Mappers.ToDto)], null),
|
||||
ApiJsonContext.Default.PageDtoTestDto);
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ public static class TrustEndpoints
|
||||
/// <summary>面板提供的脚本和 CI 签名的发布清单里记的哈希是否一致(清单由 CI 签名,节点和浏览器之外的人改不了)。没有清单或没记哈希时返回 null。</summary>
|
||||
private static async Task<bool?> MatchesReleaseAsync(ReleaseService releases, string sh, string ps1, CancellationToken ct)
|
||||
{
|
||||
foreach (string component in new[] { JoinTokenService.AgentRole, JoinTokenService.ProbeRole })
|
||||
foreach (string component in new[] { ReleaseService.AgentComponent })
|
||||
{
|
||||
if (await releases.GetAsync(component, ct) is not { } offer)
|
||||
{
|
||||
|
||||
@@ -30,43 +30,51 @@ public static class Mappers
|
||||
|
||||
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), line.Line.IpVersion, ToDto(line.Line.QuicHealth));
|
||||
line.Line.Key.Id, line.Line.Listener.Agent.Id.Value, line.Line.Dialer.Id.Value, line.Line.Dialer.Name, line.Line.Listener.Agent.Name, line.Line.Listener.Agent.Labels,
|
||||
line.Line.Listener.Online, line.Line.Connected, line.Line.Listener.EndpointChanges, ToDto(line.Summary), Summary(line.LastTest), line.Line.IpVersion, ToDto(line.Line.Link));
|
||||
|
||||
public static QuicLineHealthDto? ToDto(ProbeQuicLinkSnapshot? snapshot)
|
||||
public static QuicLineHealthDto? ToDto(ReportedLink? snapshot)
|
||||
{
|
||||
if (snapshot is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
ProbeQuicLinkHealth health = snapshot.Health;
|
||||
double? Rate(long count) => health.HandshakeAttempts > 0 ? count * 100.0 / health.HandshakeAttempts : null;
|
||||
LinkHealth link = snapshot.Health;
|
||||
QuicHealth quic = link.Task.Quic;
|
||||
double? Rate(long count) => quic.HandshakeAttempts > 0 ? count * 100.0 / quic.HandshakeAttempts : null;
|
||||
return new QuicLineHealthDto(
|
||||
health.Connected, health.HandshakeAttempts, health.HandshakeSuccesses, health.HandshakeFailures, health.HandshakeTimeouts,
|
||||
Rate(health.HandshakeSuccesses), Rate(health.HandshakeFailures), Rate(health.HandshakeTimeouts), health.HandshakeP95Ms, health.HandshakeSampleCount,
|
||||
health.TransportPacketLossRate, health.TransportPacketsSent, health.TransportCongestionEvents,
|
||||
health.TransportEcnCongestionEvents, health.PathMtu, Time(snapshot.AtMs), health.State switch
|
||||
link.Ready, quic.HandshakeAttempts, quic.HandshakeSuccesses, quic.HandshakeFailures, quic.HandshakeTimeouts,
|
||||
Rate(quic.HandshakeSuccesses), Rate(quic.HandshakeFailures), Rate(quic.HandshakeTimeouts), quic.HandshakeP95Ms, quic.HandshakeSampleCount,
|
||||
quic.TransportPacketLossRate, quic.TransportPacketsSent, quic.TransportCongestionEvents,
|
||||
quic.TransportEcnCongestionEvents, quic.PathMtu, Time(snapshot.AtMs), quic.State switch
|
||||
{
|
||||
ProbeLinkAttemptState.Connected => "connected",
|
||||
ProbeLinkAttemptState.RetryWait => "retry_wait",
|
||||
QuicState.Ready => "connected",
|
||||
QuicState.Retrying => "retry_wait",
|
||||
QuicState.Idle => "idle",
|
||||
_ => "connecting",
|
||||
}, health.RetryAttempt, health.RetryInMs);
|
||||
}, quic.RetryAttempt, quic.RetryInMs, ControlStateName(link.Control.State));
|
||||
}
|
||||
|
||||
private static string ControlStateName(ControlState state) => state switch
|
||||
{
|
||||
ControlState.Up => "up",
|
||||
ControlState.Backoff => "backoff",
|
||||
ControlState.Syncing => "syncing",
|
||||
ControlState.Authenticating => "authenticating",
|
||||
_ => "dialing",
|
||||
};
|
||||
|
||||
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), p.IpVersion);
|
||||
public static ConnectedDialerDto ToDto(ConnectedDialerInfo d) => new(d.Id, d.Version, d.Remote, Time(d.SinceMs), d.IpVersion);
|
||||
|
||||
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), c.IpVersion))], v.RuntimeStatus, ProbeIpv6Of(v.Probe), v.Probe.AddedBy);
|
||||
public static OutgoingConnectionDto ToDto(OutgoingConnection c) => new(c.ListenerId.Value, c.ListenerName, c.ListenerOnline, c.Version, c.Remote, Time(c.SinceMs), c.IpVersion);
|
||||
|
||||
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.Line.Id, r.Line.Listener.Value, r.Line.Dialer.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, r.Line.IpVersion);
|
||||
|
||||
@@ -74,12 +82,12 @@ public static class Mappers
|
||||
{
|
||||
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,
|
||||
t.Id.Value, t.AgentId.Value, t.DialerId.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), t.IpVersion);
|
||||
}
|
||||
|
||||
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, t.IpVersion);
|
||||
new(t.Id.Value, t.AgentId.Value, t.DialerId.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, t.IpVersion);
|
||||
|
||||
public static TestResultDto ToDto(TestResultView r) =>
|
||||
new(
|
||||
@@ -120,10 +128,10 @@ public static class Mappers
|
||||
}
|
||||
|
||||
HeartbeatDto? heartbeat = view.Heartbeat is { } h
|
||||
? new HeartbeatDto(h.Sessions, h.PpsIn, h.PpsOut, h.DroppedInvalid, h.DroppedRateLimited, h.QueueBytes, Time(view.HeartbeatAtMs ?? 0))
|
||||
? new HeartbeatDto(h.Dialers.Count, 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.ProbeUdpPorts, a.Labels, a.PublicVisible,
|
||||
a.Id.Value, a.Name, StatusName(a.Status), view.Online, a.Listens, view.DialerConnected, [.. view.Dialers.Select(ToDto)], [.. view.Outgoing.Select(ToDto)], view.Host is null ? null : ToDto(view.Host), a.PublicHost, a.TcpPort, a.QuicPorts, a.Labels, a.PublicVisible,
|
||||
a.Kinds, a.AgentVersion, limits, a.TaskVersion, a.AppliedTaskVersion, rejected, heartbeat, view.EndpointChanges, view.RuntimeStatus, Time(a.CreatedAtMs), Time(a.LastSeenAtMs), a.Ipv6Host, a.Ipv6Supported, Ipv6Of(view), !a.PublicHostManual, !a.Ipv6HostManual, a.AddedBy);
|
||||
}
|
||||
|
||||
@@ -153,29 +161,11 @@ public static class Mappers
|
||||
diagnostic?.HasGlobalAddress, diagnostic?.HasDefaultRoute, state, diagnostic?.NetworkScope ?? "unknown", diagnostic?.HostHasGlobalAddress, diagnostic?.HostHasDefaultRoute);
|
||||
}
|
||||
|
||||
private static Ipv6DiagnosticDto? ProbeIpv6Of(ProbeRecord probe)
|
||||
{
|
||||
if (probe.Ipv6DiagnosticsJson is not { } json)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Ipv6Diagnostic? d = JsonSerializer.Deserialize(json, MoShiroJsonContext.Default.Ipv6Diagnostic);
|
||||
return d is null ? null : new Ipv6DiagnosticDto(null, d.SocketSupported, null, d.HasGlobalAddress, d.HasDefaultRoute, d.State, d.NetworkScope, d.HostHasGlobalAddress, d.HostHasDefaultRoute);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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), JoinTokenService.RoleOf(t.Kind), t.CreatedBy);
|
||||
new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), t.CreatedBy);
|
||||
|
||||
public static TrustStateDto ToDto(TrustState s, string installUrl = "", string installSh = "", string installPs1 = "", bool? matches = null) => new(s.Initialized, s.Genesis, s.Version, s.InSync, s.Proposal is null ? null : new TrustProposalDto(s.Proposal.Statement, s.Proposal.Version, s.Proposal.Challenge, [.. s.Proposal.Added.Select(a => new ProposedCredentialDto(a.Id, a.Name, Time(a.CreatedAtMs)))], s.Proposal.Removed, s.Proposal.ServerKeyChanged), installUrl, s.Operators ?? [], installSh, installPs1, matches, s.ServerKey, s.ServerKeyListed, [.. (s.OperatorDetails ?? []).Select(o => new NodeOwnerDto(o.CredentialId, o.PasskeyName, o.Username))]);
|
||||
|
||||
@@ -224,8 +214,8 @@ public static class Mappers
|
||||
|
||||
private static string? RoleName(ColumnRole role) => role switch
|
||||
{
|
||||
ColumnRole.TsProbe => "ts_probe",
|
||||
ColumnRole.TsAgent => "ts_agent",
|
||||
ColumnRole.TsDialer => "ts_dialer",
|
||||
ColumnRole.TsListener => "ts_listener",
|
||||
ColumnRole.TsLocal => "ts_local",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
@@ -48,13 +48,13 @@ public static class ErrorCodeHttpMap
|
||||
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,
|
||||
404, MoShiroErrors.NotFound, MoShiroErrors.AgentNotFound, MoShiroErrors.AgentNotFound, 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(409, MoShiroErrors.NameConflict, MoShiroErrors.NodeRevoked, MoShiroErrors.DialerOffline, MoShiroErrors.NotListening, 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(422, MoShiroErrors.DialerRequired, MoShiroErrors.SelfLink, 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;
|
||||
|
||||
@@ -73,7 +73,7 @@ internal static class QueryHelpers
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>线路 id(<c>agent~probe</c>,或只有一个 Probe 时的 Agent id)→ 可以展示的线路。否则 404 / 422(不暴露被隐藏的线路存在与否)。</summary>
|
||||
/// <summary>线路 id(<c>listener~dialer</c>,或只有一条线路时的监听方 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);
|
||||
@@ -82,7 +82,7 @@ internal static class QueryHelpers
|
||||
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
|
||||
return line.Listener.Agent.Status == Fleet.Server.NodeStatus.Active && (line.Listener.Agent.PublicVisible || PublicGuard.CanSeeHidden(http)) && line.Dialer.Status == Fleet.Server.NodeStatus.Active
|
||||
? found
|
||||
: Result<LineView>.Fail(MoShiroErrors.AgentNotFound, "line not found");
|
||||
}
|
||||
|
||||
@@ -41,13 +41,14 @@ public sealed record AgentDto(
|
||||
string Name,
|
||||
string Status,
|
||||
bool Online,
|
||||
bool ProbeConnected,
|
||||
IReadOnlyList<ConnectedProbeDto> Probes,
|
||||
bool Listens,
|
||||
bool DialerConnected,
|
||||
IReadOnlyList<ConnectedDialerDto> Dialers,
|
||||
IReadOnlyList<OutgoingConnectionDto> Outgoing,
|
||||
HostDto? Host,
|
||||
string PublicHost,
|
||||
int ProbeTcpPort,
|
||||
IReadOnlyList<int> ProbeUdpPorts,
|
||||
IReadOnlyList<int> ProbeQuicPorts,
|
||||
int TcpPort,
|
||||
IReadOnlyList<int> QuicPorts,
|
||||
IReadOnlyList<string> Labels,
|
||||
bool PublicVisible,
|
||||
IReadOnlyList<string> Kinds,
|
||||
@@ -73,10 +74,10 @@ public sealed record Ipv6DiagnosticDto(bool? AddressConfigured, bool SocketSuppo
|
||||
|
||||
/// <summary>merge-patch:缺省或为 null 的字段不修改。</summary>
|
||||
/// <param name="PublicHostAuto">true:撤销手填,回到自动识别(<c>ipv6_host_auto</c> 同理)。手填 <c>public_host</c> 或 <c>ipv6_host</c> 就是手动,自动识别不再覆盖。</param>
|
||||
public sealed record AgentPatchRequest(string? Name, IReadOnlyList<string>? Labels, string? PublicHost, bool? PublicVisible, string? Ipv6Host = null, bool? PublicHostAuto = null, bool? Ipv6HostAuto = null);
|
||||
/// <param name="Isp">telecom / unicom / mobile / broadnet / other 是手动指定,auto 是回到自动识别;不传就不改。</param>
|
||||
public sealed record AgentPatchRequest(string? Name, IReadOnlyList<string>? Labels, string? PublicHost, bool? PublicVisible, string? Ipv6Host = null, bool? PublicHostAuto = null, bool? Ipv6HostAuto = null, string? Isp = null);
|
||||
|
||||
/// <param name="Role"><c>agent</c> 或 <c>probe</c>:用这个令牌注册出来的是哪种节点。</param>
|
||||
public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList<string> Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt, string Role = "agent", string? CreatedBy = null);
|
||||
public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList<string> Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt, string? CreatedBy = null);
|
||||
|
||||
/// <param name="Controllers">属主:允许授权这个节点 extension 会话的 passkey 凭证 id(必须是操作员集合里的);缺省是集合里的全部。没有建立信任根时忽略。</param>
|
||||
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours, string? Role = null, IReadOnlyList<string>? Controllers = null);
|
||||
@@ -123,24 +124,25 @@ public sealed record TrustStateDto(bool Initialized, string? Genesis, int Versio
|
||||
|
||||
public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands, ComposeInstallDto Compose, QuadletInstallDto Quadlet);
|
||||
|
||||
/// <summary>Agent 眼中连着的一个 Probe(Agent 上报)。</summary>
|
||||
public sealed record ConnectedProbeDto(string ProbeId, string? Version, string? Remote, DateTimeOffset ConnectedSince, string IpVersion = "ipv4");
|
||||
/// <summary>监听方眼中连着的一个拨号方(监听方上报)。</summary>
|
||||
public sealed record ConnectedDialerDto(string DialerId, string? Version, string? Remote, DateTimeOffset ConnectedSince, string IpVersion = "ipv4");
|
||||
|
||||
/// <summary>某个 Probe 现在连着的一个 Agent。</summary>
|
||||
public sealed record ProbeConnectionDto(string AgentId, string AgentName, bool AgentOnline, string? Version, string? Remote, DateTimeOffset ConnectedSince, string IpVersion = "ipv4");
|
||||
/// <summary>这个节点现在连着的一个监听节点(监听方上报)。</summary>
|
||||
public sealed record OutgoingConnectionDto(string ListenerId, string ListenerName, bool ListenerOnline, string? Version, string? Remote, DateTimeOffset ConnectedSince, string IpVersion = "ipv4");
|
||||
|
||||
public sealed record ProbeDto(string Id, string Name, DateTimeOffset CreatedAt, DateTimeOffset? RevokedAt, bool Revoked, bool Online, IReadOnlyList<ProbeConnectionDto> Connections, NodeRuntimeStatus RuntimeStatus, Ipv6DiagnosticDto? Ipv6 = null, string? AddedBy = null);
|
||||
/// <summary>
|
||||
/// 一对(拨号方,监听方)的线路开关:<c>linked</c> 是最终结果,<c>override</c> 是手动设置(<c>null</c> = 跟随自动连接),<c>connected</c> 是现在有没有连着。
|
||||
/// <c>connection_state</c>:<c>disabled</c>、<c>dialer_unavailable</c>、<c>listener_unavailable</c>、<c>waiting_report</c>、<c>control_connecting</c>、<c>control_down</c>(失联)、<c>task_connecting</c>、<c>task_retrying</c>、<c>connected</c>。
|
||||
/// </summary>
|
||||
public sealed record LinkDto(string DialerId, string DialerName, string ListenerId, string ListenerName, bool Linked, bool? Override, bool Connected, NodeRuntimeStatus DialerRuntimeStatus, NodeRuntimeStatus ListenerRuntimeStatus, string ConnectionState = "control_connecting", string? ConnectionReason = null, string? LastError = null, int RetryAttempt = 0, long? RetryInMs = null);
|
||||
|
||||
/// <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, NodeRuntimeStatus ProbeRuntimeStatus, NodeRuntimeStatus AgentRuntimeStatus, string ConnectionState = "connecting", string? ConnectionReason = null, string? LastError = null, int RetryAttempt = 0, long? RetryInMs = null);
|
||||
/// <summary>线路目录里的一个节点:<c>listens</c> 表示它可以作为监听方(被拖到的目标);任何激活的节点都可以作为拨号方。</summary>
|
||||
public sealed record LinkNodeDto(string Id, string Name, bool Listens, bool Online, NodeRuntimeStatus RuntimeStatus);
|
||||
|
||||
public sealed record LinkMatrixDto(bool AutoLink, IReadOnlyList<LinkDto> Items);
|
||||
public sealed record LinkMatrixDto(bool AutoLink, IReadOnlyList<LinkNodeDto> Nodes, IReadOnlyList<LinkDto> Items);
|
||||
|
||||
public sealed record SetLinkRequest(bool? Enabled);
|
||||
|
||||
/// <summary>Isp:telecom / unicom / mobile / broadnet / other 是手动指定,auto 是回到自动识别;不传就不改。</summary>
|
||||
public sealed record PatchProbeRequest(string? Name, string? Isp = null);
|
||||
|
||||
public sealed record TaskDto(
|
||||
string Id,
|
||||
string Scope,
|
||||
@@ -210,7 +212,7 @@ public sealed record RecomputeDto(string State, IReadOnlyList<string> Metrics, l
|
||||
/// <summary>节点的一个属主:节点报上来的(安装时固定在节点本地的)passkey 凭证 id,以及它在面板上对应的 passkey 名称和账户(对不上任何已注册的 passkey 时为空)。</summary>
|
||||
public sealed record NodeOwnerDto(string CredentialId, string? Passkey, string? Account);
|
||||
|
||||
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, IReadOnlyList<NodeOwnerDto>? Owners = null);
|
||||
public sealed record NodeBuildDto(string Id, string? Os, string? OsName, string? Arch, string? Version, string? Tag, string? Commit, string? BuiltAt, NodeUpdateDto? Update = null, IReadOnlyList<NodeOwnerDto>? Owners = 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);
|
||||
|
||||
@@ -62,12 +62,9 @@ namespace MoShiro.Backend.Api;
|
||||
[JsonSerializable(typeof(PageDto<JoinTokenDto>))]
|
||||
[JsonSerializable(typeof(CreateJoinTokenRequest))]
|
||||
[JsonSerializable(typeof(CreatedJoinTokenDto))]
|
||||
[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))]
|
||||
|
||||
@@ -19,19 +19,19 @@ public sealed record TestSummaryDto(string Id, string Plan, string Status, DateT
|
||||
public sealed record OverviewLineDto(
|
||||
string Id,
|
||||
string AgentId,
|
||||
string ProbeId,
|
||||
string ProbeName,
|
||||
string DialerId,
|
||||
string DialerName,
|
||||
string Name,
|
||||
IReadOnlyList<string> Labels,
|
||||
bool Online,
|
||||
bool ProbeConnected,
|
||||
bool DialerConnected,
|
||||
int EndpointChanges,
|
||||
SummaryDto Summary,
|
||||
TestSummaryDto? LastTest,
|
||||
string IpVersion = "ipv4",
|
||||
QuicLineHealthDto? QuicHealth = null);
|
||||
|
||||
/// <summary>Latest QUIC session health reported by the Probe; cumulative counters reset when the Probe restarts.</summary>
|
||||
/// <summary>Latest QUIC data-channel health reported by the dialing node, plus the control-layer state it runs on; cumulative counters reset when that node restarts.</summary>
|
||||
public sealed record QuicLineHealthDto(
|
||||
bool Connected,
|
||||
long HandshakeAttempts,
|
||||
@@ -51,7 +51,8 @@ public sealed record QuicLineHealthDto(
|
||||
DateTimeOffset At,
|
||||
string State = "connecting",
|
||||
int RetryAttempt = 0,
|
||||
long? RetryInMs = null);
|
||||
long? RetryInMs = null,
|
||||
string ControlState = "dialing");
|
||||
|
||||
public sealed record OverviewDto(string Range, DateTimeOffset From, DateTimeOffset To, string Step, string Source, bool Degraded, IReadOnlyList<OverviewLineDto> Lines);
|
||||
|
||||
@@ -59,7 +60,7 @@ public sealed record OverviewDto(string Range, DateTimeOffset From, DateTimeOffs
|
||||
public sealed record SeriesDto(
|
||||
string LineId,
|
||||
string AgentId,
|
||||
string ProbeId,
|
||||
string DialerId,
|
||||
DateTimeOffset From,
|
||||
DateTimeOffset To,
|
||||
string Step,
|
||||
@@ -99,16 +100,16 @@ public sealed record HeatmapDto(
|
||||
IReadOnlyList<double> Edges,
|
||||
IReadOnlyList<IReadOnlyList<long>> Counts);
|
||||
|
||||
/// <summary>一条线路 = 一对(Agent,Probe);<c>id</c> 是 <c><agent_id>~<probe_id></c>。</summary>
|
||||
/// <summary>一条线路 = 一对(监听方,拨号方);<c>agent_id</c> 是监听方,<c>id</c> 是 <c><agent_id>~<dialer_id></c>。</summary>
|
||||
public sealed record LineDto(
|
||||
string Id,
|
||||
string AgentId,
|
||||
string ProbeId,
|
||||
string ProbeName,
|
||||
string DialerId,
|
||||
string DialerName,
|
||||
string Name,
|
||||
IReadOnlyList<string> Labels,
|
||||
bool Online,
|
||||
bool ProbeConnected,
|
||||
bool DialerConnected,
|
||||
DateTimeOffset? DataSince,
|
||||
IReadOnlyList<string> Kinds,
|
||||
TestSummaryDto? LastTest,
|
||||
@@ -132,7 +133,7 @@ public sealed record TestResultDto(
|
||||
public sealed record TestDto(
|
||||
string Id,
|
||||
string AgentId,
|
||||
string ProbeId,
|
||||
string DialerId,
|
||||
string Plan,
|
||||
IReadOnlyList<string> Phases,
|
||||
string Status,
|
||||
@@ -201,9 +202,9 @@ public sealed record HostDto(
|
||||
long? UptimeSeconds,
|
||||
DateTimeOffset At);
|
||||
|
||||
/// <summary>一个节点(Agent 或 Probe)的最新主机状态;<c>stale</c> 表示超过 15 秒没有更新(离线,或者节点关闭了上报)。</summary>
|
||||
/// <summary>一个节点的最新主机状态;<c>stale</c> 表示超过 15 秒没有更新(离线,或者节点关闭了上报)。</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, bool IspManual = false);
|
||||
public sealed record NodeInfoDto(string Id, string? Os, string? OsName, string? Arch, string? Country, string? IspKey, string? IspName, string? Version = null, string? Tag = null, bool IspManual = false);
|
||||
|
||||
/// <summary>一个程序的构建信息:版本、tag、提交(短)和构建时间(UTC,ISO 8601)。没设置的是 <c>null</c>。</summary>
|
||||
public sealed record BuildDto(string Version, string? Tag, string? Commit, string? BuiltAt);
|
||||
@@ -211,10 +212,9 @@ public sealed record BuildDto(string Version, string? Tag, string? Commit, strin
|
||||
/// <summary>公开的「关于」:站点没有打开「显示构建信息」时只有 <c>show_build: false</c>。</summary>
|
||||
public sealed record AboutDto(bool ShowBuild, BuildDto? Backend);
|
||||
|
||||
/// <param name="Kind"><c>agent</c> 或 <c>probe</c>。</param>
|
||||
public sealed record HostItemDto(string Id, string Kind, string Name, bool Online, bool Stale, DateTimeOffset? LastSeenAt, HostDto? Host);
|
||||
public sealed record HostItemDto(string Id, string Name, bool Online, bool Stale, DateTimeOffset? LastSeenAt, HostDto? Host);
|
||||
|
||||
public sealed record LiveHostDto(string Id, string Kind, bool Online, HostDto Host);
|
||||
public sealed record LiveHostDto(string Id, bool Online, HostDto Host);
|
||||
|
||||
/// <summary>主机资源占用的历史(列式,按步长取平均)。</summary>
|
||||
public sealed record HostSeriesDto(string Id, DateTimeOffset From, DateTimeOffset To, string Step, IReadOnlyList<long> T, IReadOnlyDictionary<string, double?[]> Values);
|
||||
@@ -19,17 +19,17 @@ public static class InstallScripts
|
||||
|
||||
public static string Script(string resource) => Read(resource);
|
||||
|
||||
/// <summary>compose 文件的资源名(<c>agent.yaml</c>、<c>probe.yaml</c>、<c>backend.yaml</c> 对应的);不是这三个就是 null。</summary>
|
||||
public static string? ComposeResource(string file) => file is "agent.yaml" or "probe.yaml" or "backend.yaml" ? "compose-" + file : null;
|
||||
/// <summary>compose 文件的资源名(<c>agent.yaml</c>、<c>backend.yaml</c> 对应的);不是这三个就是 null。</summary>
|
||||
public static string? ComposeResource(string file) => file is "agent.yaml" or "backend.yaml" ? "compose-" + file : null;
|
||||
|
||||
/// <summary>某个角色(agent、probe)的 compose 文件的 SHA-256;其他角色返回空。</summary>
|
||||
public static string ComposeSha256(string? role) => role is "agent" or "probe" ? Sha256("compose-" + role + ".yaml") : string.Empty;
|
||||
/// <summary>某个角色(agent)的 compose 文件的 SHA-256;其他角色返回空。</summary>
|
||||
public static string ComposeSha256(string? role) => role is "agent" ? Sha256("compose-" + role + ".yaml") : string.Empty;
|
||||
|
||||
/// <summary>Quadlet 单元的资源名(<c>agent.container</c>、<c>probe.container</c>);不是这两个就是 null。</summary>
|
||||
public static string? QuadletResource(string file) => file is "agent.container" or "probe.container" ? "quadlet-" + file : null;
|
||||
/// <summary>Quadlet 单元的资源名(<c>agent.container</c>);不是这两个就是 null。</summary>
|
||||
public static string? QuadletResource(string file) => file is "agent.container" ? "quadlet-" + file : null;
|
||||
|
||||
/// <summary>某个角色(agent、probe)的 Quadlet 单元的 SHA-256;其他角色返回空。</summary>
|
||||
public static string QuadletSha256(string? role) => role is "agent" or "probe" ? Sha256("quadlet-" + role + ".container") : string.Empty;
|
||||
/// <summary>某个角色(agent)的 Quadlet 单元的 SHA-256;其他角色返回空。</summary>
|
||||
public static string QuadletSha256(string? role) => role is "agent" ? Sha256("quadlet-" + role + ".container") : string.Empty;
|
||||
|
||||
/// <summary>脚本内容的 SHA-256(小写十六进制,按 UTF-8 字节)。</summary>
|
||||
public static string Sha256(string resource) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(Read(resource))));
|
||||
|
||||
@@ -12,10 +12,8 @@
|
||||
<EmbeddedResource Include="..\..\..\packaging\install\install.ps1" LogicalName="install.ps1" />
|
||||
<EmbeddedResource Include="..\..\..\packaging\update\release-key.pub.pem" LogicalName="release-key.pub.pem" />
|
||||
<EmbeddedResource Include="..\..\..\packaging\compose\agent\compose.yaml" LogicalName="compose-agent.yaml" />
|
||||
<EmbeddedResource Include="..\..\..\packaging\compose\probe\compose.yaml" LogicalName="compose-probe.yaml" />
|
||||
<EmbeddedResource Include="..\..\..\packaging\compose\backend\compose.yaml" LogicalName="compose-backend.yaml" />
|
||||
<EmbeddedResource Include="..\..\..\packaging\quadlet\moshiro-agent.container" LogicalName="quadlet-agent.container" />
|
||||
<EmbeddedResource Include="..\..\..\packaging\quadlet\moshiro-probe.container" LogicalName="quadlet-probe.container" />
|
||||
</ItemGroup>
|
||||
<!-- 前端构建产物(web 目录下 npm run build)内嵌进 Backend;没构建时不嵌入,Backend 只提供 API -->
|
||||
<ItemGroup Condition="Exists('..\..\..\web\display\dist\index.html')">
|
||||
|
||||
@@ -22,7 +22,7 @@ public static class OpsEndpoints
|
||||
// 一行安装:面板给的命令先下载这份静态脚本、核对 SHA-256,再用环境变量里的面板地址、角色和一次性代码执行。
|
||||
// 脚本不含任何一次性的值,所以不需要代码,也可以直接拿去和发布页公布的哈希核对。
|
||||
app.MapGet("/install/openwrt.sh", (HttpContext http) => Static(http, "openwrt.sh", "text/x-shellscript"))
|
||||
.Api("install_openwrt", "The static native OpenWrt Probe installer and updater", Tag)
|
||||
.Api("install_openwrt", "The static native OpenWrt installer and updater (Agent)", Tag)
|
||||
.Produces(StatusCodes.Status200OK, contentType: "text/x-shellscript");
|
||||
|
||||
app.MapGet("/install", (HttpContext http) => Static(http, InstallScripts.Sh, "text/x-shellscript"))
|
||||
@@ -31,30 +31,29 @@ public static class OpsEndpoints
|
||||
app.MapGet("/install.sh", (HttpContext http) => Static(http, InstallScripts.Sh, "text/x-shellscript"))
|
||||
.ExcludeFromDescription();
|
||||
// 脚本要装的版本:CI 签名的发布清单(原样的字节)和签名。脚本用内置的发布公钥验签,再按清单里的 digest 拉镜像。
|
||||
app.MapGet("/install/manifest", (HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code) => ManifestAsync(http, tokens, releases, options, code, signature: false))
|
||||
.Api("install_manifest", "The signed release manifest of the component an install code is for (exact signed bytes)", Tag)
|
||||
app.MapGet("/install/manifest", (HttpContext http, JoinTokenService tokens, ReleaseService releases, string? code) => ManifestAsync(http, tokens, releases, code, signature: false))
|
||||
.Api("install_manifest", "The signed release manifest an install code may fetch (exact signed bytes)", Tag)
|
||||
.Produces(StatusCodes.Status200OK, contentType: "application/json").ProducesProblems(404);
|
||||
app.MapGet("/install/manifest.sig", (HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code) => ManifestAsync(http, tokens, releases, options, code, signature: true))
|
||||
app.MapGet("/install/manifest.sig", (HttpContext http, JoinTokenService tokens, ReleaseService releases, string? code) => ManifestAsync(http, tokens, releases, code, signature: true))
|
||||
.Api("install_manifest_signature", "Base64 DER ECDSA P-256 signature of the manifest", Tag)
|
||||
.Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(404);
|
||||
// compose 文件:Agent、Probe、Backend 各一份,静态(镜像用 :latest 或 .env 里的 MOSHIRO_VERSION,不含任何一次性的值),
|
||||
// compose 文件:Agent、Backend 各一份,静态(镜像用 :latest 或 .env 里的 MOSHIRO_VERSION,不含任何一次性的值),
|
||||
// 面板的命令把参数放在本地的 .env 里,下载后先核对 SHA-256。
|
||||
app.MapGet("/install/compose/{file}", (HttpContext http, string file) => InstallScripts.ComposeResource(file) is { } resource ? Static(http, resource, "text/yaml") : Problems.Create(http, MoShiroErrors.NotFound, "unknown compose file"))
|
||||
.Api("install_compose", "The static compose file of agent, probe or backend (agent.yaml, probe.yaml, backend.yaml; same bytes for everyone)", Tag)
|
||||
.Api("install_compose", "The static compose file of the agent or the backend (agent.yaml, backend.yaml; same bytes for everyone)", Tag)
|
||||
.Produces(StatusCodes.Status200OK, contentType: "text/yaml").ProducesProblems(404);
|
||||
// Podman Quadlet 单元(Linux 上推荐,节点的更新靠 Pull=always):Agent、Probe 各一份,静态,命令里先核对 SHA-256。
|
||||
// Podman Quadlet 单元(Linux 上推荐,节点的更新靠 Pull=always):统一 Agent 单元,静态,命令里先核对 SHA-256。
|
||||
app.MapGet("/install/quadlet/{file}", (HttpContext http, string file) => InstallScripts.QuadletResource(file) is { } resource ? Static(http, resource, "text/plain") : Problems.Create(http, MoShiroErrors.NotFound, "unknown quadlet file"))
|
||||
.Api("install_quadlet", "The static Podman Quadlet unit of agent or probe (agent.container, probe.container; same bytes for everyone)", Tag)
|
||||
.Api("install_quadlet", "The static Podman Quadlet unit of the agent (agent.container; same bytes for everyone)", Tag)
|
||||
.Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(404);
|
||||
app.MapGet("/install.ps1", (HttpContext http) => Static(http, InstallScripts.Ps1, "text/plain"))
|
||||
.Api("install_ps1", "The static one-line installer for Windows nodes (same bytes for everyone; its SHA-256 is published with each release)", Tag)
|
||||
.Produces(StatusCodes.Status200OK, contentType: "text/plain");
|
||||
}
|
||||
|
||||
private static async Task<IResult> ManifestAsync(HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code, bool signature)
|
||||
private static async Task<IResult> ManifestAsync(HttpContext http, JoinTokenService tokens, ReleaseService releases, string? code, bool signature)
|
||||
{
|
||||
string? role = string.IsNullOrEmpty(code) ? null : await tokens.RoleOfCodeAsync(code, http.RequestAborted);
|
||||
ReleaseOffer? offer = role is null ? null : await releases.GetOrFetchAsync(role, http.RequestAborted);
|
||||
ReleaseOffer? offer = !string.IsNullOrEmpty(code) && await tokens.IsUsableAsync(code, http.RequestAborted) ? await releases.GetOrFetchAsync(ReleaseService.AgentComponent, http.RequestAborted) : null;
|
||||
if (offer is null)
|
||||
{
|
||||
return Problems.Create(http, MoShiroErrors.NotFound, "no signed release is registered for this install code");
|
||||
|
||||
@@ -8,26 +8,24 @@ using MoShiro.Protocol;
|
||||
namespace MoShiro.Backend.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Agent 和 Probe 的主机资源占用:最新值(实时的部分走 SSE 的 <c>hosts</c> 主题)和历史曲线。只有汇总的数字,没有型号和 IP。
|
||||
/// 和别的公开接口一样:Agent 看得见才给(隐藏的只有登录后才看得见),Probe 没被吊销就给。
|
||||
/// 节点的主机资源占用:最新值(实时的部分走 SSE 的 <c>hosts</c> 主题)和历史曲线。只有汇总的数字,没有型号和 IP。
|
||||
/// 和别的公开接口一样:节点看得见才给(隐藏的只有登录后才看得见)。
|
||||
/// </summary>
|
||||
public static class HostEndpoints
|
||||
{
|
||||
private const string Tag = "public";
|
||||
private const string AgentKind = "agent";
|
||||
private const string ProbeKind = "probe";
|
||||
|
||||
public static void Map(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/hosts", ListAsync)
|
||||
.Api("hosts_list", "Latest host resource usage of every visible agent and probe (no hardware model, no addresses)", Tag)
|
||||
.Api("hosts_list", "Latest host resource usage of every visible node (no hardware model, no addresses)", Tag)
|
||||
.Returns<PageDto<HostItemDto>>().ProducesProblems(403);
|
||||
group.MapGet("/hosts/{node_id}", HistoryAsync)
|
||||
.Api("hosts_history", "Host resource usage history of one agent or probe (columnar, averaged per step)", Tag)
|
||||
.Api("hosts_history", "Host resource usage history of one node (columnar, averaged per step)", Tag)
|
||||
.Returns<HostSeriesDto>().ProducesProblems(403, 404, 422);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListAsync(HttpContext http, AgentService agents, ProbeService probes, HostStatusService hosts, CancellationToken ct)
|
||||
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))
|
||||
@@ -38,37 +36,16 @@ public static class HostEndpoints
|
||||
}
|
||||
|
||||
(HostSnapshot? host, bool stale) = hosts.Latest(view.Agent.Id);
|
||||
items.Add(new HostItemDto(view.Agent.Id.Value, AgentKind, view.Agent.Name, view.Online, stale, Mappers.Time(view.Agent.LastSeenAtMs), host is null ? null : Mappers.ToDto(host)));
|
||||
}
|
||||
|
||||
foreach (ProbeView view in await probes.ListAsync(ct))
|
||||
{
|
||||
if (view.Revoked)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
(HostSnapshot? host, bool stale) = hosts.Latest(new NodeId(view.Probe.Id));
|
||||
items.Add(new HostItemDto(view.Probe.Id, ProbeKind, view.Probe.Name, view.Online, stale, host is null ? null : Mappers.Time(host.AtUtcMs), host is null ? null : Mappers.ToDto(host)));
|
||||
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, ProbeService probes, HostStatusService hosts, TimeProvider time, string node_id, string? range, CancellationToken ct)
|
||||
private static async Task<IResult> HistoryAsync(HttpContext http, AgentService agents, HostStatusService hosts, TimeProvider time, string node_id, string? range, CancellationToken ct)
|
||||
{
|
||||
bool visible;
|
||||
if (node_id.StartsWith(AgentIds.Prefix, StringComparison.Ordinal))
|
||||
{
|
||||
AgentView? view = await agents.GetAsync(new NodeId(node_id), ct);
|
||||
visible = view is { Agent.Status: Fleet.Server.NodeStatus.Active } && (view.Agent.PublicVisible || PublicGuard.CanSeeHidden(http));
|
||||
}
|
||||
else
|
||||
{
|
||||
visible = node_id.StartsWith(ProbeId.Prefix, StringComparison.Ordinal) && await probes.GetAsync(node_id, ct) is { Revoked: false };
|
||||
}
|
||||
|
||||
if (!visible)
|
||||
AgentView? view = await agents.GetAsync(new NodeId(node_id), ct);
|
||||
if (view is not { Agent.Status: Fleet.Server.NodeStatus.Active } || !(view.Agent.PublicVisible || PublicGuard.CanSeeHidden(http)))
|
||||
{
|
||||
return Problems.Create(http, MoShiroErrors.AgentNotFound, "node not found");
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ public static class LiveEndpoints
|
||||
}
|
||||
|
||||
private static async Task<IResult> StreamAsync(
|
||||
HttpContext http, AgentService agents, ProbeService probes, HostStatusService hosts, LineService lineService, TestService tests, QueryService query, LiveHub hub, SseLimiter limiter,
|
||||
HttpContext http, AgentService agents, HostStatusService hosts, LineService lineService, TestService tests, QueryService query, LiveHub hub, SseLimiter limiter,
|
||||
BackendOptions options, BackendCounters counters, TimeProvider time, IHostApplicationLifetime lifetime, string? ip_version)
|
||||
{
|
||||
string ipVersion = ip_version ?? IpVersions.V4;
|
||||
@@ -84,7 +84,7 @@ public static class LiveEndpoints
|
||||
http.Response.Headers["X-Accel-Buffering"] = "no";
|
||||
await http.Response.Body.FlushAsync(http.RequestAborted);
|
||||
|
||||
var session = new LiveSession(http, agents, probes, hosts, lineService, tests, query, hub, options, time, topics, lineKeys, ipVersion);
|
||||
var session = new LiveSession(http, agents, hosts, lineService, tests, query, hub, options, time, topics, lineKeys, ipVersion);
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(http.RequestAborted, lifetime.ApplicationStopping);
|
||||
await session.RunAsync(linked.Token);
|
||||
}
|
||||
@@ -105,7 +105,6 @@ public static class LiveEndpoints
|
||||
{
|
||||
private readonly HttpContext _http;
|
||||
private readonly AgentService _agents;
|
||||
private readonly ProbeService _probes;
|
||||
private readonly HostStatusService _hosts;
|
||||
private readonly LineService _lines;
|
||||
private readonly IReadOnlyDictionary<string, LineKey> _lineKeys;
|
||||
@@ -126,11 +125,10 @@ public static class LiveEndpoints
|
||||
private long _lastWriteMs;
|
||||
private long _lastPeriodicMs;
|
||||
|
||||
public LiveSession(HttpContext http, AgentService agents, ProbeService probes, HostStatusService hosts, LineService lines, TestService tests, QueryService query, LiveHub hub, BackendOptions options, TimeProvider time, IReadOnlyList<LiveTopic> topics, IReadOnlyDictionary<string, LineKey> lineKeys, string ipVersion)
|
||||
public LiveSession(HttpContext http, AgentService agents, HostStatusService hosts, LineService lines, TestService tests, QueryService query, LiveHub hub, BackendOptions options, TimeProvider time, IReadOnlyList<LiveTopic> topics, IReadOnlyDictionary<string, LineKey> lineKeys, string ipVersion)
|
||||
{
|
||||
_http = http;
|
||||
_agents = agents;
|
||||
_probes = probes;
|
||||
_hosts = hosts;
|
||||
_lines = lines;
|
||||
_lineKeys = lineKeys;
|
||||
@@ -220,20 +218,10 @@ public static class LiveEndpoints
|
||||
|
||||
foreach (HostSignal host in hostSignals)
|
||||
{
|
||||
if (host.Node.Value.StartsWith(ProbeId.Prefix, StringComparison.Ordinal))
|
||||
{
|
||||
if (await _probes.GetAsync(host.Node.Value, ct) is { Revoked: false } probe)
|
||||
{
|
||||
await WriteAsync("host", new LiveHostDto(host.Node.Value, "probe", probe.Online, Mappers.ToDto(host.Host)), ApiJsonContext.Default.LiveHostDto, ct);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
AgentView? view = await _agents.GetAsync(host.Node, ct);
|
||||
if (view is not null && IsVisible(view) && view.Agent.Status == Fleet.Server.NodeStatus.Active)
|
||||
{
|
||||
await WriteAsync("host", new LiveHostDto(host.Node.Value, "agent", view.Online, Mappers.ToDto(host.Host)), ApiJsonContext.Default.LiveHostDto, ct);
|
||||
await WriteAsync("host", new LiveHostDto(host.Node.Value, view.Online, Mappers.ToDto(host.Host)), ApiJsonContext.Default.LiveHostDto, ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,7 +241,7 @@ public static class LiveEndpoints
|
||||
|
||||
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))))
|
||||
foreach (LiveTopic topic in _topics.Where(t => t.Kind == LiveTopicKind.Series && dirtyLines.Contains((_lineKeys[t.Raw].Listener, _lineKeys[t.Raw].Series))))
|
||||
{
|
||||
await PushSeriesAsync(topic, ct);
|
||||
}
|
||||
@@ -298,15 +286,7 @@ public static class LiveEndpoints
|
||||
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, "agent", view.Online, Mappers.ToDto(view.Host!)), ApiJsonContext.Default.LiveHostDto, ct);
|
||||
}
|
||||
|
||||
foreach (ProbeView view in await _probes.ListAsync(ct))
|
||||
{
|
||||
if (!view.Revoked && _hosts.Latest(new NodeId(view.Probe.Id)).Host is { } host)
|
||||
{
|
||||
await WriteAsync("host", new LiveHostDto(view.Probe.Id, "probe", view.Online, Mappers.ToDto(host)), ApiJsonContext.Default.LiveHostDto, ct);
|
||||
}
|
||||
await WriteAsync("host", new LiveHostDto(view.Agent.Id.Value, view.Online, Mappers.ToDto(view.Host!)), ApiJsonContext.Default.LiveHostDto, ct);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
@@ -16,7 +16,7 @@ public static class NodeEndpoints
|
||||
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)
|
||||
.Api("nodes_list", "Display info of the visible nodes: 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)
|
||||
@@ -36,9 +36,9 @@ public static class NodeEndpoints
|
||||
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)
|
||||
private static async Task<IResult> ListAsync(HttpContext http, AgentService agents, NodeInfoService nodes, SettingsService settings, CancellationToken ct)
|
||||
{
|
||||
// 和别的公开接口一样:看得见的 Agent(隐藏的只有登录后才看得见)和没有被吊销的 Probe
|
||||
// 和别的公开接口一样:看得见的节点(隐藏的只有登录后才看得见)
|
||||
var visible = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (AgentView view in await agents.ListAsync(ct))
|
||||
{
|
||||
@@ -48,20 +48,12 @@ public static class NodeEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
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, n.IspManual))
|
||||
.Select(n => new NodeInfoDto(n.NodeId, n.Os, n.OsName, n.Arch, n.Country, n.IspKey, n.IspName, showBuild ? n.Version : null, showBuild ? n.Tag : null, n.IspManual))
|
||||
.ToList();
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
return TypedResults.Json(new PageDto<NodeInfoDto>(items, null), ApiJsonContext.Default.PageDtoNodeInfoDto);
|
||||
|
||||
@@ -96,13 +96,13 @@ public static class QueryEndpoints
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
AgentView view = line.Agent;
|
||||
IReadOnlyList<TestRecord> recent = await tests.ListAsync(view.Agent.Id, new ProbeId(line.Probe.Id), 1, line.IpVersion, ct);
|
||||
AgentView view = line.Listener;
|
||||
IReadOnlyList<TestRecord> recent = await tests.ListAsync(view.Agent.Id, line.Dialer.Id, 1, line.IpVersion, 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), line.IpVersion, view.Agent.Ipv6Host.Length > 0, Mappers.ToDto(line.QuicHealth)),
|
||||
line.Key.Id, view.Agent.Id.Value, line.Dialer.Id.Value, line.Dialer.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), line.IpVersion, view.Agent.Ipv6Host.Length > 0, Mappers.ToDto(line.Link)),
|
||||
ApiJsonContext.Default.LineDto);
|
||||
}
|
||||
|
||||
@@ -244,7 +244,7 @@ public static class QueryEndpoints
|
||||
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);
|
||||
return lines.OrderBy(l => l.Line.Listener.Agent.Name, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
bool descending = order is not null
|
||||
@@ -255,6 +255,6 @@ public static class QueryEndpoints
|
||||
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));
|
||||
return ordered.ThenBy(l => l.Line.Listener.Agent.Name, StringComparer.Ordinal).Concat(without.OrderBy(l => l.Line.Listener.Agent.Name, StringComparer.Ordinal));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user