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

@@ -7,10 +7,15 @@ using MoShiro.Protocol;
namespace MoShiro.Backend.Application;
public sealed record AgentView(AgentRecord Agent, bool Online, IReadOnlyList<ConnectedProbeInfo> Probes, AgentHeartbeatExtra? Heartbeat, int EndpointChanges, long? HeartbeatAtMs = null, HostSnapshot? Host = null, string? LastDisconnectReason = null)
/// <summary>本节点拨出的一条连接:监听方现在认的这个拨号方会话(由监听方上报)。</summary>
public sealed record OutgoingConnection(NodeId ListenerId, string ListenerName, bool ListenerOnline, string? Version, string? Remote, long SinceMs, string IpVersion = IpVersions.V4);
/// <param name="Dialers">现在连着这个节点的拨号方(监听方上报)。</param>
/// <param name="Outgoing">这个节点现在连着的监听节点(监听方上报)。</param>
public sealed record AgentView(AgentRecord Agent, bool Online, IReadOnlyList<ConnectedDialerInfo> Dialers, IReadOnlyList<OutgoingConnection> Outgoing, NodeHeartbeatExtra? Heartbeat, int EndpointChanges, long? HeartbeatAtMs = null, HostSnapshot? Host = null, string? LastDisconnectReason = null)
{
/// <summary>至少有一个 Probe 连着。</summary>
public bool ProbeConnected => Probes.Count > 0;
/// <summary>至少有一个拨号方连着。</summary>
public bool DialerConnected => Dialers.Count > 0;
public NodeRuntimeStatus RuntimeStatus
{
@@ -49,7 +54,7 @@ public sealed record AgentView(AgentRecord Agent, bool Online, IReadOnlyList<Con
}
}
/// <summary>Agent 的管理:列表、修改、审批、启停、接管、删除,以及 Probe 需要的 Agent 列表。</summary>
/// <summary>节点的管理:列表、修改、审批、启停、接管、删除,以及每个节点要拨出的目标列表。</summary>
public sealed partial class AgentService
{
private readonly IAgentStore _agents;
@@ -80,20 +85,40 @@ public sealed partial class AgentService
public async ValueTask<IReadOnlyList<AgentView>> ListAsync(CancellationToken ct)
{
IReadOnlyList<AgentRecord> agents = await _agents.ListAsync(ct);
return [.. agents.Select(View)];
return [.. agents.Select(a => View(a, agents))];
}
public async ValueTask<AgentView?> GetAsync(NodeId id, CancellationToken ct) =>
await _agents.GetAsync(id, ct) is { } agent ? View(agent) : null;
await _agents.GetAsync(id, ct) is { } agent ? View(agent, await _agents.ListAsync(ct)) : null;
public AgentView View(AgentRecord agent)
public AgentView View(AgentRecord agent, IReadOnlyList<AgentRecord> all)
{
_runtime.TryGet(agent.Id, out AgentRuntimeState? state);
return new AgentView(agent, _gateway.IsOnline(agent.Id), state?.Probes ?? [], state?.LastHeartbeat, state?.EndpointChanges ?? 0, state?.LastHeartbeatAtMs, state?.Host, state?.LastDisconnectReason);
var outgoing = new List<OutgoingConnection>();
foreach ((NodeId listenerId, ConnectedDialerInfo info) in _runtime.ConnectionsOf(agent.Id.Value))
{
if (all.FirstOrDefault(a => a.Id == listenerId) is { } listener)
{
outgoing.Add(new OutgoingConnection(listenerId, listener.Name, _gateway.IsOnline(listenerId), info.Version, info.Remote, info.SinceMs, info.IpVersion));
}
}
return new AgentView(agent, _gateway.IsOnline(agent.Id), state?.Dialers ?? [], [.. outgoing.OrderBy(c => c.ListenerName, StringComparer.Ordinal)], state?.LastHeartbeat, state?.EndpointChanges ?? 0, state?.LastHeartbeatAtMs, state?.Host, state?.LastDisconnectReason);
}
public ValueTask<bool> NotifyProbeLinkRetryAsync(NodeId probe, NodeId agent, CancellationToken ct) =>
_gateway.NotifyProbeLinkRetryAsync(probe, agent, ct);
/// <summary>让监听方断开某个拨号方的会话(拨号方按退避重连,除非它被禁用)。</summary>
public async ValueTask<Result<bool>> DisconnectDialerAsync(NodeId listener, NodeId dialer, CancellationToken ct)
{
if (await _agents.GetAsync(dialer, ct) is null || await _agents.GetAsync(listener, ct) is null)
{
return Result<bool>.Fail(MoShiroErrors.AgentNotFound, "node not found");
}
return await _gateway.DisconnectDialerAsync(listener, dialer, ct);
}
public ValueTask<bool> NotifyLinkRetryAsync(NodeId dialer, NodeId listener, CancellationToken ct) =>
_gateway.NotifyLinkRetryAsync(dialer, listener, ct);
private static bool ValidHost(string host, string ipVersion)
{
@@ -152,7 +177,7 @@ public sealed partial class AgentService
await AfterAgentSetChangedAsync(ct);
}
return Result<AgentView>.Ok(View(agent));
return Result<AgentView>.Ok(View(agent, await _agents.ListAsync(ct)));
}
public ValueTask<Result<AgentView>> ApproveAsync(NodeId id, CancellationToken ct) => ChangeStatusAsync(id, NodeStatus.Active, ct);
@@ -181,7 +206,7 @@ public sealed partial class AgentService
// 公钥换了,旧连接使用的是旧密钥
await _gateway.DisconnectAsync(agent.Id, "key replaced", ct);
await AfterAgentSetChangedAsync(ct);
return Result<AgentView>.Ok(View(agent));
return Result<AgentView>.Ok(View(agent, await _agents.ListAsync(ct)));
}
public async ValueTask DeleteAsync(NodeId id, CancellationToken ct)
@@ -195,26 +220,24 @@ public sealed partial class AgentService
await AfterAgentSetChangedAsync(ct);
}
/// <summary>这个 Probe 要连接的 Agent 列表(已激活、有公开地址,并且线路是开着的)。版本号在列表变化时递增。</summary>
public async ValueTask<ProbeAgentList> ProbeAgentsAsync(string probeId, CancellationToken ct)
/// <summary>这个节点要拨出的监听节点列表(激活、可拨号,并且线路是开着的)。版本号在列表变化时递增。</summary>
public async ValueTask<DialTargets> DialTargetsAsync(AgentRecord dialer, CancellationToken ct)
{
LinkSnapshot links = await _links.SnapshotAsync(ct);
IReadOnlyList<AgentRecord> agents = await _agents.ListAsync(ct);
long version = long.TryParse(await _settings.GetAsync(TaskPlanner.ProbeAgentsVersionKey, ct), out long v) ? v : 0;
var result = new ProbeAgentList { Version = version };
// 没有上报证书公钥的 Agent(旧版本)还不能被 Probe 连接:Probe 需要它来固定证书和派生敲门密钥
foreach (AgentRecord agent in agents.Where(a => a.Status == NodeStatus.Active && a.PublicHost.Length > 0 && a.TlsPublicKey is { Length: > 0 } && links.IsLinked(probeId, a.Id.Value)))
long version = long.TryParse(await _settings.GetAsync(TaskPlanner.DialTargetsVersionKey, ct), out long v) ? v : 0;
var result = new DialTargets { Version = version };
foreach (AgentRecord listener in agents.Where(a => a.Dialable && links.IsLinked(dialer, a)))
{
result.Agents.Add(new AgentEndpoint
result.Targets.Add(new DialTarget
{
AgentId = agent.Id,
Name = agent.Name,
Host = agent.PublicHost,
Ipv6Host = agent.Ipv6Supported && agent.Ipv6Host.Length > 0 ? agent.Ipv6Host : null,
TcpPort = agent.ProbeTcpPort,
QuicPorts = [.. agent.ProbeUdpPorts],
LegacyUdpPorts = [.. agent.ProbeUdpPorts],
PublicKey = Convert.ToBase64String(agent.TlsPublicKey!),
NodeId = listener.Id,
Name = listener.Name,
Host = listener.PublicHost,
Ipv6Host = listener.Ipv6Supported && listener.Ipv6Host.Length > 0 ? listener.Ipv6Host : null,
TcpPort = listener.TcpPort,
QuicPorts = [.. listener.QuicPorts],
PublicKey = Convert.ToBase64String(listener.TlsPublicKey!),
});
}
@@ -224,10 +247,10 @@ public sealed partial class AgentService
return result;
}
/// <summary>Agent 集合变了:递增列表版本,重算各 Agent 的期望状态,并把新的 Agent 列表推给 Probe(都是期望状态,一起通知)。</summary>
/// <summary>节点集合或线路变了:递增拨号目标版本,重算各节点的期望状态(任务集和拨号目标都是期望状态,一起通知)。</summary>
public async ValueTask AfterAgentSetChangedAsync(CancellationToken ct)
{
await _planner.BumpProbeAgentsVersionAsync(ct);
await _planner.BumpDialTargetsVersionAsync(ct);
_gateway.NotifyAllTaskSetsChanged();
}
@@ -241,7 +264,7 @@ public sealed partial class AgentService
await AfterAgentSetChangedAsync(ct);
_gateway.NotifyTaskSetChanged(id);
return Result<AgentView>.Ok(View(agent));
return Result<AgentView>.Ok(View(agent, await _agents.ListAsync(ct)));
}
[GeneratedRegex(@"^[\p{L}\p{M}\p{N}._-]{1,64}$")]