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:
justme committed 2026-10-21 11:55:36 +08:00
1 parent d5ebf29e57
commit ad5efc62ff
206 files changed
+4202 -4964

No files matched your search

@@ -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>&lt;agent_id&gt;~&lt;probe_id&gt;</c>。</summary>
/// <summary>一条线路 = 一对(监听方,拨号方);<c>agent_id</c> 是监听方,<c>id</c> 是 <c>&lt;agent_id&gt;~&lt;dialer_id&gt;</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);