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

@@ -34,19 +34,19 @@ public sealed class AgentCounters
public void FragmentDiscarded() => Interlocked.Increment(ref _fragmentsDiscarded);
}
public sealed record PeerContext(ProbeSession Session, MonoClock Clock);
public sealed record PeerContext(DialerSession Session, MonoClock Clock);
/// <summary>
/// 一个已经认证的 Probe 会话:数据面的 <c>session_id</c> 和密钥(由点对点握手里的 ECDH 派生)、最近一次的 UDP 源地址、
/// 一个已经认证的拨号方会话:数据面的 <c>session_id</c> 和密钥(由点对点握手里的 ECDH 派生)、最近一次的 UDP 源地址、
/// 每个流的重放窗口。数据面的包不绑定五元组,NAT 映射变化之后自动跟随新的源地址。
/// </summary>
public sealed class ProbeSession : DataSession
public sealed class DialerSession : DataSession
{
private readonly ConcurrentDictionary<uint, ReplayWindow> _windows = new();
private readonly ConcurrentDictionary<ulong, byte> _nonces = new();
private SocketAddress? _datagramSource;
public ProbeSession(PeerSessionInfo info)
public DialerSession(PeerSessionInfo info)
: base(info.Credentials.SessionIdValue, info.Credentials.Key)
{
Info = info;
@@ -54,20 +54,20 @@ public sealed class ProbeSession : DataSession
public PeerSessionInfo Info { get; }
public string ProbeId => Info.PeerId;
public string DialerId => Info.PeerId;
public override string DiagnosticPeerId => ProbeId;
public override string DiagnosticPeerId => DialerId;
public string? CloseReason { get; set; }
public string IpVersion => Info.Remote is IPEndPoint endpoint && endpoint.Address.AddressFamily == AddressFamily.InterNetworkV6 && !endpoint.Address.IsIPv4MappedToIPv6 ? IpVersions.V6 : IpVersions.V4;
public string PathKey => ProbeId + "~" + IpVersion;
public string PathKey => DialerId + "~" + IpVersion;
/// <summary>Probe 的程序版本(握手里自报,只用于展示)。</summary>
/// <summary>拨号方 的程序版本(握手里自报,只用于展示)。</summary>
public string? Version => Info.Version;
/// <summary>Probe 的远端地址(经过 NAT 之后的公网 IP)。</summary>
/// <summary>拨号方 的远端地址(经过 NAT 之后的公网 IP)。</summary>
public string? RemoteAddress => (Info.Remote as IPEndPoint)?.Address.ToString();
public long ConnectedAtUtcMs { get; init; }
@@ -133,59 +133,59 @@ public sealed class ProbeSession : DataSession
public void ForgetStream(uint streamId) => _windows.TryRemove(streamId, out _);
}
/// <summary>每个 Probe 的 IPv4、IPv6 会话独立;重连只替换同地址族的旧会话。</summary>
/// <summary>每个拨号方的 IPv4、IPv6 会话独立;重连只替换同地址族的旧会话。</summary>
public sealed class SessionRegistry : IDataSessions
{
private readonly object _gate = new();
private readonly Dictionary<string, ProbeSession> _byProbe = new(StringComparer.Ordinal);
private readonly Dictionary<long, ProbeSession> _bySession = [];
private readonly Dictionary<string, DialerSession> _byDialer = new(StringComparer.Ordinal);
private readonly Dictionary<long, DialerSession> _bySession = [];
public IReadOnlyList<ProbeSession> All
public IReadOnlyList<DialerSession> All
{
get
{
lock (_gate)
{
return [.. _byProbe.Values];
return [.. _byDialer.Values];
}
}
}
public ProbeSession? Get(string probeId, string ipVersion = IpVersions.V4)
public DialerSession? Get(string dialerId, string ipVersion = IpVersions.V4)
{
lock (_gate)
{
return _byProbe.GetValueOrDefault(probeId + "~" + ipVersion);
return _byDialer.GetValueOrDefault(dialerId + "~" + ipVersion);
}
}
/// <summary>登记新会话;返回同一个 Probe 的旧会话(如果有)。</summary>
public ProbeSession? Replace(ProbeSession session)
/// <summary>登记新会话;返回同一个拨号方的旧会话(如果有)。</summary>
public DialerSession? Replace(DialerSession session)
{
lock (_gate)
{
_byProbe.TryGetValue(session.PathKey, out ProbeSession? old);
_byDialer.TryGetValue(session.PathKey, out DialerSession? old);
if (old is not null)
{
_bySession.Remove(old.SessionId);
}
_byProbe[session.PathKey] = session;
_byDialer[session.PathKey] = session;
_bySession[session.SessionId] = session;
return old;
}
}
public void Remove(ProbeSession session)
public void Remove(DialerSession session)
{
lock (_gate)
{
if (_byProbe.TryGetValue(session.PathKey, out ProbeSession? current) && ReferenceEquals(current, session))
if (_byDialer.TryGetValue(session.PathKey, out DialerSession? current) && ReferenceEquals(current, session))
{
_byProbe.Remove(session.PathKey);
_byDialer.Remove(session.PathKey);
}
if (_bySession.TryGetValue(session.SessionId, out ProbeSession? bySession) && ReferenceEquals(bySession, session))
if (_bySession.TryGetValue(session.SessionId, out DialerSession? bySession) && ReferenceEquals(bySession, session))
{
_bySession.Remove(session.SessionId);
}
@@ -236,8 +236,8 @@ public sealed class StreamInfo
public int? Phase { get; init; }
/// <summary>这个流属于哪个 Probe 会话:别的会话的包即使带着这个流编号也不会被接受。</summary>
public ProbeSession? Owner { get; init; }
/// <summary>这个流属于哪个拨号方会话:别的会话的包即使带着这个流编号也不会被接受。</summary>
public DialerSession? Owner { get; init; }
public int Port { get; }
@@ -290,7 +290,7 @@ public sealed class StreamRegistry : IRecordSource
public bool TryGet(uint id, out StreamInfo stream) => _streams.TryGetValue(id, out stream!);
public StreamInfo? FindByTask(TaskId task, ProbeSession owner) => _streams.Values.FirstOrDefault(s => s.TaskId == task && ReferenceEquals(s.Owner, owner));
public StreamInfo? FindByTask(TaskId task, DialerSession owner) => _streams.Values.FirstOrDefault(s => s.TaskId == task && ReferenceEquals(s.Owner, owner));
public StreamInfo? FindByTest(TestId test, int phase) => _streams.Values.FirstOrDefault(s => s.TestId == test && s.Phase == phase);
@@ -314,11 +314,11 @@ public sealed class AgentAdmission : IAdmission
}
/// <summary>NAT 映射变化,源地址变了:上报 <c>endpoint_changed</c>。</summary>
public Action<ProbeSession>? OnEndpointChanged { get; set; }
public Action<DialerSession>? OnEndpointChanged { get; set; }
public Admission AdmitDatagram(DataSession session, in MeasurementDatagramHeader header, SocketAddress source, long nowUs)
{
if (session is not ProbeSession probe || !_streams.TryGet(header.StreamId, out StreamInfo? stream) || !ReferenceEquals(stream.Owner, probe) || !probe.AcceptSequence(header.StreamId, header.Seq))
if (session is not DialerSession dialer || !_streams.TryGet(header.StreamId, out StreamInfo? stream) || !ReferenceEquals(stream.Owner, dialer) || !dialer.AcceptSequence(header.StreamId, header.Seq))
{
return Admission.Invalid;
}
@@ -328,9 +328,9 @@ public sealed class AgentAdmission : IAdmission
return Admission.RateLimited;
}
if (probe.UpdateSource(source))
if (dialer.UpdateSource(source))
{
OnEndpointChanged?.Invoke(probe);
OnEndpointChanged?.Invoke(dialer);
}
return Admission.Accepted;