Compare commits
3
Commits
4609048865
...
e2e5e437cb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2e5e437cb | ||
|
|
b25b111134 | ||
|
|
b964dc2bbc |
No files matched your search
@@ -58,7 +58,7 @@ MoShiro 是一个自托管的**网络线路质量观测工具**。它在若干 P
|
|||||||
│ Agent × 10+ │ ◀── 测量会话 ── │ Probe × N │
|
│ Agent × 10+ │ ◀── 测量会话 ── │ Probe × N │
|
||||||
│ 有公网 IP,监听 │ 先敲门再 TLS │ 家宽等出口,NAT 后 │
|
│ 有公网 IP,监听 │ 先敲门再 TLS │ 家宽等出口,NAT 后 │
|
||||||
└────────────────────┘ └──────────────────────┘
|
└────────────────────┘ └──────────────────────┘
|
||||||
测量会话:TLS 控制通道 + UDP/TCP 数据通道,由 Probe 主动拨出
|
测量会话:TLS 控制通道 + QUIC Datagram/TCP 数据通道,由 Probe 主动拨出
|
||||||
宿主机更新器(容器节点):用本机公钥验签 → 按 digest 拉镜像 → 重启,失败回滚
|
宿主机更新器(容器节点):用本机公钥验签 → 按 digest 拉镜像 → 重启,失败回滚
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ Backend 用段文件保存原始数据,用 SQLite 保存索引、指标与配
|
|||||||
|
|
||||||
- **控制链路(Fleet)**:节点到 Backend 的 `wss` 长连接(`/api/agent/v1/rpc`),JSON-RPC 2.0 信封加二进制通道;挑战加签名认证,`hello` 协商版本和能力,心跳;**期望状态同步**(Backend 下发完整快照,节点调和并回报已应用的版本和被拒绝的条目);**可靠发件箱**(原始块至少一次投递,按 id 去重)。Fleet 与业务无关,放在 `backend/fleet/`。
|
- **控制链路(Fleet)**:节点到 Backend 的 `wss` 长连接(`/api/agent/v1/rpc`),JSON-RPC 2.0 信封加二进制通道;挑战加签名认证,`hello` 协商版本和能力,心跳;**期望状态同步**(Backend 下发完整快照,节点调和并回报已应用的版本和被拒绝的条目);**可靠发件箱**(原始块至少一次投递,按 id 去重)。Fleet 与业务无关,放在 `backend/fleet/`。
|
||||||
- **业务方法**:`moshiro.*`(按需测试、Probe 断开、事件、主机汇总、更新通知等),状态文档有 `task_set`(Agent 的任务集)和 `probe_agents`(Probe 的 Agent 列表)。
|
- **业务方法**:`moshiro.*`(按需测试、Probe 断开、事件、主机汇总、更新通知等),状态文档有 `task_set`(Agent 的任务集)和 `probe_agents`(Probe 的 Agent 列表)。
|
||||||
- **测量数据面**:Probe 到 Agent 的 TLS 控制通道、UDP 和 TCP 数据通道(默认 `7443/tcp`、`7443/udp`、`7444/tcp`);会话用 `session_id` 和 MAC 认证、不绑定五元组,NAT 映射变化后自动跟随。
|
- **测量数据面**:Probe 到 Agent 的 TLS 控制通道、QUIC Datagram 和 TCP 数据通道(默认 `7443/tcp`、`7443/udp`、`7444/tcp`);测量包不走可靠重传,继续由会话 MAC、序号窗口和速率准入校验。
|
||||||
- **REST + SSE**:`/api/public`(展示,可匿名)、`/api/admin`(管理);OpenAPI 在 `backend/openapi/v1.json`,前端据此生成类型。
|
- **REST + SSE**:`/api/public`(展示,可匿名)、`/api/admin`(管理);OpenAPI 在 `backend/openapi/v1.json`,前端据此生成类型。
|
||||||
|
|
||||||
细节见 [`docs/design/protocol.md`](docs/design/protocol.md)、[`backend/fleet/README.md`](backend/fleet/README.md)、[`docs/api/api.md`](docs/api/api.md)、[`docs/security/enrollment.md`](docs/security/enrollment.md)。
|
细节见 [`docs/design/protocol.md`](docs/design/protocol.md)、[`backend/fleet/README.md`](backend/fleet/README.md)、[`docs/api/api.md`](docs/api/api.md)、[`docs/security/enrollment.md`](docs/security/enrollment.md)。
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Agent 仅开放配置的控制和数据端口。它不提供远程终端、命
|
|||||||
## 工作流程
|
## 工作流程
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Probe ──TLS 控制 / UDP、TCP 测量──> Agent ──长连接与数据上报──> Backend
|
Probe ──TLS 控制 / QUIC Datagram、TCP 测量──> Agent ──长连接与数据上报──> Backend
|
||||||
▲
|
▲
|
||||||
fleet-enroll 注册
|
fleet-enroll 注册
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ public sealed class AgentApp : IDisposable
|
|||||||
private readonly Outbox _outbox = new(64 << 20);
|
private readonly Outbox _outbox = new(64 << 20);
|
||||||
private readonly RestartUpdater? _update;
|
private readonly RestartUpdater? _update;
|
||||||
private readonly BackendUplink _uplink;
|
private readonly BackendUplink _uplink;
|
||||||
private readonly UdpMux _udp;
|
private readonly QuicDatagramTransport _datagrams;
|
||||||
private readonly TcpAcceptor _tcp;
|
private readonly TcpAcceptor _tcp;
|
||||||
private readonly PeerListener _peers;
|
private readonly PeerListener _peers;
|
||||||
private readonly PeerHub _hub;
|
private readonly PeerHub _hub;
|
||||||
@@ -62,7 +62,7 @@ public sealed class AgentApp : IDisposable
|
|||||||
|
|
||||||
_ipv6Supported = bind.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6;
|
_ipv6Supported = bind.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6;
|
||||||
_admission = new AgentAdmission(_streams);
|
_admission = new AgentAdmission(_streams);
|
||||||
_udp = new UdpMux(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.UdpPorts);
|
_datagrams = new QuicDatagramTransport(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.EffectiveQuicPorts, identity.Certificate);
|
||||||
// 预握手敲门:控制端口和数据端口共用一个关卡(限速和去重一起算);名册就是期望状态里的 peers
|
// 预握手敲门:控制端口和数据端口共用一个关卡(限速和去重一起算);名册就是期望状态里的 peers
|
||||||
_trust = TrustAnchor.Load(config.Fleet.IdentityDir, log);
|
_trust = TrustAnchor.Load(config.Fleet.IdentityDir, log);
|
||||||
_knock = new KnockGate(identity.PeerIdentity, RosterKeys, time);
|
_knock = new KnockGate(identity.PeerIdentity, RosterKeys, time);
|
||||||
@@ -74,7 +74,7 @@ public sealed class AgentApp : IDisposable
|
|||||||
Clock = _clock,
|
Clock = _clock,
|
||||||
Log = log,
|
Log = log,
|
||||||
Session = () => null, // 每个 Probe 会话各用自己绑定的环境(BlockEnv.WithSession)
|
Session = () => null, // 每个 Probe 会话各用自己绑定的环境(BlockEnv.WithSession)
|
||||||
Udp = _udp,
|
Datagrams = _datagrams,
|
||||||
TcpListen = _tcp,
|
TcpListen = _tcp,
|
||||||
Admission = _admission,
|
Admission = _admission,
|
||||||
Counters = _counters.Data,
|
Counters = _counters.Data,
|
||||||
@@ -83,14 +83,14 @@ public sealed class AgentApp : IDisposable
|
|||||||
};
|
};
|
||||||
var chunks = new ChunkAssembler(_streams, _clock, _counters, _outbox, identity.NodeId, time, log);
|
var chunks = new ChunkAssembler(_streams, _clock, _counters, _outbox, identity.NodeId, time, log);
|
||||||
var targets = new TargetRunners(chunks, identity.NodeId, _clock, time, log);
|
var targets = new TargetRunners(chunks, identity.NodeId, _clock, time, log);
|
||||||
_tasks = new TaskManager(CreateGuard, _streams, env, targets, () => _udp.BoundPorts, () => _tcp.BoundPorts, _trust);
|
_tasks = new TaskManager(CreateGuard, _streams, env, targets, () => _datagrams.BoundPorts, () => _tcp.BoundPorts, _trust);
|
||||||
_hub = new PeerHub(_sessions, _clock, chunks, _tasks, _uplink, time, log);
|
_hub = new PeerHub(_sessions, _clock, chunks, _tasks, _uplink, time, log);
|
||||||
_tests = new TestRunner(_tasks, _sessions, _streams, env, _uplink, config.Limits.MaxConcurrentTests, transport => transport == "udp" ? _udp.BoundPorts : _tcp.BoundPorts, time, log);
|
_tests = new TestRunner(_tasks, _sessions, _streams, env, _uplink, config.Limits.MaxConcurrentTests, transport => transport == "quic_datagram" ? _datagrams.BoundPorts : _tcp.BoundPorts, time, log);
|
||||||
|
|
||||||
_tasks.OnLineTasksChanged = (probe, set) => _ = _hub.PushToAsync(probe, set).AsTask();
|
_tasks.OnLineTasksChanged = (probe, set) => _ = _hub.PushToAsync(probe, set).AsTask();
|
||||||
_tasks.OnPeersChanged = _hub.CloseNotIn;
|
_tasks.OnPeersChanged = _hub.CloseNotIn;
|
||||||
_hub.OnProbeLost = probe => _tests.ProbeLost(probe);
|
_hub.OnProbeLost = probe => _tests.ProbeLost(probe);
|
||||||
_admission.OnEndpointChanged = probe => _ = _uplink.EmitEventAsync(AgentEventKinds.EndpointChanged, probe.UdpEndpoint?.ToString()).AsTask();
|
_admission.OnEndpointChanged = probe => _ = _uplink.EmitEventAsync(AgentEventKinds.EndpointChanged, probe.DatagramRemoteEndPoint?.ToString()).AsTask();
|
||||||
|
|
||||||
_peers = new PeerListener(
|
_peers = new PeerListener(
|
||||||
new PeerListenerOptions
|
new PeerListenerOptions
|
||||||
@@ -113,13 +113,13 @@ public sealed class AgentApp : IDisposable
|
|||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_udp.Dispose();
|
_datagrams.Dispose();
|
||||||
_ownedRestart?.Dispose();
|
_ownedRestart?.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
internal StreamRegistry Streams => _streams;
|
internal StreamRegistry Streams => _streams;
|
||||||
|
|
||||||
public IReadOnlyList<int> UdpPorts => _udp.BoundPorts;
|
public IReadOnlyList<int> QuicPorts => _datagrams.BoundPorts;
|
||||||
|
|
||||||
public IReadOnlyList<int> TcpPorts => _tcp.BoundPorts;
|
public IReadOnlyList<int> TcpPorts => _tcp.BoundPorts;
|
||||||
|
|
||||||
@@ -146,23 +146,23 @@ public sealed class AgentApp : IDisposable
|
|||||||
_log.Info("moshiro agent starting", MoShiro.Protocol.BuildInfo.Fields);
|
_log.Info("moshiro agent starting", MoShiro.Protocol.BuildInfo.Fields);
|
||||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
// 数据面和控制端口先开始监听,再连接 Backend:hello 里上报的是实际端口
|
// 数据面和控制端口先开始监听,再连接 Backend:hello 里上报的是实际端口
|
||||||
_udp.Bind();
|
_datagrams.Bind();
|
||||||
_tcp.Bind();
|
_tcp.Bind();
|
||||||
Task udp = _udp.RunAsync(linked.Token);
|
Task quic = _datagrams.RunAsync(linked.Token);
|
||||||
Task tcp = _tcp.RunAsync(linked.Token);
|
Task tcp = _tcp.RunAsync(linked.Token);
|
||||||
Task peers = _peers.RunAsync(linked.Token);
|
Task peers = _peers.RunAsync(linked.Token);
|
||||||
await Task.WhenAny(_peers.Started.Task, peers);
|
await Task.WhenAny(_peers.Started.Task, peers);
|
||||||
if (peers.IsFaulted)
|
if (peers.IsFaulted)
|
||||||
{
|
{
|
||||||
await linked.CancelAsync();
|
await linked.CancelAsync();
|
||||||
await Task.WhenAll(udp, tcp, peers); // 抛出监听失败的原因(端口被占用等)
|
await Task.WhenAll(quic, tcp, peers); // 抛出监听失败的原因(端口被占用等)
|
||||||
}
|
}
|
||||||
|
|
||||||
_ready.TrySetResult();
|
_ready.TrySetResult();
|
||||||
_log.Info("agent listening", ("control", ControlPort), ("udp", string.Join(',', UdpPorts)), ("tcp", string.Join(',', TcpPorts)));
|
_log.Info("agent listening", ("control", ControlPort), ("quic", string.Join(',', QuicPorts)), ("tcp", string.Join(',', TcpPorts)));
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await Task.WhenAll(udp, tcp, peers, _link.RunAsync(linked.Token), ReportHostAsync(linked.Token));
|
await Task.WhenAll(quic, tcp, peers, _link.RunAsync(linked.Token), ReportHostAsync(linked.Token));
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -203,7 +203,7 @@ public sealed class AgentApp : IDisposable
|
|||||||
MaxTotalPps = _config.Limits.MaxTotalPps,
|
MaxTotalPps = _config.Limits.MaxTotalPps,
|
||||||
AllowedTargetCidrs = _config.Limits.AllowedTargetCidrs,
|
AllowedTargetCidrs = _config.Limits.AllowedTargetCidrs,
|
||||||
MaxConcurrentTests = _config.Limits.MaxConcurrentTests,
|
MaxConcurrentTests = _config.Limits.MaxConcurrentTests,
|
||||||
AllowedUdpPorts = _udp.BoundPorts,
|
AllowedQuicPorts = _datagrams.BoundPorts,
|
||||||
AllowedTcpPorts = _tcp.BoundPorts,
|
AllowedTcpPorts = _tcp.BoundPorts,
|
||||||
},
|
},
|
||||||
BlockCatalog.AgentKinds);
|
BlockCatalog.AgentKinds);
|
||||||
@@ -310,7 +310,8 @@ public sealed class AgentApp : IDisposable
|
|||||||
Platform = MoShiro.Measurement.PlatformDetector.Detect(),
|
Platform = MoShiro.Measurement.PlatformDetector.Detect(),
|
||||||
TlsPublicKey = Convert.ToBase64String(_identity.Certificate.PublicKey.ExportSubjectPublicKeyInfo()),
|
TlsPublicKey = Convert.ToBase64String(_identity.Certificate.PublicKey.ExportSubjectPublicKeyInfo()),
|
||||||
ProbeTcpPort = ControlPort,
|
ProbeTcpPort = ControlPort,
|
||||||
ProbeUdpPorts = [.. UdpPorts],
|
ProbeQuicPorts = [.. QuicPorts],
|
||||||
|
LegacyProbeUdpPorts = [.. QuicPorts],
|
||||||
LocalLimits = new LocalLimits
|
LocalLimits = new LocalLimits
|
||||||
{
|
{
|
||||||
MaxTotalMbps = _config.Limits.MaxTotalMbps,
|
MaxTotalMbps = _config.Limits.MaxTotalMbps,
|
||||||
|
|||||||
@@ -51,8 +51,15 @@ public sealed class ListenSection
|
|||||||
[JsonPropertyName("control_port")]
|
[JsonPropertyName("control_port")]
|
||||||
public int ControlPort { get; set; } = 7443;
|
public int ControlPort { get; set; } = 7443;
|
||||||
|
|
||||||
|
[JsonPropertyName("quic_ports")]
|
||||||
|
public List<int> QuicPorts { get; set; } = [7443];
|
||||||
|
|
||||||
|
/// <summary>Legacy config key accepted while deployments migrate to QUIC Datagram.</summary>
|
||||||
[JsonPropertyName("udp_ports")]
|
[JsonPropertyName("udp_ports")]
|
||||||
public List<int> UdpPorts { get; set; } = [7443];
|
public List<int>? LegacyUdpPorts { get; set; }
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public IReadOnlyList<int> EffectiveQuicPorts => LegacyUdpPorts ?? QuicPorts;
|
||||||
|
|
||||||
[JsonPropertyName("tcp_ports")]
|
[JsonPropertyName("tcp_ports")]
|
||||||
public List<int> TcpPorts { get; set; } = [7444];
|
public List<int> TcpPorts { get; set; } = [7444];
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ public sealed class ProbeSession : DataSession
|
|||||||
{
|
{
|
||||||
private readonly ConcurrentDictionary<uint, ReplayWindow> _windows = new();
|
private readonly ConcurrentDictionary<uint, ReplayWindow> _windows = new();
|
||||||
private readonly ConcurrentDictionary<ulong, byte> _nonces = new();
|
private readonly ConcurrentDictionary<ulong, byte> _nonces = new();
|
||||||
private SocketAddress? _udpSource;
|
private SocketAddress? _datagramSource;
|
||||||
|
|
||||||
public ProbeSession(PeerSessionInfo info)
|
public ProbeSession(PeerSessionInfo info)
|
||||||
: base(info.Credentials.SessionIdValue, info.Credentials.Key)
|
: base(info.Credentials.SessionIdValue, info.Credentials.Key)
|
||||||
@@ -71,11 +71,11 @@ public sealed class ProbeSession : DataSession
|
|||||||
public RpcSession<PeerContext>? Rpc { get; set; }
|
public RpcSession<PeerContext>? Rpc { get; set; }
|
||||||
|
|
||||||
/// <summary>最近一次认证通过的 UDP 包的源地址(Agent 把单向数据发往这里)。</summary>
|
/// <summary>最近一次认证通过的 UDP 包的源地址(Agent 把单向数据发往这里)。</summary>
|
||||||
public override IPEndPoint? UdpEndpoint
|
public override IPEndPoint? DatagramRemoteEndPoint
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
SocketAddress? source = Volatile.Read(ref _udpSource);
|
SocketAddress? source = Volatile.Read(ref _datagramSource);
|
||||||
return source is null ? null : (IPEndPoint)new IPEndPoint(source.Family == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, 0).Create(source);
|
return source is null ? null : (IPEndPoint)new IPEndPoint(source.Family == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, 0).Create(source);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,7 +83,7 @@ public sealed class ProbeSession : DataSession
|
|||||||
/// <summary>记录 UDP 源地址;NAT 映射变化后地址变了返回 true(上报 endpoint_changed)。第一次看到地址不算变化。</summary>
|
/// <summary>记录 UDP 源地址;NAT 映射变化后地址变了返回 true(上报 endpoint_changed)。第一次看到地址不算变化。</summary>
|
||||||
public bool UpdateSource(SocketAddress source)
|
public bool UpdateSource(SocketAddress source)
|
||||||
{
|
{
|
||||||
SocketAddress? previous = Volatile.Read(ref _udpSource);
|
SocketAddress? previous = Volatile.Read(ref _datagramSource);
|
||||||
if (previous is not null && previous.Equals(source))
|
if (previous is not null && previous.Equals(source))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
@@ -96,7 +96,7 @@ public sealed class ProbeSession : DataSession
|
|||||||
copy[i] = source[i];
|
copy[i] = source[i];
|
||||||
}
|
}
|
||||||
|
|
||||||
Volatile.Write(ref _udpSource, copy);
|
Volatile.Write(ref _datagramSource, copy);
|
||||||
return previous is not null;
|
return previous is not null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,7 +312,7 @@ public sealed class AgentAdmission : IAdmission
|
|||||||
/// <summary>NAT 映射变化,源地址变了:上报 <c>endpoint_changed</c>。</summary>
|
/// <summary>NAT 映射变化,源地址变了:上报 <c>endpoint_changed</c>。</summary>
|
||||||
public Action<ProbeSession>? OnEndpointChanged { get; set; }
|
public Action<ProbeSession>? OnEndpointChanged { get; set; }
|
||||||
|
|
||||||
public Admission AdmitPacket(DataSession session, in UdpHeader header, SocketAddress source, long nowUs)
|
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 ProbeSession probe || !_streams.TryGet(header.StreamId, out StreamInfo? stream) || !ReferenceEquals(stream.Owner, probe) || !probe.AcceptSequence(header.StreamId, header.Seq))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ public sealed class TaskManager
|
|||||||
private readonly StreamRegistry _streams;
|
private readonly StreamRegistry _streams;
|
||||||
private readonly BlockEnv _env;
|
private readonly BlockEnv _env;
|
||||||
private readonly TargetRunners _targets;
|
private readonly TargetRunners _targets;
|
||||||
private readonly Func<IReadOnlyList<int>> _udpPorts;
|
private readonly Func<IReadOnlyList<int>> _quicPorts;
|
||||||
private readonly Func<IReadOnlyList<int>> _tcpPorts;
|
private readonly Func<IReadOnlyList<int>> _tcpPorts;
|
||||||
private readonly TrustAnchor _trust;
|
private readonly TrustAnchor _trust;
|
||||||
private readonly Dictionary<ProbeSession, Dictionary<string, LineEntry>> _sessionLines = [];
|
private readonly Dictionary<ProbeSession, Dictionary<string, LineEntry>> _sessionLines = [];
|
||||||
@@ -33,14 +33,14 @@ public sealed class TaskManager
|
|||||||
private long _version;
|
private long _version;
|
||||||
private BudgetGuard? _guard;
|
private BudgetGuard? _guard;
|
||||||
|
|
||||||
public TaskManager(Func<BudgetGuard> guardFactory, StreamRegistry streams, BlockEnv env, TargetRunners targets, Func<IReadOnlyList<int>> udpPorts, Func<IReadOnlyList<int>> tcpPorts, TrustAnchor? trust = null)
|
public TaskManager(Func<BudgetGuard> guardFactory, StreamRegistry streams, BlockEnv env, TargetRunners targets, Func<IReadOnlyList<int>> quicPorts, Func<IReadOnlyList<int>> tcpPorts, TrustAnchor? trust = null)
|
||||||
{
|
{
|
||||||
_trust = trust ?? TrustAnchor.None;
|
_trust = trust ?? TrustAnchor.None;
|
||||||
_guardFactory = guardFactory;
|
_guardFactory = guardFactory;
|
||||||
_streams = streams;
|
_streams = streams;
|
||||||
_env = env;
|
_env = env;
|
||||||
_targets = targets;
|
_targets = targets;
|
||||||
_udpPorts = udpPorts;
|
_quicPorts = quicPorts;
|
||||||
_tcpPorts = tcpPorts;
|
_tcpPorts = tcpPorts;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,7 +229,8 @@ public sealed class TaskManager
|
|||||||
{
|
{
|
||||||
Version = _version,
|
Version = _version,
|
||||||
Tasks = [.. entries.Values.Select(e => e.Task)],
|
Tasks = [.. entries.Values.Select(e => e.Task)],
|
||||||
UdpPorts = [.. _udpPorts()],
|
QuicPorts = [.. _quicPorts()],
|
||||||
|
LegacyUdpPorts = [.. _quicPorts()],
|
||||||
TcpPorts = [.. _tcpPorts()],
|
TcpPorts = [.. _tcpPorts()],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ public sealed class TestRunner
|
|||||||
|
|
||||||
// 按需测试的数据端口由 Agent 决定:Backend 给的端口如果不是本机监听的端口(模板里是出厂默认值,每个 Agent 可以改,
|
// 按需测试的数据端口由 Agent 决定:Backend 给的端口如果不是本机监听的端口(模板里是出厂默认值,每个 Agent 可以改,
|
||||||
// 而 Backend 并不知道 TCP 数据端口),就用本机监听的第一个端口。块头里记录的是实际用的端口。
|
// 而 Backend 并不知道 TCP 数据端口),就用本机监听的第一个端口。块头里记录的是实际用的端口。
|
||||||
IReadOnlyList<int> ports = _ports(kind.Blocks.Transport == "udp" ? "udp" : "tcp");
|
IReadOnlyList<int> ports = _ports(kind.Blocks.Transport == "quic_datagram" ? "quic_datagram" : "tcp");
|
||||||
bool listening = phase.Params.TryGetValue("port", out System.Text.Json.JsonElement requested) && requested.TryGetInt32(out int port) && ports.Contains(port);
|
bool listening = phase.Params.TryGetValue("port", out System.Text.Json.JsonElement requested) && requested.TryGetInt32(out int port) && ports.Contains(port);
|
||||||
if (!listening)
|
if (!listening)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5174,6 +5174,7 @@
|
|||||||
"public_host",
|
"public_host",
|
||||||
"probe_tcp_port",
|
"probe_tcp_port",
|
||||||
"probe_udp_ports",
|
"probe_udp_ports",
|
||||||
|
"probe_quic_ports",
|
||||||
"labels",
|
"labels",
|
||||||
"public_visible",
|
"public_visible",
|
||||||
"kinds",
|
"kinds",
|
||||||
@@ -5242,6 +5243,17 @@
|
|||||||
"format": "int32"
|
"format": "int32"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"probe_quic_ports": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"string"
|
||||||
|
],
|
||||||
|
"format": "int32"
|
||||||
|
}
|
||||||
|
},
|
||||||
"labels": {
|
"labels": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
@@ -6974,6 +6986,16 @@
|
|||||||
"ipv6_available": {
|
"ipv6_available": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": false
|
"default": false
|
||||||
|
},
|
||||||
|
"quic_health": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/QuicLineHealthDto"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -7601,6 +7623,16 @@
|
|||||||
"ip_version": {
|
"ip_version": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "ipv4"
|
"default": "ipv4"
|
||||||
|
},
|
||||||
|
"quic_health": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/QuicLineHealthDto"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -8581,6 +8613,156 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"QuicLineHealthDto": {
|
||||||
|
"required": [
|
||||||
|
"connected",
|
||||||
|
"handshake_attempts",
|
||||||
|
"handshake_successes",
|
||||||
|
"handshake_failures",
|
||||||
|
"handshake_timeouts",
|
||||||
|
"handshake_success_rate",
|
||||||
|
"handshake_failure_rate",
|
||||||
|
"handshake_timeout_rate",
|
||||||
|
"handshake_p95_ms",
|
||||||
|
"handshake_sample_count",
|
||||||
|
"transport_packet_loss_rate",
|
||||||
|
"transport_packets_sent",
|
||||||
|
"transport_congestion_events",
|
||||||
|
"transport_ecn_congestion_events",
|
||||||
|
"path_mtu",
|
||||||
|
"at"
|
||||||
|
],
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"connected": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"handshake_attempts": {
|
||||||
|
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"string"
|
||||||
|
],
|
||||||
|
"format": "int64"
|
||||||
|
},
|
||||||
|
"handshake_successes": {
|
||||||
|
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"string"
|
||||||
|
],
|
||||||
|
"format": "int64"
|
||||||
|
},
|
||||||
|
"handshake_failures": {
|
||||||
|
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"string"
|
||||||
|
],
|
||||||
|
"format": "int64"
|
||||||
|
},
|
||||||
|
"handshake_timeouts": {
|
||||||
|
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"string"
|
||||||
|
],
|
||||||
|
"format": "int64"
|
||||||
|
},
|
||||||
|
"handshake_success_rate": {
|
||||||
|
"pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$",
|
||||||
|
"type": [
|
||||||
|
"null",
|
||||||
|
"number",
|
||||||
|
"string"
|
||||||
|
],
|
||||||
|
"format": "double"
|
||||||
|
},
|
||||||
|
"handshake_failure_rate": {
|
||||||
|
"pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$",
|
||||||
|
"type": [
|
||||||
|
"null",
|
||||||
|
"number",
|
||||||
|
"string"
|
||||||
|
],
|
||||||
|
"format": "double"
|
||||||
|
},
|
||||||
|
"handshake_timeout_rate": {
|
||||||
|
"pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$",
|
||||||
|
"type": [
|
||||||
|
"null",
|
||||||
|
"number",
|
||||||
|
"string"
|
||||||
|
],
|
||||||
|
"format": "double"
|
||||||
|
},
|
||||||
|
"handshake_p95_ms": {
|
||||||
|
"pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$",
|
||||||
|
"type": [
|
||||||
|
"null",
|
||||||
|
"number",
|
||||||
|
"string"
|
||||||
|
],
|
||||||
|
"format": "double"
|
||||||
|
},
|
||||||
|
"handshake_sample_count": {
|
||||||
|
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"string"
|
||||||
|
],
|
||||||
|
"format": "int64"
|
||||||
|
},
|
||||||
|
"transport_packet_loss_rate": {
|
||||||
|
"pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$",
|
||||||
|
"type": [
|
||||||
|
"null",
|
||||||
|
"number",
|
||||||
|
"string"
|
||||||
|
],
|
||||||
|
"format": "double"
|
||||||
|
},
|
||||||
|
"transport_packets_sent": {
|
||||||
|
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||||
|
"type": [
|
||||||
|
"null",
|
||||||
|
"integer",
|
||||||
|
"string"
|
||||||
|
],
|
||||||
|
"format": "uint64"
|
||||||
|
},
|
||||||
|
"transport_congestion_events": {
|
||||||
|
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||||
|
"type": [
|
||||||
|
"null",
|
||||||
|
"integer",
|
||||||
|
"string"
|
||||||
|
],
|
||||||
|
"format": "uint32"
|
||||||
|
},
|
||||||
|
"transport_ecn_congestion_events": {
|
||||||
|
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||||
|
"type": [
|
||||||
|
"null",
|
||||||
|
"integer",
|
||||||
|
"string"
|
||||||
|
],
|
||||||
|
"format": "uint32"
|
||||||
|
},
|
||||||
|
"path_mtu": {
|
||||||
|
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||||
|
"type": [
|
||||||
|
"null",
|
||||||
|
"integer",
|
||||||
|
"string"
|
||||||
|
],
|
||||||
|
"format": "uint16"
|
||||||
|
},
|
||||||
|
"at": {
|
||||||
|
"format": "date-time"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"RawChunkMetaDto": {
|
"RawChunkMetaDto": {
|
||||||
"required": [
|
"required": [
|
||||||
"chunk_id",
|
"chunk_id",
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ public sealed class AgentConnection
|
|||||||
|
|
||||||
private readonly ConnectionEnv _env;
|
private readonly ConnectionEnv _env;
|
||||||
private volatile ConnectionSession? _session;
|
private volatile ConnectionSession? _session;
|
||||||
|
private int _previousQuicSourcePort;
|
||||||
|
private QuicDatagramTransport? _activeDatagrams;
|
||||||
|
private readonly object _handshakeGate = new();
|
||||||
|
private readonly Queue<double> _handshakeSamplesMs = new();
|
||||||
|
private long _handshakeAttempts;
|
||||||
|
private long _handshakeSuccesses;
|
||||||
|
private long _handshakeFailures;
|
||||||
|
private long _handshakeTimeouts;
|
||||||
|
|
||||||
public AgentConnection(KnownAgent endpoint, ConnectionEnv env)
|
public AgentConnection(KnownAgent endpoint, ConnectionEnv env)
|
||||||
{
|
{
|
||||||
@@ -45,6 +53,38 @@ public sealed class AgentConnection
|
|||||||
|
|
||||||
public long ClockOffsetUs => _session?.Clock.OffsetUs ?? 0;
|
public long ClockOffsetUs => _session?.Clock.OffsetUs ?? 0;
|
||||||
|
|
||||||
|
public ProbeQuicLinkHealth QuicHealth()
|
||||||
|
{
|
||||||
|
lock (_handshakeGate)
|
||||||
|
{
|
||||||
|
double? p95 = null;
|
||||||
|
if (_handshakeSamplesMs.Count > 0)
|
||||||
|
{
|
||||||
|
double[] sorted = [.. _handshakeSamplesMs.Order()];
|
||||||
|
p95 = sorted[(int)Math.Ceiling(sorted.Length * 0.95) - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
QuicTransportSnapshot? transport = Volatile.Read(ref _activeDatagrams)?.GetTransportSnapshot();
|
||||||
|
return new ProbeQuicLinkHealth
|
||||||
|
{
|
||||||
|
AgentId = Endpoint.AgentId,
|
||||||
|
IpVersion = Endpoint.IpVersion,
|
||||||
|
Connected = IsConnected,
|
||||||
|
HandshakeAttempts = _handshakeAttempts,
|
||||||
|
HandshakeSuccesses = _handshakeSuccesses,
|
||||||
|
HandshakeFailures = _handshakeFailures,
|
||||||
|
HandshakeTimeouts = _handshakeTimeouts,
|
||||||
|
HandshakeP95Ms = p95,
|
||||||
|
HandshakeSampleCount = _handshakeSamplesMs.Count,
|
||||||
|
TransportPacketLossRate = transport?.PacketLossRate,
|
||||||
|
TransportPacketsSent = transport?.PacketsSent,
|
||||||
|
TransportCongestionEvents = transport?.CongestionEvents,
|
||||||
|
TransportEcnCongestionEvents = transport?.EcnCongestionEvents,
|
||||||
|
PathMtu = transport?.PathMtu,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task RunAsync(CancellationToken ct)
|
public async Task RunAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
// 重连的间隔别拉太长:每次尝试本来就要等好几秒(握手上限 30 秒),线路一恢复要尽快接上;带抖动,不会和别的节点撞在一起
|
// 重连的间隔别拉太长:每次尝试本来就要等好几秒(握手上限 30 秒),线路一恢复要尽快接上;带抖动,不会和别的节点撞在一起
|
||||||
@@ -93,10 +133,22 @@ public sealed class AgentConnection
|
|||||||
byte[] agentKey = Convert.FromBase64String(Endpoint.PublicKey);
|
byte[] agentKey = Convert.FromBase64String(Endpoint.PublicKey);
|
||||||
(PeerSessionInfo info, TlsFrameTransport transport) = await PeerConnector.ConnectAsync(
|
(PeerSessionInfo info, TlsFrameTransport transport) = await PeerConnector.ConnectAsync(
|
||||||
address.ToString(), Endpoint.TcpPort, agentKey, _env.Identity, _env.ProbeId, ProbeApp.Version, 8 << 20, _env.Time, ct);
|
address.ToString(), Endpoint.TcpPort, agentKey, _env.Identity, _env.ProbeId, ProbeApp.Version, 8 << 20, _env.Time, ct);
|
||||||
var remote = (IPEndPoint)info.Remote!;
|
|
||||||
var data = new DataSession(info.Credentials.SessionIdValue, info.Credentials.Key);
|
var data = new DataSession(info.Credentials.SessionIdValue, info.Credentials.Key);
|
||||||
using var udp = new UdpMux(new SingleSession(data), null, _env.Clock, new DataPlaneCounters(), _env.Log, remote.AddressFamily == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, []);
|
var remote = (IPEndPoint)info.Remote!;
|
||||||
udp.Bind();
|
IReadOnlyList<int> quicPorts = Endpoint.EffectiveQuicPorts;
|
||||||
|
int quicPort = quicPorts.Count == 0 ? 7443 : quicPorts[0];
|
||||||
|
using var datagrams = new QuicDatagramTransport(
|
||||||
|
data,
|
||||||
|
_env.Clock,
|
||||||
|
new DataPlaneCounters(),
|
||||||
|
_env.Log,
|
||||||
|
new IPEndPoint(remote.Address, quicPort),
|
||||||
|
agentKey,
|
||||||
|
Volatile.Read(ref _previousQuicSourcePort));
|
||||||
|
Volatile.Write(ref _activeDatagrams, datagrams);
|
||||||
|
datagrams.Bind();
|
||||||
|
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
|
||||||
var outbox = new Outbox(8 << 20);
|
var outbox = new Outbox(8 << 20);
|
||||||
var clock = new ClockEstimator();
|
var clock = new ClockEstimator();
|
||||||
var shipper = new FragmentShipper(outbox, new NodeId(Endpoint.AgentId), new ProbeId(_env.ProbeId), clock, _env.Time);
|
var shipper = new FragmentShipper(outbox, new NodeId(Endpoint.AgentId), new ProbeId(_env.ProbeId), clock, _env.Time);
|
||||||
@@ -105,7 +157,7 @@ public sealed class AgentConnection
|
|||||||
Clock = _env.Clock,
|
Clock = _env.Clock,
|
||||||
Log = _env.Log,
|
Log = _env.Log,
|
||||||
Session = () => data,
|
Session = () => data,
|
||||||
Udp = udp,
|
Datagrams = datagrams,
|
||||||
PeerAddress = remote.Address,
|
PeerAddress = remote.Address,
|
||||||
PeerSpkiSha256 = NodeIdentity.Fingerprint(agentKey),
|
PeerSpkiSha256 = NodeIdentity.Fingerprint(agentKey),
|
||||||
KnockKey = Knock.DeriveKey(_env.Identity, agentKey),
|
KnockKey = Knock.DeriveKey(_env.Identity, agentKey),
|
||||||
@@ -117,14 +169,52 @@ public sealed class AgentConnection
|
|||||||
var rpc = new RpcSession<ConnectionSession>(transport, s_router, session, _env.Time);
|
var rpc = new RpcSession<ConnectionSession>(transport, s_router, session, _env.Time);
|
||||||
rpc.OnBinary = (_, _) => ValueTask.CompletedTask;
|
rpc.OnBinary = (_, _) => ValueTask.CompletedTask;
|
||||||
|
|
||||||
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
||||||
Task run = rpc.RunAsync(cts.Token);
|
Task run = rpc.RunAsync(cts.Token);
|
||||||
Task receive = udp.RunAsync(cts.Token);
|
Task receive = datagrams.RunAsync(cts.Token);
|
||||||
Task pump = PumpAsync(rpc, outbox, cts.Token);
|
Task pump = PumpAsync(rpc, outbox, cts.Token);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 先同步时钟(块头里要带偏移);Agent 随后推送线路任务集
|
// 先同步时钟(块头里要带偏移);Agent 随后推送线路任务集
|
||||||
await SyncClockAsync(rpc, clock, rounds: 5, cts.Token);
|
await SyncClockAsync(rpc, clock, rounds: 5, cts.Token);
|
||||||
|
lock (_handshakeGate)
|
||||||
|
{
|
||||||
|
_handshakeAttempts++;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await datagrams.ConnectAsync(cts.Token);
|
||||||
|
lock (_handshakeGate)
|
||||||
|
{
|
||||||
|
_handshakeSuccesses++;
|
||||||
|
if (datagrams.HandshakeDurationMs is { } elapsed)
|
||||||
|
{
|
||||||
|
_handshakeSamplesMs.Enqueue(elapsed);
|
||||||
|
while (_handshakeSamplesMs.Count > 128)
|
||||||
|
{
|
||||||
|
_handshakeSamplesMs.Dequeue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (!(ex is OperationCanceledException && cts.IsCancellationRequested))
|
||||||
|
{
|
||||||
|
lock (_handshakeGate)
|
||||||
|
{
|
||||||
|
_handshakeFailures++;
|
||||||
|
if (ex is TimeoutException)
|
||||||
|
{
|
||||||
|
_handshakeTimeouts++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
if (datagrams.BoundPorts.Count > 0)
|
||||||
|
{
|
||||||
|
Volatile.Write(ref _previousQuicSourcePort, datagrams.BoundPorts[0]);
|
||||||
|
}
|
||||||
|
|
||||||
backoff.Reset();
|
backoff.Reset();
|
||||||
_session = session;
|
_session = session;
|
||||||
_env.Log.Info("agent session up", ("agent", Endpoint.AgentId), ("offset_us", clock.OffsetUs));
|
_env.Log.Info("agent session up", ("agent", Endpoint.AgentId), ("offset_us", clock.OffsetUs));
|
||||||
@@ -137,6 +227,7 @@ public sealed class AgentConnection
|
|||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_session = null;
|
_session = null;
|
||||||
|
Volatile.Write(ref _activeDatagrams, null);
|
||||||
await cts.CancelAsync();
|
await cts.CancelAsync();
|
||||||
await session.StopAllAsync();
|
await session.StopAllAsync();
|
||||||
await Quiet(run);
|
await Quiet(run);
|
||||||
|
|||||||
@@ -73,14 +73,15 @@ public sealed class AgentDirectory
|
|||||||
_trust.Update(result.TrustChain);
|
_trust.Update(result.TrustChain);
|
||||||
foreach (AgentEndpoint agent in result.Agents)
|
foreach (AgentEndpoint agent in result.Agents)
|
||||||
{
|
{
|
||||||
|
List<int> ports = agent.QuicPorts.Count > 0 ? agent.QuicPorts : agent.LegacyUdpPorts ?? [];
|
||||||
if (!string.IsNullOrWhiteSpace(agent.Host))
|
if (!string.IsNullOrWhiteSpace(agent.Host))
|
||||||
{
|
{
|
||||||
next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Host, TcpPort = agent.TcpPort, PublicKey = agent.PublicKey });
|
next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Host, TcpPort = agent.TcpPort, QuicPorts = [.. ports], PublicKey = agent.PublicKey });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(agent.Ipv6Host))
|
if (!string.IsNullOrWhiteSpace(agent.Ipv6Host))
|
||||||
{
|
{
|
||||||
next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Ipv6Host, TcpPort = agent.TcpPort, PublicKey = agent.PublicKey, IpVersion = IpVersions.V6 });
|
next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Ipv6Host, TcpPort = agent.TcpPort, QuicPorts = [.. ports], PublicKey = agent.PublicKey, IpVersion = IpVersions.V6 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
@@ -111,5 +112,5 @@ public sealed class AgentDirectory
|
|||||||
a.Count == b.Count && a.Zip(b).All(p => Equal(p.First, p.Second));
|
a.Count == b.Count && a.Zip(b).All(p => Equal(p.First, p.Second));
|
||||||
|
|
||||||
public static bool Equal(KnownAgent a, KnownAgent b) =>
|
public static bool Equal(KnownAgent a, KnownAgent b) =>
|
||||||
a.AgentId == b.AgentId && a.IpVersion == b.IpVersion && a.Host == b.Host && a.TcpPort == b.TcpPort && a.PublicKey == b.PublicKey;
|
a.AgentId == b.AgentId && a.IpVersion == b.IpVersion && a.Host == b.Host && a.TcpPort == b.TcpPort && a.QuicPorts.SequenceEqual(b.QuicPorts) && a.PublicKey == b.PublicKey;
|
||||||
}
|
}
|
||||||
@@ -45,7 +45,7 @@ public sealed class ProbeApp : IDisposable
|
|||||||
NodeId = identity.NodeId,
|
NodeId = identity.NodeId,
|
||||||
NodeVersion = Version,
|
NodeVersion = Version,
|
||||||
Capabilities = [MoShiroCapabilities.Base],
|
Capabilities = [MoShiroCapabilities.Base],
|
||||||
HeartbeatExtra = () => JsonSerializer.SerializeToElement(new ProbeHeartbeatExtra { Update = update?.Status(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHeartbeatExtra),
|
HeartbeatExtra = () => JsonSerializer.SerializeToElement(new ProbeHeartbeatExtra { Update = update?.Status(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")), QuicLinks = SnapshotQuicLinks() }, MoShiroJsonContext.Default.ProbeHeartbeatExtra),
|
||||||
HelloMeta = () => JsonSerializer.SerializeToElement(new ProbeHelloMeta { Owners = [.. trust.Controllers], Platform = PlatformDetector.Detect(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHelloMeta),
|
HelloMeta = () => JsonSerializer.SerializeToElement(new ProbeHelloMeta { Owners = [.. trust.Controllers], Platform = PlatformDetector.Detect(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHelloMeta),
|
||||||
};
|
};
|
||||||
var list = new StateHandler<ProbeAgentList>(
|
var list = new StateHandler<ProbeAgentList>(
|
||||||
@@ -101,6 +101,14 @@ public sealed class ProbeApp : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<ProbeQuicLinkHealth> SnapshotQuicLinks()
|
||||||
|
{
|
||||||
|
lock (_connections)
|
||||||
|
{
|
||||||
|
return [.. _connections.Values.Select(r => r.Connection.QuicHealth())];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task RunAsync(CancellationToken ct)
|
public async Task RunAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
_log.Info("moshiro probe starting", MoShiro.Protocol.BuildInfo.Fields);
|
_log.Info("moshiro probe starting", MoShiro.Protocol.BuildInfo.Fields);
|
||||||
|
|||||||
@@ -57,6 +57,15 @@ public sealed class KnownAgent
|
|||||||
[JsonPropertyName("tcp_port")]
|
[JsonPropertyName("tcp_port")]
|
||||||
public int TcpPort { get; set; }
|
public int TcpPort { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("quic_ports")]
|
||||||
|
public List<int> QuicPorts { get; set; } = [7443];
|
||||||
|
|
||||||
|
[JsonPropertyName("udp_ports")]
|
||||||
|
public List<int>? LegacyUdpPorts { get; set; }
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public IReadOnlyList<int> EffectiveQuicPorts => LegacyUdpPorts ?? QuicPorts;
|
||||||
|
|
||||||
/// <summary>Agent 的 TLS 证书公钥(SPKI,base64):证书按它的 SHA-256 指纹固定,敲门密钥由它派生。</summary>
|
/// <summary>Agent 的 TLS 证书公钥(SPKI,base64):证书按它的 SHA-256 指纹固定,敲门密钥由它派生。</summary>
|
||||||
[JsonPropertyName("public_key")]
|
[JsonPropertyName("public_key")]
|
||||||
public string PublicKey { get; set; } = "";
|
public string PublicKey { get; set; } = "";
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ public static class BlockCatalog
|
|||||||
{
|
{
|
||||||
public static readonly IReadOnlyList<BlockPair> All =
|
public static readonly IReadOnlyList<BlockPair> All =
|
||||||
[
|
[
|
||||||
new("udp_echo", (e, s) => new UdpEchoDialer(e, s), (e, s) => new UdpEchoListener(e, s)),
|
new("udp_echo", (e, s) => new DatagramEchoDialer(e, s), (e, s) => new DatagramEchoListener(e, s)),
|
||||||
new("tcp_echo", (e, s) => new TcpEchoDialer(e, s), (e, s) => new TcpEchoListener(e, s)),
|
new("tcp_echo", (e, s) => new TcpEchoDialer(e, s), (e, s) => new TcpEchoListener(e, s)),
|
||||||
new("udp_train", (e, s) => new TrainDialer(e, s), (e, s) => new TrainListener(e, s), OnDemandOnly: true, Directions: ["rev"]),
|
new("udp_train", (e, s) => new TrainDialer(e, s), (e, s) => new TrainListener(e, s), OnDemandOnly: true, Directions: ["rev"]),
|
||||||
new("tcp_bulk", (e, s) => new TcpBulkDialer(e, s), (e, s) => new TcpBulkListener(e, s), OnDemandOnly: true, Directions: ["fwd", "rev"]),
|
new("tcp_bulk", (e, s) => new TcpBulkDialer(e, s), (e, s) => new TcpBulkListener(e, s), OnDemandOnly: true, Directions: ["fwd", "rev"]),
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ namespace MoShiro.Measurement;
|
|||||||
internal sealed class EchoRows
|
internal sealed class EchoRows
|
||||||
{
|
{
|
||||||
/// <summary>UDP:没有在这段时间内回来的包就是丢了。</summary>
|
/// <summary>UDP:没有在这段时间内回来的包就是丢了。</summary>
|
||||||
public const long UdpGraceUs = 2_000_000;
|
public const long DatagramGraceUs = 2_000_000;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// TCP 不会丢包,只会延迟(重传、队头阻塞):一次几秒的中断之后,积压的回显会陆续回来,RTT 就是那几秒。
|
/// TCP 不会丢包,只会延迟(重传、队头阻塞):一次几秒的中断之后,积压的回显会陆续回来,RTT 就是那几秒。
|
||||||
@@ -65,26 +65,27 @@ internal sealed class EchoRows
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// UDP 回显,拨号方(Probe):按声明的速率发包,t1 在发出时记录,回复到达时回填 t4。回复丢失的行 t4 缺失(Agent 一侧再补 t2/t3)。
|
/// UDP 回显,拨号方(Probe):按声明的速率发包,t1 在发出时记录,回复到达时回填 t4。回复丢失的行 t4 缺失(Agent 一侧再补 t2/t3)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class UdpEchoDialer : BlockBase
|
public sealed class DatagramEchoDialer : BlockBase
|
||||||
{
|
{
|
||||||
private readonly EchoRows _rows;
|
private readonly EchoRows _rows;
|
||||||
|
|
||||||
public UdpEchoDialer(BlockEnv env, BlockSpec spec)
|
public DatagramEchoDialer(BlockEnv env, BlockSpec spec)
|
||||||
: base(env, spec)
|
: base(env, spec)
|
||||||
{
|
{
|
||||||
_rows = new EchoRows(spec.Kind, 16384, EchoRows.UdpGraceUs, settleOnReply: false);
|
_rows = new EchoRows(spec.Kind, 16384, EchoRows.DatagramGraceUs, settleOnReply: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override long Produced => _rows.Count;
|
public override long Produced => _rows.Count;
|
||||||
|
|
||||||
public override async Task RunAsync(CancellationToken ct)
|
public override async Task RunAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
UdpMux udp = Env.Udp!;
|
QuicDatagramTransport datagrams = Env.Datagrams!;
|
||||||
DataSession session = Env.Session() ?? throw new BlockFaultException("no data session");
|
DataSession session = Env.Session() ?? throw new BlockFaultException("no data session");
|
||||||
|
await datagrams.WaitUntilConnectedAsync(ct);
|
||||||
IPEndPoint target = PeerEndpoint(Spec.Port);
|
IPEndPoint target = PeerEndpoint(Spec.Port);
|
||||||
int size = (int)Spec.Int("size");
|
int size = (int)Spec.Int("size");
|
||||||
byte[] packet = new byte[size];
|
byte[] packet = new byte[size];
|
||||||
udp.Register(Spec.StreamId, OnPacket);
|
datagrams.Register(Spec.StreamId, OnPacket);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1.0 / Spec.Int("rate_hz")), Env.Time);
|
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1.0 / Spec.Int("rate_hz")), Env.Time);
|
||||||
@@ -92,8 +93,8 @@ public sealed class UdpEchoDialer : BlockBase
|
|||||||
{
|
{
|
||||||
long t1 = Env.Clock.NowUs();
|
long t1 = Env.Clock.NowUs();
|
||||||
uint seq = _rows.Add(size, t1);
|
uint seq = _rows.Add(size, t1);
|
||||||
UdpPacket.Write(packet, new UdpHeader(UdpPacketType.EchoRequest, session.SessionId, Spec.StreamId, seq, t1), session.Key);
|
MeasurementDatagram.Write(packet, new MeasurementDatagramHeader(MeasurementDatagramType.EchoRequest, session.SessionId, Spec.StreamId, seq, t1), session.Key);
|
||||||
udp.Send(target, packet);
|
datagrams.Send(session, target, packet);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
@@ -102,7 +103,7 @@ public sealed class UdpEchoDialer : BlockBase
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
udp.Unregister(Spec.StreamId);
|
datagrams.Unregister(Spec.StreamId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,9 +119,9 @@ public sealed class UdpEchoDialer : BlockBase
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnPacket(in UdpDelivery delivery)
|
private void OnPacket(in QuicDatagramDelivery delivery)
|
||||||
{
|
{
|
||||||
if (delivery.Header.Type == UdpPacketType.EchoReply)
|
if (delivery.Header.Type == MeasurementDatagramType.EchoReply)
|
||||||
{
|
{
|
||||||
_rows.Reply(delivery.Header.Seq, delivery.ReceiveUs);
|
_rows.Reply(delivery.Header.Seq, delivery.ReceiveUs);
|
||||||
}
|
}
|
||||||
@@ -128,11 +129,11 @@ public sealed class UdpEchoDialer : BlockBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>UDP 回显,监听方(Agent):校验后立即回复一个等长的包,记录 t2(到达)和 t3(回复发出),按序号留给合并。</summary>
|
/// <summary>UDP 回显,监听方(Agent):校验后立即回复一个等长的包,记录 t2(到达)和 t3(回复发出),按序号留给合并。</summary>
|
||||||
public sealed class UdpEchoListener : BlockBase
|
public sealed class DatagramEchoListener : BlockBase
|
||||||
{
|
{
|
||||||
private readonly RowStore _records;
|
private readonly RowStore _records;
|
||||||
|
|
||||||
public UdpEchoListener(BlockEnv env, BlockSpec spec)
|
public DatagramEchoListener(BlockEnv env, BlockSpec spec)
|
||||||
: base(env, spec)
|
: base(env, spec)
|
||||||
{
|
{
|
||||||
_records = new RowStore(spec.Kind.FindStream("msg")!, Side.Agent, 32768);
|
_records = new RowStore(spec.Kind.FindStream("msg")!, Side.Agent, 32768);
|
||||||
@@ -142,26 +143,26 @@ public sealed class UdpEchoListener : BlockBase
|
|||||||
|
|
||||||
public override async Task RunAsync(CancellationToken ct)
|
public override async Task RunAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
Env.Udp!.Register(Spec.StreamId, OnPacket);
|
Env.Datagrams!.Register(Spec.StreamId, OnPacket);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await WaitUntilCancelledAsync(ct);
|
await WaitUntilCancelledAsync(ct);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
Env.Udp.Unregister(Spec.StreamId);
|
Env.Datagrams.Unregister(Spec.StreamId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnPacket(in UdpDelivery delivery)
|
private void OnPacket(in QuicDatagramDelivery delivery)
|
||||||
{
|
{
|
||||||
if (delivery.Header.Type != UdpPacketType.EchoRequest)
|
if (delivery.Header.Type != MeasurementDatagramType.EchoRequest)
|
||||||
{
|
{
|
||||||
return; // Data:探活包,只用来保持 NAT 映射和让准入策略学到源地址
|
return; // Data:探活包,只用来保持 NAT 映射和让准入策略学到源地址
|
||||||
}
|
}
|
||||||
|
|
||||||
long t2 = delivery.ReceiveUs;
|
long t2 = delivery.ReceiveUs;
|
||||||
delivery.ReplyAs(UdpPacketType.EchoReply);
|
delivery.ReplyAs(MeasurementDatagramType.EchoReply);
|
||||||
Span<long> row = [t2, Env.Clock.NowUs()];
|
Span<long> row = [t2, Env.Clock.NowUs()];
|
||||||
_records.SetRow(delivery.Header.Seq, row);
|
_records.SetRow(delivery.Header.Seq, row);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,11 +30,11 @@ public sealed class TrainListener : BlockBase
|
|||||||
public override async Task RunAsync(CancellationToken ct)
|
public override async Task RunAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
// Probe 开始前发来的探活包(Data)要被接受,准入策略才能据此学到它当前的源地址(NAT 映射);它们本身没有内容
|
// Probe 开始前发来的探活包(Data)要被接受,准入策略才能据此学到它当前的源地址(NAT 映射);它们本身没有内容
|
||||||
Env.Udp!.Register(Spec.StreamId, static (in UdpDelivery _) => { });
|
Env.Datagrams!.Register(Spec.StreamId, static (in QuicDatagramDelivery _) => { });
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
DataSession session = await WaitForPeerAsync(ct);
|
DataSession session = await WaitForPeerAsync(ct);
|
||||||
IPEndPoint target = session.UdpEndpoint!;
|
IPEndPoint target = session.DatagramRemoteEndPoint!;
|
||||||
TimeSpan duration = TimeSpan.FromSeconds(Spec.Int("duration_s"));
|
TimeSpan duration = TimeSpan.FromSeconds(Spec.Int("duration_s"));
|
||||||
var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
var thread = new Thread(() =>
|
var thread = new Thread(() =>
|
||||||
@@ -58,7 +58,7 @@ public sealed class TrainListener : BlockBase
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
Env.Udp.Unregister(Spec.StreamId);
|
Env.Datagrams.Unregister(Spec.StreamId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ public sealed class TrainListener : BlockBase
|
|||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
DataSession? session = Env.Session();
|
DataSession? session = Env.Session();
|
||||||
if (session?.UdpEndpoint is not null)
|
if (session?.DatagramRemoteEndPoint is not null)
|
||||||
{
|
{
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
@@ -120,8 +120,8 @@ public sealed class TrainListener : BlockBase
|
|||||||
for (; seq < due; seq++)
|
for (; seq < due; seq++)
|
||||||
{
|
{
|
||||||
long sendUs = Env.Clock.NowUs();
|
long sendUs = Env.Clock.NowUs();
|
||||||
UdpPacket.Write(packet, new UdpHeader(UdpPacketType.Data, session.SessionId, Spec.StreamId, seq, sendUs), session.Key);
|
MeasurementDatagram.Write(packet, new MeasurementDatagramHeader(MeasurementDatagramType.Data, session.SessionId, Spec.StreamId, seq, sendUs), session.Key);
|
||||||
Env.Udp!.Send(target, packet);
|
Env.Datagrams!.Send(session, target, packet);
|
||||||
Span<long> row = [size, sendUs];
|
Span<long> row = [size, sendUs];
|
||||||
_records.SetRow(seq, row);
|
_records.SetRow(seq, row);
|
||||||
Interlocked.Exchange(ref _sent, seq + 1L);
|
Interlocked.Exchange(ref _sent, seq + 1L);
|
||||||
@@ -159,18 +159,19 @@ public sealed class TrainDialer : BlockBase
|
|||||||
|
|
||||||
public override async Task RunAsync(CancellationToken ct)
|
public override async Task RunAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
UdpMux udp = Env.Udp!;
|
QuicDatagramTransport datagrams = Env.Datagrams!;
|
||||||
DataSession session = Env.Session() ?? throw new BlockFaultException("no data session");
|
DataSession session = Env.Session() ?? throw new BlockFaultException("no data session");
|
||||||
|
await datagrams.WaitUntilConnectedAsync(ct);
|
||||||
_startUs = Env.Clock.NowUs();
|
_startUs = Env.Clock.NowUs();
|
||||||
udp.Register(Spec.StreamId, OnPacket);
|
datagrams.Register(Spec.StreamId, OnPacket);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
byte[] hello = new byte[UdpPacket.MinBytes + 20];
|
byte[] hello = new byte[MeasurementDatagram.MinBytes + 20];
|
||||||
IPEndPoint target = PeerEndpoint(Spec.Port);
|
IPEndPoint target = PeerEndpoint(Spec.Port);
|
||||||
for (uint seq = 0; seq < 3; seq++)
|
for (uint seq = 0; seq < 3; seq++)
|
||||||
{
|
{
|
||||||
UdpPacket.Write(hello, new UdpHeader(UdpPacketType.Data, session.SessionId, Spec.StreamId, seq, Env.Clock.NowUs()), session.Key);
|
MeasurementDatagram.Write(hello, new MeasurementDatagramHeader(MeasurementDatagramType.Data, session.SessionId, Spec.StreamId, seq, Env.Clock.NowUs()), session.Key);
|
||||||
udp.Send(target, hello);
|
datagrams.Send(session, target, hello);
|
||||||
}
|
}
|
||||||
|
|
||||||
await WaitUntilCancelledAsync(ct);
|
await WaitUntilCancelledAsync(ct);
|
||||||
@@ -178,7 +179,7 @@ public sealed class TrainDialer : BlockBase
|
|||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_endUs = Env.Clock.NowUs();
|
_endUs = Env.Clock.NowUs();
|
||||||
udp.Unregister(Spec.StreamId);
|
datagrams.Unregister(Spec.StreamId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,10 +229,10 @@ public sealed class TrainDialer : BlockBase
|
|||||||
private static System.Text.Json.JsonElement Number(long value) =>
|
private static System.Text.Json.JsonElement Number(long value) =>
|
||||||
System.Text.Json.JsonDocument.Parse(value.ToString(System.Globalization.CultureInfo.InvariantCulture)).RootElement.Clone();
|
System.Text.Json.JsonDocument.Parse(value.ToString(System.Globalization.CultureInfo.InvariantCulture)).RootElement.Clone();
|
||||||
|
|
||||||
private void OnPacket(in UdpDelivery delivery)
|
private void OnPacket(in QuicDatagramDelivery delivery)
|
||||||
{
|
{
|
||||||
long seq = delivery.Header.Seq;
|
long seq = delivery.Header.Seq;
|
||||||
if (delivery.Header.Type == UdpPacketType.Data && seq < _maxSeq && !_received.IsSet(seq, _tRecv))
|
if (delivery.Header.Type == MeasurementDatagramType.Data && seq < _maxSeq && !_received.IsSet(seq, _tRecv))
|
||||||
{
|
{
|
||||||
_received.Set(seq, _tRecv, delivery.ReceiveUs); // 只记第一次到达(重复的包不覆盖)
|
_received.Set(seq, _tRecv, delivery.ReceiveUs); // 只记第一次到达(重复的包不覆盖)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ namespace MoShiro.Measurement;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class DataSession
|
public class DataSession
|
||||||
{
|
{
|
||||||
|
private IDatagramConnection? _datagramConnection;
|
||||||
|
|
||||||
public DataSession(long sessionId, byte[] key)
|
public DataSession(long sessionId, byte[] key)
|
||||||
{
|
{
|
||||||
SessionId = sessionId;
|
SessionId = sessionId;
|
||||||
@@ -21,13 +23,26 @@ public class DataSession
|
|||||||
|
|
||||||
public byte[] Key { get; }
|
public byte[] Key { get; }
|
||||||
|
|
||||||
|
public IDatagramConnection? DatagramConnection => Volatile.Read(ref _datagramConnection);
|
||||||
|
|
||||||
|
public void BindDatagramConnection(IDatagramConnection connection) => Volatile.Write(ref _datagramConnection, connection);
|
||||||
|
|
||||||
/// <summary>对端最近一次通过认证的 UDP 源地址(监听方把单向数据发往这里;拨号方不需要)。</summary>
|
/// <summary>对端最近一次通过认证的 UDP 源地址(监听方把单向数据发往这里;拨号方不需要)。</summary>
|
||||||
public virtual IPEndPoint? UdpEndpoint => null;
|
public virtual IPEndPoint? DatagramRemoteEndPoint => null;
|
||||||
|
|
||||||
/// <summary>TCP 首帧的随机数只能用一次。拨号方不接受连接,所以默认放行。</summary>
|
/// <summary>TCP 首帧的随机数只能用一次。拨号方不接受连接,所以默认放行。</summary>
|
||||||
public virtual bool UseNonce(ReadOnlySpan<byte> nonce) => true;
|
public virtual bool UseNonce(ReadOnlySpan<byte> nonce) => true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Unreliable QUIC datagram sender bound to one authenticated measurement session.</summary>
|
||||||
|
public interface IDatagramConnection
|
||||||
|
{
|
||||||
|
bool Send(ReadOnlySpan<byte> datagram);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Transport-layer QUIC counters. These are separate from application Datagram delivery/loss.</summary>
|
||||||
|
public sealed record QuicTransportSnapshot(double? PacketLossRate, ulong PacketsSent, uint CongestionEvents, uint EcnCongestionEvents, ushort PathMtu);
|
||||||
|
|
||||||
public interface IDataSessions
|
public interface IDataSessions
|
||||||
{
|
{
|
||||||
DataSession? Find(long sessionId);
|
DataSession? Find(long sessionId);
|
||||||
@@ -60,7 +75,7 @@ public enum Admission
|
|||||||
public interface IAdmission
|
public interface IAdmission
|
||||||
{
|
{
|
||||||
/// <summary>UDP 包:<paramref name="source"/> 是认证通过的源地址(实现可以据此学习 NAT 重绑定)。</summary>
|
/// <summary>UDP 包:<paramref name="source"/> 是认证通过的源地址(实现可以据此学习 NAT 重绑定)。</summary>
|
||||||
Admission AdmitPacket(DataSession session, in UdpHeader header, SocketAddress source, long nowUs);
|
Admission AdmitDatagram(DataSession session, in MeasurementDatagramHeader header, SocketAddress source, long nowUs);
|
||||||
|
|
||||||
/// <summary>TCP 回显的一条消息(只限速)。</summary>
|
/// <summary>TCP 回显的一条消息(只限速)。</summary>
|
||||||
bool AdmitMessage(DataSession session, uint streamId, long nowUs);
|
bool AdmitMessage(DataSession session, uint streamId, long nowUs);
|
||||||
@@ -201,7 +216,7 @@ public sealed class BlockEnv
|
|||||||
/// <summary>当前的数据面会话(拨号方固定;监听方是 Agent 当前的 Probe 会话,可能没有)。</summary>
|
/// <summary>当前的数据面会话(拨号方固定;监听方是 Agent 当前的 Probe 会话,可能没有)。</summary>
|
||||||
public required Func<DataSession?> Session { get; init; }
|
public required Func<DataSession?> Session { get; init; }
|
||||||
|
|
||||||
public UdpMux? Udp { get; init; }
|
public QuicDatagramTransport? Datagrams { get; init; }
|
||||||
|
|
||||||
/// <summary>监听方的准入策略(限速等);拨号方为 null。</summary>
|
/// <summary>监听方的准入策略(限速等);拨号方为 null。</summary>
|
||||||
public IAdmission? Admission { get; init; }
|
public IAdmission? Admission { get; init; }
|
||||||
@@ -238,7 +253,7 @@ public sealed class BlockEnv
|
|||||||
Clock = Clock,
|
Clock = Clock,
|
||||||
Log = Log,
|
Log = Log,
|
||||||
Session = () => session,
|
Session = () => session,
|
||||||
Udp = Udp,
|
Datagrams = Datagrams,
|
||||||
Admission = Admission,
|
Admission = Admission,
|
||||||
Counters = Counters,
|
Counters = Counters,
|
||||||
TcpListen = TcpListen,
|
TcpListen = TcpListen,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ public sealed class NodeLimits
|
|||||||
public int MaxConcurrentTests { get; init; } = 1;
|
public int MaxConcurrentTests { get; init; } = 1;
|
||||||
|
|
||||||
/// <summary>任务参数里的 <c>port</c> 只能取这些端口(本机配置的 listen 列表)。</summary>
|
/// <summary>任务参数里的 <c>port</c> 只能取这些端口(本机配置的 listen 列表)。</summary>
|
||||||
public IReadOnlyList<int> AllowedUdpPorts { get; init; } = [7443];
|
public IReadOnlyList<int> AllowedQuicPorts { get; init; } = [7443];
|
||||||
|
|
||||||
public IReadOnlyList<int> AllowedTcpPorts { get; init; } = [7444];
|
public IReadOnlyList<int> AllowedTcpPorts { get; init; } = [7444];
|
||||||
}
|
}
|
||||||
@@ -143,7 +143,7 @@ public sealed class BudgetGuard
|
|||||||
}
|
}
|
||||||
|
|
||||||
int port = (int)values.GetInt("port");
|
int port = (int)values.GetInt("port");
|
||||||
return kind.Blocks.Transport == "udp" ? _limits.AllowedUdpPorts.Contains(port) : _limits.AllowedTcpPorts.Contains(port);
|
return kind.Blocks.Transport == "quic_datagram" ? _limits.AllowedQuicPorts.Contains(port) : _limits.AllowedTcpPorts.Contains(port);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static long EstimatePps(string kindId, ParamValues values) => kindId switch
|
private static long EstimatePps(string kindId, ParamValues values) => kindId switch
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using MoShiro.Protocol;
|
||||||
|
|
||||||
|
namespace MoShiro.Measurement;
|
||||||
|
|
||||||
|
/// <summary>An authenticated, per-stream QUIC Datagram delivered to a measurement block.</summary>
|
||||||
|
public readonly ref struct QuicDatagramDelivery
|
||||||
|
{
|
||||||
|
private readonly Span<byte> _payload;
|
||||||
|
private readonly MsQuicConnection _connection;
|
||||||
|
private readonly DataPlaneCounters _counters;
|
||||||
|
|
||||||
|
internal QuicDatagramDelivery(MeasurementDatagramHeader header, Span<byte> payload, long receiveUs, DataSession session, MsQuicConnection connection, DataPlaneCounters counters)
|
||||||
|
{
|
||||||
|
Header = header;
|
||||||
|
_payload = payload;
|
||||||
|
ReceiveUs = receiveUs;
|
||||||
|
Session = session;
|
||||||
|
_connection = connection;
|
||||||
|
_counters = counters;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MeasurementDatagramHeader Header { get; }
|
||||||
|
public ReadOnlySpan<byte> Payload => _payload;
|
||||||
|
public long ReceiveUs { get; }
|
||||||
|
public DataSession Session { get; }
|
||||||
|
|
||||||
|
public void ReplyAs(MeasurementDatagramType type)
|
||||||
|
{
|
||||||
|
_payload[3] = (byte)type;
|
||||||
|
MeasurementDatagram.Seal(_payload, Session.Key);
|
||||||
|
if (_connection.Send(_payload))
|
||||||
|
{
|
||||||
|
_counters.PacketOut();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public delegate void QuicDatagramHandler(in QuicDatagramDelivery delivery);
|
||||||
|
|
||||||
|
/// <summary>Authenticates datagrams, applies Agent admission policy, and dispatches by stream.</summary>
|
||||||
|
internal sealed class MeasurementDatagramRouter
|
||||||
|
{
|
||||||
|
private readonly IDataSessions _sessions;
|
||||||
|
private readonly IAdmission? _admission;
|
||||||
|
private readonly MonoClock _clock;
|
||||||
|
private readonly ConcurrentDictionary<uint, QuicDatagramHandler> _handlers = new();
|
||||||
|
|
||||||
|
public MeasurementDatagramRouter(IDataSessions sessions, IAdmission? admission, MonoClock clock, DataPlaneCounters counters)
|
||||||
|
{
|
||||||
|
_sessions = sessions;
|
||||||
|
_admission = admission;
|
||||||
|
_clock = clock;
|
||||||
|
Counters = counters;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DataPlaneCounters Counters { get; }
|
||||||
|
|
||||||
|
public void Register(uint streamId, QuicDatagramHandler handler) => _handlers[streamId] = handler;
|
||||||
|
|
||||||
|
public void Unregister(uint streamId) => _handlers.TryRemove(streamId, out _);
|
||||||
|
|
||||||
|
public void Dispatch(MsQuicConnection connection, ReadOnlySpan<byte> datagram)
|
||||||
|
{
|
||||||
|
Counters.PacketIn();
|
||||||
|
Span<byte> payload = stackalloc byte[MeasurementDatagram.MaxBytes];
|
||||||
|
if (datagram.Length > payload.Length)
|
||||||
|
{
|
||||||
|
Counters.DropInvalid();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
datagram.CopyTo(payload);
|
||||||
|
payload = payload[..datagram.Length];
|
||||||
|
if (!MeasurementDatagram.TryParseHeader(payload, out MeasurementDatagramHeader header)
|
||||||
|
|| !_handlers.TryGetValue(header.StreamId, out QuicDatagramHandler? handler)
|
||||||
|
|| _sessions.Find(header.SessionId) is not { } session
|
||||||
|
|| !MeasurementDatagram.VerifyMac(payload, session.Key))
|
||||||
|
{
|
||||||
|
Counters.DropInvalid();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IPEndPoint? remote = connection.RefreshRemoteEndPoint();
|
||||||
|
if (remote is null)
|
||||||
|
{
|
||||||
|
Counters.DropInvalid();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
long receiveUs = _clock.NowUs();
|
||||||
|
if (_admission is not null)
|
||||||
|
{
|
||||||
|
switch (_admission.AdmitDatagram(session, header, remote.Serialize(), receiveUs))
|
||||||
|
{
|
||||||
|
case Admission.Invalid:
|
||||||
|
Counters.DropInvalid();
|
||||||
|
return;
|
||||||
|
case Admission.RateLimited:
|
||||||
|
Counters.DropRateLimited();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session.BindDatagramConnection(connection);
|
||||||
|
handler(new QuicDatagramDelivery(header, payload, receiveUs, session, connection, Counters));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace MoShiro.Measurement;
|
||||||
|
|
||||||
|
/// <summary>Small, versioned view of the MsQuic v2 C API table used by this transport.</summary>
|
||||||
|
internal static unsafe class MsQuicApi
|
||||||
|
{
|
||||||
|
private const int SetContextIndex = 0;
|
||||||
|
private const int SetCallbackHandlerIndex = 2;
|
||||||
|
private const int SetParamIndex = 3;
|
||||||
|
private const int GetParamIndex = 4;
|
||||||
|
private const int RegistrationOpenIndex = 5;
|
||||||
|
private const int RegistrationCloseIndex = 6;
|
||||||
|
private const int ConfigurationOpenIndex = 8;
|
||||||
|
private const int ConfigurationCloseIndex = 9;
|
||||||
|
private const int ConfigurationLoadCredentialIndex = 10;
|
||||||
|
private const int ListenerOpenIndex = 11;
|
||||||
|
private const int ListenerCloseIndex = 12;
|
||||||
|
private const int ListenerStartIndex = 13;
|
||||||
|
private const int ListenerStopIndex = 14;
|
||||||
|
private const int ConnectionOpenIndex = 15;
|
||||||
|
private const int ConnectionCloseIndex = 16;
|
||||||
|
private const int ConnectionShutdownIndex = 17;
|
||||||
|
private const int ConnectionStartIndex = 18;
|
||||||
|
private const int ConnectionSetConfigurationIndex = 19;
|
||||||
|
private const int DatagramSendIndex = 28;
|
||||||
|
private const uint ConnectionStatisticsV2Parameter = 0x05000016;
|
||||||
|
|
||||||
|
private static readonly SetContextDelegate s_setContext = MsQuicRuntime.Function<SetContextDelegate>(SetContextIndex);
|
||||||
|
private static readonly SetCallbackHandlerDelegate s_setCallbackHandler = MsQuicRuntime.Function<SetCallbackHandlerDelegate>(SetCallbackHandlerIndex);
|
||||||
|
private static readonly SetParamDelegate s_setParam = MsQuicRuntime.Function<SetParamDelegate>(SetParamIndex);
|
||||||
|
private static readonly GetParamDelegate s_getParam = MsQuicRuntime.Function<GetParamDelegate>(GetParamIndex);
|
||||||
|
private static readonly RegistrationOpenDelegate s_registrationOpen = MsQuicRuntime.Function<RegistrationOpenDelegate>(RegistrationOpenIndex);
|
||||||
|
private static readonly HandleCloseDelegate s_registrationClose = MsQuicRuntime.Function<HandleCloseDelegate>(RegistrationCloseIndex);
|
||||||
|
private static readonly ConfigurationOpenDelegate s_configurationOpen = MsQuicRuntime.Function<ConfigurationOpenDelegate>(ConfigurationOpenIndex);
|
||||||
|
private static readonly HandleCloseDelegate s_configurationClose = MsQuicRuntime.Function<HandleCloseDelegate>(ConfigurationCloseIndex);
|
||||||
|
private static readonly ConfigurationLoadCredentialDelegate s_loadCredential = MsQuicRuntime.Function<ConfigurationLoadCredentialDelegate>(ConfigurationLoadCredentialIndex);
|
||||||
|
private static readonly ListenerOpenDelegate s_listenerOpen = MsQuicRuntime.Function<ListenerOpenDelegate>(ListenerOpenIndex);
|
||||||
|
private static readonly HandleCloseDelegate s_listenerClose = MsQuicRuntime.Function<HandleCloseDelegate>(ListenerCloseIndex);
|
||||||
|
private static readonly ListenerStartDelegate s_listenerStart = MsQuicRuntime.Function<ListenerStartDelegate>(ListenerStartIndex);
|
||||||
|
private static readonly ListenerStopDelegate s_listenerStop = MsQuicRuntime.Function<ListenerStopDelegate>(ListenerStopIndex);
|
||||||
|
private static readonly ConnectionOpenDelegate s_connectionOpen = MsQuicRuntime.Function<ConnectionOpenDelegate>(ConnectionOpenIndex);
|
||||||
|
private static readonly HandleCloseDelegate s_connectionClose = MsQuicRuntime.Function<HandleCloseDelegate>(ConnectionCloseIndex);
|
||||||
|
private static readonly ConnectionShutdownDelegate s_connectionShutdown = MsQuicRuntime.Function<ConnectionShutdownDelegate>(ConnectionShutdownIndex);
|
||||||
|
private static readonly ConnectionStartDelegate s_connectionStart = MsQuicRuntime.Function<ConnectionStartDelegate>(ConnectionStartIndex);
|
||||||
|
private static readonly ConnectionSetConfigurationDelegate s_connectionSetConfiguration = MsQuicRuntime.Function<ConnectionSetConfigurationDelegate>(ConnectionSetConfigurationIndex);
|
||||||
|
private static readonly DatagramSendDelegate s_datagramSend = MsQuicRuntime.Function<DatagramSendDelegate>(DatagramSendIndex);
|
||||||
|
|
||||||
|
public static void SetContext(nint handle, nint context) => s_setContext(handle, context);
|
||||||
|
|
||||||
|
public static void SetCallbackHandler<T>(nint handle, T callback, nint context)
|
||||||
|
where T : Delegate => s_setCallbackHandler(handle, Marshal.GetFunctionPointerForDelegate(callback), context);
|
||||||
|
|
||||||
|
public static void SetParam(nint handle, uint parameter, byte value)
|
||||||
|
{
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
uint status;
|
||||||
|
status = s_setParam(handle, parameter, sizeof(byte), &value);
|
||||||
|
Check(status, "SetParam");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] GetAddress(nint handle, uint parameter)
|
||||||
|
{
|
||||||
|
byte[] address = new byte[128];
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
fixed (byte* buffer = address)
|
||||||
|
{
|
||||||
|
uint length = (uint)address.Length;
|
||||||
|
uint status = s_getParam(handle, parameter, &length, buffer);
|
||||||
|
Check(status, "GetParam(address)");
|
||||||
|
Array.Resize(ref address, checked((int)length));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return address;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static QuicStatisticsV2 GetStatisticsV2(nint connection)
|
||||||
|
{
|
||||||
|
QuicStatisticsV2 statistics = default;
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
uint length = (uint)sizeof(QuicStatisticsV2);
|
||||||
|
uint status = s_getParam(connection, ConnectionStatisticsV2Parameter, &length, &statistics);
|
||||||
|
Check(status, "GetParam(connection statistics v2)");
|
||||||
|
}
|
||||||
|
|
||||||
|
return statistics;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static nint RegistrationOpen(string appName)
|
||||||
|
{
|
||||||
|
nint utf8 = Marshal.StringToCoTaskMemUTF8(appName);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var config = new RegistrationConfig { AppName = utf8, ExecutionProfile = 0 };
|
||||||
|
uint status = s_registrationOpen(ref config, out nint registration);
|
||||||
|
Check(status, "RegistrationOpen");
|
||||||
|
return registration;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Marshal.FreeCoTaskMem(utf8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void RegistrationClose(nint handle) => s_registrationClose(handle);
|
||||||
|
|
||||||
|
public static nint ConfigurationOpen(nint registration, byte[] alpn)
|
||||||
|
{
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
fixed (byte* alpnBytes = alpn)
|
||||||
|
{
|
||||||
|
var buffer = new QuicBuffer { Length = (uint)alpn.Length, Buffer = (nint)alpnBytes };
|
||||||
|
uint status = s_configurationOpen(registration, ref buffer, 1, 0, 0, 0, out nint configuration);
|
||||||
|
Check(status, "ConfigurationOpen");
|
||||||
|
return configuration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ConfigurationClose(nint handle) => s_configurationClose(handle);
|
||||||
|
|
||||||
|
public static void ConfigurationLoadCredential(nint configuration, ref CredentialConfig credential)
|
||||||
|
{
|
||||||
|
uint status = s_loadCredential(configuration, ref credential);
|
||||||
|
Check(status, "ConfigurationLoadCredential");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static nint ListenerOpen<T>(nint registration, T callback, nint context)
|
||||||
|
where T : Delegate
|
||||||
|
{
|
||||||
|
uint status = s_listenerOpen(registration, Marshal.GetFunctionPointerForDelegate(callback), context, out nint listener);
|
||||||
|
Check(status, "ListenerOpen");
|
||||||
|
return listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ListenerClose(nint handle) => s_listenerClose(handle);
|
||||||
|
|
||||||
|
public static void ListenerStart(nint listener, byte[] alpn, byte[] address)
|
||||||
|
{
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
fixed (byte* alpnBytes = alpn)
|
||||||
|
fixed (byte* addressBytes = address)
|
||||||
|
{
|
||||||
|
var buffer = new QuicBuffer { Length = (uint)alpn.Length, Buffer = (nint)alpnBytes };
|
||||||
|
uint status = s_listenerStart(listener, ref buffer, 1, (nint)addressBytes);
|
||||||
|
Check(status, "ListenerStart");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ListenerStop(nint handle) => s_listenerStop(handle);
|
||||||
|
|
||||||
|
public static nint ConnectionOpen<T>(nint registration, T callback, nint context)
|
||||||
|
where T : Delegate
|
||||||
|
{
|
||||||
|
uint status = s_connectionOpen(registration, Marshal.GetFunctionPointerForDelegate(callback), context, out nint connection);
|
||||||
|
Check(status, "ConnectionOpen");
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ConnectionClose(nint handle) => s_connectionClose(handle);
|
||||||
|
|
||||||
|
public static void ConnectionShutdown(nint handle, ulong errorCode = 0) => s_connectionShutdown(handle, 0x00000001, errorCode);
|
||||||
|
|
||||||
|
public static void ConnectionStart(nint handle, nint configuration, ushort addressFamily, nint serverName, ushort port)
|
||||||
|
{
|
||||||
|
uint status = s_connectionStart(handle, configuration, addressFamily, serverName, port);
|
||||||
|
Check(status, "ConnectionStart");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ConnectionBind(nint handle, byte[] address)
|
||||||
|
{
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
fixed (byte* addressBytes = address)
|
||||||
|
{
|
||||||
|
uint status = s_setParam(handle, 0x05000001, (uint)address.Length, addressBytes);
|
||||||
|
Check(status, "SetParam(local address)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ConnectionSetConfiguration(nint handle, nint configuration)
|
||||||
|
{
|
||||||
|
uint status = s_connectionSetConfiguration(handle, configuration);
|
||||||
|
Check(status, "ConnectionSetConfiguration");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static uint DatagramSend(nint connection, nint buffers, nint clientContext) => s_datagramSend(connection, buffers, 1, 0, clientContext);
|
||||||
|
|
||||||
|
|
||||||
|
public static void Check(uint status, string operation)
|
||||||
|
{
|
||||||
|
if (!Succeeded(status))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"MsQuic {operation} failed with status 0x{status:X8}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// QUIC_STATUS_PENDING (0xFFFFFFFE) is a successful asynchronous result. Other
|
||||||
|
// negative HRESULT/NTSTATUS values are failures on both Windows and POSIX.
|
||||||
|
public static bool Succeeded(uint status) => status == 0 || status == 0xFFFFFFFE;
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
internal struct QuicBuffer
|
||||||
|
{
|
||||||
|
public uint Length;
|
||||||
|
public nint Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MsQuic QUIC_STATISTICS_V2 through v2.5. All fields are fixed-width on
|
||||||
|
// the supported 64-bit RIDs; keeping the field order aligned with msquic.h.
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
internal struct QuicStatisticsV2
|
||||||
|
{
|
||||||
|
public ulong CorrelationId;
|
||||||
|
public uint Flags;
|
||||||
|
public uint RttUs;
|
||||||
|
public uint MinRttUs;
|
||||||
|
public uint MaxRttUs;
|
||||||
|
public ulong TimingStart;
|
||||||
|
public ulong TimingInitialFlightEnd;
|
||||||
|
public ulong TimingHandshakeFlightEnd;
|
||||||
|
public uint HandshakeClientFlight1Bytes;
|
||||||
|
public uint HandshakeServerFlight1Bytes;
|
||||||
|
public uint HandshakeClientFlight2Bytes;
|
||||||
|
public ushort SendPathMtu;
|
||||||
|
public ulong SendTotalPackets;
|
||||||
|
public ulong SendRetransmittablePackets;
|
||||||
|
public ulong SendSuspectedLostPackets;
|
||||||
|
public ulong SendSpuriousLostPackets;
|
||||||
|
public ulong SendTotalBytes;
|
||||||
|
public ulong SendTotalStreamBytes;
|
||||||
|
public uint SendCongestionCount;
|
||||||
|
public uint SendPersistentCongestionCount;
|
||||||
|
public ulong RecvTotalPackets;
|
||||||
|
public ulong RecvReorderedPackets;
|
||||||
|
public ulong RecvDroppedPackets;
|
||||||
|
public ulong RecvDuplicatePackets;
|
||||||
|
public ulong RecvTotalBytes;
|
||||||
|
public ulong RecvTotalStreamBytes;
|
||||||
|
public ulong RecvDecryptionFailures;
|
||||||
|
public ulong RecvValidAckFrames;
|
||||||
|
public uint KeyUpdateCount;
|
||||||
|
public uint SendCongestionWindow;
|
||||||
|
public uint DestCidUpdateCount;
|
||||||
|
public uint SendEcnCongestionCount;
|
||||||
|
public byte HandshakeHopLimitTtl;
|
||||||
|
public uint RttVarianceUs;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
internal struct RegistrationConfig
|
||||||
|
{
|
||||||
|
public nint AppName;
|
||||||
|
public int ExecutionProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
internal struct CredentialConfig
|
||||||
|
{
|
||||||
|
public uint Type;
|
||||||
|
public uint Flags;
|
||||||
|
public nint Certificate;
|
||||||
|
public nint Principal;
|
||||||
|
public nint Reserved;
|
||||||
|
public nint AsyncHandler;
|
||||||
|
public uint AllowedCipherSuites;
|
||||||
|
public nint CaCertificateFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
internal struct Pkcs12Certificate
|
||||||
|
{
|
||||||
|
public nint Asn1Blob;
|
||||||
|
public uint Asn1BlobLength;
|
||||||
|
public nint PrivateKeyPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||||
|
internal delegate uint ListenerCallback(nint listener, nint context, nint @event);
|
||||||
|
|
||||||
|
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||||
|
internal delegate uint ConnectionCallback(nint connection, nint context, nint @event);
|
||||||
|
|
||||||
|
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||||
|
private delegate void SetContextDelegate(nint handle, nint context);
|
||||||
|
|
||||||
|
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||||
|
private delegate void SetCallbackHandlerDelegate(nint handle, nint handler, nint context);
|
||||||
|
|
||||||
|
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||||
|
private delegate uint SetParamDelegate(nint handle, uint parameter, uint bufferLength, void* buffer);
|
||||||
|
|
||||||
|
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||||
|
private delegate uint GetParamDelegate(nint handle, uint parameter, uint* bufferLength, void* buffer);
|
||||||
|
|
||||||
|
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||||
|
private delegate uint RegistrationOpenDelegate(ref RegistrationConfig config, out nint registration);
|
||||||
|
|
||||||
|
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||||
|
private delegate void HandleCloseDelegate(nint handle);
|
||||||
|
|
||||||
|
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||||
|
private delegate uint ConfigurationOpenDelegate(nint registration, ref QuicBuffer alpn, uint alpnCount, nint settings, uint settingsLength, nint context, out nint configuration);
|
||||||
|
|
||||||
|
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||||
|
private delegate uint ConfigurationLoadCredentialDelegate(nint configuration, ref CredentialConfig credential);
|
||||||
|
|
||||||
|
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||||
|
private delegate uint ListenerOpenDelegate(nint registration, nint callback, nint context, out nint listener);
|
||||||
|
|
||||||
|
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||||
|
private delegate uint ListenerStartDelegate(nint listener, ref QuicBuffer alpn, uint alpnCount, nint address);
|
||||||
|
|
||||||
|
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||||
|
private delegate void ListenerStopDelegate(nint listener);
|
||||||
|
|
||||||
|
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||||
|
private delegate uint ConnectionOpenDelegate(nint registration, nint callback, nint context, out nint connection);
|
||||||
|
|
||||||
|
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||||
|
private delegate void ConnectionShutdownDelegate(nint connection, uint flags, ulong errorCode);
|
||||||
|
|
||||||
|
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||||
|
private delegate uint ConnectionStartDelegate(nint connection, nint configuration, ushort family, nint serverName, ushort serverPort);
|
||||||
|
|
||||||
|
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||||
|
private delegate uint ConnectionSetConfigurationDelegate(nint connection, nint configuration);
|
||||||
|
|
||||||
|
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||||
|
private delegate uint DatagramSendDelegate(nint connection, nint buffers, uint bufferCount, uint flags, nint clientContext);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,610 @@
|
|||||||
|
using System.Buffers;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using Fleet.Core;
|
||||||
|
|
||||||
|
namespace MoShiro.Measurement;
|
||||||
|
|
||||||
|
internal delegate void MsQuicDatagramCallback(MsQuicConnection connection, ReadOnlySpan<byte> datagram);
|
||||||
|
|
||||||
|
/// <summary>A single MsQuic connection with connection-bound datagram delivery.</summary>
|
||||||
|
internal sealed class MsQuicConnection : IAsyncDisposable, IDatagramConnection
|
||||||
|
{
|
||||||
|
private const uint DatagramReceiveEnabled = 0x0500000D;
|
||||||
|
private const uint ClientCredentialFlags = 0x00000001 | 0x00000004 | 0x00000010 | 0x00004000;
|
||||||
|
private const uint CertificateCredentialNone = 0;
|
||||||
|
private const uint CertificateCredentialContext = 3;
|
||||||
|
private const uint CertificateCredentialPkcs12 = 6;
|
||||||
|
private const uint ConnectionEventConnected = 0;
|
||||||
|
private const uint ConnectionEventShutdownByTransport = 1;
|
||||||
|
private const uint ConnectionEventShutdownByPeer = 2;
|
||||||
|
private const uint ConnectionEventShutdownComplete = 3;
|
||||||
|
private const uint ConnectionEventPeerAddressChanged = 5;
|
||||||
|
private const uint ConnectionEventDatagramStateChanged = 10;
|
||||||
|
private const uint ConnectionEventDatagramReceived = 11;
|
||||||
|
private const uint ConnectionEventDatagramSendStateChanged = 12;
|
||||||
|
private const uint ConnectionEventPeerCertificateReceived = 15;
|
||||||
|
private const int DatagramStateFinal = 3;
|
||||||
|
private const int QuicAddressBytes = 28;
|
||||||
|
private static readonly MsQuicApi.ConnectionCallback s_callback = OnEvent;
|
||||||
|
|
||||||
|
private readonly object _sendGate = new();
|
||||||
|
private readonly TaskCompletionSource _connected = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
private readonly TaskCompletionSource _shutdown = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
private readonly GCHandle _self;
|
||||||
|
private readonly byte[]? _expectedPeerSpki;
|
||||||
|
private readonly Action<MsQuicConnection>? _closed;
|
||||||
|
private readonly Action<IPEndPoint>? _peerAddressChanged;
|
||||||
|
private readonly INodeLog _log;
|
||||||
|
private readonly Dictionary<nint, SendState> _sends = [];
|
||||||
|
private nint _registration;
|
||||||
|
private nint _configuration;
|
||||||
|
private nint _handle;
|
||||||
|
private int _maxDatagramBytes;
|
||||||
|
private int _sendEnabled;
|
||||||
|
private int _disposeStarted;
|
||||||
|
private double? _handshakeDurationMs;
|
||||||
|
|
||||||
|
private MsQuicConnection(
|
||||||
|
nint handle,
|
||||||
|
nint registration,
|
||||||
|
nint configuration,
|
||||||
|
byte[]? expectedPeerSpki,
|
||||||
|
MsQuicDatagramCallback? datagramHandler,
|
||||||
|
INodeLog log,
|
||||||
|
Action<MsQuicConnection>? closed,
|
||||||
|
Action<IPEndPoint>? peerAddressChanged)
|
||||||
|
{
|
||||||
|
_handle = handle;
|
||||||
|
_registration = registration;
|
||||||
|
_configuration = configuration;
|
||||||
|
_expectedPeerSpki = expectedPeerSpki;
|
||||||
|
DatagramHandler = datagramHandler;
|
||||||
|
_log = log;
|
||||||
|
_closed = closed;
|
||||||
|
_peerAddressChanged = peerAddressChanged;
|
||||||
|
_self = GCHandle.Alloc(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public MsQuicDatagramCallback? DatagramHandler { get; set; }
|
||||||
|
|
||||||
|
public IPEndPoint? RemoteEndPoint { get; private set; }
|
||||||
|
|
||||||
|
public IPEndPoint? LocalEndPoint => Volatile.Read(ref _handle) is nint handle && handle != 0
|
||||||
|
? ReadEndpoint(MsQuicApi.GetAddress(handle, 0x05000001))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
public nint Handle => Volatile.Read(ref _handle);
|
||||||
|
|
||||||
|
public IPEndPoint? RefreshRemoteEndPoint()
|
||||||
|
{
|
||||||
|
if (RemoteEndPoint is not null || Handle == 0)
|
||||||
|
{
|
||||||
|
return RemoteEndPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
RemoteEndPoint = ReadEndpoint(MsQuicApi.GetAddress(Handle, 0x05000002));
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
// Address isn't available until the handshake finishes.
|
||||||
|
}
|
||||||
|
|
||||||
|
return RemoteEndPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsReady => _connected.Task.IsCompletedSuccessfully && Volatile.Read(ref _sendEnabled) != 0;
|
||||||
|
|
||||||
|
public int MaxDatagramBytes => Volatile.Read(ref _maxDatagramBytes);
|
||||||
|
|
||||||
|
public double? HandshakeDurationMs => _handshakeDurationMs;
|
||||||
|
|
||||||
|
public bool TryGetStatistics(out MsQuicApi.QuicStatisticsV2 statistics)
|
||||||
|
{
|
||||||
|
nint handle = Volatile.Read(ref _handle);
|
||||||
|
if (handle == 0)
|
||||||
|
{
|
||||||
|
statistics = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
statistics = MsQuicApi.GetStatisticsV2(handle);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
statistics = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<MsQuicConnection> ConnectAsync(
|
||||||
|
IPAddress address,
|
||||||
|
int port,
|
||||||
|
int localPort,
|
||||||
|
byte[] expectedPeerSpki,
|
||||||
|
MsQuicDatagramCallback datagramHandler,
|
||||||
|
INodeLog log,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
nint registration = 0;
|
||||||
|
nint configuration = 0;
|
||||||
|
MsQuicConnection? connection = null;
|
||||||
|
long handshakeStarted = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
log.Info("opening MsQuic client registration");
|
||||||
|
registration = MsQuicApi.RegistrationOpen("moshiro-probe");
|
||||||
|
log.Info("opening MsQuic client configuration");
|
||||||
|
configuration = MsQuicApi.ConfigurationOpen(registration, QuicAlpn.Value);
|
||||||
|
log.Info("loading MsQuic client credentials");
|
||||||
|
LoadClientCredentials(configuration);
|
||||||
|
var endpoint = new IPEndPoint(address.AddressFamily == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, localPort);
|
||||||
|
SocketAddress serialized = endpoint.Serialize();
|
||||||
|
byte[] localAddress = new byte[QuicAddressBytes];
|
||||||
|
for (int i = 0; i < serialized.Size; i++)
|
||||||
|
{
|
||||||
|
localAddress[i] = serialized[i];
|
||||||
|
}
|
||||||
|
ushort nativeFamily = ReadAddressFamily(localAddress);
|
||||||
|
nint name = Marshal.StringToCoTaskMemUTF8(address.ToString());
|
||||||
|
try
|
||||||
|
{
|
||||||
|
nint handle = MsQuicApi.ConnectionOpen(registration, s_callback, 0);
|
||||||
|
log.Info("opened MsQuic client connection");
|
||||||
|
connection = new MsQuicConnection(handle, registration, configuration, expectedPeerSpki, datagramHandler, log, null, null);
|
||||||
|
registration = 0;
|
||||||
|
configuration = 0;
|
||||||
|
MsQuicApi.SetContext(handle, GCHandle.ToIntPtr(connection._self));
|
||||||
|
log.Info("enabling MsQuic datagram receive");
|
||||||
|
MsQuicApi.SetParam(handle, DatagramReceiveEnabled, 1);
|
||||||
|
log.Info("binding MsQuic local address", ("local_port", localPort));
|
||||||
|
MsQuicApi.ConnectionBind(handle, localAddress);
|
||||||
|
log.Info("starting MsQuic QUIC handshake", ("remote", address), ("port", port), ("local_port", localPort));
|
||||||
|
handshakeStarted = Stopwatch.GetTimestamp();
|
||||||
|
MsQuicApi.ConnectionStart(handle, connection._configuration, nativeFamily, name, checked((ushort)port));
|
||||||
|
log.Info("MsQuic connection start returned");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Marshal.FreeCoTaskMem(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
await connection._connected.Task.WaitAsync(TimeSpan.FromSeconds(10), ct);
|
||||||
|
connection._handshakeDurationMs = Stopwatch.GetElapsedTime(handshakeStarted).TotalMilliseconds;
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
if (connection is not null)
|
||||||
|
{
|
||||||
|
await connection.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configuration != 0)
|
||||||
|
{
|
||||||
|
MsQuicApi.ConfigurationClose(configuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registration != 0)
|
||||||
|
{
|
||||||
|
MsQuicApi.RegistrationClose(registration);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MsQuicConnection Accept(
|
||||||
|
nint handle,
|
||||||
|
nint configuration,
|
||||||
|
MsQuicDatagramCallback datagramHandler,
|
||||||
|
INodeLog log,
|
||||||
|
Action<MsQuicConnection> closed,
|
||||||
|
Action<IPEndPoint>? peerAddressChanged)
|
||||||
|
{
|
||||||
|
var connection = new MsQuicConnection(handle, 0, 0, null, datagramHandler, log, closed, peerAddressChanged);
|
||||||
|
MsQuicApi.SetContext(handle, GCHandle.ToIntPtr(connection._self));
|
||||||
|
MsQuicApi.SetCallbackHandler(handle, s_callback, GCHandle.ToIntPtr(connection._self));
|
||||||
|
MsQuicApi.ConnectionSetConfiguration(handle, configuration);
|
||||||
|
MsQuicApi.SetParam(handle, DatagramReceiveEnabled, 1);
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Send(ReadOnlySpan<byte> datagram)
|
||||||
|
{
|
||||||
|
nint handle = Volatile.Read(ref _handle);
|
||||||
|
if (handle == 0 || !IsReady || datagram.Length > MaxDatagramBytes)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] buffer = ArrayPool<byte>.Shared.Rent(datagram.Length);
|
||||||
|
datagram.CopyTo(buffer);
|
||||||
|
GCHandle pinned = GCHandle.Alloc(buffer, GCHandleType.Pinned);
|
||||||
|
nint nativeBuffer = Marshal.AllocHGlobal(Marshal.SizeOf<MsQuicApi.QuicBuffer>());
|
||||||
|
var state = new SendState(buffer, pinned, nativeBuffer);
|
||||||
|
GCHandle stateHandle = GCHandle.Alloc(state);
|
||||||
|
nint context = GCHandle.ToIntPtr(stateHandle);
|
||||||
|
lock (_sendGate)
|
||||||
|
{
|
||||||
|
if (_handle == 0)
|
||||||
|
{
|
||||||
|
stateHandle.Free();
|
||||||
|
state.Release();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_sends.Add(context, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
var nativeBufferValue = new MsQuicApi.QuicBuffer
|
||||||
|
{
|
||||||
|
Length = (uint)datagram.Length,
|
||||||
|
Buffer = pinned.AddrOfPinnedObject(),
|
||||||
|
};
|
||||||
|
Marshal.StructureToPtr(nativeBufferValue, nativeBuffer, false);
|
||||||
|
uint status = MsQuicApi.DatagramSend(handle, nativeBuffer, context);
|
||||||
|
if (MsQuicApi.Succeeded(status))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ReleaseSend(context);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (Interlocked.Exchange(ref _disposeStarted, 1) == 0 && Volatile.Read(ref _handle) is nint handle && handle != 0)
|
||||||
|
{
|
||||||
|
MsQuicApi.ConnectionShutdown(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Volatile.Read(ref _handle) != 0)
|
||||||
|
{
|
||||||
|
await _shutdown.Task.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task WaitForShutdownAsync() => _shutdown.Task;
|
||||||
|
|
||||||
|
private static void LoadClientCredentials(nint configuration)
|
||||||
|
{
|
||||||
|
var credential = new MsQuicApi.CredentialConfig { Type = CertificateCredentialNone, Flags = ClientCredentialFlags };
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
credential.Flags &= ~0x00004000u;
|
||||||
|
}
|
||||||
|
|
||||||
|
MsQuicApi.ConfigurationLoadCredential(configuration, ref credential);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static void LoadServerCredentials(nint configuration, X509Certificate2 certificate)
|
||||||
|
{
|
||||||
|
var credential = new MsQuicApi.CredentialConfig();
|
||||||
|
GCHandle pfxPin = default;
|
||||||
|
nint pkcs12 = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
credential.Type = CertificateCredentialContext;
|
||||||
|
credential.Certificate = certificate.Handle;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
byte[] pfxBytes = certificate.Export(X509ContentType.Pkcs12);
|
||||||
|
pfxPin = GCHandle.Alloc(pfxBytes, GCHandleType.Pinned);
|
||||||
|
var pfxCertificate = new MsQuicApi.Pkcs12Certificate
|
||||||
|
{
|
||||||
|
Asn1Blob = pfxPin.AddrOfPinnedObject(),
|
||||||
|
Asn1BlobLength = (uint)pfxBytes.Length,
|
||||||
|
};
|
||||||
|
pkcs12 = Marshal.AllocHGlobal(Marshal.SizeOf<MsQuicApi.Pkcs12Certificate>());
|
||||||
|
Marshal.StructureToPtr(pfxCertificate, pkcs12, false);
|
||||||
|
credential.Type = CertificateCredentialPkcs12;
|
||||||
|
credential.Flags = 0x00004000;
|
||||||
|
credential.Certificate = pkcs12;
|
||||||
|
}
|
||||||
|
|
||||||
|
MsQuicApi.ConfigurationLoadCredential(configuration, ref credential);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (pkcs12 != 0)
|
||||||
|
{
|
||||||
|
Marshal.FreeHGlobal(pkcs12);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pfxPin.IsAllocated)
|
||||||
|
{
|
||||||
|
pfxPin.Free();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint OnEvent(nint handle, nint context, nint eventPointer)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var connection = (MsQuicConnection)GCHandle.FromIntPtr(context).Target!;
|
||||||
|
uint eventType = unchecked((uint)Marshal.ReadInt32(eventPointer));
|
||||||
|
switch (eventType)
|
||||||
|
{
|
||||||
|
case ConnectionEventConnected:
|
||||||
|
connection._log.Info("MsQuic QUIC handshake completed");
|
||||||
|
connection.RefreshRemoteEndPoint();
|
||||||
|
connection._connected.TrySetResult();
|
||||||
|
break;
|
||||||
|
case ConnectionEventShutdownByTransport:
|
||||||
|
case ConnectionEventShutdownByPeer:
|
||||||
|
connection._connected.TrySetException(new IOException("QUIC connection closed during handshake."));
|
||||||
|
break;
|
||||||
|
case ConnectionEventShutdownComplete:
|
||||||
|
connection.OnShutdownComplete(handle);
|
||||||
|
break;
|
||||||
|
case ConnectionEventPeerAddressChanged:
|
||||||
|
{
|
||||||
|
nint address = Marshal.ReadIntPtr(eventPointer, IntPtr.Size);
|
||||||
|
IPEndPoint? endpoint = ReadEndpoint(address);
|
||||||
|
if (endpoint is not null)
|
||||||
|
{
|
||||||
|
connection.RemoteEndPoint = endpoint;
|
||||||
|
connection._peerAddressChanged?.Invoke(endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ConnectionEventDatagramStateChanged:
|
||||||
|
connection.OnDatagramStateChanged(eventPointer);
|
||||||
|
break;
|
||||||
|
case ConnectionEventDatagramReceived:
|
||||||
|
connection.OnDatagramReceived(eventPointer);
|
||||||
|
break;
|
||||||
|
case ConnectionEventDatagramSendStateChanged:
|
||||||
|
connection.OnDatagramSendStateChanged(eventPointer);
|
||||||
|
break;
|
||||||
|
case ConnectionEventPeerCertificateReceived:
|
||||||
|
return connection.ValidatePeerCertificate(eventPointer);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (context != 0 && GCHandle.FromIntPtr(context).Target is MsQuicConnection connection)
|
||||||
|
{
|
||||||
|
connection._log.Error("MsQuic connection callback failed", ("err", ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Keep exceptions inside the native callback boundary.
|
||||||
|
}
|
||||||
|
|
||||||
|
return OperatingSystem.IsWindows() ? 0x80004005u : 1u;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDatagramStateChanged(nint eventPointer)
|
||||||
|
{
|
||||||
|
byte sendEnabled = Marshal.ReadByte(eventPointer, IntPtr.Size);
|
||||||
|
int maxSendLength = (ushort)Marshal.ReadInt16(eventPointer, IntPtr.Size + 1);
|
||||||
|
Volatile.Write(ref _maxDatagramBytes, maxSendLength);
|
||||||
|
Volatile.Write(ref _sendEnabled, sendEnabled == 0 ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDatagramReceived(nint eventPointer)
|
||||||
|
{
|
||||||
|
nint bufferPointer = Marshal.ReadIntPtr(eventPointer, IntPtr.Size);
|
||||||
|
if (bufferPointer == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var buffer = Marshal.PtrToStructure<MsQuicApi.QuicBuffer>(bufferPointer);
|
||||||
|
if (buffer.Buffer != 0 && buffer.Length > 0 && buffer.Length <= 65535)
|
||||||
|
{
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
DatagramHandler?.Invoke(this, new ReadOnlySpan<byte>((void*)buffer.Buffer, checked((int)buffer.Length)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDatagramSendStateChanged(nint eventPointer)
|
||||||
|
{
|
||||||
|
nint context = Marshal.ReadIntPtr(eventPointer, IntPtr.Size);
|
||||||
|
int state = Marshal.ReadInt32(eventPointer, IntPtr.Size * 2);
|
||||||
|
if (state >= DatagramStateFinal)
|
||||||
|
{
|
||||||
|
ReleaseSend(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private uint ValidatePeerCertificate(nint eventPointer)
|
||||||
|
{
|
||||||
|
nint certificate = Marshal.ReadIntPtr(eventPointer, IntPtr.Size);
|
||||||
|
if (certificate == 0 || _expectedPeerSpki is null)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] der;
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
nint encoded = Marshal.ReadIntPtr(certificate, IntPtr.Size);
|
||||||
|
int length = Marshal.ReadInt32(certificate, IntPtr.Size * 2);
|
||||||
|
if (encoded == 0 || length <= 0 || length > 64 * 1024)
|
||||||
|
{
|
||||||
|
return 0x80004005u;
|
||||||
|
}
|
||||||
|
|
||||||
|
der = new byte[length];
|
||||||
|
Marshal.Copy(encoded, der, 0, length);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var nativeBuffer = Marshal.PtrToStructure<MsQuicApi.QuicBuffer>(certificate);
|
||||||
|
if (nativeBuffer.Buffer == 0 || nativeBuffer.Length == 0 || nativeBuffer.Length > 64 * 1024)
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
der = new byte[checked((int)nativeBuffer.Length)];
|
||||||
|
Marshal.Copy(nativeBuffer.Buffer, der, 0, der.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
using X509Certificate2 peerCertificate = X509CertificateLoader.LoadCertificate(der);
|
||||||
|
bool valid = ExportSubjectPublicKeyInfo(peerCertificate).AsSpan().SequenceEqual(_expectedPeerSpki);
|
||||||
|
return valid ? 0 : OperatingSystem.IsWindows() ? 0x80004005u : 1u;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] ExportSubjectPublicKeyInfo(X509Certificate2 certificate)
|
||||||
|
{
|
||||||
|
using ECDsa? ecdsa = certificate.GetECDsaPublicKey();
|
||||||
|
if (ecdsa is not null)
|
||||||
|
{
|
||||||
|
return ecdsa.ExportSubjectPublicKeyInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
using RSA? rsa = certificate.GetRSAPublicKey();
|
||||||
|
if (rsa is not null)
|
||||||
|
{
|
||||||
|
return rsa.ExportSubjectPublicKeyInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
using DSA? dsa = certificate.GetDSAPublicKey();
|
||||||
|
if (dsa is not null)
|
||||||
|
{
|
||||||
|
return dsa.ExportSubjectPublicKeyInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReleaseSend(nint context)
|
||||||
|
{
|
||||||
|
SendState? state;
|
||||||
|
lock (_sendGate)
|
||||||
|
{
|
||||||
|
if (!_sends.Remove(context, out state))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (GCHandle.FromIntPtr(context).IsAllocated)
|
||||||
|
{
|
||||||
|
GCHandle.FromIntPtr(context).Free();
|
||||||
|
}
|
||||||
|
|
||||||
|
state.Release();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnShutdownComplete(nint handle)
|
||||||
|
{
|
||||||
|
if (Interlocked.Exchange(ref _handle, 0) == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MsQuicApi.ConnectionClose(handle);
|
||||||
|
lock (_sendGate)
|
||||||
|
{
|
||||||
|
foreach ((nint context, SendState state) in _sends)
|
||||||
|
{
|
||||||
|
GCHandle.FromIntPtr(context).Free();
|
||||||
|
state.Release();
|
||||||
|
}
|
||||||
|
|
||||||
|
_sends.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_configuration != 0)
|
||||||
|
{
|
||||||
|
MsQuicApi.ConfigurationClose(_configuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_registration != 0)
|
||||||
|
{
|
||||||
|
MsQuicApi.RegistrationClose(_registration);
|
||||||
|
}
|
||||||
|
|
||||||
|
_self.Free();
|
||||||
|
_shutdown.TrySetResult();
|
||||||
|
_closed?.Invoke(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IPEndPoint? ReadEndpoint(nint address)
|
||||||
|
{
|
||||||
|
if (address == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
ushort family = unchecked((ushort)Marshal.ReadInt16(address));
|
||||||
|
if (family == 2)
|
||||||
|
{
|
||||||
|
byte[] bytes = new byte[4];
|
||||||
|
Marshal.Copy(address + 4, bytes, 0, bytes.Length);
|
||||||
|
int port = (Marshal.ReadByte(address, 2) << 8) | Marshal.ReadByte(address, 3);
|
||||||
|
return new IPEndPoint(new IPAddress(bytes), port);
|
||||||
|
}
|
||||||
|
|
||||||
|
ushort ipv6Family = OperatingSystem.IsWindows() ? (ushort)23 : OperatingSystem.IsMacOS() ? (ushort)30 : (ushort)10;
|
||||||
|
if (family == ipv6Family)
|
||||||
|
{
|
||||||
|
byte[] bytes = new byte[16];
|
||||||
|
Marshal.Copy(address + 8, bytes, 0, bytes.Length);
|
||||||
|
int port = (Marshal.ReadByte(address, 2) << 8) | Marshal.ReadByte(address, 3);
|
||||||
|
long scope = unchecked((uint)Marshal.ReadInt32(address, 24));
|
||||||
|
return new IPEndPoint(new IPAddress(bytes, scope), port);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IPEndPoint? ReadEndpoint(byte[] address)
|
||||||
|
{
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
fixed (byte* addressBytes = address)
|
||||||
|
{
|
||||||
|
return ReadEndpoint((nint)addressBytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ushort ReadAddressFamily(byte[] address) => (ushort)(address[0] | (address[1] << 8));
|
||||||
|
|
||||||
|
private sealed class SendState(byte[] buffer, GCHandle pin, nint nativeBuffer)
|
||||||
|
{
|
||||||
|
public void Release()
|
||||||
|
{
|
||||||
|
if (pin.IsAllocated)
|
||||||
|
{
|
||||||
|
pin.Free();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nativeBuffer != 0)
|
||||||
|
{
|
||||||
|
Marshal.FreeHGlobal(nativeBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
ArrayPool<byte>.Shared.Return(buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using Fleet.Core;
|
||||||
|
|
||||||
|
namespace MoShiro.Measurement;
|
||||||
|
|
||||||
|
/// <summary>Agent-side MsQuic listener. Measurement frames arrive only as QUIC DATAGRAM frames.</summary>
|
||||||
|
internal sealed class MsQuicListener : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private const int QuicAddressBytes = 28;
|
||||||
|
private const uint ListenerEventNewConnection = 0;
|
||||||
|
private const uint ListenerLocalAddress = 0x04000000;
|
||||||
|
private static readonly MsQuicApi.ListenerCallback s_callback = OnListenerEvent;
|
||||||
|
|
||||||
|
private readonly IPAddress _bindAddress;
|
||||||
|
private readonly IReadOnlyList<int> _ports;
|
||||||
|
private readonly MsQuicDatagramCallback _datagramHandler;
|
||||||
|
private readonly INodeLog _log;
|
||||||
|
private readonly Action<IPEndPoint>? _peerAddressChanged;
|
||||||
|
private readonly X509Certificate2 _certificate;
|
||||||
|
private readonly GCHandle _self;
|
||||||
|
private readonly ConcurrentDictionary<nint, MsQuicConnection> _connections = new();
|
||||||
|
private readonly List<nint> _listeners = [];
|
||||||
|
private nint _registration;
|
||||||
|
private nint _configuration;
|
||||||
|
private int _disposed;
|
||||||
|
|
||||||
|
public MsQuicListener(
|
||||||
|
IPAddress bindAddress,
|
||||||
|
IReadOnlyList<int> ports,
|
||||||
|
X509Certificate2 certificate,
|
||||||
|
MsQuicDatagramCallback datagramHandler,
|
||||||
|
INodeLog log,
|
||||||
|
Action<IPEndPoint>? peerAddressChanged)
|
||||||
|
{
|
||||||
|
_bindAddress = bindAddress;
|
||||||
|
_ports = ports.Count == 0 ? [7443] : ports;
|
||||||
|
_certificate = certificate;
|
||||||
|
_datagramHandler = datagramHandler;
|
||||||
|
_log = log;
|
||||||
|
_peerAddressChanged = peerAddressChanged;
|
||||||
|
_self = GCHandle.Alloc(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<int> BoundPorts { get; private set; } = [];
|
||||||
|
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
if (_registration != 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("MsQuic listener is already started.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_registration = MsQuicApi.RegistrationOpen("moshiro-agent");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_configuration = MsQuicApi.ConfigurationOpen(_registration, QuicAlpn.Value);
|
||||||
|
MsQuicConnection.LoadServerCredentials(_configuration, _certificate);
|
||||||
|
var bound = new List<int>(_ports.Count);
|
||||||
|
foreach (int port in _ports)
|
||||||
|
{
|
||||||
|
nint listener = MsQuicApi.ListenerOpen(_registration, s_callback, GCHandle.ToIntPtr(_self));
|
||||||
|
_listeners.Add(listener);
|
||||||
|
byte[] address = Serialize(new IPEndPoint(_bindAddress, port));
|
||||||
|
MsQuicApi.ListenerStart(listener, QuicAlpn.Value, address);
|
||||||
|
bound.Add(ReadPort(MsQuicApi.GetAddress(listener, ListenerLocalAddress)));
|
||||||
|
}
|
||||||
|
|
||||||
|
BoundPorts = bound;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
foreach (nint listener in _listeners)
|
||||||
|
{
|
||||||
|
MsQuicApi.ListenerStop(listener);
|
||||||
|
MsQuicApi.ListenerClose(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
_listeners.Clear();
|
||||||
|
DisposeHandles();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (nint listener in _listeners)
|
||||||
|
{
|
||||||
|
MsQuicApi.ListenerStop(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (nint listener in _listeners)
|
||||||
|
{
|
||||||
|
MsQuicApi.ListenerClose(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
_listeners.Clear();
|
||||||
|
MsQuicConnection[] connections = [.. _connections.Values];
|
||||||
|
await Task.WhenAll(connections.Select(c => c.DisposeAsync().AsTask())).ConfigureAwait(false);
|
||||||
|
DisposeHandles();
|
||||||
|
_self.Free();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint OnListenerEvent(nint listener, nint context, nint @event)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var owner = (MsQuicListener)GCHandle.FromIntPtr(context).Target!;
|
||||||
|
uint eventType = unchecked((uint)Marshal.ReadInt32(@event));
|
||||||
|
if (eventType != ListenerEventNewConnection || Volatile.Read(ref owner._disposed) != 0)
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
nint connectionHandle = Marshal.ReadIntPtr(@event, IntPtr.Size * 2);
|
||||||
|
owner._log.Info("accepted MsQuic QUIC connection");
|
||||||
|
MsQuicConnection? connection = null;
|
||||||
|
connection = MsQuicConnection.Accept(
|
||||||
|
connectionHandle,
|
||||||
|
owner._configuration,
|
||||||
|
owner._datagramHandler,
|
||||||
|
owner._log,
|
||||||
|
closed => owner._connections.TryRemove(connectionHandle, out _),
|
||||||
|
owner._peerAddressChanged);
|
||||||
|
if (!owner._connections.TryAdd(connectionHandle, connection))
|
||||||
|
{
|
||||||
|
_ = connection.DisposeAsync().AsTask();
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (context != 0 && GCHandle.FromIntPtr(context).Target is MsQuicListener owner)
|
||||||
|
{
|
||||||
|
owner._log.Error("MsQuic listener callback failed", ("err", ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Keep exceptions inside the native callback boundary.
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DisposeHandles()
|
||||||
|
{
|
||||||
|
if (_configuration != 0)
|
||||||
|
{
|
||||||
|
MsQuicApi.ConfigurationClose(_configuration);
|
||||||
|
_configuration = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_registration != 0)
|
||||||
|
{
|
||||||
|
MsQuicApi.RegistrationClose(_registration);
|
||||||
|
_registration = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] Serialize(IPEndPoint endpoint)
|
||||||
|
{
|
||||||
|
SocketAddress source = endpoint.Serialize();
|
||||||
|
byte[] address = new byte[QuicAddressBytes];
|
||||||
|
for (int i = 0; i < source.Size; i++)
|
||||||
|
{
|
||||||
|
address[i] = source[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
return address;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ReadPort(byte[] address)
|
||||||
|
{
|
||||||
|
if (address.Length < 4)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("MsQuic returned a truncated bound address.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (address[2] << 8) | address[3];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace MoShiro.Measurement;
|
||||||
|
|
||||||
|
/// <summary>Loads the MsQuic v2 ABI used by the QUIC datagram transport.</summary>
|
||||||
|
internal static class MsQuicRuntime
|
||||||
|
{
|
||||||
|
private const uint ApiVersion = 2;
|
||||||
|
private static readonly object s_gate = new();
|
||||||
|
private static nint s_library;
|
||||||
|
private static nint s_api;
|
||||||
|
private static OpenVersionDelegate? s_open;
|
||||||
|
private static CloseDelegate? s_close;
|
||||||
|
private static readonly List<nint> s_supportLibraries = [];
|
||||||
|
|
||||||
|
public static nint Api
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (s_gate)
|
||||||
|
{
|
||||||
|
if (s_api != 0)
|
||||||
|
{
|
||||||
|
return s_api;
|
||||||
|
}
|
||||||
|
|
||||||
|
string[] candidates = OperatingSystem.IsWindows()
|
||||||
|
? ["msquic.dll"]
|
||||||
|
: OperatingSystem.IsMacOS()
|
||||||
|
? ["libmsquic.dylib"]
|
||||||
|
: ["libmsquic.so.2", "libmsquic.so"];
|
||||||
|
LoadBundledSupportLibraries();
|
||||||
|
string appDirectory = AppContext.BaseDirectory;
|
||||||
|
foreach (string candidate in candidates)
|
||||||
|
{
|
||||||
|
if (NativeLibrary.TryLoad(Path.Combine(appDirectory, candidate), out s_library)
|
||||||
|
|| NativeLibrary.TryLoad(candidate, out s_library))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s_library == 0)
|
||||||
|
{
|
||||||
|
throw new DllNotFoundException($"MsQuic runtime was not found. Expected one of: {string.Join(", ", candidates)}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
s_open = Marshal.GetDelegateForFunctionPointer<OpenVersionDelegate>(NativeLibrary.GetExport(s_library, "MsQuicOpenVersion"));
|
||||||
|
s_close = Marshal.GetDelegateForFunctionPointer<CloseDelegate>(NativeLibrary.GetExport(s_library, "MsQuicClose"));
|
||||||
|
int status = s_open(ApiVersion, out s_api);
|
||||||
|
if (status != 0 || s_api == 0)
|
||||||
|
{
|
||||||
|
NativeLibrary.Free(s_library);
|
||||||
|
s_library = 0;
|
||||||
|
s_api = 0;
|
||||||
|
throw new InvalidOperationException($"MsQuic API v{ApiVersion} could not be opened (status {status}).");
|
||||||
|
}
|
||||||
|
|
||||||
|
return s_api;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Close()
|
||||||
|
{
|
||||||
|
lock (s_gate)
|
||||||
|
{
|
||||||
|
if (s_api != 0)
|
||||||
|
{
|
||||||
|
s_close!(s_api);
|
||||||
|
s_api = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s_library != 0)
|
||||||
|
{
|
||||||
|
NativeLibrary.Free(s_library);
|
||||||
|
s_library = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = s_supportLibraries.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
NativeLibrary.Free(s_supportLibraries[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
s_supportLibraries.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void LoadBundledSupportLibraries()
|
||||||
|
{
|
||||||
|
if (OperatingSystem.IsLinux())
|
||||||
|
{
|
||||||
|
TryLoadSupport("libnuma.so.1");
|
||||||
|
}
|
||||||
|
else if (OperatingSystem.IsMacOS())
|
||||||
|
{
|
||||||
|
TryLoadSupport("libcrypto.3.dylib");
|
||||||
|
TryLoadSupport("libssl.3.dylib");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryLoadSupport(string library)
|
||||||
|
{
|
||||||
|
if (NativeLibrary.TryLoad(Path.Combine(AppContext.BaseDirectory, library), out nint handle))
|
||||||
|
{
|
||||||
|
s_supportLibraries.Add(handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static T Function<T>(int index)
|
||||||
|
where T : Delegate => Marshal.GetDelegateForFunctionPointer<T>(Marshal.ReadIntPtr(Api, checked(index * IntPtr.Size)));
|
||||||
|
|
||||||
|
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||||
|
private delegate int OpenVersionDelegate(uint version, out nint api);
|
||||||
|
|
||||||
|
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||||
|
private delegate void CloseDelegate(nint api);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace MoShiro.Measurement;
|
||||||
|
|
||||||
|
/// <summary>The application protocol negotiated by the Agent and Probe QUIC endpoints.</summary>
|
||||||
|
internal static class QuicAlpn
|
||||||
|
{
|
||||||
|
internal static readonly byte[] Value = "h3"u8.ToArray();
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using Fleet.Core;
|
||||||
|
|
||||||
|
namespace MoShiro.Measurement;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Measurement data transport. Agent instances listen on configured QUIC UDP ports; Probe instances
|
||||||
|
/// open one QUIC connection from a newly selected ephemeral source port. Measurement frames are sent
|
||||||
|
/// only with MsQuic DATAGRAM calls; the existing TLS peer connection continues to carry session control.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class QuicDatagramTransport : IDisposable
|
||||||
|
{
|
||||||
|
private readonly MeasurementDatagramRouter _router;
|
||||||
|
private readonly INodeLog _log;
|
||||||
|
private readonly MsQuicDatagramCallback _onDatagram;
|
||||||
|
private readonly IPAddress _bind;
|
||||||
|
private readonly IReadOnlyList<int> _ports;
|
||||||
|
private readonly X509Certificate2? _serverCertificate;
|
||||||
|
private readonly DataSession? _clientSession;
|
||||||
|
private readonly IPEndPoint? _remote;
|
||||||
|
private readonly byte[]? _peerSpki;
|
||||||
|
private readonly int _previousSourcePort;
|
||||||
|
private readonly TaskCompletionSource _clientReady = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
private MsQuicListener? _listener;
|
||||||
|
private MsQuicConnection? _client;
|
||||||
|
private int _disposed;
|
||||||
|
|
||||||
|
/// <summary>Constructs an Agent-side QUIC listener.</summary>
|
||||||
|
public QuicDatagramTransport(IDataSessions sessions, IAdmission? admission, MonoClock clock, DataPlaneCounters counters, INodeLog log, IPAddress bind, IReadOnlyList<int> ports, X509Certificate2 serverCertificate)
|
||||||
|
{
|
||||||
|
_router = new MeasurementDatagramRouter(sessions, admission, clock, counters);
|
||||||
|
_log = log;
|
||||||
|
_onDatagram = _router.Dispatch;
|
||||||
|
_bind = bind;
|
||||||
|
_ports = ports;
|
||||||
|
_serverCertificate = serverCertificate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Constructs a Probe-side QUIC dialer for one Agent session.</summary>
|
||||||
|
public QuicDatagramTransport(DataSession session, MonoClock clock, DataPlaneCounters counters, INodeLog log, IPEndPoint remote, byte[] peerSpki, int previousSourcePort)
|
||||||
|
{
|
||||||
|
_router = new MeasurementDatagramRouter(new SingleSession(session), null, clock, counters);
|
||||||
|
_log = log;
|
||||||
|
_onDatagram = _router.Dispatch;
|
||||||
|
_bind = remote.AddressFamily == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any;
|
||||||
|
_ports = [];
|
||||||
|
_clientSession = session;
|
||||||
|
_remote = remote;
|
||||||
|
_peerSpki = peerSpki;
|
||||||
|
_previousSourcePort = previousSourcePort;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<int> BoundPorts { get; private set; } = [];
|
||||||
|
|
||||||
|
public DataPlaneCounters Counters => _router.Counters;
|
||||||
|
|
||||||
|
public bool IsConnected => _client?.IsReady == true;
|
||||||
|
|
||||||
|
public double? HandshakeDurationMs => _client?.HandshakeDurationMs;
|
||||||
|
|
||||||
|
public QuicTransportSnapshot? GetTransportSnapshot()
|
||||||
|
{
|
||||||
|
if (_client is null || !_client.TryGetStatistics(out MsQuicApi.QuicStatisticsV2 stats))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
ulong lost = stats.SendSuspectedLostPackets >= stats.SendSpuriousLostPackets
|
||||||
|
? stats.SendSuspectedLostPackets - stats.SendSpuriousLostPackets
|
||||||
|
: 0;
|
||||||
|
double? packetLossRate = stats.SendTotalPackets > 0 ? lost * 100.0 / stats.SendTotalPackets : null;
|
||||||
|
return new QuicTransportSnapshot(packetLossRate, stats.SendTotalPackets, stats.SendCongestionCount, stats.SendEcnCongestionCount, stats.SendPathMtu);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Bind()
|
||||||
|
{
|
||||||
|
if (_remote is not null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_listener is not null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("QUIC listener is already bound.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_listener = new MsQuicListener(_bind, _ports, _serverCertificate!, _onDatagram, _log, null);
|
||||||
|
_listener.Start();
|
||||||
|
BoundPorts = _listener.BoundPorts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ConnectAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (_remote is null || _clientSession is null || _peerSpki is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Only Probe-side QUIC transports can connect.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int sourcePort = SelectSourcePort(_remote.AddressFamily, _previousSourcePort);
|
||||||
|
_client = await MsQuicConnection.ConnectAsync(
|
||||||
|
_remote.Address,
|
||||||
|
_remote.Port,
|
||||||
|
sourcePort,
|
||||||
|
_peerSpki,
|
||||||
|
_onDatagram,
|
||||||
|
_log,
|
||||||
|
ct);
|
||||||
|
_clientSession.BindDatagramConnection(_client);
|
||||||
|
BoundPorts = _client.LocalEndPoint is { } local ? [local.Port] : [sourcePort];
|
||||||
|
_clientReady.TrySetResult();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.Error("QUIC client connection failed", ("err", ex.Message));
|
||||||
|
_clientReady.TrySetException(ex);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task WaitUntilConnectedAsync(CancellationToken ct) => _remote is null ? Task.CompletedTask : _clientReady.Task.WaitAsync(ct);
|
||||||
|
|
||||||
|
public void Register(uint streamId, QuicDatagramHandler handler) => _router.Register(streamId, handler);
|
||||||
|
|
||||||
|
public void Unregister(uint streamId) => _router.Unregister(streamId);
|
||||||
|
|
||||||
|
/// <summary>Sends one measurement datagram on the connection for the supplied session.</summary>
|
||||||
|
public void Send(DataSession session, EndPoint target, ReadOnlySpan<byte> packet)
|
||||||
|
{
|
||||||
|
_ = target; // A QUIC connection owns its path; the Agent learns it from authenticated datagrams.
|
||||||
|
if (session.DatagramConnection?.Send(packet) == true)
|
||||||
|
{
|
||||||
|
_router.Counters.PacketOut();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RunAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (_remote is not null)
|
||||||
|
{
|
||||||
|
await _clientReady.Task.WaitAsync(ct);
|
||||||
|
await _client!.WaitForShutdownAsync().WaitAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(Timeout.InfiniteTimeSpan, ct);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// Listener lifetime is controlled by AgentApp.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = DisposeNativeResourcesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DisposeNativeResourcesAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_client is not null)
|
||||||
|
{
|
||||||
|
await _client.DisposeAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_listener is not null)
|
||||||
|
{
|
||||||
|
await _listener.DisposeAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or InvalidOperationException or ObjectDisposedException)
|
||||||
|
{
|
||||||
|
_log.Error("QUIC datagram transport shutdown failed", ("err", ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int SelectSourcePort(AddressFamily family, int previousPort)
|
||||||
|
{
|
||||||
|
IPAddress local = family == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any;
|
||||||
|
for (int attempt = 0; attempt < 8; attempt++)
|
||||||
|
{
|
||||||
|
using var socket = new Socket(family, SocketType.Dgram, ProtocolType.Udp);
|
||||||
|
socket.Bind(new IPEndPoint(local, 0));
|
||||||
|
int port = ((IPEndPoint)socket.LocalEndPoint!).Port;
|
||||||
|
if (port != previousPort)
|
||||||
|
{
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new SocketException((int)SocketError.AddressAlreadyInUse);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Net;
|
|
||||||
using System.Net.Sockets;
|
|
||||||
using Fleet.Core;
|
|
||||||
using MoShiro.Protocol;
|
|
||||||
|
|
||||||
namespace MoShiro.Measurement;
|
|
||||||
|
|
||||||
/// <summary>一个通过校验的 UDP 包,交给按 <c>stream_id</c> 登记的处理函数。处理函数在接收循环里同步执行,不能阻塞。</summary>
|
|
||||||
public readonly ref struct UdpDelivery
|
|
||||||
{
|
|
||||||
private readonly Span<byte> _packet;
|
|
||||||
private readonly Socket _socket;
|
|
||||||
private readonly SocketAddress _source;
|
|
||||||
private readonly DataPlaneCounters _counters;
|
|
||||||
|
|
||||||
internal UdpDelivery(UdpHeader header, Span<byte> packet, long receiveUs, DataSession session, Socket socket, SocketAddress source, DataPlaneCounters counters)
|
|
||||||
{
|
|
||||||
Header = header;
|
|
||||||
_packet = packet;
|
|
||||||
ReceiveUs = receiveUs;
|
|
||||||
Session = session;
|
|
||||||
_socket = socket;
|
|
||||||
_source = source;
|
|
||||||
_counters = counters;
|
|
||||||
}
|
|
||||||
|
|
||||||
public UdpHeader Header { get; }
|
|
||||||
|
|
||||||
public ReadOnlySpan<byte> Packet => _packet;
|
|
||||||
|
|
||||||
/// <summary>包到达的时刻(本节点的单调时钟,微秒):从接收返回的那一刻取,不含校验的耗时。</summary>
|
|
||||||
public long ReceiveUs { get; }
|
|
||||||
|
|
||||||
public DataSession Session { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 原地回复:同样的长度,只把 type 改成 <paramref name="type"/> 并重新计算 MAC,发回请求的实际源地址。
|
|
||||||
/// 回复与请求等长,所以监听方不能被当成放大器。发送失败(源地址已经不可达)不抛出。
|
|
||||||
/// </summary>
|
|
||||||
public void ReplyAs(UdpPacketType type)
|
|
||||||
{
|
|
||||||
_packet[3] = (byte)type;
|
|
||||||
UdpPacket.Seal(_packet, Session.Key);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_socket.SendTo(_packet, SocketFlags.None, _source);
|
|
||||||
_counters.PacketOut();
|
|
||||||
}
|
|
||||||
catch (SocketException)
|
|
||||||
{
|
|
||||||
// 回复丢失由对端的缺失体现
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public delegate void UdpHandler(in UdpDelivery delivery);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// UDP 数据面:一个或多个套接字,按 <c>stream_id</c> 把包分发给积木。校验顺序:长度、magic、version、type →
|
|
||||||
/// 流和会话都存在 → MAC → (监听方)<see cref="IAdmission"/>(重放窗口、限速)。任何一步失败都静默丢弃并计数,不回复。
|
|
||||||
/// 接收循环只做校验和分发,不分配对象。两端共用这一份代码:Agent 监听多个端口并带准入策略,Probe 每个 Agent 一个临时端口。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class UdpMux : IDisposable
|
|
||||||
{
|
|
||||||
private const int WindowsUdpConnReset = -1744830452; // SIO_UDP_CONNRESET
|
|
||||||
|
|
||||||
private readonly IDataSessions _sessions;
|
|
||||||
private readonly IAdmission? _admission;
|
|
||||||
private readonly MonoClock _clock;
|
|
||||||
private readonly DataPlaneCounters _counters;
|
|
||||||
private readonly INodeLog _log;
|
|
||||||
private readonly IPAddress _bind;
|
|
||||||
private readonly IReadOnlyList<int> _ports;
|
|
||||||
private readonly List<Socket> _sockets = [];
|
|
||||||
private readonly ConcurrentDictionary<uint, UdpHandler> _handlers = new();
|
|
||||||
|
|
||||||
/// <param name="ports">要监听的端口(0 表示由系统分配);空表示一个临时端口。</param>
|
|
||||||
public UdpMux(IDataSessions sessions, IAdmission? admission, MonoClock clock, DataPlaneCounters counters, INodeLog log, IPAddress bind, IReadOnlyList<int> ports)
|
|
||||||
{
|
|
||||||
_sessions = sessions;
|
|
||||||
_admission = admission;
|
|
||||||
_clock = clock;
|
|
||||||
_counters = counters;
|
|
||||||
_log = log;
|
|
||||||
_bind = bind;
|
|
||||||
_ports = ports.Count == 0 ? [0] : ports;
|
|
||||||
}
|
|
||||||
|
|
||||||
public IReadOnlyList<int> BoundPorts { get; private set; } = [];
|
|
||||||
|
|
||||||
public DataPlaneCounters Counters => _counters;
|
|
||||||
|
|
||||||
/// <summary>绑定套接字(端口被占用等错误在这里抛出)。</summary>
|
|
||||||
public void Bind()
|
|
||||||
{
|
|
||||||
var bound = new List<int>();
|
|
||||||
foreach (int port in _ports)
|
|
||||||
{
|
|
||||||
var socket = new Socket(_bind.AddressFamily, SocketType.Dgram, ProtocolType.Udp) { ReceiveBufferSize = 4 << 20, SendBufferSize = 4 << 20 };
|
|
||||||
if (_bind.AddressFamily == AddressFamily.InterNetworkV6)
|
|
||||||
{
|
|
||||||
socket.DualMode = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (OperatingSystem.IsWindows())
|
|
||||||
{
|
|
||||||
// Windows 上对一个已关闭的端口发送之后,下一次接收会抛 ConnectionReset:关掉这个行为
|
|
||||||
socket.IOControl(WindowsUdpConnReset, [0], null);
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.Bind(new IPEndPoint(_bind, port));
|
|
||||||
bound.Add(((IPEndPoint)socket.LocalEndPoint!).Port);
|
|
||||||
_sockets.Add(socket);
|
|
||||||
}
|
|
||||||
|
|
||||||
BoundPorts = bound;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Register(uint streamId, UdpHandler handler) => _handlers[streamId] = handler;
|
|
||||||
|
|
||||||
public void Unregister(uint streamId) => _handlers.TryRemove(streamId, out _);
|
|
||||||
|
|
||||||
/// <summary>从第一个套接字发出一个已经封好的包。发送失败(没有路由、缓冲区满)不抛出:丢包本身就是测量结果。</summary>
|
|
||||||
public void Send(EndPoint to, ReadOnlySpan<byte> packet)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_sockets[0].SendTo(packet, SocketFlags.None, to);
|
|
||||||
_counters.PacketOut();
|
|
||||||
}
|
|
||||||
catch (SocketException)
|
|
||||||
{
|
|
||||||
// 记为丢失
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task RunAsync(CancellationToken ct)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Task.WhenAll(_sockets.Select(s => ReceiveLoopAsync(s, ct)));
|
|
||||||
}
|
|
||||||
catch (ObjectDisposedException)
|
|
||||||
{
|
|
||||||
// 套接字已释放:停止
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
foreach (Socket socket in _sockets)
|
|
||||||
{
|
|
||||||
socket.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ReceiveLoopAsync(Socket socket, CancellationToken ct)
|
|
||||||
{
|
|
||||||
byte[] buffer = new byte[UdpPacket.MaxBytes + 64];
|
|
||||||
var source = new SocketAddress(socket.AddressFamily);
|
|
||||||
while (!ct.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
int length;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
length = await socket.ReceiveFromAsync(buffer.AsMemory(), SocketFlags.None, source, ct);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
catch (ObjectDisposedException)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
catch (SocketException ex)
|
|
||||||
{
|
|
||||||
// 个别错误(例如对端的 ICMP 不可达)不能让接收循环退出
|
|
||||||
_log.Warn("udp receive failed", ("err", ex.SocketErrorCode));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
Dispatch(socket, buffer, length, source, _clock.NowUs());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void Dispatch(Socket socket, byte[] buffer, int length, SocketAddress source, long receiveUs)
|
|
||||||
{
|
|
||||||
_counters.PacketIn();
|
|
||||||
Span<byte> packet = buffer.AsSpan(0, length);
|
|
||||||
if (!UdpPacket.TryParseHeader(packet, out UdpHeader header)
|
|
||||||
|| !_handlers.TryGetValue(header.StreamId, out UdpHandler? handler)
|
|
||||||
|| _sessions.Find(header.SessionId) is not { } session
|
|
||||||
|| !UdpPacket.VerifyMac(packet, session.Key))
|
|
||||||
{
|
|
||||||
_counters.DropInvalid();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_admission is not null)
|
|
||||||
{
|
|
||||||
switch (_admission.AdmitPacket(session, header, source, receiveUs))
|
|
||||||
{
|
|
||||||
case Admission.Invalid:
|
|
||||||
_counters.DropInvalid();
|
|
||||||
return;
|
|
||||||
case Admission.RateLimited:
|
|
||||||
_counters.DropRateLimited();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handler(new UdpDelivery(header, packet, receiveUs, session, socket, source, _counters));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -27,10 +27,10 @@ public static class Kinds
|
|||||||
|
|
||||||
public static readonly KindSpec UdpEcho = new(
|
public static readonly KindSpec UdpEcho = new(
|
||||||
"udp_echo",
|
"udp_echo",
|
||||||
"UDP echo",
|
"QUIC Datagram echo",
|
||||||
"Fixed-rate UDP echo with four timestamps per packet.",
|
"Fixed-rate QUIC Datagram echo with four timestamps per packet.",
|
||||||
Scope.Line,
|
Scope.Line,
|
||||||
new BlockSet("udp", "fixed", "fixed", "echo", []),
|
new BlockSet("quic_datagram", "fixed", "fixed", "echo", []),
|
||||||
[
|
[
|
||||||
P.Int("rate_hz", 10, 1, 200, "Hz"),
|
P.Int("rate_hz", 10, 1, 200, "Hz"),
|
||||||
P.Int("size", 64, 44, 1400, "B"),
|
P.Int("size", 64, 44, 1400, "B"),
|
||||||
@@ -41,10 +41,10 @@ public static class Kinds
|
|||||||
|
|
||||||
public static readonly KindSpec UdpTrain = new(
|
public static readonly KindSpec UdpTrain = new(
|
||||||
"udp_train",
|
"udp_train",
|
||||||
"UDP train",
|
"QUIC Datagram train",
|
||||||
"Fixed-rate or laddered one-way UDP stream (Agent to Probe) for loss and jitter under load.",
|
"Fixed-rate or laddered one-way QUIC Datagram stream (Agent to Probe) for loss and delay variation under load.",
|
||||||
Scope.Line,
|
Scope.Line,
|
||||||
new BlockSet("udp", "ladder", "fixed", "sink", []),
|
new BlockSet("quic_datagram", "ladder", "fixed", "sink", []),
|
||||||
[
|
[
|
||||||
P.Number("mbps", 10, 0.1, 1000, "Mbit/s"),
|
P.Number("mbps", 10, 0.1, 1000, "Mbit/s"),
|
||||||
P.Int("size", 1250, 200, 1400, "B"),
|
P.Int("size", 1250, 200, 1400, "B"),
|
||||||
|
|||||||
@@ -146,8 +146,11 @@ public sealed class AgentEndpoint
|
|||||||
[JsonPropertyName("tcp_port")]
|
[JsonPropertyName("tcp_port")]
|
||||||
public int TcpPort { get; set; }
|
public int TcpPort { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("quic_ports")]
|
||||||
|
public List<int> QuicPorts { get; set; } = [];
|
||||||
|
|
||||||
[JsonPropertyName("udp_ports")]
|
[JsonPropertyName("udp_ports")]
|
||||||
public List<int> UdpPorts { get; set; } = [];
|
public List<int> LegacyUdpPorts { get; set; } = [];
|
||||||
|
|
||||||
/// <summary>Agent 面向 Probe 的 TLS 证书公钥(SPKI,base64):证书按它的 SHA-256 指纹固定,敲门密钥由它和 Probe 的身份密钥派生。</summary>
|
/// <summary>Agent 面向 Probe 的 TLS 证书公钥(SPKI,base64):证书按它的 SHA-256 指纹固定,敲门密钥由它和 Probe 的身份密钥派生。</summary>
|
||||||
[JsonPropertyName("public_key")]
|
[JsonPropertyName("public_key")]
|
||||||
@@ -175,8 +178,11 @@ public sealed class AgentEnrollMeta
|
|||||||
[JsonPropertyName("probe_tcp_port")]
|
[JsonPropertyName("probe_tcp_port")]
|
||||||
public int ProbeTcpPort { get; set; } = 7443;
|
public int ProbeTcpPort { get; set; } = 7443;
|
||||||
|
|
||||||
|
[JsonPropertyName("probe_quic_ports")]
|
||||||
|
public List<int> ProbeQuicPorts { get; set; } = [];
|
||||||
|
|
||||||
[JsonPropertyName("probe_udp_ports")]
|
[JsonPropertyName("probe_udp_ports")]
|
||||||
public List<int> ProbeUdpPorts { get; set; } = [7443];
|
public List<int>? LegacyProbeUdpPorts { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("public_host")]
|
[JsonPropertyName("public_host")]
|
||||||
public string? PublicHost { get; set; }
|
public string? PublicHost { get; set; }
|
||||||
@@ -296,6 +302,9 @@ public sealed class UpdateStatus
|
|||||||
/// <summary>Probe 的 <c>fleet.heartbeat</c> 附加字段:更新器的状态。</summary>
|
/// <summary>Probe 的 <c>fleet.heartbeat</c> 附加字段:更新器的状态。</summary>
|
||||||
public sealed class ProbeHeartbeatExtra
|
public sealed class ProbeHeartbeatExtra
|
||||||
{
|
{
|
||||||
|
[JsonPropertyName("quic_links")]
|
||||||
|
public List<ProbeQuicLinkHealth>? QuicLinks { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("ipv6")]
|
[JsonPropertyName("ipv6")]
|
||||||
public Ipv6Diagnostic? Ipv6 { get; set; }
|
public Ipv6Diagnostic? Ipv6 { get; set; }
|
||||||
|
|
||||||
@@ -303,6 +312,53 @@ public sealed class ProbeHeartbeatExtra
|
|||||||
public UpdateStatus? Update { get; set; }
|
public UpdateStatus? Update { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Probe's rolling QUIC connection health snapshot for one Agent/IP path.</summary>
|
||||||
|
public sealed class ProbeQuicLinkHealth
|
||||||
|
{
|
||||||
|
[JsonPropertyName("agent_id")]
|
||||||
|
public string AgentId { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("ip_version")]
|
||||||
|
public string IpVersion { get; set; } = IpVersions.V4;
|
||||||
|
|
||||||
|
[JsonPropertyName("connected")]
|
||||||
|
public bool Connected { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("handshake_attempts")]
|
||||||
|
public long HandshakeAttempts { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("handshake_successes")]
|
||||||
|
public long HandshakeSuccesses { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("handshake_failures")]
|
||||||
|
public long HandshakeFailures { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("handshake_timeouts")]
|
||||||
|
public long HandshakeTimeouts { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("handshake_p95_ms")]
|
||||||
|
public double? HandshakeP95Ms { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("handshake_sample_count")]
|
||||||
|
public long HandshakeSampleCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>QUIC packet-loss estimate from MsQuic's non-spurious loss counters; not Datagram application loss or retransmission.</summary>
|
||||||
|
[JsonPropertyName("transport_packet_loss_rate")]
|
||||||
|
public double? TransportPacketLossRate { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("transport_packets_sent")]
|
||||||
|
public ulong? TransportPacketsSent { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("transport_congestion_events")]
|
||||||
|
public uint? TransportCongestionEvents { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("transport_ecn_congestion_events")]
|
||||||
|
public uint? TransportEcnCongestionEvents { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("path_mtu")]
|
||||||
|
public ushort? PathMtu { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Probe 的 <c>fleet.hello</c> 附加字段:系统、架构和构建信息(只用于展示和更新)。</summary>
|
/// <summary>Probe 的 <c>fleet.hello</c> 附加字段:系统、架构和构建信息(只用于展示和更新)。</summary>
|
||||||
public sealed class ProbeHelloMeta
|
public sealed class ProbeHelloMeta
|
||||||
{
|
{
|
||||||
@@ -345,8 +401,11 @@ public sealed class AgentHelloMeta
|
|||||||
[JsonPropertyName("probe_tcp_port")]
|
[JsonPropertyName("probe_tcp_port")]
|
||||||
public int? ProbeTcpPort { get; set; }
|
public int? ProbeTcpPort { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("probe_quic_ports")]
|
||||||
|
public List<int>? ProbeQuicPorts { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("probe_udp_ports")]
|
[JsonPropertyName("probe_udp_ports")]
|
||||||
public List<int>? ProbeUdpPorts { get; set; }
|
public List<int>? LegacyProbeUdpPorts { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("local_limits")]
|
[JsonPropertyName("local_limits")]
|
||||||
public LocalLimits? LocalLimits { get; set; }
|
public LocalLimits? LocalLimits { get; set; }
|
||||||
@@ -482,8 +541,11 @@ public sealed class LineTaskSet
|
|||||||
public List<LineTask> Tasks { get; set; } = [];
|
public List<LineTask> Tasks { get; set; } = [];
|
||||||
|
|
||||||
/// <summary>Agent 的数据面端口(Probe 把包发到这里)。</summary>
|
/// <summary>Agent 的数据面端口(Probe 把包发到这里)。</summary>
|
||||||
|
[JsonPropertyName("quic_ports")]
|
||||||
|
public List<int> QuicPorts { get; set; } = [];
|
||||||
|
|
||||||
[JsonPropertyName("udp_ports")]
|
[JsonPropertyName("udp_ports")]
|
||||||
public List<int> UdpPorts { get; set; } = [];
|
public List<int> LegacyUdpPorts { get; set; } = [];
|
||||||
|
|
||||||
[JsonPropertyName("tcp_ports")]
|
[JsonPropertyName("tcp_ports")]
|
||||||
public List<int> TcpPorts { get; set; } = [];
|
public List<int> TcpPorts { get; set; } = [];
|
||||||
|
|||||||
+8
-8
@@ -3,7 +3,7 @@ using System.Security.Cryptography;
|
|||||||
|
|
||||||
namespace MoShiro.Protocol;
|
namespace MoShiro.Protocol;
|
||||||
|
|
||||||
public enum UdpPacketType : byte
|
public enum MeasurementDatagramType : byte
|
||||||
{
|
{
|
||||||
EchoRequest = 1,
|
EchoRequest = 1,
|
||||||
EchoReply = 2,
|
EchoReply = 2,
|
||||||
@@ -12,7 +12,7 @@ public enum UdpPacketType : byte
|
|||||||
Data = 3,
|
Data = 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
public readonly record struct UdpHeader(UdpPacketType Type, long SessionId, uint StreamId, uint Seq, long SendTsUs);
|
public readonly record struct MeasurementDatagramHeader(MeasurementDatagramType Type, long SessionId, uint StreamId, uint Seq, long SendTsUs);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 数据面的 UDP 包(Fleet 之外的线格式):
|
/// 数据面的 UDP 包(Fleet 之外的线格式):
|
||||||
@@ -20,7 +20,7 @@ public readonly record struct UdpHeader(UdpPacketType Type, long SessionId, uint
|
|||||||
/// mac = HMAC-SHA256(session_key, 前面全部字节) 截断到 16 字节。所有整数小端。
|
/// mac = HMAC-SHA256(session_key, 前面全部字节) 截断到 16 字节。所有整数小端。
|
||||||
/// 接收方对不合法的包静默丢弃并计数,不回复(防放大)。
|
/// 接收方对不合法的包静默丢弃并计数,不回复(防放大)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class UdpPacket
|
public static class MeasurementDatagram
|
||||||
{
|
{
|
||||||
public const ushort Magic = 0x4D4E; // "MN"
|
public const ushort Magic = 0x4D4E; // "MN"
|
||||||
public const byte Version = 1;
|
public const byte Version = 1;
|
||||||
@@ -30,19 +30,19 @@ public static class UdpPacket
|
|||||||
public const int MaxBytes = 1472;
|
public const int MaxBytes = 1472;
|
||||||
|
|
||||||
/// <summary>检查长度、magic、version、type 并解析头部(不校验 MAC)。</summary>
|
/// <summary>检查长度、magic、version、type 并解析头部(不校验 MAC)。</summary>
|
||||||
public static bool TryParseHeader(ReadOnlySpan<byte> packet, out UdpHeader header)
|
public static bool TryParseHeader(ReadOnlySpan<byte> packet, out MeasurementDatagramHeader header)
|
||||||
{
|
{
|
||||||
header = default;
|
header = default;
|
||||||
if (packet.Length is < MinBytes or > MaxBytes
|
if (packet.Length is < MinBytes or > MaxBytes
|
||||||
|| BinaryPrimitives.ReadUInt16LittleEndian(packet) != Magic
|
|| BinaryPrimitives.ReadUInt16LittleEndian(packet) != Magic
|
||||||
|| packet[2] != Version
|
|| packet[2] != Version
|
||||||
|| packet[3] is < (byte)UdpPacketType.EchoRequest or > (byte)UdpPacketType.Data)
|
|| packet[3] is < (byte)MeasurementDatagramType.EchoRequest or > (byte)MeasurementDatagramType.Data)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
header = new UdpHeader(
|
header = new MeasurementDatagramHeader(
|
||||||
(UdpPacketType)packet[3],
|
(MeasurementDatagramType)packet[3],
|
||||||
BinaryPrimitives.ReadInt64LittleEndian(packet[4..]),
|
BinaryPrimitives.ReadInt64LittleEndian(packet[4..]),
|
||||||
BinaryPrimitives.ReadUInt32LittleEndian(packet[12..]),
|
BinaryPrimitives.ReadUInt32LittleEndian(packet[12..]),
|
||||||
BinaryPrimitives.ReadUInt32LittleEndian(packet[16..]),
|
BinaryPrimitives.ReadUInt32LittleEndian(packet[16..]),
|
||||||
@@ -51,7 +51,7 @@ public static class UdpPacket
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>写入整个包(长度就是 <paramref name="destination"/> 的长度),填充为零,末尾是 MAC。</summary>
|
/// <summary>写入整个包(长度就是 <paramref name="destination"/> 的长度),填充为零,末尾是 MAC。</summary>
|
||||||
public static void Write(Span<byte> destination, in UdpHeader header, ReadOnlySpan<byte> key)
|
public static void Write(Span<byte> destination, in MeasurementDatagramHeader header, ReadOnlySpan<byte> key)
|
||||||
{
|
{
|
||||||
if (destination.Length is < MinBytes or > MaxBytes)
|
if (destination.Length is < MinBytes or > MaxBytes)
|
||||||
{
|
{
|
||||||
@@ -19,8 +19,8 @@ public static class TcpWire
|
|||||||
|
|
||||||
public static void WriteAuth(Span<byte> destination, long sessionId, uint streamId, ReadOnlySpan<byte> nonce, ReadOnlySpan<byte> key)
|
public static void WriteAuth(Span<byte> destination, long sessionId, uint streamId, ReadOnlySpan<byte> nonce, ReadOnlySpan<byte> key)
|
||||||
{
|
{
|
||||||
BinaryPrimitives.WriteUInt16LittleEndian(destination, UdpPacket.Magic);
|
BinaryPrimitives.WriteUInt16LittleEndian(destination, MeasurementDatagram.Magic);
|
||||||
destination[2] = UdpPacket.Version;
|
destination[2] = MeasurementDatagram.Version;
|
||||||
BinaryPrimitives.WriteInt64LittleEndian(destination[3..], sessionId);
|
BinaryPrimitives.WriteInt64LittleEndian(destination[3..], sessionId);
|
||||||
BinaryPrimitives.WriteUInt32LittleEndian(destination[11..], streamId);
|
BinaryPrimitives.WriteUInt32LittleEndian(destination[11..], streamId);
|
||||||
nonce[..NonceBytes].CopyTo(destination[15..]);
|
nonce[..NonceBytes].CopyTo(destination[15..]);
|
||||||
@@ -35,7 +35,7 @@ public static class TcpWire
|
|||||||
sessionId = 0;
|
sessionId = 0;
|
||||||
streamId = 0;
|
streamId = 0;
|
||||||
nonce = default;
|
nonce = default;
|
||||||
if (frame.Length != AuthBytes || BinaryPrimitives.ReadUInt16LittleEndian(frame) != UdpPacket.Magic || frame[2] != UdpPacket.Version)
|
if (frame.Length != AuthBytes || BinaryPrimitives.ReadUInt16LittleEndian(frame) != MeasurementDatagram.Magic || frame[2] != MeasurementDatagram.Version)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ public sealed class MeasurementTests
|
|||||||
Params = parameters.ToDictionary(p => p.Key, p => Json(p.Raw)),
|
Params = parameters.ToDictionary(p => p.Key, p => Json(p.Raw)),
|
||||||
};
|
};
|
||||||
|
|
||||||
private static BudgetGuard Guard(NodeLimits? limits = null) => new(limits ?? new NodeLimits { AllowedUdpPorts = [7443], AllowedTcpPorts = [7444] }, BlockCatalog.AgentKinds);
|
private static BudgetGuard Guard(NodeLimits? limits = null) => new(limits ?? new NodeLimits { AllowedQuicPorts = [7443], AllowedTcpPorts = [7444] }, BlockCatalog.AgentKinds);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BudgetGuard_AcceptsDefaults_RejectsUnknownKindsBadParamsAndForeignPorts()
|
public void BudgetGuard_AcceptsDefaults_RejectsUnknownKindsBadParamsAndForeignPorts()
|
||||||
@@ -34,7 +34,7 @@ public sealed class MeasurementTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void BudgetGuard_AccumulatesTheBudget_AndRejectsWhatDoesNotFit()
|
public void BudgetGuard_AccumulatesTheBudget_AndRejectsWhatDoesNotFit()
|
||||||
{
|
{
|
||||||
BudgetGuard guard = Guard(new NodeLimits { MaxTotalMbps = 15, AllowedUdpPorts = [7443] });
|
BudgetGuard guard = Guard(new NodeLimits { MaxTotalMbps = 15, AllowedQuicPorts = [7443] });
|
||||||
double mbps = 0;
|
double mbps = 0;
|
||||||
long pps = 0;
|
long pps = 0;
|
||||||
Assert.True(guard.CheckPhase("udp_train", new Dictionary<string, JsonElement> { ["mbps"] = Json("10") }).Accepted);
|
Assert.True(guard.CheckPhase("udp_train", new Dictionary<string, JsonElement> { ["mbps"] = Json("10") }).Accepted);
|
||||||
@@ -298,7 +298,7 @@ public sealed class MeasurementTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void EchoRows_Udp_GivesUpOnAMissingReplyAfterTwoSeconds()
|
public void EchoRows_Udp_GivesUpOnAMissingReplyAfterTwoSeconds()
|
||||||
{
|
{
|
||||||
var rows = new EchoRows(Kinds.UdpEcho, 64, EchoRows.UdpGraceUs, settleOnReply: false);
|
var rows = new EchoRows(Kinds.UdpEcho, 64, EchoRows.DatagramGraceUs, settleOnReply: false);
|
||||||
rows.Add(64, 0);
|
rows.Add(64, 0);
|
||||||
rows.Add(64, 1_000_000);
|
rows.Add(64, 1_000_000);
|
||||||
RawStream cut = rows.Take(2_500_000, all: false)!;
|
RawStream cut = rows.Take(2_500_000, all: false)!;
|
||||||
|
|||||||
@@ -26,10 +26,18 @@ public static class LinkMetrics
|
|||||||
"jitter.down", s_udpMsg, Ops.OwdVar(Dir.Down), "Jitter (down)", "One-way delay variation, offset and drift removed.", Unit.Ms, [Stat.P95], good: 3, poor: 20);
|
"jitter.down", s_udpMsg, Ops.OwdVar(Dir.Down), "Jitter (down)", "One-way delay variation, offset and drift removed.", Unit.Ms, [Stat.P95], good: 3, poor: 20);
|
||||||
|
|
||||||
public static readonly MetricDef LossRun = Metric.RunLength(
|
public static readonly MetricDef LossRun = Metric.RunLength(
|
||||||
"loss.run", s_udpMsg, Ops.LossRuns, "Loss runs", "Lengths of consecutive lost packets.",
|
"loss.run", s_udpMsg, Ops.LossRuns(Dir.Down), "Loss runs (legacy downstream alias)", "Legacy downstream-only consecutive loss metric.",
|
||||||
new MetricExport("loss.run_max", Stat.Max, Unit.Packets, 2, 10),
|
new MetricExport("loss.run_max", Stat.Max, Unit.Packets, 2, 10),
|
||||||
new MetricExport("loss.run_mean", Stat.Mean, Unit.Packets));
|
new MetricExport("loss.run_mean", Stat.Mean, Unit.Packets));
|
||||||
|
|
||||||
|
public static readonly MetricDef LossRunUp = Metric.Max(
|
||||||
|
"loss.run.up", s_udpMsg, Ops.LossRunMax(Dir.Up), "Upstream longest loss run", "Longest consecutive Probe-to-Agent Datagram loss run in each chunk; zero means no loss.",
|
||||||
|
Unit.Packets, "loss.run.up_max", good: 0, poor: 10);
|
||||||
|
|
||||||
|
public static readonly MetricDef LossRunDown = Metric.Max(
|
||||||
|
"loss.run.down", s_udpMsg, Ops.LossRunMax(Dir.Down), "Downstream longest loss run", "Longest consecutive Agent-to-Probe Datagram loss run in each chunk, conditioned on the request reaching the Agent; zero means no loss.",
|
||||||
|
Unit.Packets, "loss.run.down_max", good: 0, poor: 10);
|
||||||
|
|
||||||
public static readonly MetricDef Reorder = Metric.Counter(
|
public static readonly MetricDef Reorder = Metric.Counter(
|
||||||
"reorder", s_udpMsg, Ops.Reorder, "Reordered", "Replies that arrived after a higher sequence number.", new MetricExport("reorder", Stat.Count, Unit.Count));
|
"reorder", s_udpMsg, Ops.Reorder, "Reordered", "Replies that arrived after a higher sequence number.", new MetricExport("reorder", Stat.Count, Unit.Count));
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ public static class MetricRegistry
|
|||||||
public static readonly IReadOnlyList<MetricDef> All =
|
public static readonly IReadOnlyList<MetricDef> All =
|
||||||
[
|
[
|
||||||
LinkMetrics.Rtt, LinkMetrics.LossUp, LinkMetrics.LossDown, LinkMetrics.LossTotal,
|
LinkMetrics.Rtt, LinkMetrics.LossUp, LinkMetrics.LossDown, LinkMetrics.LossTotal,
|
||||||
LinkMetrics.JitterUp, LinkMetrics.JitterDown, LinkMetrics.LossRun, LinkMetrics.Reorder, LinkMetrics.Dup, LinkMetrics.SamplesUdp,
|
LinkMetrics.JitterUp, LinkMetrics.JitterDown, LinkMetrics.LossRun, LinkMetrics.LossRunUp, LinkMetrics.LossRunDown, LinkMetrics.Reorder, LinkMetrics.Dup, LinkMetrics.SamplesUdp,
|
||||||
TcpMetrics.Rtt, TcpMetrics.Slow, TcpMetrics.Stall, TcpMetrics.StallMax, TcpMetrics.Retrans, TcpMetrics.Srtt, TcpMetrics.BulkRate, TcpMetrics.BulkPeak, TcpMetrics.SamplesTcp,
|
TcpMetrics.Rtt, TcpMetrics.Slow, TcpMetrics.Stall, TcpMetrics.StallMax, TcpMetrics.Retrans, TcpMetrics.Srtt, TcpMetrics.BulkRate, TcpMetrics.BulkPeak, TcpMetrics.SamplesTcp,
|
||||||
SessionMetrics.ConnectSuccess, SessionMetrics.ConnectTcp, SessionMetrics.ConnectTls,
|
SessionMetrics.ConnectSuccess, SessionMetrics.ConnectTcp, SessionMetrics.ConnectTls,
|
||||||
SessionMetrics.FailTimeout, SessionMetrics.FailReset, SessionMetrics.FailTls,
|
SessionMetrics.FailTimeout, SessionMetrics.FailReset, SessionMetrics.FailTls,
|
||||||
@@ -22,7 +22,8 @@ public static class MetricRegistry
|
|||||||
/// <summary>指标分组:前端按分组展示,新增指标只需要把它加进分组。</summary>
|
/// <summary>指标分组:前端按分组展示,新增指标只需要把它加进分组。</summary>
|
||||||
public static readonly IReadOnlyList<PresetDef> Presets =
|
public static readonly IReadOnlyList<PresetDef> Presets =
|
||||||
[
|
[
|
||||||
new("udp", "UDP", ["loss.up", "loss.down", "loss.total", "rtt.p50", "rtt.p95", "rtt.p99", "jitter.up.p95", "jitter.down.p95", "loss.run_max", "reorder", "dup"]),
|
new("quic", "QUIC Datagram", ["loss.up", "loss.down", "loss.total", "rtt.p50", "rtt.p95", "rtt.p99", "jitter.up.p95", "jitter.down.p95", "loss.run.up_max", "loss.run.down_max", "reorder", "dup"]),
|
||||||
|
new("udp", "QUIC Datagram (legacy alias)", ["loss.up", "loss.down", "loss.total", "rtt.p50", "rtt.p95", "rtt.p99", "jitter.up.p95", "jitter.down.p95", "loss.run_max", "reorder", "dup"]),
|
||||||
new("tcp", "TCP", ["tcp.rtt.p50", "tcp.rtt.p95", "tcp.rtt.p99", "tcp.rtt.p999", "tcp.rtt.max", "tcp.rtt.tail", "tcp.slow", "tcp.stall.per_min", "tcp.stall_max", "tcp.retrans", "tcp.srtt.p95"]),
|
new("tcp", "TCP", ["tcp.rtt.p50", "tcp.rtt.p95", "tcp.rtt.p99", "tcp.rtt.p999", "tcp.rtt.max", "tcp.rtt.tail", "tcp.slow", "tcp.stall.per_min", "tcp.stall_max", "tcp.retrans", "tcp.srtt.p95"]),
|
||||||
new("connect", "Connect", ["connect.success", "connect.tcp_ms.p95", "connect.tls_ms.p95", "connect.fail.timeout", "connect.fail.rst", "connect.fail.tls"]),
|
new("connect", "Connect", ["connect.success", "connect.tcp_ms.p95", "connect.tls_ms.p95", "connect.fail.timeout", "connect.fail.rst", "connect.fail.tls"]),
|
||||||
new("session", "Session", ["hold.resets.per_h", "hold.lifetime_max"]),
|
new("session", "Session", ["hold.resets.per_h", "hold.lifetime_max"]),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
namespace MoShiro.Backend.Analysis;
|
namespace MoShiro.Backend.Analysis;
|
||||||
|
|
||||||
/// <summary>连接建立、长连接、UDP 压测(udp_train)的指标。</summary>
|
/// <summary>TCP 建连、长连接、QUIC Datagram 压测(legacy kind udp_train)的指标。</summary>
|
||||||
public static class SessionMetrics
|
public static class SessionMetrics
|
||||||
{
|
{
|
||||||
private static readonly string[] s_conn = ["connect.conn"];
|
private static readonly string[] s_conn = ["connect.conn"];
|
||||||
@@ -33,13 +33,13 @@ public static class SessionMetrics
|
|||||||
"hold.lifetime", s_hold, Ops.HoldLifetime, "Longest connection lifetime", "Longest observed lifetime of the held connection (ms).", Unit.Ms, "hold.lifetime_max", Better.Higher);
|
"hold.lifetime", s_hold, Ops.HoldLifetime, "Longest connection lifetime", "Longest observed lifetime of the held connection (ms).", Unit.Ms, "hold.lifetime_max", Better.Higher);
|
||||||
|
|
||||||
public static readonly MetricDef TrainLoss = Metric.Ratio(
|
public static readonly MetricDef TrainLoss = Metric.Ratio(
|
||||||
"train.loss", s_train, Ops.TrainLoss, "Train loss", "Loss of a fixed-rate UDP stream.", good: 1, poor: 10);
|
"train.loss", s_train, Ops.TrainLoss, "Datagram train loss", "Loss of a fixed-rate QUIC Datagram stream.", good: 1, poor: 10);
|
||||||
|
|
||||||
public static readonly MetricDef TrainJitter = Metric.Histogram(
|
public static readonly MetricDef TrainJitter = Metric.Histogram(
|
||||||
"train.jitter", s_train, Ops.TrainJitter, "Train jitter", "One-way delay variation of a fixed-rate UDP stream.", Unit.Ms, [Stat.P95], good: 3, poor: 20);
|
"train.jitter", s_train, Ops.TrainJitter, "Datagram train delay variation", "One-way delay variation of a fixed-rate QUIC Datagram stream.", Unit.Ms, [Stat.P95], good: 3, poor: 20);
|
||||||
|
|
||||||
public static readonly MetricDef TrainRate = Metric.Histogram(
|
public static readonly MetricDef TrainRate = Metric.Histogram(
|
||||||
"train.rate", s_train, Ops.TrainRate, "Train rate", "Delivered rate of a fixed-rate UDP stream per second.", Unit.Mbps, [Stat.P50, Stat.Mean], Better.Higher);
|
"train.rate", s_train, Ops.TrainRate, "Datagram train rate", "Delivered rate of a fixed-rate QUIC Datagram stream per second.", Unit.Mbps, [Stat.P50, Stat.Mean], Better.Higher);
|
||||||
|
|
||||||
public static readonly MetricDef SamplesConnect = Metric.Counter(
|
public static readonly MetricDef SamplesConnect = Metric.Counter(
|
||||||
"n.connect", s_conn, Ops.RowCount, "connect samples", "Number of connection attempts.", new MetricExport("n.connect", Stat.Count, Unit.Count), Better.None);
|
"n.connect", s_conn, Ops.RowCount, "connect samples", "Number of connection attempts.", new MetricExport("n.connect", Stat.Count, Unit.Count), Better.None);
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ public sealed class BaseModel : ServiceModelBase
|
|||||||
|
|
||||||
public override string Title => "Line quality";
|
public override string Title => "Line quality";
|
||||||
|
|
||||||
public override int Version => 1;
|
public override int Version => 2;
|
||||||
|
|
||||||
public override IReadOnlyList<string> Needs { get; } = ["rtt.p95", "loss.total", "jitter.down.p95", "jitter.up.p95", "loss.run_max"];
|
public override IReadOnlyList<string> Needs { get; } = ["rtt.p95", "loss.total", "jitter.down.p95", "jitter.up.p95", "loss.run.up_max", "loss.run.down_max"];
|
||||||
|
|
||||||
public override ModelScore Score(MetricView view) =>
|
public override ModelScore Score(MetricView view) =>
|
||||||
Combine(
|
Combine(
|
||||||
@@ -24,7 +24,8 @@ public sealed class BaseModel : ServiceModelBase
|
|||||||
Dim(view, "loss.total", "Packet loss", 4, s_loss),
|
Dim(view, "loss.total", "Packet loss", 4, s_loss),
|
||||||
Dim(view, "jitter.down.p95", "Jitter (down)", 1.5, s_jitter),
|
Dim(view, "jitter.down.p95", "Jitter (down)", 1.5, s_jitter),
|
||||||
Dim(view, "jitter.up.p95", "Jitter (up)", 1.5, s_jitter),
|
Dim(view, "jitter.up.p95", "Jitter (up)", 1.5, s_jitter),
|
||||||
Dim(view, "loss.run_max", "Longest loss run", 1, s_run),
|
Dim(view, "loss.run.up_max", "Longest upstream loss run", 0.5, s_run),
|
||||||
|
Dim(view, "loss.run.down_max", "Longest downstream loss run", 0.5, s_run),
|
||||||
],
|
],
|
||||||
estimated: false);
|
estimated: false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -273,7 +273,14 @@ internal static class JitterEmitter
|
|||||||
/// <summary>连续丢失段的长度(按发送顺序)。</summary>
|
/// <summary>连续丢失段的长度(按发送顺序)。</summary>
|
||||||
internal sealed class LossRunsOp : MetricOp
|
internal sealed class LossRunsOp : MetricOp
|
||||||
{
|
{
|
||||||
public override string Signature => "loss_runs/1";
|
private readonly Dir _direction;
|
||||||
|
|
||||||
|
public LossRunsOp(Dir direction)
|
||||||
|
{
|
||||||
|
_direction = direction;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string Signature => $"loss_runs/{_direction}/2";
|
||||||
|
|
||||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||||
{
|
{
|
||||||
@@ -292,7 +299,13 @@ internal sealed class LossRunsOp : MetricOp
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (c.IsReplyLost(i))
|
bool lost = _direction switch
|
||||||
|
{
|
||||||
|
Dir.Up => c.IsUpLost(i),
|
||||||
|
Dir.Down => !c.IsUpLost(i) && c.IsReplyLost(i),
|
||||||
|
_ => c.IsReplyLost(i),
|
||||||
|
};
|
||||||
|
if (lost)
|
||||||
{
|
{
|
||||||
if (run == 0)
|
if (run == 0)
|
||||||
{
|
{
|
||||||
@@ -315,6 +328,59 @@ internal sealed class LossRunsOp : MetricOp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>每个块一个最长连续丢包值;无丢包的有效块明确输出 0。</summary>
|
||||||
|
internal sealed class LossRunMaxOp(Dir direction) : MetricOp
|
||||||
|
{
|
||||||
|
public override string Signature => $"loss_run_max/{direction}/1";
|
||||||
|
|
||||||
|
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||||
|
{
|
||||||
|
var c = new EchoColumns(stream);
|
||||||
|
if (!c.HasBasics || (direction != Dir.Total && c.T2 is null))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int run = 0;
|
||||||
|
int maximum = 0;
|
||||||
|
bool hasSample = false;
|
||||||
|
long sampleAt = 0;
|
||||||
|
for (int i = 0; i < c.Count; i++)
|
||||||
|
{
|
||||||
|
if (!c.T1!.IsValid(i))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool lost = direction switch
|
||||||
|
{
|
||||||
|
Dir.Up => c.IsUpLost(i),
|
||||||
|
Dir.Down => !c.IsUpLost(i) && c.IsReplyLost(i),
|
||||||
|
_ => c.IsReplyLost(i),
|
||||||
|
};
|
||||||
|
if (direction == Dir.Down && c.IsUpLost(i))
|
||||||
|
{
|
||||||
|
// A request lost before reaching the Agent is not a downstream sample.
|
||||||
|
run = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasSample)
|
||||||
|
{
|
||||||
|
sampleAt = c.SentUtcMs(view, i);
|
||||||
|
hasSample = true;
|
||||||
|
}
|
||||||
|
run = lost ? run + 1 : 0;
|
||||||
|
maximum = Math.Max(maximum, run);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasSample)
|
||||||
|
{
|
||||||
|
sink.Emit(sampleAt, maximum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>乱序:按到达顺序,序号比之前见过的最大序号还小。重复的序号不算乱序。</summary>
|
/// <summary>乱序:按到达顺序,序号比之前见过的最大序号还小。重复的序号不算乱序。</summary>
|
||||||
internal sealed class ReorderOp : MetricOp
|
internal sealed class ReorderOp : MetricOp
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ public static class Ops
|
|||||||
|
|
||||||
public static MetricOp OwdVar(Dir dir) => new OwdVarOp(dir);
|
public static MetricOp OwdVar(Dir dir) => new OwdVarOp(dir);
|
||||||
|
|
||||||
public static MetricOp LossRuns { get; } = new LossRunsOp();
|
public static MetricOp LossRuns(Dir direction) => new LossRunsOp(direction);
|
||||||
|
|
||||||
|
public static MetricOp LossRunMax(Dir direction) => new LossRunMaxOp(direction);
|
||||||
|
|
||||||
public static MetricOp Reorder { get; } = new ReorderOp();
|
public static MetricOp Reorder { get; } = new ReorderOp();
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,23 @@ public static class Mappers
|
|||||||
public static OverviewLineDto ToDto(OverviewLine line) =>
|
public static OverviewLineDto ToDto(OverviewLine line) =>
|
||||||
new(
|
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.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);
|
line.Line.Agent.Online, line.Line.Connected, line.Line.Agent.EndpointChanges, ToDto(line.Summary), Summary(line.LastTest), line.Line.IpVersion, ToDto(line.Line.QuicHealth));
|
||||||
|
|
||||||
|
public static QuicLineHealthDto? ToDto(ProbeQuicLinkSnapshot? snapshot)
|
||||||
|
{
|
||||||
|
if (snapshot is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
ProbeQuicLinkHealth health = snapshot.Health;
|
||||||
|
double? Rate(long count) => health.HandshakeAttempts > 0 ? count * 100.0 / health.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));
|
||||||
|
}
|
||||||
|
|
||||||
public static HostDto ToDto(HostSnapshot h) =>
|
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));
|
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));
|
||||||
@@ -102,7 +118,7 @@ public static class Mappers
|
|||||||
? new HeartbeatDto(h.Sessions, h.PpsIn, h.PpsOut, h.DroppedInvalid, h.DroppedRateLimited, h.QueueBytes, Time(view.HeartbeatAtMs ?? 0))
|
? new HeartbeatDto(h.Sessions, h.PpsIn, h.PpsOut, h.DroppedInvalid, h.DroppedRateLimited, h.QueueBytes, Time(view.HeartbeatAtMs ?? 0))
|
||||||
: null;
|
: null;
|
||||||
return new AgentDto(
|
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.Labels, a.PublicVisible,
|
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.Kinds, a.AgentVersion, limits, a.TaskVersion, a.AppliedTaskVersion, rejected, heartbeat, view.EndpointChanges, Time(a.CreatedAtMs), Time(a.LastSeenAtMs), a.Ipv6Host, a.Ipv6Supported, Ipv6Of(view), !a.PublicHostManual, !a.Ipv6HostManual, a.AddedBy);
|
a.Kinds, a.AgentVersion, limits, a.TaskVersion, a.AppliedTaskVersion, rejected, heartbeat, view.EndpointChanges, Time(a.CreatedAtMs), Time(a.LastSeenAtMs), a.Ipv6Host, a.Ipv6Supported, Ipv6Of(view), !a.PublicHostManual, !a.Ipv6HostManual, a.AddedBy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ public sealed record AgentDto(
|
|||||||
string PublicHost,
|
string PublicHost,
|
||||||
int ProbeTcpPort,
|
int ProbeTcpPort,
|
||||||
IReadOnlyList<int> ProbeUdpPorts,
|
IReadOnlyList<int> ProbeUdpPorts,
|
||||||
|
IReadOnlyList<int> ProbeQuicPorts,
|
||||||
IReadOnlyList<string> Labels,
|
IReadOnlyList<string> Labels,
|
||||||
bool PublicVisible,
|
bool PublicVisible,
|
||||||
IReadOnlyList<string> Kinds,
|
IReadOnlyList<string> Kinds,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ namespace MoShiro.Backend.Api;
|
|||||||
[JsonSerializable(typeof(DistributionDto))]
|
[JsonSerializable(typeof(DistributionDto))]
|
||||||
[JsonSerializable(typeof(HeatmapDto))]
|
[JsonSerializable(typeof(HeatmapDto))]
|
||||||
[JsonSerializable(typeof(LineDto))]
|
[JsonSerializable(typeof(LineDto))]
|
||||||
|
[JsonSerializable(typeof(QuicLineHealthDto))]
|
||||||
[JsonSerializable(typeof(TestDto))]
|
[JsonSerializable(typeof(TestDto))]
|
||||||
[JsonSerializable(typeof(PageDto<TestDto>))]
|
[JsonSerializable(typeof(PageDto<TestDto>))]
|
||||||
[JsonSerializable(typeof(PageDto<KindDto>))]
|
[JsonSerializable(typeof(PageDto<KindDto>))]
|
||||||
|
|||||||
@@ -28,7 +28,27 @@ public sealed record OverviewLineDto(
|
|||||||
int EndpointChanges,
|
int EndpointChanges,
|
||||||
SummaryDto Summary,
|
SummaryDto Summary,
|
||||||
TestSummaryDto? LastTest,
|
TestSummaryDto? LastTest,
|
||||||
string IpVersion = "ipv4");
|
string IpVersion = "ipv4",
|
||||||
|
QuicLineHealthDto? QuicHealth = null);
|
||||||
|
|
||||||
|
/// <summary>Latest QUIC session health reported by the Probe; cumulative counters reset when the Probe restarts.</summary>
|
||||||
|
public sealed record QuicLineHealthDto(
|
||||||
|
bool Connected,
|
||||||
|
long HandshakeAttempts,
|
||||||
|
long HandshakeSuccesses,
|
||||||
|
long HandshakeFailures,
|
||||||
|
long HandshakeTimeouts,
|
||||||
|
double? HandshakeSuccessRate,
|
||||||
|
double? HandshakeFailureRate,
|
||||||
|
double? HandshakeTimeoutRate,
|
||||||
|
double? HandshakeP95Ms,
|
||||||
|
long HandshakeSampleCount,
|
||||||
|
double? TransportPacketLossRate,
|
||||||
|
ulong? TransportPacketsSent,
|
||||||
|
uint? TransportCongestionEvents,
|
||||||
|
uint? TransportEcnCongestionEvents,
|
||||||
|
ushort? PathMtu,
|
||||||
|
DateTimeOffset At);
|
||||||
|
|
||||||
public sealed record OverviewDto(string Range, DateTimeOffset From, DateTimeOffset To, string Step, string Source, bool Degraded, IReadOnlyList<OverviewLineDto> Lines);
|
public sealed record OverviewDto(string Range, DateTimeOffset From, DateTimeOffset To, string Step, string Source, bool Degraded, IReadOnlyList<OverviewLineDto> Lines);
|
||||||
|
|
||||||
@@ -90,7 +110,8 @@ public sealed record LineDto(
|
|||||||
IReadOnlyList<string> Kinds,
|
IReadOnlyList<string> Kinds,
|
||||||
TestSummaryDto? LastTest,
|
TestSummaryDto? LastTest,
|
||||||
string IpVersion = "ipv4",
|
string IpVersion = "ipv4",
|
||||||
bool Ipv6Available = false);
|
bool Ipv6Available = false,
|
||||||
|
QuicLineHealthDto? QuicHealth = null);
|
||||||
|
|
||||||
public sealed record DimensionDto(string Id, string Label, double Weight, double? Value, double? Score);
|
public sealed record DimensionDto(string Id, string Label, double Weight, double? Value, double? Score);
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ namespace MoShiro.Backend.Api;
|
|||||||
public static class QueryEndpoints
|
public static class QueryEndpoints
|
||||||
{
|
{
|
||||||
private const string Tag = "public";
|
private const string Tag = "public";
|
||||||
private const string DefaultOverviewPreset = "udp,tcp,scores";
|
private const string DefaultOverviewPreset = "quic,tcp,scores";
|
||||||
private const string DefaultSeriesPreset = "udp,tcp";
|
private const string DefaultSeriesPreset = "quic,tcp";
|
||||||
|
|
||||||
public static void Map(RouteGroupBuilder group)
|
public static void Map(RouteGroupBuilder group)
|
||||||
{
|
{
|
||||||
@@ -102,7 +102,7 @@ public static class QueryEndpoints
|
|||||||
return TypedResults.Json(
|
return TypedResults.Json(
|
||||||
new LineDto(
|
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,
|
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.Time(await query.EarliestAsync(line.Key, ct)), view.Agent.Kinds, Mappers.Summary(last), line.IpVersion, view.Agent.Ipv6Host.Length > 0, Mappers.ToDto(line.QuicHealth)),
|
||||||
ApiJsonContext.Default.LineDto);
|
ApiJsonContext.Default.LineDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -173,7 +173,8 @@ public sealed partial class AgentService
|
|||||||
Host = agent.PublicHost,
|
Host = agent.PublicHost,
|
||||||
Ipv6Host = agent.Ipv6Supported && agent.Ipv6Host.Length > 0 ? agent.Ipv6Host : null,
|
Ipv6Host = agent.Ipv6Supported && agent.Ipv6Host.Length > 0 ? agent.Ipv6Host : null,
|
||||||
TcpPort = agent.ProbeTcpPort,
|
TcpPort = agent.ProbeTcpPort,
|
||||||
UdpPorts = [.. agent.ProbeUdpPorts],
|
QuicPorts = [.. agent.ProbeUdpPorts],
|
||||||
|
LegacyUdpPorts = [.. agent.ProbeUdpPorts],
|
||||||
PublicKey = Convert.ToBase64String(agent.TlsPublicKey!),
|
PublicKey = Convert.ToBase64String(agent.TlsPublicKey!),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ public sealed class MoShiroEnrollmentHook : IEnrollmentHook
|
|||||||
// 注册请求带了 public_host 就用它;否则来源地址是公网地址时用来源地址(私网或隧道出口不用,等 hello 里的本机地址)
|
// 注册请求带了 public_host 就用它;否则来源地址是公网地址时用来源地址(私网或隧道出口不用,等 hello 里的本机地址)
|
||||||
string host = !string.IsNullOrWhiteSpace(meta.PublicHost) ? meta.PublicHost
|
string host = !string.IsNullOrWhiteSpace(meta.PublicHost) ? meta.PublicHost
|
||||||
: System.Net.IPAddress.TryParse(context.RemoteIp, out System.Net.IPAddress? remote) && PublicAddress.IsPublicIpv4(remote) ? remote.ToString() : "";
|
: System.Net.IPAddress.TryParse(context.RemoteIp, out System.Net.IPAddress? remote) && PublicAddress.IsPublicIpv4(remote) ? remote.ToString() : "";
|
||||||
await _agents.ApplyEnrollmentAsync(node.Id, new AgentEnrollment(host, meta.ProbeTcpPort, meta.ProbeUdpPorts, meta.Kinds), ct);
|
await _agents.ApplyEnrollmentAsync(node.Id, new AgentEnrollment(host, meta.ProbeTcpPort, meta.ProbeQuicPorts.Count > 0 ? meta.ProbeQuicPorts : meta.LegacyProbeUdpPorts ?? [7443], meta.Kinds), ct);
|
||||||
if (await _agents.GetAsync(node.Id, ct) is { } enrolled)
|
if (await _agents.GetAsync(node.Id, ct) is { } enrolled)
|
||||||
{
|
{
|
||||||
(_, string? observedV6) = AgentAddressPolicy.Choose(enrolled, [], context.RemoteIp);
|
(_, string? observedV6) = AgentAddressPolicy.Choose(enrolled, [], context.RemoteIp);
|
||||||
@@ -70,10 +70,12 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
|
|||||||
private readonly ScheduleService _schedule;
|
private readonly ScheduleService _schedule;
|
||||||
private readonly IProbeStore _probes;
|
private readonly IProbeStore _probes;
|
||||||
private readonly NodeOwnerService _owners;
|
private readonly NodeOwnerService _owners;
|
||||||
|
private readonly ProbeQuicRuntime _quicRuntime;
|
||||||
|
|
||||||
public MoShiroNodeEventSink(IAgentStore agents, AgentRuntime runtime, IEventStore events, LiveHub live, AgentService service, NodeInfoService nodes, ReleaseService releases, ScheduleService schedule, IProbeStore probes, NodeOwnerService owners)
|
public MoShiroNodeEventSink(IAgentStore agents, AgentRuntime runtime, IEventStore events, LiveHub live, AgentService service, NodeInfoService nodes, ReleaseService releases, ScheduleService schedule, IProbeStore probes, NodeOwnerService owners, ProbeQuicRuntime quicRuntime)
|
||||||
{
|
{
|
||||||
_owners = owners;
|
_owners = owners;
|
||||||
|
_quicRuntime = quicRuntime;
|
||||||
_releases = releases;
|
_releases = releases;
|
||||||
_schedule = schedule;
|
_schedule = schedule;
|
||||||
_probes = probes;
|
_probes = probes;
|
||||||
@@ -168,12 +170,17 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
|
|||||||
case NodeEventKind.Offline:
|
case NodeEventKind.Offline:
|
||||||
_live.Publish(new NodeSignal("probe", id.Value, false, nodeEvent.AtMs));
|
_live.Publish(new NodeSignal("probe", id.Value, false, nodeEvent.AtMs));
|
||||||
_releases.RecordStatus(id, null);
|
_releases.RecordStatus(id, null);
|
||||||
|
_quicRuntime.MarkProbeOffline(id.Value, nodeEvent.AtMs);
|
||||||
break;
|
break;
|
||||||
case NodeEventKind.Heartbeat when nodeEvent.Extra is { } extra:
|
case NodeEventKind.Heartbeat when nodeEvent.Extra is { } extra:
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
ProbeHeartbeatExtra? heartbeat = extra.Deserialize(MoShiroJsonContext.Default.ProbeHeartbeatExtra);
|
ProbeHeartbeatExtra? heartbeat = extra.Deserialize(MoShiroJsonContext.Default.ProbeHeartbeatExtra);
|
||||||
_releases.RecordStatus(id, heartbeat?.Update);
|
_releases.RecordStatus(id, heartbeat?.Update);
|
||||||
|
if (heartbeat?.QuicLinks is { } quicLinks)
|
||||||
|
{
|
||||||
|
_quicRuntime.Replace(id.Value, nodeEvent.AtMs, quicLinks);
|
||||||
|
}
|
||||||
if (heartbeat?.Ipv6 is { } ipv6)
|
if (heartbeat?.Ipv6 is { } ipv6)
|
||||||
{
|
{
|
||||||
await _probes.SetIpv6DiagnosticsAsync(id.Value, JsonSerializer.Serialize(ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic), ct);
|
await _probes.SetIpv6DiagnosticsAsync(id.Value, JsonSerializer.Serialize(ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic), ct);
|
||||||
@@ -236,7 +243,8 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
|
|||||||
AgentRecord? before = await _agents.GetAsync(id, ct);
|
AgentRecord? before = await _agents.GetAsync(id, ct);
|
||||||
string? limitsJson = meta.LocalLimits is null ? null : JsonSerializer.Serialize(meta.LocalLimits, LocalLimitsJson.Default.LocalLimits);
|
string? limitsJson = meta.LocalLimits is null ? null : JsonSerializer.Serialize(meta.LocalLimits, LocalLimitsJson.Default.LocalLimits);
|
||||||
string? ipv6Json = meta.Ipv6 is null ? null : JsonSerializer.Serialize(meta.Ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic);
|
string? ipv6Json = meta.Ipv6 is null ? null : JsonSerializer.Serialize(meta.Ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic);
|
||||||
await _agents.ApplyHelloAsync(id, new AgentHello(kinds, hello.NodeVersion, limitsJson, spki is { Length: 32 } ? spki : null, meta.ProbeTcpPort, meta.ProbeUdpPorts, meta.Ipv6Supported, ipv6Json, meta.Addresses, tlsKey), ct);
|
IReadOnlyList<int>? quicPorts = meta.ProbeQuicPorts is { Count: > 0 } ? meta.ProbeQuicPorts : meta.LegacyProbeUdpPorts;
|
||||||
|
await _agents.ApplyHelloAsync(id, new AgentHello(kinds, hello.NodeVersion, limitsJson, spki is { Length: 32 } ? spki : null, meta.ProbeTcpPort, quicPorts, meta.Ipv6Supported, ipv6Json, meta.Addresses, tlsKey), ct);
|
||||||
|
|
||||||
// 自动识别对外地址:Agent 上报的公网地址;手填的不动
|
// 自动识别对外地址:Agent 上报的公网地址;手填的不动
|
||||||
if (meta.Addresses is not null && await _agents.GetAsync(id, ct) is { } reported)
|
if (meta.Addresses is not null && await _agents.GetAsync(id, ct) is { } reported)
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using MoShiro.Protocol;
|
||||||
|
|
||||||
|
namespace MoShiro.Backend.Application;
|
||||||
|
|
||||||
|
public sealed record ProbeQuicLinkSnapshot(ProbeQuicLinkHealth Health, long AtMs);
|
||||||
|
|
||||||
|
/// <summary>Latest per-line QUIC health reported by connected Probes. Volatile runtime state, not historical metrics.</summary>
|
||||||
|
public sealed class ProbeQuicRuntime
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<(string Probe, string Agent, string IpVersion), ProbeQuicLinkSnapshot> _links = new();
|
||||||
|
|
||||||
|
public ProbeQuicLinkSnapshot? Get(string probeId, string agentId, string ipVersion) =>
|
||||||
|
_links.TryGetValue((probeId, agentId, ipVersion), out ProbeQuicLinkSnapshot? snapshot) ? snapshot : null;
|
||||||
|
|
||||||
|
public void Replace(string probeId, long atMs, IReadOnlyList<ProbeQuicLinkHealth> links)
|
||||||
|
{
|
||||||
|
var current = new HashSet<(string Probe, string Agent, string IpVersion)>();
|
||||||
|
foreach (ProbeQuicLinkHealth link in links)
|
||||||
|
{
|
||||||
|
var key = (probeId, link.AgentId, link.IpVersion);
|
||||||
|
current.Add(key);
|
||||||
|
_links[key] = new ProbeQuicLinkSnapshot(link, atMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ((string Probe, string Agent, string IpVersion) key in _links.Keys.Where(k => k.Probe == probeId && !current.Contains(k)))
|
||||||
|
{
|
||||||
|
_links.TryRemove(key, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void MarkProbeOffline(string probeId, long atMs)
|
||||||
|
{
|
||||||
|
foreach (KeyValuePair<(string Probe, string Agent, string IpVersion), ProbeQuicLinkSnapshot> pair in _links.ToArray().Where(p => p.Key.Probe == probeId))
|
||||||
|
{
|
||||||
|
ProbeQuicLinkHealth old = pair.Value.Health;
|
||||||
|
_links[pair.Key] = new ProbeQuicLinkSnapshot(new ProbeQuicLinkHealth
|
||||||
|
{
|
||||||
|
AgentId = old.AgentId,
|
||||||
|
IpVersion = old.IpVersion,
|
||||||
|
Connected = false,
|
||||||
|
HandshakeAttempts = old.HandshakeAttempts,
|
||||||
|
HandshakeSuccesses = old.HandshakeSuccesses,
|
||||||
|
HandshakeFailures = old.HandshakeFailures,
|
||||||
|
HandshakeTimeouts = old.HandshakeTimeouts,
|
||||||
|
HandshakeP95Ms = old.HandshakeP95Ms,
|
||||||
|
HandshakeSampleCount = old.HandshakeSampleCount,
|
||||||
|
TransportPacketLossRate = old.TransportPacketLossRate,
|
||||||
|
TransportPacketsSent = old.TransportPacketsSent,
|
||||||
|
TransportCongestionEvents = old.TransportCongestionEvents,
|
||||||
|
TransportEcnCongestionEvents = old.TransportEcnCongestionEvents,
|
||||||
|
PathMtu = old.PathMtu,
|
||||||
|
}, atMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ public static class ApplicationServiceCollectionExtensions
|
|||||||
services.AddHostedService<StartupTasks>();
|
services.AddHostedService<StartupTasks>();
|
||||||
|
|
||||||
services.AddSingleton<AgentRuntime>();
|
services.AddSingleton<AgentRuntime>();
|
||||||
|
services.AddSingleton<ProbeQuicRuntime>();
|
||||||
services.AddSingleton<LiveHub>();
|
services.AddSingleton<LiveHub>();
|
||||||
services.AddSingleton<HotLayer>();
|
services.AddSingleton<HotLayer>();
|
||||||
services.AddSingleton<BackendCounters>();
|
services.AddSingleton<BackendCounters>();
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ public readonly record struct LineKey(NodeId Agent, ProbeId Probe, string IpVers
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>一条线路的展示视图:Agent 的状态、Probe 记录,以及这一对现在是否连着。</summary>
|
/// <summary>一条线路的展示视图:Agent 的状态、Probe 记录,以及这一对现在是否连着。</summary>
|
||||||
public sealed record LineView(AgentView Agent, ProbeRecord Probe, string IpVersion = IpVersions.V4)
|
public sealed record LineView(AgentView Agent, ProbeRecord Probe, string IpVersion = IpVersions.V4, ProbeQuicLinkSnapshot? QuicHealth = null)
|
||||||
{
|
{
|
||||||
public LineKey Key => new(Agent.Agent.Id, new ProbeId(Probe.Id), IpVersion);
|
public LineKey Key => new(Agent.Agent.Id, new ProbeId(Probe.Id), IpVersion);
|
||||||
|
|
||||||
@@ -40,12 +40,14 @@ public sealed class LineService
|
|||||||
private readonly AgentService _agents;
|
private readonly AgentService _agents;
|
||||||
private readonly IProbeStore _probes;
|
private readonly IProbeStore _probes;
|
||||||
private readonly LinkService _links;
|
private readonly LinkService _links;
|
||||||
|
private readonly ProbeQuicRuntime _quicRuntime;
|
||||||
|
|
||||||
public LineService(AgentService agents, IProbeStore probes, LinkService links)
|
public LineService(AgentService agents, IProbeStore probes, LinkService links, ProbeQuicRuntime quicRuntime)
|
||||||
{
|
{
|
||||||
_links = links;
|
_links = links;
|
||||||
_agents = agents;
|
_agents = agents;
|
||||||
_probes = probes;
|
_probes = probes;
|
||||||
|
_quicRuntime = quicRuntime;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>所有线路:每个 Agent 和每个<b>未吊销</b>的 Probe 配成一对,只留下线路开着的(见 <see cref="LinkService"/>);关掉的线路历史数据还在,按 id 仍然查得到。</summary>
|
/// <summary>所有线路:每个 Agent 和每个<b>未吊销</b>的 Probe 配成一对,只留下线路开着的(见 <see cref="LinkService"/>);关掉的线路历史数据还在,按 id 仍然查得到。</summary>
|
||||||
@@ -59,10 +61,10 @@ public sealed class LineService
|
|||||||
{
|
{
|
||||||
foreach (ProbeRecord probe in probes.Where(p => links.IsLinked(p.Id, agent.Agent.Id.Value)))
|
foreach (ProbeRecord probe in probes.Where(p => links.IsLinked(p.Id, agent.Agent.Id.Value)))
|
||||||
{
|
{
|
||||||
result.Add(new LineView(agent, probe));
|
result.Add(new LineView(agent, probe, IpVersions.V4, _quicRuntime.Get(probe.Id, agent.Agent.Id.Value, IpVersions.V4)));
|
||||||
if (agent.Agent.Ipv6Host.Length > 0)
|
if (agent.Agent.Ipv6Host.Length > 0)
|
||||||
{
|
{
|
||||||
result.Add(new LineView(agent, probe, IpVersions.V6));
|
result.Add(new LineView(agent, probe, IpVersions.V6, _quicRuntime.Get(probe.Id, agent.Agent.Id.Value, IpVersions.V6)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,6 +119,6 @@ public sealed class LineService
|
|||||||
probe = active[0];
|
probe = active[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
return Result<LineView>.Ok(new LineView(agent, probe, ipVersion));
|
return Result<LineView>.Ok(new LineView(agent, probe, ipVersion, _quicRuntime.Get(probe.Id, agent.Agent.Id.Value, ipVersion)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -240,7 +240,7 @@ public sealed class TestService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 没有指定端口的 UDP 阶段使用 Agent 上报的数据端口(目录里的默认值只是出厂默认,每个 Agent 可以改)
|
// 没有指定端口的 UDP 阶段使用 Agent 上报的数据端口(目录里的默认值只是出厂默认,每个 Agent 可以改)
|
||||||
if (!merged.ContainsKey("port") && kind.Blocks.Transport == "udp" && kind.Params.Any(s => s.Name == "port") && agent.ProbeUdpPorts.Count > 0)
|
if (!merged.ContainsKey("port") && kind.Blocks.Transport == "quic_datagram" && kind.Params.Any(s => s.Name == "port") && agent.ProbeUdpPorts.Count > 0)
|
||||||
{
|
{
|
||||||
merged["port"] = JsonSerializer.SerializeToElement(agent.ProbeUdpPorts[0], PlanPortJson.Default.Int32);
|
merged["port"] = JsonSerializer.SerializeToElement(agent.ProbeUdpPorts[0], PlanPortJson.Default.Int32);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ public sealed class ModelAndTestEvaluatorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void BaseModel_PerfectLine_ScoresHigh_AndBadLine_ScoresLow()
|
public void BaseModel_PerfectLine_ScoresHigh_AndBadLine_ScoresLow()
|
||||||
{
|
{
|
||||||
double good = new BaseModel().Score(View(("rtt.p95", 25), ("loss.total", 0), ("jitter.down.p95", 1), ("jitter.up.p95", 1), ("loss.run_max", 1))).Score!.Value;
|
double good = new BaseModel().Score(View(("rtt.p95", 25), ("loss.total", 0), ("jitter.down.p95", 1), ("jitter.up.p95", 1), ("loss.run.up_max", 1), ("loss.run.down_max", 1))).Score!.Value;
|
||||||
double bad = new BaseModel().Score(View(("rtt.p95", 400), ("loss.total", 12), ("jitter.down.p95", 90), ("jitter.up.p95", 90), ("loss.run_max", 40))).Score!.Value;
|
double bad = new BaseModel().Score(View(("rtt.p95", 400), ("loss.total", 12), ("jitter.down.p95", 90), ("jitter.up.p95", 90), ("loss.run.up_max", 40), ("loss.run.down_max", 40))).Score!.Value;
|
||||||
Assert.True(good > 95);
|
Assert.True(good > 95);
|
||||||
Assert.True(bad < 10);
|
Assert.True(bad < 10);
|
||||||
}
|
}
|
||||||
@@ -83,7 +83,7 @@ public sealed class ModelAndTestEvaluatorTests
|
|||||||
var ranges = new Dictionary<string, double[]>
|
var ranges = new Dictionary<string, double[]>
|
||||||
{
|
{
|
||||||
["rtt.p95"] = [10, 30, 80, 150, 300, 600], ["loss.total"] = [0, 0.5, 1, 3, 10, 30],
|
["rtt.p95"] = [10, 30, 80, 150, 300, 600], ["loss.total"] = [0, 0.5, 1, 3, 10, 30],
|
||||||
["jitter.down.p95"] = [0, 2, 8, 20, 80, 200], ["jitter.up.p95"] = [0, 2, 8, 20, 80, 200], ["loss.run_max"] = [1, 3, 10, 30, 60],
|
["jitter.down.p95"] = [0, 2, 8, 20, 80, 200], ["jitter.up.p95"] = [0, 2, 8, 20, 80, 200], ["loss.run.up_max"] = [1, 3, 10, 30, 60], ["loss.run.down_max"] = [1, 3, 10, 30, 60],
|
||||||
["tcp.rtt.p95"] = [10, 40, 100, 200, 400, 800], ["tcp.stall.per_min"] = [0, 1, 5, 20, 60, 200], ["tcp.stall_max"] = [50, 150, 500, 2000, 5000],
|
["tcp.rtt.p95"] = [10, 40, 100, 200, 400, 800], ["tcp.stall.per_min"] = [0, 1, 5, 20, 60, 200], ["tcp.stall_max"] = [50, 150, 500, 2000, 5000],
|
||||||
["tcp.retrans"] = [0, 0.1, 1, 3, 10, 30], ["connect.tls_ms.p95"] = [50, 150, 400, 1000, 3000], ["hold.resets.per_h"] = [0, 1, 4, 12, 30],
|
["tcp.retrans"] = [0, 0.1, 1, 3, 10, 30], ["connect.tls_ms.p95"] = [50, 150, 400, 1000, 3000], ["hold.resets.per_h"] = [0, 1, 4, 12, 30],
|
||||||
["connect.success"] = [100, 99, 95, 80, 50], ["tcp.bulk.peak.max"] = [800, 400, 100, 10, 1], ["hy2.sustainable_mbps"] = [100, 50, 25, 10, 0],
|
["connect.success"] = [100, 99, 95, 80, 50], ["tcp.bulk.peak.max"] = [800, 400, 100, 10, 1], ["hy2.sustainable_mbps"] = [100, 50, 25, 10, 0],
|
||||||
|
|||||||
@@ -90,6 +90,28 @@ public sealed class OpTests
|
|||||||
Assert.Equal(2, ((RunLengthAcc)EvaluateOneBucket(chunk, "loss.run").Items["loss.run"]).Max);
|
Assert.Equal(2, ((RunLengthAcc)EvaluateOneBucket(chunk, "loss.run").Items["loss.run"]).Max);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LossRuns_AreSeparatedByDirection_AndDownstreamExcludesRequestsLostUpstream()
|
||||||
|
{
|
||||||
|
bool[] upLost = [false, true, true, false, false, false, true, true];
|
||||||
|
bool[] downLost = [false, false, true, true, false, true, false, true];
|
||||||
|
RawChunk chunk = ChunkFactory.Echo(s_agent, "udp_echo", T0, upLost.Length, 100,
|
||||||
|
i => new EchoPacket(upLost[i], downLost[i], 20_000, 20_000));
|
||||||
|
|
||||||
|
MetricSet set = EvaluateOneBucket(chunk, "loss.run.up", "loss.run.down");
|
||||||
|
Assert.Equal(2, ((MaxAcc)set.Items["loss.run.up"]).Result);
|
||||||
|
Assert.Equal(1, ((MaxAcc)set.Items["loss.run.down"]).Result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LossRuns_NoLoss_ExportsZeroForBothDirections()
|
||||||
|
{
|
||||||
|
RawChunk chunk = ChunkFactory.UdpEchoClean(s_agent, T0, count: 8);
|
||||||
|
MetricSet set = EvaluateOneBucket(chunk, "loss.run.up", "loss.run.down");
|
||||||
|
Assert.Equal(0, ((MaxAcc)set.Items["loss.run.up"]).Result);
|
||||||
|
Assert.Equal(0, ((MaxAcc)set.Items["loss.run.down"]).Result);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Reorder_CountsLateArrivals_AndIgnoresDuplicates()
|
public void Reorder_CountsLateArrivals_AndIgnoresDuplicates()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ public sealed class AgentEndToEndTests
|
|||||||
AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
||||||
await agent.OverrideLineTasksAsync(rateHz: 30);
|
await agent.OverrideLineTasksAsync(rateHz: 30);
|
||||||
ProbeSimulator probe = await agent.ConnectProbeAsync();
|
ProbeSimulator probe = await agent.ConnectProbeAsync();
|
||||||
LineTaskSet set = await probe.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo" && t.Port == agent.App.UdpPorts[0]));
|
LineTaskSet set = await probe.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo" && t.Port == agent.App.QuicPorts[0]));
|
||||||
return (agent, probe, set.Tasks.Single(t => t.Kind == "udp_echo"));
|
return (agent, probe, set.Tasks.Single(t => t.Kind == "udp_echo"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,8 +159,8 @@ public sealed class AgentEndToEndTests
|
|||||||
|
|
||||||
// The probe process restarts: a new session with its own streams; sequence numbers start from 0 again
|
// The probe process restarts: a new session with its own streams; sequence numbers start from 0 again
|
||||||
await using ProbeSimulator second = first.Restarted();
|
await using ProbeSimulator second = first.Restarted();
|
||||||
await second.ConnectAsync("127.0.0.1", agent.App.ControlPort, agent.TlsPublicKey, agent.App.UdpPorts[0], agent.NodeId);
|
await second.ConnectAsync("127.0.0.1", agent.App.ControlPort, agent.TlsPublicKey, agent.App.QuicPorts[0], agent.NodeId);
|
||||||
LineTaskSet set = await second.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo" && t.Port == agent.App.UdpPorts[0]));
|
LineTaskSet set = await second.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo" && t.Port == agent.App.QuicPorts[0]));
|
||||||
LineTask udp2 = set.Tasks.Single(t => t.Kind == "udp_echo");
|
LineTask udp2 = set.Tasks.Single(t => t.Kind == "udp_echo");
|
||||||
Assert.NotEqual(udp.StreamId, udp2.StreamId);
|
Assert.NotEqual(udp.StreamId, udp2.StreamId);
|
||||||
|
|
||||||
@@ -221,11 +221,11 @@ public sealed class AgentEndToEndTests
|
|||||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
||||||
await agent.CreateProbeAsync();
|
await agent.CreateProbeAsync();
|
||||||
await using var stranger = new ProbeSimulator("prb_stranger", Fleet.Identity.NodeIdentity.Generate());
|
await using var stranger = new ProbeSimulator("prb_stranger", Fleet.Identity.NodeIdentity.Generate());
|
||||||
await Assert.ThrowsAnyAsync<Exception>(() => stranger.ConnectAsync("127.0.0.1", agent.App.ControlPort, agent.TlsPublicKey, agent.App.UdpPorts[0], agent.NodeId));
|
await Assert.ThrowsAnyAsync<Exception>(() => stranger.ConnectAsync("127.0.0.1", agent.App.ControlPort, agent.TlsPublicKey, agent.App.QuicPorts[0], agent.NodeId));
|
||||||
|
|
||||||
// 证书公钥不对(中间人)也不会连上
|
// 证书公钥不对(中间人)也不会连上
|
||||||
await using ProbeSimulator probe = await agent.CreateProbeAsync();
|
await using ProbeSimulator probe = await agent.CreateProbeAsync();
|
||||||
await Assert.ThrowsAnyAsync<Exception>(() => probe.ConnectAsync("127.0.0.1", agent.App.ControlPort, Fleet.Identity.NodeIdentity.Generate().PublicKeySpki, agent.App.UdpPorts[0], agent.NodeId));
|
await Assert.ThrowsAnyAsync<Exception>(() => probe.ConnectAsync("127.0.0.1", agent.App.ControlPort, Fleet.Identity.NodeIdentity.Generate().PublicKeySpki, agent.App.QuicPorts[0], agent.NodeId));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -335,7 +335,7 @@ public sealed class AgentEndToEndTests
|
|||||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend, config => config.Limits.MaxTotalMbps = 20);
|
await using AgentHarness agent = await AgentHarness.StartAsync(_backend, config => config.Limits.MaxTotalMbps = 20);
|
||||||
await agent.OverrideLineTasksAsync();
|
await agent.OverrideLineTasksAsync();
|
||||||
|
|
||||||
var phase = new TestPhase { Kind = "udp_train", DurationSeconds = 1, Params = { ["port"] = System.Text.Json.JsonSerializer.SerializeToElement(agent.App.UdpPorts[0]) } };
|
var phase = new TestPhase { Kind = "udp_train", DurationSeconds = 1, Params = { ["port"] = System.Text.Json.JsonSerializer.SerializeToElement(agent.App.QuicPorts[0]) } };
|
||||||
var run = new TestRunParams { TestId = new TestId("tst_x"), ProbeId = new ProbeId("prb_nobody"), Plan = "x", Phases = [phase] };
|
var run = new TestRunParams { TestId = new TestId("tst_x"), ProbeId = new ProbeId("prb_nobody"), Plan = "x", Phases = [phase] };
|
||||||
Assert.Equal("probe_offline", agent.App.Tests.Start(run).Error.Code.Value);
|
Assert.Equal("probe_offline", agent.App.Tests.Start(run).Error.Code.Value);
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ public sealed class AgentHarness : IAsyncDisposable
|
|||||||
var config = new AgentConfig
|
var config = new AgentConfig
|
||||||
{
|
{
|
||||||
Fleet = { Server = backend.BaseAddress.ToString(), IdentityDir = directory },
|
Fleet = { Server = backend.BaseAddress.ToString(), IdentityDir = directory },
|
||||||
Listen = { Bind = "127.0.0.1", ControlPort = 0, UdpPorts = [0], TcpPorts = [0] },
|
Listen = { Bind = "127.0.0.1", ControlPort = 0, QuicPorts = [0], TcpPorts = [0] },
|
||||||
};
|
};
|
||||||
configure?.Invoke(config);
|
configure?.Invoke(config);
|
||||||
LoadedNodeIdentity identity = LoadedNodeIdentity.Load(directory);
|
LoadedNodeIdentity identity = LoadedNodeIdentity.Load(directory);
|
||||||
@@ -87,7 +87,7 @@ public sealed class AgentHarness : IAsyncDisposable
|
|||||||
public async Task<ProbeSimulator> ConnectProbeAsync()
|
public async Task<ProbeSimulator> ConnectProbeAsync()
|
||||||
{
|
{
|
||||||
ProbeSimulator probe = await CreateProbeAsync();
|
ProbeSimulator probe = await CreateProbeAsync();
|
||||||
await probe.ConnectAsync("127.0.0.1", App.ControlPort, TlsPublicKey, App.UdpPorts[0], NodeId);
|
await probe.ConnectAsync("127.0.0.1", App.ControlPort, TlsPublicKey, App.QuicPorts[0], NodeId);
|
||||||
return probe;
|
return probe;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@ public sealed class AgentHarness : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
string kind = task["kind"]!.GetValue<string>();
|
string kind = task["kind"]!.GetValue<string>();
|
||||||
var parameters = new JsonObject { ["port"] = kind == "udp_echo" ? App.UdpPorts[0] : App.TcpPorts[0] };
|
var parameters = new JsonObject { ["port"] = kind == "udp_echo" ? App.QuicPorts[0] : App.TcpPorts[0] };
|
||||||
// 默认的 connect 模板在这些用例里不需要(也占预算):对这个 Agent 关掉它
|
// 默认的 connect 模板在这些用例里不需要(也占预算):对这个 Agent 关掉它
|
||||||
bool enabled = kind != "connect";
|
bool enabled = kind != "connect";
|
||||||
if (rateHz is { } hz && kind != "connect")
|
if (rateHz is { } hz && kind != "connect")
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ public sealed class AgentSimulator : IAsyncDisposable
|
|||||||
["meta"] = new JsonObject
|
["meta"] = new JsonObject
|
||||||
{
|
{
|
||||||
["probe_tcp_port"] = 7443,
|
["probe_tcp_port"] = 7443,
|
||||||
|
["probe_quic_ports"] = new JsonArray(7443),
|
||||||
["probe_udp_ports"] = new JsonArray(7443),
|
["probe_udp_ports"] = new JsonArray(7443),
|
||||||
["public_host"] = publicHost,
|
["public_host"] = publicHost,
|
||||||
["kinds"] = new JsonArray((kinds ?? ["udp_echo", "tcp_echo", "udp_train", "connect", "hold", "tcp_bulk", "icmp_echo"]).Select(k => (JsonNode?)JsonValue.Create(k)).ToArray()),
|
["kinds"] = new JsonArray((kinds ?? ["udp_echo", "tcp_echo", "udp_train", "connect", "hold", "tcp_bulk", "icmp_echo"]).Select(k => (JsonNode?)JsonValue.Create(k)).ToArray()),
|
||||||
@@ -154,7 +155,8 @@ public sealed class AgentSimulator : IAsyncDisposable
|
|||||||
TlsPublicKey = Convert.ToBase64String(_tlsKey.PublicKeySpki),
|
TlsPublicKey = Convert.ToBase64String(_tlsKey.PublicKeySpki),
|
||||||
Addresses = Addresses is null ? null : [.. Addresses],
|
Addresses = Addresses is null ? null : [.. Addresses],
|
||||||
ProbeTcpPort = 7443,
|
ProbeTcpPort = 7443,
|
||||||
ProbeUdpPorts = [7443],
|
ProbeQuicPorts = [7443],
|
||||||
|
LegacyProbeUdpPorts = [7443],
|
||||||
LocalLimits = new LocalLimits { MaxTotalMbps = 200, MaxTotalPps = 20_000, MaxConcurrentTests = 1 },
|
LocalLimits = new LocalLimits { MaxTotalMbps = 200, MaxTotalPps = 20_000, MaxConcurrentTests = 1 },
|
||||||
},
|
},
|
||||||
MoShiroJsonContext.Default.AgentHelloMeta);
|
MoShiroJsonContext.Default.AgentHelloMeta);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\shared\MoShiro.Protocol\MoShiro.Protocol.csproj" />
|
<ProjectReference Include="..\..\shared\MoShiro.Protocol\MoShiro.Protocol.csproj" />
|
||||||
|
<ProjectReference Include="..\..\shared\MoShiro.Measurement\MoShiro.Measurement.csproj" />
|
||||||
<ProjectReference Include="..\..\fleet\src\Fleet.Identity\Fleet.Identity.csproj" />
|
<ProjectReference Include="..\..\fleet\src\Fleet.Identity\Fleet.Identity.csproj" />
|
||||||
<ProjectReference Include="..\..\fleet\src\Fleet.Link\Fleet.Link.csproj" />
|
<ProjectReference Include="..\..\fleet\src\Fleet.Link\Fleet.Link.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ using Fleet.Core;
|
|||||||
using Fleet.Identity;
|
using Fleet.Identity;
|
||||||
using Fleet.Link;
|
using Fleet.Link;
|
||||||
using Fleet.Rpc;
|
using Fleet.Rpc;
|
||||||
|
using MoShiro.Measurement;
|
||||||
using MoShiro.Protocol;
|
using MoShiro.Protocol;
|
||||||
|
|
||||||
namespace MoShiro.TestSupport;
|
namespace MoShiro.TestSupport;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 假 Probe:通过真实的点对点握手(TLS 固定证书 + 签名 + ECDH)连接真实的 Agent,接收 <c>line_task_set</c>,
|
/// 假 Probe:通过真实的点对点握手(TLS 固定证书 + 签名 + ECDH)连接真实的 Agent,接收 <c>line_task_set</c>,
|
||||||
/// 在自己的 UDP 套接字上发回显包、接收单向数据流、估计时钟偏移,并把 Probe 一侧的列块经发件箱通道 17 交给 Agent。
|
/// 通过 QUIC Datagram 发回显包、接收单向数据流、估计时钟偏移,并把 Probe 一侧的列块经发件箱通道 17 交给 Agent。
|
||||||
/// 它实现 Probe 的协议职责,是将来真实 Probe 的参照。
|
/// 它实现 Probe 的协议职责,是将来真实 Probe 的参照。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ProbeSimulator : IAsyncDisposable
|
public sealed class ProbeSimulator : IAsyncDisposable
|
||||||
@@ -30,8 +31,9 @@ public sealed class ProbeSimulator : IAsyncDisposable
|
|||||||
private readonly CancellationTokenSource _stop = new();
|
private readonly CancellationTokenSource _stop = new();
|
||||||
private NodeId _agentId;
|
private NodeId _agentId;
|
||||||
private RpcSession<ProbeSimulator>? _rpc;
|
private RpcSession<ProbeSimulator>? _rpc;
|
||||||
private UdpClient? _udp;
|
private QuicDatagramTransport? _quic;
|
||||||
private IPEndPoint? _agentUdp;
|
private DataSession? _dataSession;
|
||||||
|
private IPEndPoint? _agentQuic;
|
||||||
private PeerSessionInfo? _info;
|
private PeerSessionInfo? _info;
|
||||||
private Task? _receiver;
|
private Task? _receiver;
|
||||||
private Task? _run;
|
private Task? _run;
|
||||||
@@ -72,13 +74,22 @@ public sealed class ProbeSimulator : IAsyncDisposable
|
|||||||
(PeerSessionInfo info, TlsFrameTransport transport) = await PeerConnector.ConnectAsync(host, controlPort, agentPublicKey, _identity, ProbeId, "0.0.0-sim", 8 << 20, time ?? TimeProvider.System, ct);
|
(PeerSessionInfo info, TlsFrameTransport transport) = await PeerConnector.ConnectAsync(host, controlPort, agentPublicKey, _identity, ProbeId, "0.0.0-sim", 8 << 20, time ?? TimeProvider.System, ct);
|
||||||
_info = info;
|
_info = info;
|
||||||
_agentKey = agentPublicKey;
|
_agentKey = agentPublicKey;
|
||||||
_udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0));
|
_dataSession = new DataSession(info.Credentials.SessionIdValue, info.Credentials.Key);
|
||||||
_agentUdp = new IPEndPoint(IPAddress.Parse(host), udpPort);
|
_agentQuic = new IPEndPoint(IPAddress.Parse(host), udpPort);
|
||||||
|
_quic = new QuicDatagramTransport(
|
||||||
|
_dataSession,
|
||||||
|
new MonoClock(time ?? TimeProvider.System),
|
||||||
|
new DataPlaneCounters(),
|
||||||
|
new TestNodeLog(),
|
||||||
|
_agentQuic,
|
||||||
|
agentPublicKey,
|
||||||
|
0);
|
||||||
_rpc = new RpcSession<ProbeSimulator>(transport, _router, this, time ?? TimeProvider.System);
|
_rpc = new RpcSession<ProbeSimulator>(transport, _router, this, time ?? TimeProvider.System);
|
||||||
_rpc.OnBinary = (_, _) => ValueTask.CompletedTask;
|
_rpc.OnBinary = (_, _) => ValueTask.CompletedTask;
|
||||||
_run = _rpc.RunAsync(_stop.Token);
|
_run = _rpc.RunAsync(_stop.Token);
|
||||||
_receiver = Task.Run(ReceiveLoopAsync, _stop.Token);
|
_receiver = _quic.RunAsync(_stop.Token);
|
||||||
await SyncClockAsync();
|
await SyncClockAsync();
|
||||||
|
await _quic.ConnectAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<LineTaskSet> WaitForTasksAsync(Func<LineTaskSet, bool> predicate, TimeSpan? timeout = null)
|
public async Task<LineTaskSet> WaitForTasksAsync(Func<LineTaskSet, bool> predicate, TimeSpan? timeout = null)
|
||||||
@@ -123,19 +134,21 @@ public sealed class ProbeSimulator : IAsyncDisposable
|
|||||||
int size = (int)task.Params["size"].GetInt64();
|
int size = (int)task.Params["size"].GetInt64();
|
||||||
var collector = new EchoCollector();
|
var collector = new EchoCollector();
|
||||||
_echoes[task.StreamId] = collector;
|
_echoes[task.StreamId] = collector;
|
||||||
|
_quic!.Register(task.StreamId, OnDatagram);
|
||||||
long startUtc = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds();
|
long startUtc = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds();
|
||||||
byte[] packet = new byte[size];
|
byte[] packet = new byte[size];
|
||||||
for (uint seq = 0; seq < count; seq++)
|
for (uint seq = 0; seq < count; seq++)
|
||||||
{
|
{
|
||||||
long t1 = NowUs();
|
long t1 = NowUs();
|
||||||
collector.Sent[seq] = t1;
|
collector.Sent[seq] = t1;
|
||||||
UdpPacket.Write(packet, new UdpHeader(UdpPacketType.EchoRequest, _info!.Credentials.SessionIdValue, task.StreamId, seq, t1), _info.Credentials.Key);
|
MeasurementDatagram.Write(packet, new MeasurementDatagramHeader(MeasurementDatagramType.EchoRequest, _info!.Credentials.SessionIdValue, task.StreamId, seq, t1), _info.Credentials.Key);
|
||||||
await _udp!.SendAsync(packet, packet.Length, _agentUdp!);
|
_quic.Send(_dataSession!, _agentQuic!, packet);
|
||||||
await Task.Delay(interval, ct);
|
await Task.Delay(interval, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
await Task.Delay(300, ct); // 等最后的回复
|
await Task.Delay(300, ct); // 等最后的回复
|
||||||
_echoes.TryRemove(task.StreamId, out _);
|
_echoes.TryRemove(task.StreamId, out _);
|
||||||
|
_quic.Unregister(task.StreamId);
|
||||||
|
|
||||||
var msg = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq"), Col.U16("size"), Col.TsProbe("t1"), Col.TsProbe("t4", nullable: true)]));
|
var msg = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq"), Col.U16("size"), Col.TsProbe("t1"), Col.TsProbe("t4", nullable: true)]));
|
||||||
foreach ((uint seq, long t1) in collector.Sent.OrderBy(p => p.Key))
|
foreach ((uint seq, long t1) in collector.Sent.OrderBy(p => p.Key))
|
||||||
@@ -191,22 +204,28 @@ public sealed class ProbeSimulator : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>发送任意字节到 Agent 的 UDP 端口(用来测试 Agent 对垃圾包的处理)。</summary>
|
/// <summary>发送任意字节到 Agent 的 UDP 端口(用来测试 Agent 对垃圾包的处理)。</summary>
|
||||||
public Task SendRawUdpAsync(byte[] datagram) => _udp!.SendAsync(datagram, datagram.Length, _agentUdp!);
|
public Task SendRawUdpAsync(byte[] datagram)
|
||||||
|
{
|
||||||
|
_quic!.Send(_dataSession!, _agentQuic!, datagram);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>用伪造的密钥发一个回显包。</summary>
|
/// <summary>用伪造的密钥发一个回显包。</summary>
|
||||||
public Task SendForgedEchoAsync(LineTask task, uint seq, byte[]? key = null)
|
public Task SendForgedEchoAsync(LineTask task, uint seq, byte[]? key = null)
|
||||||
{
|
{
|
||||||
byte[] packet = new byte[64];
|
byte[] packet = new byte[64];
|
||||||
UdpPacket.Write(packet, new UdpHeader(UdpPacketType.EchoRequest, _info!.Credentials.SessionIdValue, task.StreamId, seq, NowUs()), key ?? new byte[32]);
|
MeasurementDatagram.Write(packet, new MeasurementDatagramHeader(MeasurementDatagramType.EchoRequest, _info!.Credentials.SessionIdValue, task.StreamId, seq, NowUs()), key ?? new byte[32]);
|
||||||
return _udp!.SendAsync(packet, packet.Length, _agentUdp!);
|
_quic!.Send(_dataSession!, _agentQuic!, packet);
|
||||||
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>重放:用同一个序号、同样的密钥再发一次。</summary>
|
/// <summary>重放:用同一个序号、同样的密钥再发一次。</summary>
|
||||||
public Task SendEchoAsync(LineTask task, uint seq)
|
public Task SendEchoAsync(LineTask task, uint seq)
|
||||||
{
|
{
|
||||||
byte[] packet = new byte[64];
|
byte[] packet = new byte[64];
|
||||||
UdpPacket.Write(packet, new UdpHeader(UdpPacketType.EchoRequest, _info!.Credentials.SessionIdValue, task.StreamId, seq, NowUs()), _info.Credentials.Key);
|
MeasurementDatagram.Write(packet, new MeasurementDatagramHeader(MeasurementDatagramType.EchoRequest, _info!.Credentials.SessionIdValue, task.StreamId, seq, NowUs()), _info.Credentials.Key);
|
||||||
return _udp!.SendAsync(packet, packet.Length, _agentUdp!);
|
_quic!.Send(_dataSession!, _agentQuic!, packet);
|
||||||
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public int EchoRepliesSeen(uint streamId) => _echoes.TryGetValue(streamId, out EchoCollector? c) ? c.Received.Count : 0;
|
public int EchoRepliesSeen(uint streamId) => _echoes.TryGetValue(streamId, out EchoCollector? c) ? c.Received.Count : 0;
|
||||||
@@ -227,7 +246,7 @@ public sealed class ProbeSimulator : IAsyncDisposable
|
|||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
await _stop.CancelAsync();
|
await _stop.CancelAsync();
|
||||||
_udp?.Dispose();
|
_quic?.Dispose();
|
||||||
if (_rpc is not null)
|
if (_rpc is not null)
|
||||||
{
|
{
|
||||||
await _rpc.DisposeAsync();
|
await _rpc.DisposeAsync();
|
||||||
@@ -277,41 +296,18 @@ public sealed class ProbeSimulator : IAsyncDisposable
|
|||||||
return header;
|
return header;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ReceiveLoopAsync()
|
private void OnDatagram(in QuicDatagramDelivery delivery)
|
||||||
{
|
{
|
||||||
while (!_stop.IsCancellationRequested)
|
long now = delivery.ReceiveUs;
|
||||||
|
MeasurementDatagramHeader header = delivery.Header;
|
||||||
|
if (header.Type == MeasurementDatagramType.EchoReply && _echoes.TryGetValue(header.StreamId, out EchoCollector? echo))
|
||||||
{
|
{
|
||||||
UdpReceiveResult result;
|
echo.Received.TryAdd(header.Seq, now);
|
||||||
try
|
}
|
||||||
{
|
else if (header.Type == MeasurementDatagramType.Data && _trains.TryGetValue(header.StreamId, out TrainCollector? train))
|
||||||
result = await _udp!.ReceiveAsync(_stop.Token);
|
{
|
||||||
}
|
train.Received.TryAdd(header.Seq, now);
|
||||||
catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException or SocketException)
|
train.Size = delivery.Payload.Length;
|
||||||
{
|
|
||||||
if (_stop.IsCancellationRequested || ex is ObjectDisposedException)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
long now = NowUs();
|
|
||||||
byte[] data = result.Buffer;
|
|
||||||
if (!UdpPacket.TryParseHeader(data, out UdpHeader header) || !UdpPacket.VerifyMac(data, _info!.Credentials.Key))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (header.Type == UdpPacketType.EchoReply && _echoes.TryGetValue(header.StreamId, out EchoCollector? echo))
|
|
||||||
{
|
|
||||||
echo.Received.TryAdd(header.Seq, now);
|
|
||||||
}
|
|
||||||
else if (header.Type == UdpPacketType.Data && _trains.TryGetValue(header.StreamId, out TrainCollector? train))
|
|
||||||
{
|
|
||||||
train.Received.TryAdd(header.Seq, now);
|
|
||||||
train.Size = data.Length;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,33 +362,39 @@ public sealed class ProbeSimulator : IAsyncDisposable
|
|||||||
|
|
||||||
private readonly ConcurrentDictionary<uint, TestPhaseStartParams> _activePhases = new();
|
private readonly ConcurrentDictionary<uint, TestPhaseStartParams> _activePhases = new();
|
||||||
|
|
||||||
private async Task<bool> StartPhaseAsync(TestPhaseStartParams start, CancellationToken ct)
|
private Task<bool> StartPhaseAsync(TestPhaseStartParams start, CancellationToken ct)
|
||||||
{
|
{
|
||||||
Phases.Enqueue(start);
|
Phases.Enqueue(start);
|
||||||
if (RefusePhases)
|
if (RefusePhases)
|
||||||
{
|
{
|
||||||
return false;
|
return Task.FromResult(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
_activePhases[start.StreamId] = start;
|
_activePhases[start.StreamId] = start;
|
||||||
if (start.Kind == "udp_train")
|
if (start.Kind == "udp_train")
|
||||||
{
|
{
|
||||||
_trains[start.StreamId] = new TrainCollector { StartUtcMs = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds() };
|
_trains[start.StreamId] = new TrainCollector { StartUtcMs = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds() };
|
||||||
|
_quic!.Register(start.StreamId, OnDatagram);
|
||||||
// 先发几个探活包,让 Agent 学到(最新的)源地址,再回复 accepted
|
// 先发几个探活包,让 Agent 学到(最新的)源地址,再回复 accepted
|
||||||
byte[] hello = new byte[64];
|
byte[] hello = new byte[64];
|
||||||
for (uint seq = 0; seq < 3; seq++)
|
for (uint seq = 0; seq < 3; seq++)
|
||||||
{
|
{
|
||||||
UdpPacket.Write(hello, new UdpHeader(UdpPacketType.Data, _info!.Credentials.SessionIdValue, start.StreamId, seq, NowUs()), _info.Credentials.Key);
|
MeasurementDatagram.Write(hello, new MeasurementDatagramHeader(MeasurementDatagramType.Data, _info!.Credentials.SessionIdValue, start.StreamId, seq, NowUs()), _info.Credentials.Key);
|
||||||
await _udp!.SendAsync(hello.AsMemory(), _agentUdp!, ct);
|
_quic.Send(_dataSession!, _agentQuic!, hello);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
_ = ct;
|
||||||
|
return Task.FromResult(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<int> EndPhaseAsync(TestPhaseEndParams end, CancellationToken ct)
|
private async Task<int> EndPhaseAsync(TestPhaseEndParams end, CancellationToken ct)
|
||||||
{
|
{
|
||||||
_activePhases.TryRemove(end.StreamId, out TestPhaseStartParams? start);
|
_activePhases.TryRemove(end.StreamId, out TestPhaseStartParams? start);
|
||||||
|
if (start is not null)
|
||||||
|
{
|
||||||
|
_quic!.Unregister(start.StreamId);
|
||||||
|
}
|
||||||
if (start is null || DropFragments)
|
if (start is null || DropFragments)
|
||||||
{
|
{
|
||||||
_trains.TryRemove(end.StreamId, out _);
|
_trains.TryRemove(end.StreamId, out _);
|
||||||
@@ -456,4 +458,9 @@ public sealed class ProbeSimulator : IAsyncDisposable
|
|||||||
|
|
||||||
public long StartUtcMs { get; init; }
|
public long StartUtcMs { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class TestNodeLog : INodeLog
|
||||||
|
{
|
||||||
|
public void Write(NodeLogLevel level, string message, params (string Key, object? Value)[] fields) { }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
│ Agent × 10+ │ ◀── 测量会话 ── │ Probe × N │
|
│ Agent × 10+ │ ◀── 测量会话 ── │ Probe × N │
|
||||||
│ 有公网 IP,监听 │ 先敲门再 TLS │ 家宽等出口,NAT 后 │
|
│ 有公网 IP,监听 │ 先敲门再 TLS │ 家宽等出口,NAT 后 │
|
||||||
└────────────────────┘ └──────────────────────┘
|
└────────────────────┘ └──────────────────────┘
|
||||||
测量会话:TLS 控制通道 + UDP/TCP 数据通道,由 Probe 主动拨出
|
测量会话:TLS 控制通道 + QUIC Datagram/TCP 数据通道,由 Probe 主动拨出(Agent 固定 QUIC 端口,Probe 本地源端口轮换)
|
||||||
宿主机更新器(容器节点):用本机公钥验签 → 按 digest 拉镜像 → 重启,失败回滚
|
宿主机更新器(容器节点):用本机公钥验签 → 按 digest 拉镜像 → 重启,失败回滚
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
| 任务模型 | `Task = {kind, target, params, schedule}`;持续采集、按需测试、目标任务统一建模;下发“期望任务集”,由节点自行调和 | [measurement.md](measurement.md) |
|
| 任务模型 | `Task = {kind, target, params, schedule}`;持续采集、按需测试、目标任务统一建模;下发“期望任务集”,由节点自行调和 | [measurement.md](measurement.md) |
|
||||||
| 采集扩展 | Kind 由积木组合声明;采集密度靠参数调整;原始块自描述 | [measurement.md](measurement.md) |
|
| 采集扩展 | Kind 由积木组合声明;采集密度靠参数调整;原始块自描述 | [measurement.md](measurement.md) |
|
||||||
| 统计扩展 | 指标注册制:少数几种累加器 + 统计算子;一行 `MetricDef` 就是一个新指标 | [analysis.md](analysis.md) |
|
| 统计扩展 | 指标注册制:少数几种累加器 + 统计算子;一行 `MetricDef` 就是一个新指标 | [analysis.md](analysis.md) |
|
||||||
| 协议 | 控制链路使用 Fleet(JSON-RPC 2.0 + 二进制通道);MoShiro 只定义 `moshiro.*` 方法、`TaskSet` 状态文档和 UDP/TCP 数据面 | [protocol.md](protocol.md) |
|
| 协议 | 控制链路使用 Fleet(JSON-RPC 2.0 + 二进制通道);MoShiro 只定义 `moshiro.*` 方法、`TaskSet` 状态文档和 QUIC Datagram/TCP 数据面 | [protocol.md](protocol.md) |
|
||||||
| 存储 | 原始块追加写入段文件,SQLite 记录偏移量;指标窗口通用存储并降采样 | [storage.md](storage.md) |
|
| 存储 | 原始块追加写入段文件,SQLite 记录偏移量;指标窗口通用存储并降采样 | [storage.md](storage.md) |
|
||||||
| 展示查询 | 任意时间段(相对或绝对);自动步长(每条曲线最多约 720 个点);1s 内存热层(1 小时)加上 10s/1m/5m/1h 各层;累加器现场合并,汇总精确;列式响应;SSE 实时跟随 | [query.md](query.md) |
|
| 展示查询 | 任意时间段(相对或绝对);自动步长(每条曲线最多约 720 个点);1s 内存热层(1 小时)加上 10s/1m/5m/1h 各层;累加器现场合并,汇总精确;列式响应;SSE 实时跟随 | [query.md](query.md) |
|
||||||
| API | `/api/public`(只读,可设为公开)、`/api/admin`(登录)、`/api/agent`(注册与 RPC);实时数据用 SSE | [api.md](../api/api.md) |
|
| API | `/api/public`(只读,可设为公开)、`/api/admin`(登录)、`/api/agent`(注册与 RPC);实时数据用 SSE | [api.md](../api/api.md) |
|
||||||
@@ -157,7 +157,7 @@ Kind 目录编译在二进制中,节点不接受远程下发代码(安全边
|
|||||||
| D15 | 注册独立为 `fleet-enroll` 工具;常驻进程不包含注册代码 | 常驻进程去掉 HttpClient,边界更纯 |
|
| D15 | 注册独立为 `fleet-enroll` 工具;常驻进程不包含注册代码 | 常驻进程去掉 HttpClient,边界更纯 |
|
||||||
| D16 | 更新与业务进程分离:发布密钥签名、本地策略、健康确认与回滚。已实现:宿主机 `moshiro-update`(Backend、脚本装的节点,清单里是镜像 digest),Quadlet 节点则靠退出后由 systemd 重新拉镜像;纯二进制节点的更新见 [TODO.md](../../TODO.md) | 面板被攻破时也无法推送恶意二进制 |
|
| D16 | 更新与业务进程分离:发布密钥签名、本地策略、健康确认与回滚。已实现:宿主机 `moshiro-update`(Backend、脚本装的节点,清单里是镜像 digest),Quadlet 节点则靠退出后由 systemd 重新拉镜像;纯二进制节点的更新见 [TODO.md](../../TODO.md) | 面板被攻破时也无法推送恶意二进制 |
|
||||||
| D17 | Probe 支持 OpenWrt:正式支持 `linux-musl-x64`、`linux-musl-arm64`;armv7 视工具链验证结果决定;MIPS 不支持,改为在局域网设备上运行 Probe | 由用户确认;NativeAOT 不支持 MIPS |
|
| D17 | Probe 支持 OpenWrt:正式支持 `linux-musl-x64`、`linux-musl-arm64`;armv7 视工具链验证结果决定;MIPS 不支持,改为在局域网设备上运行 Probe | 由用户确认;NativeAOT 不支持 MIPS |
|
||||||
| D18 | 默认测量端口不使用 443:控制 7443/tcp,UDP 数据 7443/udp,TCP 数据 7444/tcp;443 只在 Agent 本地显式开启时才允许使用 | 由用户确认;避免与 VPS 上的真实服务冲突;结果会记录实际端口 |
|
| D18 | 默认测量端口不使用 443:控制 7443/tcp,QUIC Datagram 数据 7443/udp,TCP 数据 7444/tcp;443 只在 Agent 本地显式开启时才允许使用 | 由用户确认;避免与 VPS 上的真实服务冲突;结果会记录实际端口 |
|
||||||
| D19 | 展示查询最细 1s:最近 1 小时来自内存热层,更早的数据来自 10s/1m/5m/1h 各层,现场合并;时间段内的汇总由累加器精确合并得出 | 由用户确认;5 分钟、15 分钟这类视图足够精细且快 |
|
| D19 | 展示查询最细 1s:最近 1 小时来自内存热层,更早的数据来自 10s/1m/5m/1h 各层,现场合并;时间段内的汇总由累加器精确合并得出 | 由用户确认;5 分钟、15 分钟这类视图足够精细且快 |
|
||||||
| D20 | 时序响应采用列式格式(`t[]` + `values{}`,时间轴使用 Unix 毫秒) | 由用户确认;体积小,可以直接交给图表库 |
|
| D20 | 时序响应采用列式格式(`t[]` + `values{}`,时间轴使用 Unix 毫秒) | 由用户确认;体积小,可以直接交给图表库 |
|
||||||
| D21 | 国家和运营商只由 Backend 向 IP 归属服务查询(缺省 `https://ip.007995.xyz`,可关闭),只查公网地址;节点不访问任何外部服务;公开接口不返回 IP、城市、ASN | 客户的防火墙常常要审计节点的出站连接;访客拿不到敏感信息(有测试保证) |
|
| D21 | 国家和运营商只由 Backend 向 IP 归属服务查询(缺省 `https://ip.007995.xyz`,可关闭),只查公网地址;节点不访问任何外部服务;公开接口不返回 IP、城市、ASN | 客户的防火墙常常要审计节点的出站连接;访客拿不到敏感信息(有测试保证) |
|
||||||
|
|||||||
@@ -145,10 +145,10 @@ MoShiro.Measurement/ 依赖:MoShiro.Protocol(仅 BCL,
|
|||||||
│ ├─ MonoClock.cs 单调时钟(微秒)与 UTC 锚点
|
│ ├─ MonoClock.cs 单调时钟(微秒)与 UTC 锚点
|
||||||
│ └─ RunningBlock.cs 一个运行中的积木(取消令牌 + 后台任务)
|
│ └─ RunningBlock.cs 一个运行中的积木(取消令牌 + 后台任务)
|
||||||
├─ Net/
|
├─ Net/
|
||||||
│ ├─ UdpMux.cs UDP 数据面:按 stream 分发;校验顺序固定;准入策略(重放、限速)通过 IAdmission 注入
|
│ ├─ QuicDatagramTransport.cs、MeasurementDatagramRouter.cs MsQuic 数据面与逐流认证分发
|
||||||
│ └─ Tcp.cs TcpLink(拨号:TLS 按指纹固定 + 认证首帧)、TcpAcceptor(监听:TLS 自动识别 + 认证首帧)
|
│ └─ Tcp.cs TcpLink(拨号:TLS 按指纹固定 + 认证首帧)、TcpAcceptor(监听:TLS 自动识别 + 认证首帧)
|
||||||
├─ Blocks/ 一个文件 = 一种采集方法的两个角色
|
├─ Blocks/ 一个文件 = 一种采集方法的两个角色
|
||||||
│ ├─ Echo.cs UdpEchoDialer/Listener、TcpEchoDialer/Listener
|
│ ├─ Echo.cs DatagramEchoDialer/Listener、TcpEchoDialer/Listener
|
||||||
│ ├─ Session.cs ConnectDialer、HoldDialer、IcmpEchoSolo(没有对端)、IdleListener
|
│ ├─ Session.cs ConnectDialer、HoldDialer、IcmpEchoSolo(没有对端)、IdleListener
|
||||||
│ ├─ Train.cs TrainDialer(接收,记 t_recv)、TrainListener(按码率发包,记发送行)
|
│ ├─ Train.cs TrainDialer(接收,记 t_recv)、TrainListener(按码率发包,记发送行)
|
||||||
│ └─ BlockCatalog.cs Kind → (拨号方, 监听方);capabilities、本地预算的支持列表都从这里推出来
|
│ └─ BlockCatalog.cs Kind → (拨号方, 监听方);capabilities、本地预算的支持列表都从这里推出来
|
||||||
@@ -174,7 +174,7 @@ MoShiro.Measurement/ 依赖:MoShiro.Protocol(仅 BCL,
|
|||||||
| 用途 | 默认端口 | 说明 |
|
| 用途 | 默认端口 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Probe 控制通道(TLS,Fleet 点对点会话) | 7443/tcp | |
|
| Probe 控制通道(TLS,Fleet 点对点会话) | 7443/tcp | |
|
||||||
| UDP 数据(`udp_echo`、`udp_train`) | 7443/udp | |
|
| QUIC Datagram 数据(`udp_echo`、`udp_train`) | 7443/udp | Agent 固定监听;Probe 每次新连接轮换本地源端口 |
|
||||||
| TCP/TLS 数据(`tcp_echo`、`tcp_bulk`、`connect`、`hold`) | 7444/tcp | 按首帧中的 `stream_id` 分发 |
|
| TCP/TLS 数据(`tcp_echo`、`tcp_bulk`、`connect`、`hold`) | 7444/tcp | 按首帧中的 `stream_id` 分发 |
|
||||||
|
|
||||||
- **默认不使用 443**,避免与 VPS 上已有的 REALITY 或 Hysteria2 服务冲突,也不需要特权端口权限。
|
- **默认不使用 443**,避免与 VPS 上已有的 REALITY 或 Hysteria2 服务冲突,也不需要特权端口权限。
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ MoShiro 的两条控制链路都建立在 Fleet 之上:
|
|||||||
- **Agent ↔ Backend**:Fleet 连接服务端(wss),见 [../backend/fleet/README.md](../../backend/fleet/README.md);
|
- **Agent ↔ Backend**:Fleet 连接服务端(wss),见 [../backend/fleet/README.md](../../backend/fleet/README.md);
|
||||||
- **Probe ↔ Agent**:Fleet 节点间点对点会话(TLS),见 [../backend/fleet/README.md](../../backend/fleet/README.md) 的「点对点会话」一节。
|
- **Probe ↔ Agent**:Fleet 节点间点对点会话(TLS),见 [../backend/fleet/README.md](../../backend/fleet/README.md) 的「点对点会话」一节。
|
||||||
|
|
||||||
认证、心跳、重连、能力协商、可靠发件箱、期望状态同步、JSON-RPC 信封都由 Fleet 负责。本文只定义 **MoShiro 自己的内容**:`moshiro.*` 方法、状态文档、二进制通道,以及 Fleet 之外的数据面(UDP 包和数据连接)。所有类型都放在 `MoShiro.Protocol` 中。
|
认证、心跳、重连、能力协商、可靠发件箱、期望状态同步、JSON-RPC 信封都由 Fleet 负责。本文只定义 **MoShiro 自己的内容**:`moshiro.*` 方法、状态文档、二进制通道,以及 Fleet 之外的数据面(QUIC Datagram 和 TCP 数据连接)。所有类型都放在 `MoShiro.Protocol` 中。
|
||||||
|
|
||||||
## 1. Fleet 能力的映射
|
## 1. Fleet 能力的映射
|
||||||
|
|
||||||
| MoShiro 需要的能力 | 使用的 Fleet 机制 | MoShiro 提供的内容 |
|
| MoShiro 需要的能力 | 使用的 Fleet 机制 | MoShiro 提供的内容 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 下发任务 | 期望状态 `fleet.state.sync` | 文档类型 `TaskSet`,包括 `tasks[]`、`peers`(允许连这个 Agent 的 Probe 公钥,已按线路开关过滤) |
|
| 下发任务 | 期望状态 `fleet.state.sync` | 文档类型 `TaskSet`,包括 `tasks[]`、`peers`(允许连这个 Agent 的 Probe 公钥,已按线路开关过滤) |
|
||||||
| 下发 Agent 列表给 Probe | 期望状态 `fleet.state.sync`(Probe 直连面板) | 文档类型 `probe_agents`:`version`、`agents[]`(`agent_id`、`host`、`tcp_port`、`udp_ports`、`public_key`),`public_key` 是 Agent 面向 Probe 的 TLS 证书公钥(SPKI,base64):Probe 用它固定证书并派生敲门密钥;没有上报公钥的 Agent 不会下发。已按线路开关过滤;Agent 集合或线路开关变化时重新推送 |
|
| 下发 Agent 列表给 Probe | 期望状态 `fleet.state.sync`(Probe 直连面板) | 文档类型 `probe_agents`:`version`、`agents[]`(`agent_id`、`host`、`tcp_port`、`quic_ports`、`public_key`),`public_key` 是 Agent 面向 Probe 的 TLS 证书公钥(SPKI,base64):Probe 用它固定证书并派生敲门密钥;没有上报公钥的 Agent 不会下发。已按线路开关过滤;Agent 集合或线路开关变化时重新推送 |
|
||||||
| 定时重启 | 通知 `moshiro.schedule`(B→A、B→P) | `{restart_at}`,形如 `04:30+08:00`,空表示关闭;连上时补发、管理端改了设置就群发。节点本地写了 `restart_at` 或 `MOSHIRO_RESTART_AT` 时以本地为准;节点只是到点自己有序退出,由服务管理器拉起 |
|
| 定时重启 | 通知 `moshiro.schedule`(B→A、B→P) | `{restart_at}`,形如 `04:30+08:00`,空表示关闭;连上时补发、管理端改了设置就群发。节点本地写了 `restart_at` 或 `MOSHIRO_RESTART_AT` 时以本地为准;节点只是到点自己有序退出,由服务管理器拉起 |
|
||||||
| 更新 | 通知 `moshiro.update.offer` / `moshiro.update.apply`(B→A、B→P),心跳附加字段 `update` | `offer` 带 CI 签名的发布清单原文和签名,节点只写进数据目录;`apply` 请求更新;`update` 是宿主机更新器的状态(`state`、`current`、`available`、`error`、`at`),见 [deployment.md](../ops/deployment.md) |
|
| 更新 | 通知 `moshiro.update.offer` / `moshiro.update.apply`(B→A、B→P),心跳附加字段 `update` | `offer` 带 CI 签名的发布清单原文和签名,节点只写进数据目录;`apply` 请求更新;`update` 是宿主机更新器的状态(`state`、`current`、`available`、`error`、`at`),见 [deployment.md](../ops/deployment.md) |
|
||||||
| 上报原始块 | 发件箱,二进制通道 **16** | `RawChunk`(自描述,见 [measurement.md](measurement.md) 第 4 节),`msg_id = chunk_id` |
|
| 上报原始块 | 发件箱,二进制通道 **16** | `RawChunk`(自描述,见 [measurement.md](measurement.md) 第 4 节),`msg_id = chunk_id` |
|
||||||
@@ -113,5 +113,5 @@ Probe 只记录自己一侧的列,Agent 在数据面记录另一侧,由 Agen
|
|||||||
- Fleet 层的兼容规则见 [../backend/fleet/README.md](../../backend/fleet/README.md)。
|
- Fleet 层的兼容规则见 [../backend/fleet/README.md](../../backend/fleet/README.md)。
|
||||||
- `moshiro.*` 方法:新增方法或可选字段不需要升级版本,接收方忽略未知字段。
|
- `moshiro.*` 方法:新增方法或可选字段不需要升级版本,接收方忽略未知字段。
|
||||||
- RawChunk:块头自描述,新增列或新增 Kind 不需要升级 `container_version`;只有容器格式变化时才升级。
|
- RawChunk:块头自描述,新增列或新增 Kind 不需要升级 `container_version`;只有容器格式变化时才升级。
|
||||||
- UDP 包和数据连接首帧的布局变化时,必须升级 `protocol_version`,并更新黄金字节测试。
|
- QUIC Datagram 和 TCP 数据连接首帧的布局变化时,必须升级 `protocol_version`,并更新黄金字节测试。
|
||||||
- Agent 同时支持当前版本和上一个版本的 Probe;Backend 能解码保留期内所有的 `container_version`。
|
- Agent 同时支持当前版本和上一个版本的 Probe;Backend 能解码保留期内所有的 `container_version`。
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
## 1. 目标
|
## 1. 目标
|
||||||
|
|
||||||
1. **三个独立项目加一个前端**:Probe、Agent、Backend 各自拥有解决方案,可以单独构建、测试和发布;Web 是独立的 Vite 项目(`web/`)。
|
1. **三个独立项目加一个前端**:Probe、Agent、Backend 各自拥有解决方案,可以单独构建、测试和发布;Web 是独立的 Vite 项目(`web/`)。
|
||||||
2. **通用能力只写一次**:TCP/UDP/TLS/ICMP 采集积木(类似 iperf3 的能力,但只记录原始数据)放在 `MoShiro.Measurement` 中,Probe 和 Agent 共用;采集方法(Kind)由积木组合声明而成。
|
2. **通用能力只写一次**:TCP/QUIC Datagram/TLS/ICMP 采集积木(类似 iperf3 的能力,但只记录原始数据)放在 `MoShiro.Measurement` 中,Probe 和 Agent 共用;采集方法(Kind)由积木组合声明而成。
|
||||||
3. **节点机制通用化**:注册、身份、连接、RPC、可靠上报、状态同步、宿主,由与业务无关的 **Fleet** 框架提供(见 [../backend/fleet/README.md](../../backend/fleet/README.md))。MoShiro 只写业务,其他产品也可以复用 Fleet。
|
3. **节点机制通用化**:注册、身份、连接、RPC、可靠上报、状态同步、宿主,由与业务无关的 **Fleet** 框架提供(见 [../backend/fleet/README.md](../../backend/fleet/README.md))。MoShiro 只写业务,其他产品也可以复用 Fleet。
|
||||||
4. **改一处,整条链路受益**:协议、原始数据格式、测试方案、指标、错误码、版本号、编译设置、代码风格都只有**一个真源**。改动之后,下游要么自动更新,要么在编译期或 CI 中直接报错。
|
4. **改一处,整条链路受益**:协议、原始数据格式、测试方案、指标、错误码、版本号、编译设置、代码风格都只有**一个真源**。改动之后,下游要么自动更新,要么在编译期或 CI 中直接报错。
|
||||||
5. **模块化**:Probe/Agent 由可插拔的模块组成;Backend 按层分成多个项目,层之间的依赖在编译期强制。
|
5. **模块化**:Probe/Agent 由可插拔的模块组成;Backend 按层分成多个项目,层之间的依赖在编译期强制。
|
||||||
@@ -111,7 +111,7 @@ MoShiro.Protocol/
|
|||||||
│ └─ ParamSpec 参数定义(类型、默认值、上下限),节点与 Backend 共用同一套校验
|
│ └─ ParamSpec 参数定义(类型、默认值、上下限),节点与 Backend 共用同一套校验
|
||||||
├─ Tasks/ TaskSet(Fleet 期望状态的文档类型)、调和结果
|
├─ Tasks/ TaskSet(Fleet 期望状态的文档类型)、调和结果
|
||||||
├─ Rpc/ moshiro.* 方法名、params/result、二进制通道号(从 16 开始)
|
├─ Rpc/ moshiro.* 方法名、params/result、二进制通道号(从 16 开始)
|
||||||
├─ Wire/ UDP 包、数据连接首帧
|
├─ Wire/ QUIC Datagram 测量包
|
||||||
└─ Raw/ 自描述 RawChunk:头部、列类型、列编解码、Brotli、crc32c
|
└─ Raw/ 自描述 RawChunk:头部、列类型、列编解码、Brotli、crc32c
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -142,7 +142,7 @@ AgentApp Probe(ProbeApp)
|
|||||||
发件箱 RawChunk(通道 16) 心跳带更新器的状态;moshiro.update.* 通知
|
发件箱 RawChunk(通道 16) 心跳带更新器的状态;moshiro.update.* 通知
|
||||||
心跳带更新器的状态;moshiro.update.* AgentDirectory → 每个 Agent 一个 AgentConnection(点对点会话)
|
心跳带更新器的状态;moshiro.update.* AgentDirectory → 每个 Agent 一个 AgentConnection(点对点会话)
|
||||||
PeerListener / PeerHub(Probe 会话) 会话内:线路任务集 line_task_set 的调和、角色、列块发件箱(通道 17)
|
PeerListener / PeerHub(Probe 会话) 会话内:线路任务集 line_task_set 的调和、角色、列块发件箱(通道 17)
|
||||||
UdpMux / TcpAcceptor(数据端口)
|
QuicDatagramTransport / TcpAcceptor(数据端口)
|
||||||
TaskManager / TestRunner / ChunkAssembler
|
TaskManager / TestRunner / ChunkAssembler
|
||||||
RestartUpdater(只报告有无新版本、请求时退出) RestartUpdater
|
RestartUpdater(只报告有无新版本、请求时退出) RestartUpdater
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 部署
|
# 部署
|
||||||
|
|
||||||
节点(Agent、Probe)是单个 NativeAOT 可执行文件,没有运行时依赖,只需要系统的 OpenSSL 3(`libssl.so.3`)。启动时会自检,缺少时第一步就明确报错。
|
节点(Agent、Probe)使用 NativeAOT。QUIC 目前通过应用目录优先加载 MsQuic v2;Linux 还需要 OpenSSL 3(`libssl.so.3`)和 MsQuic 依赖的 `libnuma.so.1`。CI 发布包尚需补上这些原生 sidecar;在这项打包接通前,Linux 主机要从系统包安装 MsQuic 和 libnuma,发布包不能视为免依赖安装包。启动时会检查 MsQuic 是否可加载。
|
||||||
|
|
||||||
## 1. 构建与打包
|
## 1. 构建与打包
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ Agent 和 Probe 在 hello 初始化及心跳中上报 IPv6 检测结果,包括
|
|||||||
|
|
||||||
升级 Backend、Agent、Probe 后,在管理端的 Agent 编辑页保留 IPv4 地址,并填写 IPv6 地址或带 AAAA 记录的域名。未填写 IPv6 地址的 VPS 只采集 IPv4;Probe 没有 IPv6 路由或 IPv6 连接失败时,IPv4 会话继续工作。
|
升级 Backend、Agent、Probe 后,在管理端的 Agent 编辑页保留 IPv4 地址,并填写 IPv6 地址或带 AAAA 记录的域名。未填写 IPv6 地址的 VPS 只采集 IPv4;Probe 没有 IPv6 路由或 IPv6 连接失败时,IPv4 会话继续工作。
|
||||||
|
|
||||||
Agent 的新默认监听地址为 `::`(双栈);现有 `agent.json` 如果显式配置了 `0.0.0.0`,需要在宿主机改为 `::` 并重启节点。系统不支持 IPv6 套接字时,新默认值退回 IPv4 监听。防火墙需允许 IPv6 的 `7443/tcp`、`7443/udp`、`7444/tcp`(自定义端口时用实际端口);容器推荐宿主网络。管理端的「IPv6 监听」显示节点实际是否启用。
|
Agent 的新默认监听地址为 `::`(双栈);现有 `agent.json` 如果显式配置了 `0.0.0.0`,需要在宿主机改为 `::` 并重启节点。系统不支持 IPv6 套接字时,新默认值退回 IPv4 监听。防火墙需允许 IPv6 的 `7443/tcp`、`7443/udp`(QUIC)、`7444/tcp`(自定义端口时用实际端口);容器推荐宿主网络。管理端的「IPv6 监听」显示节点实际是否启用。
|
||||||
|
|
||||||
原来的 SQLite 数据库自动迁移,旧原始块、指标和测试结果继续属于 IPv4。先升级 Backend,再升级节点,最后填写 IPv6 地址;旧 Agent 不会上报 IPv6 支持,Backend 不会给它安排第二条会话。
|
原来的 SQLite 数据库自动迁移,旧原始块、指标和测试结果继续属于 IPv4。先升级 Backend,再升级节点,最后填写 IPv6 地址;旧 Agent 不会上报 IPv6 支持,Backend 不会给它安排第二条会话。
|
||||||
|
|
||||||
@@ -155,7 +155,7 @@ Linux 上比 Windows 大,是因为 .NET 的加密、压缩、网络本机库
|
|||||||
以 `moshiro` 用户运行,沙箱:`NoNewPrivileges`、`ProtectSystem=strict`、`ProtectHome`、`PrivateTmp/Devices`、`ProtectKernel*`、`RestrictAddressFamilies=AF_INET AF_INET6`、`RestrictNamespaces`、`MemoryDenyWriteExecute`(NativeAOT 不 JIT)、`SystemCallArchitectures=native`,`MemoryMax`(Agent 128 MB、Probe 64 MB)。
|
以 `moshiro` 用户运行,沙箱:`NoNewPrivileges`、`ProtectSystem=strict`、`ProtectHome`、`PrivateTmp/Devices`、`ProtectKernel*`、`RestrictAddressFamilies=AF_INET AF_INET6`、`RestrictNamespaces`、`MemoryDenyWriteExecute`(NativeAOT 不 JIT)、`SystemCallArchitectures=native`,`MemoryMax`(Agent 128 MB、Probe 64 MB)。
|
||||||
|
|
||||||
- **Agent** 只保留 `CAP_NET_RAW`(ICMP 目标任务用),`/etc/moshiro` 只读;Probe 没有任何 capability,只对 `agents.json` 所在的目录可写。
|
- **Agent** 只保留 `CAP_NET_RAW`(ICMP 目标任务用),`/etc/moshiro` 只读;Probe 没有任何 capability,只对 `agents.json` 所在的目录可写。
|
||||||
- 监听 1024 以下的端口(例如把数据面放到 443)需要在 drop-in 里加 `AmbientCapabilities=CAP_NET_BIND_SERVICE` 和对应的 `CapabilityBoundingSet`。默认端口是 7443/tcp+udp(Probe 控制通道和 UDP 数据面)、7444/tcp。
|
- 监听 1024 以下的端口(例如把数据面放到 443)需要在 drop-in 里加 `AmbientCapabilities=CAP_NET_BIND_SERVICE` 和对应的 `CapabilityBoundingSet`。默认端口是 7443/tcp(Probe 控制通道)、7443/udp(QUIC Datagram 数据面)、7444/tcp(TCP 测量)。Agent 在 UDP 端口固定监听;Probe 的 QUIC 连接使用轮换的临时本地源端口。
|
||||||
|
|
||||||
### OpenWrt(`packaging/openwrt/`)
|
### OpenWrt(`packaging/openwrt/`)
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
├─ 用 Docker 或 Podman 起容器,身份和配置存在命名卷里;节点名字默认取主机名
|
├─ 用 Docker 或 Podman 起容器,身份和配置存在命名卷里;节点名字默认取主机名
|
||||||
└─ 容器入口用代码注册:fleet-enroll --server … --join jt_… --genesis … --controllers …
|
└─ 容器入口用代码注册:fleet-enroll --server … --join jt_… --genesis … --controllers …
|
||||||
├─ 先验操作员集合链(创世指纹、属主在集合里),验不过就不交出密钥
|
├─ 先验操作员集合链(创世指纹、属主在集合里),验不过就不交出密钥
|
||||||
├─ 注册元数据(MoShiro 定义):{ probe_tcp_port, probe_udp_ports, public_host?, kinds[] }
|
├─ 注册元数据(MoShiro 定义):{ probe_tcp_port, probe_quic_ports, public_host?, kinds[] }
|
||||||
└─ 写 trust.json(创世指纹、链、属主)和 agent.json(本地硬上限)
|
└─ 写 trust.json(创世指纹、链、属主)和 agent.json(本地硬上限)
|
||||||
下载压缩包的二进制安装(packaging/install.sh)做同样的注册,另外创建系统用户 moshiro、启用 moshiro-agent.service,见 deployment.md。
|
下载压缩包的二进制安装(packaging/install.sh)做同样的注册,另外创建系统用户 moshiro、启用 moshiro-agent.service,见 deployment.md。
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ probe.json:{ fleet: { server, identity_dir }, restart_at?, limits? } ← lim
|
|||||||
|
|
||||||
Probe 启动
|
Probe 启动
|
||||||
├─ 连接 server:Fleet 长连接(用 probe_id 和私钥认证),fleet.hello 带上系统、架构和构建信息
|
├─ 连接 server:Fleet 长连接(用 probe_id 和私钥认证),fleet.hello 带上系统、架构和构建信息
|
||||||
├─ 面板推送期望状态 probe_agents:{ version, agents: [ {agent_id, host, tcp_port, udp_ports, public_key}... ] }
|
├─ 面板推送期望状态 probe_agents:{ version, agents: [ {agent_id, host, tcp_port, quic_ports, public_key}... ] }
|
||||||
├─ 调和:新增的 Agent → 建立点对点会话(peer 用途签名);被移除或禁用的 → 断开
|
├─ 调和:新增的 Agent → 建立点对点会话(peer 用途签名);被移除或禁用的 → 断开
|
||||||
└─ 之后列表的任何变化(Agent 注册、禁用、地址或证书变化),面板都重新推送同一种期望状态;
|
└─ 之后列表的任何变化(Agent 注册、禁用、地址或证书变化),面板都重新推送同一种期望状态;
|
||||||
Probe 用 Fleet.Hosting.ConfigStore 把最新列表写回 agents.json(这是配置,不是测量数据;面板暂时连不上时,Probe 靠它继续测量)
|
Probe 用 Fleet.Hosting.ConfigStore 把最新列表写回 agents.json(这是配置,不是测量数据;面板暂时连不上时,Probe 靠它继续测量)
|
||||||
@@ -67,7 +67,7 @@ Probe 启动
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"fleet": { "server": "https://panel.example.com", "identity_dir": "/etc/moshiro" },
|
"fleet": { "server": "https://panel.example.com", "identity_dir": "/etc/moshiro" },
|
||||||
"listen": { "control_port": 7443, "udp_ports": [7443], "tcp_ports": [7444] },
|
"listen": { "control_port": 7443, "quic_ports": [7443], "tcp_ports": [7444] },
|
||||||
"limits": {
|
"limits": {
|
||||||
"max_total_mbps": 200,
|
"max_total_mbps": 200,
|
||||||
"max_total_pps": 20000,
|
"max_total_pps": 20000,
|
||||||
@@ -80,7 +80,7 @@ Probe 启动
|
|||||||
|
|
||||||
- `limits` 是**本地硬上限**:期望任务集超出这些范围时,调和器拒绝执行,并在 `rejected[]` 中给出原因。
|
- `limits` 是**本地硬上限**:期望任务集超出这些范围时,调和器拒绝执行,并在 `rejected[]` 中给出原因。
|
||||||
- `allowed_target_cidrs` 默认为空,即不允许执行任何目标任务;需要时由节点所有者在本机显式开启。
|
- `allowed_target_cidrs` 默认为空,即不允许执行任何目标任务;需要时由节点所有者在本机显式开启。
|
||||||
- `listen` 默认不包含 443。要用 443 测量,需要在本机把它加入 `udp_ports` 或 `tcp_ports`(端口不能被真实服务占用),见 [measurement.md](../design/measurement.md) 第 8 节。
|
- `listen` 默认不包含 443。要用 443 测量,需要在本机把它加入 `quic_ports` 或 `tcp_ports`(端口不能被真实服务占用),见 [measurement.md](../design/measurement.md) 第 8 节。
|
||||||
- `fleet_policy.allow_methods` 在 Fleet 默认值的基础上进一步收窄。
|
- `fleet_policy.allow_methods` 在 Fleet 默认值的基础上进一步收窄。
|
||||||
|
|
||||||
## 5. 相关 API
|
## 5. 相关 API
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ Agent、Backend、Probe 之间互相不信任。这份文档回答三个问题
|
|||||||
- 被攻破的 Probe 只能拿到 Backend 授权的会话,Agent 的速率上限由本地兜底;被攻破的 Agent 发来的数据只会影响它自己的线路。
|
- 被攻破的 Probe 只能拿到 Backend 授权的会话,Agent 的速率上限由本地兜底;被攻破的 Agent 发来的数据只会影响它自己的线路。
|
||||||
- **Probe 只拨公网地址**(G3):固定 SPKI 只能防止建立会话,防不了探测本身;要在 Probe 本地拒绝私网、回环、链路本地地址。
|
- **Probe 只拨公网地址**(G3):固定 SPKI 只能防止建立会话,防不了探测本身;要在 Probe 本地拒绝私网、回环、链路本地地址。
|
||||||
- **名册里的主机名解析之后再检查地址**:签名保护的是字符串,不是解析结果;默认只放行公网地址,内网需要节点本地显式允许。
|
- **名册里的主机名解析之后再检查地址**:签名保护的是字符串,不是解析结果;默认只放行公网地址,内网需要节点本地显式允许。
|
||||||
- **UDP 会话迁移要做路径验证**:协议允许会话在 NAT 映射变化后跟随新的源地址,路径上的攻击者可以借此把会话牵到自己的地址。迁移前先做一次挑战应答,验证前限制发送量(不超过收到字节数的 3 倍),类似 QUIC 的 path validation。
|
- **QUIC 会话迁移要做路径验证**:协议允许会话在 NAT 映射变化后跟随新的源地址,路径上的攻击者可以借此把会话牵到自己的地址。迁移前先做一次挑战应答,验证前限制发送量(不超过收到字节数的 3 倍),由 QUIC path validation 提供。
|
||||||
- **不协商、不降级**:现在固定 ECDSA P-256 和固定的签名串格式,没有算法协商,保持这一点。
|
- **不协商、不降级**:现在固定 ECDSA P-256 和固定的签名串格式,没有算法协商,保持这一点。
|
||||||
- Agent 的 `max_total_mbps` 默认是 1200,对 1 核 1 GB 的小机器没有意义,却是被攻破的 Backend 能让 Agent 向某个 Probe 打出去的上限。默认值应该保守,需要的人再调高;`allowed_target_cidrs` 默认为空是对的。
|
- Agent 的 `max_total_mbps` 默认是 1200,对 1 核 1 GB 的小机器没有意义,却是被攻破的 Backend 能让 Agent 向某个 Probe 打出去的上限。默认值应该保守,需要的人再调高;`allowed_target_cidrs` 默认为空是对的。
|
||||||
|
|
||||||
@@ -181,7 +181,7 @@ Agent 和 Probe 的核心永远是纯采集器。**所有敏感能力**(exec
|
|||||||
| 用途 | 现状 |
|
| 用途 | 现状 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 身份签名 | ECDSA P-256 加 SHA-256(BCL) |
|
| 身份签名 | ECDSA P-256 加 SHA-256(BCL) |
|
||||||
| 数据面逐包认证 | HMAC-SHA256 截断到 16 字节(`UdpPacket`、`TcpWire`) |
|
| 数据面逐包认证 | HMAC-SHA256 截断到 16 字节(`MeasurementDatagram`、`TcpWire`) |
|
||||||
| 会话密钥派生 | HKDF-SHA256(`SessionKeys`) |
|
| 会话密钥派生 | HKDF-SHA256(`SessionKeys`) |
|
||||||
| 控制链路加密 | AES-256-GCM(每个方向一把密钥,严格递增的计数器) |
|
| 控制链路加密 | AES-256-GCM(每个方向一把密钥,严格递增的计数器) |
|
||||||
| 令牌哈希、SPKI 指纹 | SHA-256 |
|
| 令牌哈希、SPKI 指纹 | SHA-256 |
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ write_config() {
|
|||||||
cat >"$CONFIG/agent.json" <<JSON
|
cat >"$CONFIG/agent.json" <<JSON
|
||||||
{
|
{
|
||||||
"fleet": { "server": "$2", "identity_dir": "$CONFIG" },
|
"fleet": { "server": "$2", "identity_dir": "$CONFIG" },
|
||||||
"listen": { "bind": "::", "control_port": 7443, "udp_ports": [7443], "tcp_ports": [7444] },
|
"listen": { "bind": "::", "control_port": 7443, "quic_ports": [7443], "tcp_ports": [7444] },
|
||||||
"limits": { "max_total_mbps": 200, "max_total_pps": 20000, "allowed_target_cidrs": [], "max_concurrent_tests": 1 }
|
"limits": { "max_total_mbps": 200, "max_total_pps": 20000, "allowed_target_cidrs": [], "max_concurrent_tests": 1 }
|
||||||
}
|
}
|
||||||
JSON
|
JSON
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ export function AgentDetail() {
|
|||||||
<dt>控制端口</dt>
|
<dt>控制端口</dt>
|
||||||
<dd className="mono">{a.probe_tcp_port}/tcp</dd>
|
<dd className="mono">{a.probe_tcp_port}/tcp</dd>
|
||||||
<dt>数据端口</dt>
|
<dt>数据端口</dt>
|
||||||
<dd className="mono">{a.probe_udp_ports.map((p) => `${p}/udp`).join(', ') || '—'}</dd>
|
<dd className="mono">{a.probe_quic_ports.map((p) => `${p}/udp (QUIC)`).join(', ') || '—'}</dd>
|
||||||
<dt>版本</dt>
|
<dt>版本</dt>
|
||||||
<dd>{a.agent_version ?? '—'}</dd>
|
<dd>{a.agent_version ?? '—'}</dd>
|
||||||
<dt>展示页可见</dt>
|
<dt>展示页可见</dt>
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ import { OwnerPicker } from './OwnerPicker'
|
|||||||
*/
|
*/
|
||||||
function LinkMatrix() {
|
function LinkMatrix() {
|
||||||
const matrix = useQuery(() => api.get<LinkMatrixDto>('/api/admin/links'), [], 8000)
|
const matrix = useQuery(() => api.get<LinkMatrixDto>('/api/admin/links'), [], 8000)
|
||||||
|
const probeState = useQuery(() => api.get<Page<ProbeDto>>('/api/admin/probes'), [], 8000)
|
||||||
const { busy, run } = useAction()
|
const { busy, run } = useAction()
|
||||||
const items = matrix.data?.items ?? []
|
const items = matrix.data?.items ?? []
|
||||||
|
const onlineByProbe = new Map((probeState.data?.items ?? []).map((probe) => [probe.id, probe.online]))
|
||||||
const probes = [...new Map(items.map((i) => [i.probe_id, i.probe_name])).entries()]
|
const probes = [...new Map(items.map((i) => [i.probe_id, i.probe_name])).entries()]
|
||||||
const agents = [...new Map(items.map((i) => [i.agent_id, i.agent_name])).entries()]
|
const agents = [...new Map(items.map((i) => [i.agent_id, i.agent_name])).entries()]
|
||||||
const cell = (probeId: string, agentId: string) => items.find((i) => i.probe_id === probeId && i.agent_id === agentId)
|
const cell = (probeId: string, agentId: string) => items.find((i) => i.probe_id === probeId && i.agent_id === agentId)
|
||||||
@@ -58,12 +60,20 @@ function LinkMatrix() {
|
|||||||
{agents.map(([agentId]) => {
|
{agents.map(([agentId]) => {
|
||||||
const link = cell(probeId, agentId)
|
const link = cell(probeId, agentId)
|
||||||
if (!link) return <td key={agentId} />
|
if (!link) return <td key={agentId} />
|
||||||
|
const probeOnline = onlineByProbe.get(probeId)
|
||||||
const tone = link.connected ? 'ok' : link.linked ? 'warn' : ''
|
const tone = link.connected ? 'ok' : link.linked ? 'warn' : ''
|
||||||
const text = link.connected ? '已连接' : link.linked ? '待连接' : '不连'
|
const text = link.connected ? '已连接' : !link.linked ? '不连' : probeOnline === false ? 'Probe 离线' : '待连接'
|
||||||
|
const statusHint = link.connected
|
||||||
|
? 'Probe 已和 Agent 建立测量会话'
|
||||||
|
: !link.linked
|
||||||
|
? '这条线路已关闭'
|
||||||
|
: probeOnline === false
|
||||||
|
? 'Probe 当前没有连接到面板;Probe 上线后才会尝试连接 Agent'
|
||||||
|
: '线路已启用,等待 Probe 与 Agent 建立测量会话'
|
||||||
return (
|
return (
|
||||||
<td key={agentId}>
|
<td key={agentId}>
|
||||||
<span className="row" style={{ gap: 6 }}>
|
<span className="row" style={{ gap: 6 }}>
|
||||||
<button type="button" className="btn small" disabled={busy} title={link.linked ? '点一下:手动关闭' : '点一下:手动开启'} onClick={() => set(link, !link.linked)}>
|
<button type="button" className="btn small" disabled={busy} title={`${statusHint}。点一下${link.linked ? '手动关闭' : '手动开启'}线路`} onClick={() => set(link, !link.linked)}>
|
||||||
<Chip tone={tone}>{text}</Chip>
|
<Chip tone={tone}>{text}</Chip>
|
||||||
</button>
|
</button>
|
||||||
{link.override !== null && link.override !== undefined ? (
|
{link.override !== null && link.override !== undefined ? (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { NavLink, Navigate, Route, Routes, useLocation, useParams } from 'react-
|
|||||||
import { SourceButton, ThemeToggle } from '@shared/ui'
|
import { SourceButton, ThemeToggle } from '@shared/ui'
|
||||||
import { Compare } from './pages/Compare'
|
import { Compare } from './pages/Compare'
|
||||||
import { Line } from './pages/Line'
|
import { Line } from './pages/Line'
|
||||||
|
import { Host } from './pages/Host'
|
||||||
import { Overview } from './pages/Overview'
|
import { Overview } from './pages/Overview'
|
||||||
import { useBackgroundTint } from './backgroundTint'
|
import { useBackgroundTint } from './backgroundTint'
|
||||||
import { useBackgroundParallax } from './parallax'
|
import { useBackgroundParallax } from './parallax'
|
||||||
@@ -51,6 +52,7 @@ export function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Overview />} />
|
<Route path="/" element={<Overview />} />
|
||||||
<Route path="/line/:id" element={<LineRoute />} />
|
<Route path="/line/:id" element={<LineRoute />} />
|
||||||
|
<Route path="/host/:id" element={<Host />} />
|
||||||
<Route path="/compare" element={<Compare />} />
|
<Route path="/compare" element={<Compare />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -19,52 +19,6 @@ const levelOf = (loss: number | null | undefined): Level => (loss === null || lo
|
|||||||
|
|
||||||
const clock = (ms: number) => new Date(ms).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
|
const clock = (ms: number) => new Date(ms).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||||
|
|
||||||
interface StripProps {
|
|
||||||
/** 左上:什么延迟(UDP 延迟 p50)。 */
|
|
||||||
rttLabel: string
|
|
||||||
rtt: number | null | undefined
|
|
||||||
/** 右上:丢包还是重传。 */
|
|
||||||
badLabel: string
|
|
||||||
bad: number | null | undefined
|
|
||||||
badHint?: string
|
|
||||||
/** 每 5 分钟一格:这一格的丢包(重传)率、延迟。 */
|
|
||||||
t: number[]
|
|
||||||
badSeries: (number | null)[]
|
|
||||||
ready: boolean
|
|
||||||
/** 提示里这一格的「丢包」叫什么。 */
|
|
||||||
tipBad: string
|
|
||||||
/** 没有数据时占位的格数,和每格长度的说明(「每 5 分钟」)。 */
|
|
||||||
cells: number
|
|
||||||
stepText: string
|
|
||||||
hoursText: string
|
|
||||||
/** 悬停的那一格(UDP 和 TCP 共用);`null` = 没有。 */
|
|
||||||
hover: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 一排小格(每 5 分钟一格,按这 5 分钟的丢包率着色:绿 / 黄 / 红 / 灰 = 没有数据)
|
|
||||||
* 尽量轻。
|
|
||||||
*/
|
|
||||||
function Strip({ rttLabel, rtt, badLabel, bad, badHint, t, badSeries, ready, cells, stepText, hoursText, hover }: StripProps) {
|
|
||||||
return (
|
|
||||||
<div className="udp-strip">
|
|
||||||
<div className="udp-head">
|
|
||||||
<span className="udp-title">
|
|
||||||
{rttLabel} <b className="num">{fmtMs(rtt)}</b>
|
|
||||||
</span>
|
|
||||||
<span className="udp-title" title={badHint}>
|
|
||||||
{badLabel} <b className={`udp-rate num ${levelOf(bad)}`}>{fmtPct(bad)}</b>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="bars" role="img" aria-label={`近 ${hoursText}每 ${stepText}的${badLabel}情况`}>
|
|
||||||
{(ready ? badSeries : Array.from({ length: cells }, () => null)).map((v, i) => (
|
|
||||||
<span key={t[i] ?? i} className={`bar ${ready ? levelOf(v) : 'none'}${hover === i ? ' on' : ''}`} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
lineId: string
|
lineId: string
|
||||||
range: OverviewRange
|
range: OverviewRange
|
||||||
@@ -72,23 +26,23 @@ interface Props {
|
|||||||
m: Record<string, number | null | undefined>
|
m: Record<string, number | null | undefined>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 一条线路近一段时间的 UDP 和 TCP:各是一排小格,共用一次请求。 */
|
/** 一条线路的 QUIC/TCP 双行摘要,各自显示时延、质量点阵和丢包/重传率。 */
|
||||||
export function LineStrips({ lineId, range, m }: Props) {
|
export function LineStrips({ lineId, range, m }: Props) {
|
||||||
const info = OVERVIEW_RANGES.find((r) => r.value === range) ?? OVERVIEW_RANGES[0]
|
const info = OVERVIEW_RANGES.find((r) => r.value === range) ?? OVERVIEW_RANGES[0]
|
||||||
const series = useQuery(
|
const series = useQuery(
|
||||||
() => api.get<SeriesDto>(`/api/public/lines/${encodeURIComponent(lineId)}/series`, { range: info.value, anchor: 'latest', step: info.step, preset: 'udp,tcp', metrics: 'loss.total,rtt.p50,tcp.retrans,tcp.rtt.p50' }),
|
() => api.get<SeriesDto>(`/api/public/lines/${encodeURIComponent(lineId)}/series`, { range: info.value, anchor: 'latest', step: info.step, preset: 'quic,tcp', metrics: 'loss.up,loss.down,loss.total,rtt.p50,rtt.p95,rtt.p99,tcp.retrans,tcp.rtt.p50' }),
|
||||||
[lineId, info.value],
|
[lineId, info.value],
|
||||||
30_000,
|
30_000,
|
||||||
)
|
)
|
||||||
const s = series.data
|
const s = series.data
|
||||||
const t = s?.t ?? []
|
const t = s?.t ?? []
|
||||||
const col = (key: string): (number | null)[] => s?.values[key] ?? []
|
const col = (key: string): (number | null)[] => s?.values[key] ?? []
|
||||||
// 悬停的那一格:UDP 和 TCP 的时间轴一样,所以同一格同时高亮;提示弹在卡片右侧,一次展示这段时间的 UDP 和 TCP
|
// 悬停的那一格:QUIC 上下行和 TCP 共用时间轴。
|
||||||
const [hover, setHover] = useState<number | null>(null)
|
const [hover, setHover] = useState<number | null>(null)
|
||||||
const last = useRef<number | null>(null) // 提示收回去的动画期间,内容还要留着
|
const last = useRef<number | null>(null) // 提示收回去的动画期间,内容还要留着
|
||||||
if (hover !== null) last.current = hover
|
if (hover !== null) last.current = hover
|
||||||
const shown = hover ?? last.current
|
const shown = hover ?? last.current
|
||||||
// 整块线路区域都能触发:按鼠标横向位置对应到小格(UDP 和 TCP 的小格一样宽,量第一排就行),不用精确指到小格上
|
// 整块线路区域都能触发:按鼠标横向位置对应到采样格,不用精确指到小格上。
|
||||||
const box = useRef<HTMLDivElement>(null)
|
const box = useRef<HTMLDivElement>(null)
|
||||||
const onMove = (e: MouseEvent<HTMLDivElement>) => {
|
const onMove = (e: MouseEvent<HTMLDivElement>) => {
|
||||||
const bars = box.current?.querySelector('.bars')
|
const bars = box.current?.querySelector('.bars')
|
||||||
@@ -98,19 +52,50 @@ export function LineStrips({ lineId, range, m }: Props) {
|
|||||||
}
|
}
|
||||||
const common = { t, ready: Boolean(s), cells: Math.round((info.hours * 60) / info.stepMin), stepText: `${info.stepMin} 分钟`, hoursText: `${info.hours} 小时`, hover }
|
const common = { t, ready: Boolean(s), cells: Math.round((info.hours * 60) / info.stepMin), stepText: `${info.stepMin} 分钟`, hoursText: `${info.hours} 小时`, hover }
|
||||||
const at = shown !== null && t[shown] !== undefined ? shown : null
|
const at = shown !== null && t[shown] !== undefined ? shown : null
|
||||||
|
const lossUp = col('loss.up')
|
||||||
|
const lossDown = col('loss.down')
|
||||||
|
const retrans = col('tcp.retrans')
|
||||||
|
const up = m['loss.up']
|
||||||
|
const down = m['loss.down']
|
||||||
|
const upText = up === null || up === undefined ? '丢包 —' : `${fmtPct(up)} 丢包`
|
||||||
|
const downText = down === null || down === undefined ? '丢包 —' : `${fmtPct(down)} 丢包`
|
||||||
|
const retransValue = m['tcp.retrans']
|
||||||
|
const retransTone = levelOf(retransValue)
|
||||||
|
const retransText = retransValue === null || retransValue === undefined ? '重传 —' : `${fmtPct(retransValue)} 重传`
|
||||||
return (
|
return (
|
||||||
<div className="strips" ref={box} onMouseMove={onMove} onMouseLeave={() => setHover(null)}>
|
<div className="strips" ref={box} onMouseMove={onMove} onMouseLeave={() => setHover(null)}>
|
||||||
<Strip
|
<div className="line-mini-stack">
|
||||||
{...common}
|
<div className="line-mini-quic-head" title={`QUIC RTT p50 ${fmtMs(m['rtt.p50'])} · p95 ${fmtMs(m['rtt.p95'])} · p99 ${fmtMs(m['rtt.p99'])}`}>
|
||||||
rttLabel="UDP 延迟 p50"
|
<span>QUIC</span><strong className="num">{fmtMs(m['rtt.p50'])}</strong><small>悬停看 p95 / p99</small>
|
||||||
rtt={m['rtt.p50']}
|
</div>
|
||||||
badLabel="丢包"
|
<div className="line-mini-direction-row" title={`去程 Probe → Agent 丢包 ${fmtPct(up)}`}>
|
||||||
bad={m['loss.total']}
|
<span>去程</span>
|
||||||
badHint={`上行丢包 ${fmtPct(m['loss.up'])} 下行丢包 ${fmtPct(m['loss.down'])}`}
|
<div className="bars" role="img" aria-label={`近 ${common.hoursText}每 ${common.stepText}的去程丢包`}>
|
||||||
badSeries={col('loss.total')}
|
{(s ? lossUp : Array.from({ length: common.cells }, () => null)).map((value, i) => (
|
||||||
tipBad="丢包"
|
<span key={`up-${t[i] ?? i}`} className={`bar ${s ? levelOf(value) : 'none'}${hover === i ? ' on' : ''}`} />
|
||||||
/>
|
))}
|
||||||
<Strip {...common} rttLabel="TCP RTT p50" rtt={m['tcp.rtt.p50']} badLabel="重传" bad={m['tcp.retrans']} badSeries={col('tcp.retrans')} tipBad="重传" />
|
</div>
|
||||||
|
<span className={`line-mini-badge ${levelOf(up)}`}>{upText}</span>
|
||||||
|
</div>
|
||||||
|
<div className="line-mini-direction-row" title={`回程 Agent → Probe 丢包 ${fmtPct(down)}`}>
|
||||||
|
<span>回程</span>
|
||||||
|
<div className="bars" role="img" aria-label={`近 ${common.hoursText}每 ${common.stepText}的回程丢包`}>
|
||||||
|
{(s ? lossDown : Array.from({ length: common.cells }, () => null)).map((value, i) => (
|
||||||
|
<span key={`down-${t[i] ?? i}`} className={`bar ${s ? levelOf(value) : 'none'}${hover === i ? ' on' : ''}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className={`line-mini-badge ${levelOf(down)}`}>{downText}</span>
|
||||||
|
</div>
|
||||||
|
<div className="line-mini-tcp-row" title={`TCP RTT p50 ${fmtMs(m['tcp.rtt.p50'])}`}>
|
||||||
|
<span>TCP</span><strong className="num">{fmtMs(m['tcp.rtt.p50'])}</strong>
|
||||||
|
<div className="bars" role="img" aria-label={`近 ${common.hoursText}每 ${common.stepText}的 TCP 重传`}>
|
||||||
|
{(s ? retrans : Array.from({ length: common.cells }, () => null)).map((value, i) => (
|
||||||
|
<span key={`retrans-${t[i] ?? i}`} className={`bar ${s ? levelOf(value) : 'none'}${hover === i ? ' on' : ''}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className={`line-mini-badge ${retransTone}`}>{retransText}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className={`pop num${hover !== null && at !== null ? ' open' : ''}`} role="tooltip" aria-hidden={hover === null}>
|
<div className={`pop num${hover !== null && at !== null ? ' open' : ''}`} role="tooltip" aria-hidden={hover === null}>
|
||||||
{at !== null ? (
|
{at !== null ? (
|
||||||
<>
|
<>
|
||||||
@@ -118,12 +103,15 @@ export function LineStrips({ lineId, range, m }: Props) {
|
|||||||
{clock(t[at])}–{clock(t[at] + info.stepMin * 60_000)}
|
{clock(t[at])}–{clock(t[at] + info.stepMin * 60_000)}
|
||||||
</b>
|
</b>
|
||||||
<div className="pop-group">
|
<div className="pop-group">
|
||||||
<span className="pop-name">UDP</span>
|
<span className="pop-name">QUIC Datagram</span>
|
||||||
|
<span>RTT p50 <em>{fmtMs(col('rtt.p50')[at])}</em></span>
|
||||||
|
<span>RTT p95 <em>{fmtMs(col('rtt.p95')[at])}</em></span>
|
||||||
|
<span>RTT p99 <em>{fmtMs(col('rtt.p99')[at])}</em></span>
|
||||||
<span>
|
<span>
|
||||||
丢包 <em className={levelOf(col('loss.total')[at])}>{fmtPct(col('loss.total')[at])}</em>
|
去程丢包 <em className={levelOf(col('loss.up')[at])}>{fmtPct(col('loss.up')[at])}</em>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
延迟 p50 <em>{fmtMs(col('rtt.p50')[at])}</em>
|
回程丢包 <em className={levelOf(col('loss.down')[at])}>{fmtPct(col('loss.down')[at])}</em>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="pop-group">
|
<div className="pop-group">
|
||||||
|
|||||||
@@ -209,7 +209,8 @@ export function Minimap({ domainStart, domainMin, t, values, fromMs, toMs, onSel
|
|||||||
<path d={path} />
|
<path d={path} />
|
||||||
</svg>
|
</svg>
|
||||||
{ticks
|
{ticks
|
||||||
.filter((x) => x.midnight)
|
// Outer edges already have date labels; only draw midnight markers inside the selected domain.
|
||||||
|
.filter((x) => x.midnight && x.frac > 0 && x.frac < 1)
|
||||||
.map((x) => (
|
.map((x) => (
|
||||||
<div key={x.frac} className="midnight" style={{ left: `${x.frac * 100}%` }} />
|
<div key={x.frac} className="midnight" style={{ left: `${x.frac * 100}%` }} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
+613
-20
@@ -70,6 +70,523 @@
|
|||||||
padding: 40px 24px 80px;
|
padding: 40px 24px 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 线路页是数据工作区:宽屏充分利用空间,图表和辅助文字保持清晰对比。 */
|
||||||
|
.line-page {
|
||||||
|
--line-secondary: color-mix(in srgb, var(--app-ink) 58%, var(--app-ink-muted));
|
||||||
|
width: min(100%, 1640px);
|
||||||
|
max-width: 1640px;
|
||||||
|
padding-top: 24px;
|
||||||
|
padding-bottom: 56px;
|
||||||
|
/* 正文背景上的全局柔光会让标题、节点状态和说明显得发虚;数据页文字保持锐利。 */
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .hero h1 {
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-title-row h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(24px, 2vw, 32px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-version-switch {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-route-meta {
|
||||||
|
gap: 6px 12px;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-route-status {
|
||||||
|
color: var(--app-ink);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-range-picker {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .faint,
|
||||||
|
.line-page .chart-hint,
|
||||||
|
.line-page .chart-section h2,
|
||||||
|
.line-page .stat .k {
|
||||||
|
color: var(--line-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-peer-name {
|
||||||
|
color: var(--line-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-section-label {
|
||||||
|
color: var(--app-ink);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 16px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: var(--r-card);
|
||||||
|
background: color-mix(in srgb, var(--app-card) 78%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-toolbar-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-smoothing {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-header-surface,
|
||||||
|
.line-page .line-metric-surface {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: var(--glass-filter);
|
||||||
|
box-shadow: inset 0 1px 0 var(--glass-top), var(--glass-drop);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-header-surface {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 18px 20px 16px;
|
||||||
|
border-radius: var(--r-group-outer);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-metric-surface {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: var(--r-group-outer);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-metric-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-metric-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-metric-heading h2 {
|
||||||
|
color: var(--app-ink);
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-metric-heading > span {
|
||||||
|
color: var(--line-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .range-toolbar {
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .range-toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 14px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-data-notes {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 14px;
|
||||||
|
color: var(--line-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 450;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .crumbs,
|
||||||
|
.line-page .minimap .tick,
|
||||||
|
.line-page .minimap .readout,
|
||||||
|
.line-page .chart-legend,
|
||||||
|
.line-page .stat .v small {
|
||||||
|
color: var(--line-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .minimap .tick {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .chart-section h2,
|
||||||
|
.line-page .chart-card h3 {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .chart-card {
|
||||||
|
min-width: 0;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: color-mix(in srgb, var(--glass-line) 4%, transparent);
|
||||||
|
box-shadow: none;
|
||||||
|
backdrop-filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .chart-card h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--app-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .quic-chart-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-quic-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-combo-card {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
min-height: 84px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--r-control);
|
||||||
|
background: color-mix(in srgb, var(--glass-line) 4%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-combo-card h3 {
|
||||||
|
color: var(--line-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-combo-primary {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--line-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-combo-primary strong {
|
||||||
|
color: var(--app-ink);
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .combo-tone-ok {
|
||||||
|
color: var(--app-ok) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .combo-tone-warn {
|
||||||
|
color: var(--app-warn) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .combo-tone-bad {
|
||||||
|
color: var(--app-bad) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-combo-secondary,
|
||||||
|
.line-page .line-jitter-pair {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 10px;
|
||||||
|
color: var(--line-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-combo-secondary b,
|
||||||
|
.line-page .line-jitter-pair b {
|
||||||
|
color: var(--app-ink);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .combo-divider {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-combo-badge {
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
background: var(--app-card-inset);
|
||||||
|
color: var(--line-secondary);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.4;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-jitter-pair {
|
||||||
|
gap: 8px 20px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-session-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 18px;
|
||||||
|
padding: 10px 4px 2px;
|
||||||
|
border-top: 1px solid var(--glass-head-line);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--line-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-session-title {
|
||||||
|
color: var(--app-ink);
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-session-items {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-session-items b {
|
||||||
|
color: var(--app-ink);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-session-bar small {
|
||||||
|
flex-basis: 100%;
|
||||||
|
color: var(--line-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .tiles {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .tiles > .stat {
|
||||||
|
box-shadow: 0 8px 24px -20px var(--app-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-metric-subhead {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--app-outline-soft);
|
||||||
|
color: var(--app-ink);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-wide-note {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .stack {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .chart-section {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .chart-section h2 {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .minimap {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .minimap .track {
|
||||||
|
height: 42px;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
135deg,
|
||||||
|
color-mix(in srgb, var(--glass-bg) 94%, var(--glass-shade) 6%) 0 8px,
|
||||||
|
color-mix(in srgb, var(--glass-bg) 86%, var(--glass-shade) 14%) 8px 11px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .minimap .ticks {
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .minimap .tick {
|
||||||
|
height: 20px;
|
||||||
|
padding-top: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .minimap .tick.date {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .minimap .readout {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .minimap .readout > span {
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .minimap .sel {
|
||||||
|
border-block: 1px solid var(--pick);
|
||||||
|
border-inline: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, var(--pick) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .minimap .sel .h {
|
||||||
|
top: 8px;
|
||||||
|
bottom: 8px;
|
||||||
|
width: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .minimap .sel .h::before {
|
||||||
|
position: absolute;
|
||||||
|
inset: -8px -4px;
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .minimap .sel .h.l {
|
||||||
|
left: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .minimap .sel .h.r {
|
||||||
|
right: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .minimap svg rect.has {
|
||||||
|
fill: var(--app-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .range-picker > .row:last-child {
|
||||||
|
margin-top: 4px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.line-page {
|
||||||
|
padding: 20px 14px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-header-surface {
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: var(--r-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-metric-surface {
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: var(--r-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-metric-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .range-toolbar > .group {
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .range-toolbar > .group > button,
|
||||||
|
.line-page .range-toolbar-right .group > button {
|
||||||
|
flex: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .range-toolbar-right {
|
||||||
|
width: 100%;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
justify-content: flex-start;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .minimap .tick {
|
||||||
|
width: max-content;
|
||||||
|
min-width: 0;
|
||||||
|
padding-right: 1px;
|
||||||
|
padding-left: 1px;
|
||||||
|
font-size: 9px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .minimap .tick:not(.date):not(:nth-child(6n + 1)) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .hero h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-title-row {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-smoothing {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-page .line-quic-summary {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.line-page .quic-chart-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.hero h1 {
|
.hero h1 {
|
||||||
font-size: 40px;
|
font-size: 40px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -716,7 +1233,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ── 线路详情的图表:按 UDP、TCP、评分分组,宽图占满一行 ── */
|
/* ── 线路详情的图表:按 QUIC、TCP、评分分组,宽图占满一行 ── */
|
||||||
.chart-section {
|
.chart-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -799,7 +1316,7 @@
|
|||||||
color: var(--app-ink-faint);
|
color: var(--app-ink-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 总览卡片里的 UDP:一排小格(每 5 分钟一格,按丢包着色)+ 延迟小走势,尽量轻 ── */
|
/* ── 总览卡片里的 QUIC Datagram:按方向显示丢包小格 ── */
|
||||||
.line-name {
|
.line-name {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -809,47 +1326,123 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.udp-strip {
|
.host-link {
|
||||||
position: relative;
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 160ms ease, box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-link:hover,
|
||||||
|
.host-link:focus-visible {
|
||||||
|
color: inherit;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 12px 32px -14px color-mix(in srgb, var(--glass-shade) 75%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-link:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-mini-stack {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.udp-head {
|
.line-mini-quic-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
min-width: 0;
|
||||||
justify-content: space-between;
|
align-items: center;
|
||||||
font-size: 11px;
|
gap: 8px;
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.udp-title {
|
.line-mini-quic-head > span,
|
||||||
color: var(--app-ink-faint);
|
.line-mini-direction-row > span:first-child,
|
||||||
|
.line-mini-tcp-row > span:first-child {
|
||||||
|
color: var(--app-ink-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.udp-title b {
|
.line-mini-quic-head > strong,
|
||||||
margin-left: 2px;
|
.line-mini-tcp-row > strong {
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--app-ink);
|
color: var(--app-ink);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.udp-title b.udp-rate.ok {
|
.line-mini-quic-head > small {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--app-ink-faint);
|
||||||
|
font-size: 9px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-mini-direction-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 34px minmax(0, 1fr) 66px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-mini-direction-row > span:first-child,
|
||||||
|
.line-mini-tcp-row > span:first-child {
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-mini-tcp-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 34px 46px minmax(0, 1fr) max-content;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
padding-top: 5px;
|
||||||
|
border-top: 1px solid var(--app-outline-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-mini-tcp-row > strong {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-mini-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
background: var(--app-card-inset);
|
||||||
|
color: var(--app-ink-muted);
|
||||||
|
font-size: 9px;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-mini-badge.ok {
|
||||||
|
background: var(--app-ok-container);
|
||||||
color: var(--app-ok);
|
color: var(--app-ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
.udp-title b.udp-rate.warn {
|
.line-mini-badge.warn {
|
||||||
|
background: var(--app-warn-container);
|
||||||
color: var(--app-warn);
|
color: var(--app-warn);
|
||||||
}
|
}
|
||||||
|
|
||||||
.udp-title b.udp-rate.bad {
|
.line-mini-badge.bad {
|
||||||
|
background: var(--app-bad-container);
|
||||||
color: var(--app-bad);
|
color: var(--app-bad);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bars {
|
.bars {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 2px;
|
min-width: 0;
|
||||||
height: 10px;
|
gap: 1.5px;
|
||||||
|
height: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bar {
|
.bar {
|
||||||
@@ -875,7 +1468,7 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* UDP 和 TCP 两段之间留一点空 */
|
/* QUIC 和 TCP 两段之间留一点空 */
|
||||||
.strips {
|
.strips {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ interface MetricInfo {
|
|||||||
const METRICS: MetricInfo[] = [
|
const METRICS: MetricInfo[] = [
|
||||||
{ id: 'rtt.p50', label: '延迟 p50', unit: 'ms', better: 'lower' },
|
{ id: 'rtt.p50', label: '延迟 p50', unit: 'ms', better: 'lower' },
|
||||||
{ id: 'rtt.p95', label: '延迟 p95', unit: 'ms', better: 'lower' },
|
{ id: 'rtt.p95', label: '延迟 p95', unit: 'ms', better: 'lower' },
|
||||||
{ id: 'loss.total', label: 'UDP 丢包', unit: '%', better: 'lower' },
|
{ id: 'loss.total', label: 'QUIC 丢包', unit: '%', better: 'lower' },
|
||||||
{ id: 'loss.up', label: 'UDP 上行丢包', unit: '%', better: 'lower' },
|
{ id: 'loss.up', label: 'QUIC 上行丢包', unit: '%', better: 'lower' },
|
||||||
{ id: 'loss.down', label: 'UDP 下行丢包', unit: '%', better: 'lower' },
|
{ id: 'loss.down', label: 'QUIC 下行丢包', unit: '%', better: 'lower' },
|
||||||
|
{ id: 'loss.run.up_max', label: 'QUIC 上行最长连续丢包', unit: '', better: 'lower' },
|
||||||
|
{ id: 'loss.run.down_max', label: 'QUIC 下行最长连续丢包', unit: '', better: 'lower' },
|
||||||
{ id: 'jitter.up.p95', label: '上行抖动', unit: 'ms', better: 'lower' },
|
{ id: 'jitter.up.p95', label: '上行抖动', unit: 'ms', better: 'lower' },
|
||||||
{ id: 'jitter.down.p95', label: '下行抖动', unit: 'ms', better: 'lower' },
|
{ id: 'jitter.down.p95', label: '下行抖动', unit: 'ms', better: 'lower' },
|
||||||
{ id: 'tcp.rtt.p50', label: 'TCP RTT p50', unit: 'ms', better: 'lower' },
|
{ id: 'tcp.rtt.p50', label: 'TCP RTT p50', unit: 'ms', better: 'lower' },
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { ChevronLeft } from 'lucide-react'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
||||||
|
import { api } from '@shared/api/client'
|
||||||
|
import { TimeChart } from '@shared/charts/TimeChart'
|
||||||
|
import { fmtBytes, fmtSpeed, fmtUptime, pct, useHosts } from '../live'
|
||||||
|
import { useQuery } from '@shared/hooks'
|
||||||
|
import { Chip, Dot, Segmented, Spinner, Stat } from '@shared/ui'
|
||||||
|
import { fmtAgo } from '@shared/format'
|
||||||
|
import { OsIcon } from '@shared/osIcon'
|
||||||
|
import { Flag, IspIcon, useNodeInfos } from '@shared/nodes'
|
||||||
|
import { PageError } from '../lib'
|
||||||
|
|
||||||
|
interface HostHistory {
|
||||||
|
t: number[]
|
||||||
|
values: Record<string, (number | null)[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
const RANGES = [
|
||||||
|
{ value: '1h', label: '近 1 小时' },
|
||||||
|
{ value: '6h', label: '近 6 小时' },
|
||||||
|
{ value: '24h', label: '近 24 小时' },
|
||||||
|
{ value: '7d', label: '近 7 天' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export function Host() {
|
||||||
|
const { id = '' } = useParams()
|
||||||
|
const [params, setParams] = useSearchParams()
|
||||||
|
const range = RANGES.find((item) => item.value === params.get('range'))?.value ?? '24h'
|
||||||
|
const { hosts, live } = useHosts()
|
||||||
|
const infos = useNodeInfos()
|
||||||
|
const node = hosts.get(id)
|
||||||
|
const info = infos.get(id)
|
||||||
|
const history = useQuery(() => api.get<HostHistory>(`/api/public/hosts/${encodeURIComponent(id)}`, { range }), [id, range], 30_000)
|
||||||
|
const data = history.data
|
||||||
|
const usage = useMemo(() => data ? [
|
||||||
|
{ label: 'CPU', values: data.values.cpu ?? [], color: '--chart-1' },
|
||||||
|
{ label: '内存', values: data.values.mem ?? [], color: '--chart-2' },
|
||||||
|
{ label: '磁盘', values: data.values.disk ?? [], color: '--chart-4' },
|
||||||
|
] : [], [data])
|
||||||
|
const network = useMemo(() => data ? [
|
||||||
|
{ label: '上行', values: data.values.tx_bps ?? [], color: '--chart-2' },
|
||||||
|
{ label: '下行', values: data.values.rx_bps ?? [], color: '--chart-1' },
|
||||||
|
] : [], [data])
|
||||||
|
const host = node?.host
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="page">
|
||||||
|
<div className="crumbs"><Link to="/" className="row"><ChevronLeft size={16} />总览</Link></div>
|
||||||
|
<section className="hero">
|
||||||
|
<h1>{node?.name ?? id}</h1>
|
||||||
|
<div className="row wrap" style={{ marginTop: 12 }}>
|
||||||
|
{info?.country ? <Flag code={info.country} /> : null}
|
||||||
|
{info?.isp_key ? <IspIcon ispKey={info.isp_key} name={info.isp_name} /> : null}
|
||||||
|
{info?.os_name ? <span className="row"><OsIcon os={info.os} name={info.os_name} />{[info.os_name, info.arch].filter(Boolean).join(' / ')}</span> : null}
|
||||||
|
{node ? <><Chip>{node.kind === 'agent' ? 'Agent' : 'Probe'}</Chip><Dot tone={node.online ? 'ok' : 'bad'} /><span className="muted">{node.online ? (node.stale ? '状态数据暂未更新' : '在线') : '离线'}</span></> : <span className="muted">节点信息加载中…</span>}
|
||||||
|
{node?.last_seen_at && !node.online ? <span className="faint">最后在线 {fmtAgo(node.last_seen_at)}</span> : null}
|
||||||
|
<span className={`live-dot ${live ? 'on' : ''}`} title={live ? '实时更新中' : '实时连接中断,正在重连'} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<PageError error={history.error} />
|
||||||
|
{host ? <section className="stack" style={{ marginTop: 24 }}>
|
||||||
|
<div className="tiles">
|
||||||
|
<Stat label="CPU" value={host.cpu_percent === null ? '—' : host.cpu_percent.toFixed(1)} unit="%" />
|
||||||
|
<Stat label="内存" value={pct(host.mem_used, host.mem_total)?.toFixed(1) ?? '—'} unit="%" />
|
||||||
|
<Stat label="磁盘" value={pct(host.disk_used, host.disk_total)?.toFixed(1) ?? '—'} unit="%" />
|
||||||
|
<Stat label="累计流量 ↑ / ↓" value={`${fmtBytes(host.net_tx_total)} / ${fmtBytes(host.net_rx_total)}`} />
|
||||||
|
<Stat label="当前网速 ↑ / ↓" value={`${fmtSpeed(node?.online ? host.net_tx_bps : null)} / ${fmtSpeed(node?.online ? host.net_rx_bps : null)}`} />
|
||||||
|
<Stat label="链路速率" value={host.net_link_mbps ? `${host.net_link_mbps} Mbit/s` : '—'} />
|
||||||
|
<Stat label="运行时间" value={fmtUptime(host.uptime_seconds)} />
|
||||||
|
<Stat label="状态更新时间" value={fmtAgo(host.at)} />
|
||||||
|
</div>
|
||||||
|
</section> : <p className="faint" style={{ marginTop: 24 }}>尚无主机资源快照;节点上线并上报状态后会显示实时数据。</p>}
|
||||||
|
|
||||||
|
<section className="card host-history" style={{ marginTop: 20 }}>
|
||||||
|
<div className="agent-head">
|
||||||
|
<span className="agent-name">资源历史</span>
|
||||||
|
<span className="right"><Segmented options={RANGES.map((item) => item)} value={range} onChange={(value) => setParams((prev) => { const next = new URLSearchParams(prev); next.set('range', value); return next }, { replace: true })} /></span>
|
||||||
|
{history.loading ? <Spinner /> : null}
|
||||||
|
</div>
|
||||||
|
{data ? <div className="grid cols-2 host-history-charts">
|
||||||
|
<div><h2>CPU、内存与磁盘</h2><TimeChart t={data.t} unit="%" series={usage} /></div>
|
||||||
|
<div><h2>网络吞吐</h2><TimeChart t={data.t} unit=" B/s" series={network} /></div>
|
||||||
|
</div> : !history.loading ? <p className="faint">所选时间段暂无历史数据。</p> : null}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
+116
-141
@@ -1,5 +1,5 @@
|
|||||||
import { ChevronLeft } from 'lucide-react'
|
import { ChevronLeft } from 'lucide-react'
|
||||||
import { useMemo } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { IP_VERSIONS, lineIdFor } from '@shared/ipVersions'
|
import { IP_VERSIONS, lineIdFor } from '@shared/ipVersions'
|
||||||
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
||||||
import { api } from '@shared/api/client'
|
import { api } from '@shared/api/client'
|
||||||
@@ -7,7 +7,6 @@ import type { LineDto, TestDto } from '@shared/api/types'
|
|||||||
import { TimeChart, type ChartSeries } from '@shared/charts/TimeChart'
|
import { TimeChart, type ChartSeries } from '@shared/charts/TimeChart'
|
||||||
import { fmtAgo, fmtMbps, fmtMs, fmtNum, fmtPct, fmtTime } from '@shared/format'
|
import { fmtAgo, fmtMbps, fmtMs, fmtNum, fmtPct, fmtTime } from '@shared/format'
|
||||||
import { NodeTag, useNodeInfos } from '@shared/nodes'
|
import { NodeTag, useNodeInfos } from '@shared/nodes'
|
||||||
import { fmtBytes, useHosts } from '../live'
|
|
||||||
import { useQuery } from '@shared/hooks'
|
import { useQuery } from '@shared/hooks'
|
||||||
import { Chip, Dot, LoadingBar, Segmented, Spinner, Stat } from '@shared/ui'
|
import { Chip, Dot, LoadingBar, Segmented, Spinner, Stat } from '@shared/ui'
|
||||||
import { PageError, ScoreChips } from '../lib'
|
import { PageError, ScoreChips } from '../lib'
|
||||||
@@ -24,28 +23,25 @@ function tone(value: number | null | undefined, good: number, poor: number): 'ok
|
|||||||
return 'warn'
|
return 'warn'
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HostSeries {
|
|
||||||
t: number[]
|
|
||||||
values: Record<string, (number | null)[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 指标卡片:好坏的分界(越低越好)和显示格式。 */
|
/** 指标卡片:好坏的分界(越低越好)和显示格式。 */
|
||||||
const TILES: { label: string; key: string; fmt: (v: number | null | undefined) => string; good?: number; poor?: number; unit?: string; /** 这个线路没有数据时不显示(建连任务不是每条线路都开着)。 */ optional?: boolean }[] = [
|
type MetricGroup = 'quic' | 'tcp-connect' | 'score'
|
||||||
{ label: 'UDP 延迟 p50', key: 'rtt.p50', fmt: fmtMs },
|
|
||||||
{ label: 'UDP 延迟 p95', key: 'rtt.p95', fmt: fmtMs },
|
const METRIC_GROUPS: { value: MetricGroup; label: string }[] = [
|
||||||
{ label: 'UDP 丢包', key: 'loss.total', fmt: fmtPct, good: 0.5, poor: 3 },
|
{ value: 'quic', label: 'QUIC' },
|
||||||
{ label: 'UDP 上行丢包', key: 'loss.up', fmt: fmtPct, good: 0.5, poor: 3 },
|
{ value: 'tcp-connect', label: 'TCP' },
|
||||||
{ label: 'UDP 下行丢包', key: 'loss.down', fmt: fmtPct, good: 0.5, poor: 3 },
|
{ value: 'score', label: '评分' },
|
||||||
{ label: 'UDP 最长连续丢包', key: 'loss.run_max', fmt: fmtNum, unit: '包' },
|
]
|
||||||
{ label: 'UDP 上行抖动 p95', key: 'jitter.up.p95', fmt: fmtMs },
|
|
||||||
{ label: 'UDP 下行抖动 p95', key: 'jitter.down.p95', fmt: fmtMs },
|
const QUIC_METRICS = 'rtt.p50,rtt.p95,rtt.p99,loss.total,loss.up,loss.down,loss.run.up_max,loss.run.down_max,jitter.up.p95,jitter.down.p95'
|
||||||
{ label: 'TCP RTT p50', key: 'tcp.rtt.p50', fmt: fmtMs },
|
|
||||||
{ label: 'TCP RTT p99', key: 'tcp.rtt.p99', fmt: fmtMs },
|
const TILES: { group: Exclude<MetricGroup, 'score'>; label: string; key: string; fmt: (v: number | null | undefined) => string; good?: number; poor?: number; unit?: string; /** 这个线路没有数据时不显示(建连任务不是每条线路都开着)。 */ optional?: boolean }[] = [
|
||||||
{ label: 'TCP RTT 最大', key: 'tcp.rtt.max', fmt: fmtMs },
|
{ group: 'tcp-connect', label: 'TCP RTT p50', key: 'tcp.rtt.p50', fmt: fmtMs },
|
||||||
{ label: 'TCP 重传率', key: 'tcp.retrans', fmt: fmtPct, good: 0.5, poor: 3 },
|
{ group: 'tcp-connect', label: 'TCP RTT p99', key: 'tcp.rtt.p99', fmt: fmtMs },
|
||||||
{ label: 'TCP 建连 p95', key: 'connect.tcp_ms.p95', fmt: fmtMs, optional: true },
|
{ group: 'tcp-connect', label: 'TCP RTT 最大', key: 'tcp.rtt.max', fmt: fmtMs },
|
||||||
{ label: 'TLS 握手 p95', key: 'connect.tls_ms.p95', fmt: fmtMs, optional: true },
|
{ group: 'tcp-connect', label: 'TCP 重传率', key: 'tcp.retrans', fmt: fmtPct, good: 0.5, poor: 3 },
|
||||||
{ label: '建连成功率', key: 'connect.success', fmt: fmtPct, optional: true },
|
{ group: 'tcp-connect', label: 'TCP 建连 p95', key: 'connect.tcp_ms.p95', fmt: fmtMs, optional: true },
|
||||||
|
{ group: 'tcp-connect', label: 'TLS 握手 p95', key: 'connect.tls_ms.p95', fmt: fmtMs, optional: true },
|
||||||
|
{ group: 'tcp-connect', label: '建连成功率', key: 'connect.success', fmt: fmtPct, optional: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
interface ChartDef {
|
interface ChartDef {
|
||||||
@@ -55,26 +51,26 @@ interface ChartDef {
|
|||||||
zero?: boolean
|
zero?: boolean
|
||||||
fill?: boolean
|
fill?: boolean
|
||||||
series: { key: string; label: string; color: string; dash?: number[] }[]
|
series: { key: string; label: string; color: string; dash?: number[] }[]
|
||||||
group: 'udp' | 'tcp' | 'connect' | 'score'
|
group: MetricGroup
|
||||||
/** 占满一行。 */
|
/** 占满一行。 */
|
||||||
wide?: boolean
|
wide?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const GROUPS = [
|
const GROUPS = [
|
||||||
{ id: 'udp', title: 'UDP(往返探测)' },
|
{ id: 'quic', title: 'QUIC Datagram' },
|
||||||
{ id: 'tcp', title: 'TCP' },
|
{ id: 'tcp-connect', title: 'TCP' },
|
||||||
{ id: 'connect', title: '建连(TCP 握手、TLS 握手)' },
|
|
||||||
{ id: 'score', title: '评分' },
|
{ id: 'score', title: '评分' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const CHARTS: ChartDef[] = [
|
const CHARTS: ChartDef[] = [
|
||||||
{ id: 'rtt', title: '延迟', group: 'udp', wide: true, unit: 'ms', series: [{ key: 'rtt.p50', label: 'p50', color: '--chart-1' }, { key: 'rtt.p95', label: 'p95', color: '--chart-2' }, { key: 'rtt.p99', label: 'p99', color: '--chart-3' }] },
|
{ id: 'rtt', title: '延迟', group: 'quic', unit: 'ms', series: [{ key: 'rtt.p50', label: 'p50', color: '--chart-1' }, { key: 'rtt.p95', label: 'p95', color: '--chart-2' }, { key: 'rtt.p99', label: 'p99', color: '--chart-3' }] },
|
||||||
{ id: 'loss', title: '丢包', group: 'udp', unit: '%', series: [{ key: 'loss.up', label: '上行', color: '--chart-2' }, { key: 'loss.down', label: '下行', color: '--chart-3' }, { key: 'loss.total', label: '合计', color: '--chart-1', dash: [4, 3] }] },
|
{ id: 'jitter', title: '抖动(p95)', group: 'quic', unit: 'ms', series: [{ key: 'jitter.up.p95', label: '上行', color: '--chart-2' }, { key: 'jitter.down.p95', label: '下行', color: '--chart-1' }] },
|
||||||
{ id: 'jitter', title: '抖动(p95)', group: 'udp', unit: 'ms', series: [{ key: 'jitter.up.p95', label: '上行', color: '--chart-2' }, { key: 'jitter.down.p95', label: '下行', color: '--chart-1' }] },
|
{ id: 'loss', title: '丢包', group: 'quic', unit: '%', series: [{ key: 'loss.up', label: '上行', color: '--chart-2' }, { key: 'loss.down', label: '下行', color: '--chart-3' }, { key: 'loss.total', label: '合计', color: '--chart-1', dash: [4, 3] }] },
|
||||||
{ id: 'tcp', title: '往返时延(RTT)', group: 'tcp', unit: 'ms', series: [{ key: 'tcp.rtt.p50', label: 'p50', color: '--chart-1' }, { key: 'tcp.rtt.p95', label: 'p95', color: '--chart-2' }, { key: 'tcp.rtt.max', label: '最大', color: '--chart-3' }] },
|
{ id: 'loss-runs', title: '最长连续丢包', group: 'quic', unit: '包', series: [{ key: 'loss.run.up_max', label: '上行', color: '--chart-2' }, { key: 'loss.run.down_max', label: '下行', color: '--chart-3' }] },
|
||||||
{ id: 'retrans', title: '重传率', group: 'tcp', unit: '%', fill: true, series: [{ key: 'tcp.retrans', label: '重传率', color: '--chart-3' }] },
|
{ id: 'tcp', title: '往返时延(RTT)', group: 'tcp-connect', unit: 'ms', series: [{ key: 'tcp.rtt.p50', label: 'p50', color: '--chart-1' }, { key: 'tcp.rtt.p95', label: 'p95', color: '--chart-2' }, { key: 'tcp.rtt.max', label: '最大', color: '--chart-3' }] },
|
||||||
{ id: 'connect-time', title: '建连耗时(p95)', group: 'connect', unit: 'ms', series: [{ key: 'connect.tcp_ms.p95', label: 'TCP 建连', color: '--chart-1' }, { key: 'connect.tls_ms.p95', label: 'TLS 握手', color: '--chart-2' }] },
|
{ id: 'retrans', title: '重传率', group: 'tcp-connect', unit: '%', fill: true, series: [{ key: 'tcp.retrans', label: '重传率', color: '--chart-3' }] },
|
||||||
{ id: 'connect-ok', title: '建连成功率', group: 'connect', unit: '%', zero: false, series: [{ key: 'connect.success', label: '成功率', color: '--chart-3' }] },
|
{ id: 'connect-time', title: '建连耗时(p95)', group: 'tcp-connect', unit: 'ms', series: [{ key: 'connect.tcp_ms.p95', label: 'TCP 建连', color: '--chart-1' }, { key: 'connect.tls_ms.p95', label: 'TLS 握手', color: '--chart-2' }] },
|
||||||
|
{ id: 'connect-ok', title: '建连成功率', group: 'tcp-connect', unit: '%', zero: false, series: [{ key: 'connect.success', label: '成功率', color: '--chart-3' }] },
|
||||||
{ id: 'score', title: '综合评分', group: 'score', wide: true, unit: '', series: [{ key: 'score.reality', label: 'REALITY', color: '--chart-1' }, { key: 'score.hy2', label: 'Hy2', color: '--chart-2' }, { key: 'score.mc', label: 'MC', color: '--chart-4' }, { key: 'score.base', label: '综合', color: '--chart-5', dash: [4, 3] }] },
|
{ id: 'score', title: '综合评分', group: 'score', wide: true, unit: '', series: [{ key: 'score.reality', label: 'REALITY', color: '--chart-1' }, { key: 'score.hy2', label: 'Hy2', color: '--chart-2' }, { key: 'score.mc', label: 'MC', color: '--chart-4' }, { key: 'score.base', label: '综合', color: '--chart-5', dash: [4, 3] }] },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -100,58 +96,10 @@ function ewma(values: (number | null)[], alpha: number): (number | null)[] {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 主机状态:实时数据(SSE)每隔几秒来一次,只重绘这一块,不影响上面的图表。 */
|
|
||||||
function HostCard({ agentId, range }: { agentId: string | undefined; range: string | null }) {
|
|
||||||
const history = useQuery(
|
|
||||||
() => (agentId && range ? api.get<HostSeries>(`/api/public/hosts/${agentId}`, { range }) : Promise.resolve(undefined)),
|
|
||||||
[agentId, range],
|
|
||||||
30_000,
|
|
||||||
)
|
|
||||||
const { hosts } = useHosts()
|
|
||||||
const host = agentId ? hosts.get(agentId)?.host : undefined
|
|
||||||
const data = history.data
|
|
||||||
const charts = useMemo(
|
|
||||||
() =>
|
|
||||||
data
|
|
||||||
? {
|
|
||||||
usage: [
|
|
||||||
{ label: 'CPU', values: data.values['cpu'] ?? [], color: '--chart-1' },
|
|
||||||
{ label: '内存', values: data.values['mem'] ?? [], color: '--chart-2' },
|
|
||||||
{ label: '磁盘', values: data.values['disk'] ?? [], color: '--chart-4' },
|
|
||||||
],
|
|
||||||
speed: [
|
|
||||||
{ label: '上行', values: data.values['tx_bps'] ?? [], color: '--chart-2' },
|
|
||||||
{ label: '下行', values: data.values['rx_bps'] ?? [], color: '--chart-1' },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
[data],
|
|
||||||
)
|
|
||||||
if (!host && !data) return null
|
|
||||||
return (
|
|
||||||
<div className="card">
|
|
||||||
<div className="card-title">主机状态</div>
|
|
||||||
{host ? (
|
|
||||||
<div className="grid stats">
|
|
||||||
<Stat label="CPU" value={host.cpu_percent === null ? '—' : host.cpu_percent.toFixed(0)} unit="%" />
|
|
||||||
<Stat label="内存" value={host.mem_total ? ((100 * (host.mem_used ?? 0)) / host.mem_total).toFixed(0) : '—'} unit="%" />
|
|
||||||
<Stat label="磁盘" value={host.disk_total ? ((100 * (host.disk_used ?? 0)) / host.disk_total).toFixed(0) : '—'} unit="%" />
|
|
||||||
<Stat label="累计流量 ↑ / ↓" value={`${fmtBytes(host.net_tx_total)} / ${fmtBytes(host.net_rx_total)}`} />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{data && charts ? (
|
|
||||||
<div className="grid cols-2" style={{ marginTop: 16 }}>
|
|
||||||
<TimeChart t={data.t} unit="%" series={charts.usage} />
|
|
||||||
<TimeChart t={data.t} unit=" B/s" series={charts.speed} />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Line() {
|
export function Line() {
|
||||||
const { id = '' } = useParams()
|
const { id = '' } = useParams()
|
||||||
const [params, setParams] = useSearchParams()
|
const [params, setParams] = useSearchParams()
|
||||||
|
const [metricGroup, setMetricGroup] = useState<MetricGroup>('quic')
|
||||||
|
|
||||||
const line = useQuery(() => api.get<LineDto>(`/api/public/lines/${encodeURIComponent(id)}`), [id], 15_000)
|
const line = useQuery(() => api.get<LineDto>(`/api/public/lines/${encodeURIComponent(id)}`), [id], 15_000)
|
||||||
const infos = useNodeInfos()
|
const infos = useNodeInfos()
|
||||||
@@ -162,7 +110,13 @@ export function Line() {
|
|||||||
|
|
||||||
const spec = readSpec(params)
|
const spec = readSpec(params)
|
||||||
const period = usePeriod(id, ready, sinceMs, nowMs, spec)
|
const period = usePeriod(id, ready, sinceMs, nowMs, spec)
|
||||||
const series = usePeriodSeries(id, ready, period)
|
const series = usePeriodSeries(
|
||||||
|
id,
|
||||||
|
ready,
|
||||||
|
period,
|
||||||
|
metricGroup === 'score' ? 'scores' : metricGroup === 'tcp-connect' ? 'tcp,connect' : metricGroup,
|
||||||
|
metricGroup === 'quic' ? QUIC_METRICS : undefined,
|
||||||
|
)
|
||||||
|
|
||||||
const update = (fn: (q: URLSearchParams) => void) => {
|
const update = (fn: (q: URLSearchParams) => void) => {
|
||||||
const q = new URLSearchParams(params)
|
const q = new URLSearchParams(params)
|
||||||
@@ -198,7 +152,6 @@ export function Line() {
|
|||||||
writeSpec(q, { ...current, span: scale === 24 ? '24' : null, at: scale === 24 && period.domainMin > 1440 ? null : current.at })
|
writeSpec(q, { ...current, span: scale === 24 ? '24' : null, at: scale === 24 && period.domainMin > 1440 ? null : current.at })
|
||||||
})
|
})
|
||||||
|
|
||||||
const agentId = line.data?.agent_id
|
|
||||||
const lastTestId = line.data?.last_test?.id
|
const lastTestId = line.data?.last_test?.id
|
||||||
const test = useQuery(() => (lastTestId ? api.get<TestDto>(`/api/public/tests/${lastTestId}`) : Promise.resolve(undefined)), [lastTestId])
|
const test = useQuery(() => (lastTestId ? api.get<TestDto>(`/api/public/tests/${lastTestId}`) : Promise.resolve(undefined)), [lastTestId])
|
||||||
|
|
||||||
@@ -211,7 +164,7 @@ export function Line() {
|
|||||||
const charts = useMemo(
|
const charts = useMemo(
|
||||||
() =>
|
() =>
|
||||||
s
|
s
|
||||||
? GROUPS.map((group) => {
|
? GROUPS.filter((group) => group.id === metricGroup).map((group) => {
|
||||||
const cards = CHARTS.filter((def) => def.group === group.id).map((def) => {
|
const cards = CHARTS.filter((def) => def.group === group.id).map((def) => {
|
||||||
const raw = def.series.map((x) => ({ x, values: s.values[x.key] ?? [] }))
|
const raw = def.series.map((x) => ({ x, values: s.values[x.key] ?? [] }))
|
||||||
// 开了 EWMA:原始数据画成淡线垫在后面,平滑后的线画在上面
|
// 开了 EWMA:原始数据画成淡线垫在后面,平滑后的线画在上面
|
||||||
@@ -223,20 +176,19 @@ export function Line() {
|
|||||||
]
|
]
|
||||||
: raw.map(({ x, values }) => ({ label: x.label, values, color: x.color, dash: x.dash, fill: def.fill }))
|
: raw.map(({ x, values }) => ({ label: x.label, values, color: x.color, dash: x.dash, fill: def.fill }))
|
||||||
if (!list.some((x) => x.values.some((v) => v !== null))) return null
|
if (!list.some((x) => x.values.some((v) => v !== null))) return null
|
||||||
return { def, node: <TimeChart t={s.t} series={list} unit={def.unit} gaps={gaps} zero={def.zero !== false} height={def.wide ? 260 : 220} /> }
|
return { def, node: <TimeChart t={s.t} series={list} unit={def.unit} gaps={gaps} zero={def.zero !== false} height={metricGroup === 'quic' ? 220 : def.wide ? 230 : 190} /> }
|
||||||
})
|
})
|
||||||
return { group, cards: cards.filter((c): c is NonNullable<typeof c> => c !== null) }
|
return { group, cards: cards.filter((c): c is NonNullable<typeof c> => c !== null) }
|
||||||
}).filter((g) => g.cards.length > 0)
|
}).filter((g) => g.cards.length > 0)
|
||||||
: [],
|
: [],
|
||||||
[s, gaps, smooth.alpha],
|
[s, gaps, smooth.alpha, metricGroup],
|
||||||
)
|
)
|
||||||
const loadingNow = !ready || series.busy
|
const loadingNow = !ready || series.busy
|
||||||
const chartSections = charts.map(({ group, cards }) => (
|
const chartSections = charts.map(({ group, cards }) => (
|
||||||
<section className="chart-section" key={group.id}>
|
<section className="chart-section" key={group.id}>
|
||||||
<h2>{group.title}</h2>
|
<div className={`grid cols-2 ${group.id === 'quic' ? 'quic-chart-grid' : ''}`}>
|
||||||
<div className="grid cols-2">
|
|
||||||
{cards.map(({ def, node }) => (
|
{cards.map(({ def, node }) => (
|
||||||
<div className={`card chart-card has-loading ${def.wide ? 'wide' : ''}`} key={def.id}>
|
<div className={`card chart-card has-loading ${def.wide || cards.length === 1 ? 'wide' : ''}`} key={def.id}>
|
||||||
<LoadingBar active={loadingNow} />
|
<LoadingBar active={loadingNow} />
|
||||||
<h3>{def.title}</h3>
|
<h3>{def.title}</h3>
|
||||||
{node}
|
{node}
|
||||||
@@ -250,7 +202,7 @@ export function Line() {
|
|||||||
const peak = result?.metrics?.['tcp.bulk.peak.max']
|
const peak = result?.metrics?.['tcp.bulk.peak.max']
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="page">
|
<main className="page line-page">
|
||||||
<div className="crumbs">
|
<div className="crumbs">
|
||||||
<Link to={id.endsWith('~ipv6') ? '/?ip_version=ipv6' : '/'} className="row">
|
<Link to={id.endsWith('~ipv6') ? '/?ip_version=ipv6' : '/'} className="row">
|
||||||
<ChevronLeft size={16} />
|
<ChevronLeft size={16} />
|
||||||
@@ -258,12 +210,13 @@ export function Line() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="hero">
|
<section className="hero line-header-surface">
|
||||||
<h1>
|
<div className="line-title-row">
|
||||||
{line.data?.name ?? '线路'}
|
<h1>
|
||||||
{line.data ? <span className="muted" style={{ fontWeight: 400 }}> → {line.data.probe_name}</span> : null}
|
{line.data?.name ?? '线路'}
|
||||||
</h1>
|
{line.data ? <span className="muted line-peer-name"> → {line.data.probe_name}</span> : null}
|
||||||
<div className="row" style={{ marginTop: 12 }}>
|
</h1>
|
||||||
|
<div className="row line-version-switch">
|
||||||
{IP_VERSIONS.map((v) => {
|
{IP_VERSIONS.map((v) => {
|
||||||
const query = new URLSearchParams(params)
|
const query = new URLSearchParams(params)
|
||||||
query.set('ip_version', v.value)
|
query.set('ip_version', v.value)
|
||||||
@@ -274,11 +227,12 @@ export function Line() {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{line.data && !line.data.ipv6_available && id.endsWith('~ipv6') ? <span className="muted">此 Agent 未配置 IPv6 地址</span> : null}
|
{line.data && !line.data.ipv6_available && id.endsWith('~ipv6') ? <span className="muted">此 Agent 未配置 IPv6 地址</span> : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{line.data ? (
|
{line.data ? (
|
||||||
<div className="row wrap" style={{ marginTop: 12 }}>
|
<div className="row wrap line-route-meta">
|
||||||
<Dot tone={line.data.online && line.data.probe_connected ? 'ok' : line.data.online ? 'warn' : 'bad'} />
|
<Dot tone={line.data.online && line.data.probe_connected ? 'ok' : line.data.online ? 'warn' : 'bad'} />
|
||||||
<span className="muted">{line.data.online ? (line.data.probe_connected ? '在线' : 'Agent 在线,测量通道未建立(Probe 没能和它建立测量会话;机器可能是通的,ping 通不代表测量用的 TCP 和 UDP 通道可用)') : 'Agent 离线'}</span>
|
<span className="line-route-status">{line.data.online ? (line.data.probe_connected ? '在线' : '测量未连接') : '离线'}</span>
|
||||||
<span className="row" style={{ gap: 6 }}>
|
<span className="row" style={{ gap: 6 }}>
|
||||||
<span className="faint">Agent</span>
|
<span className="faint">Agent</span>
|
||||||
<NodeTag info={infos.get(line.data.agent_id)} />
|
<NodeTag info={infos.get(line.data.agent_id)} />
|
||||||
@@ -293,7 +247,7 @@ export function Line() {
|
|||||||
{series.loading && !s ? <Spinner /> : null}
|
{series.loading && !s ? <Spinner /> : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div style={{ marginTop: 16 }}>
|
<div className="line-range-picker">
|
||||||
<RangePicker
|
<RangePicker
|
||||||
dayStart={period.dayStart}
|
dayStart={period.dayStart}
|
||||||
fromMs={period.fromMs}
|
fromMs={period.fromMs}
|
||||||
@@ -305,6 +259,16 @@ export function Line() {
|
|||||||
sinceMs={sinceMs}
|
sinceMs={sinceMs}
|
||||||
nowMs={nowMs}
|
nowMs={nowMs}
|
||||||
spanMs={period.spanMs}
|
spanMs={period.spanMs}
|
||||||
|
toolbar={
|
||||||
|
<div className="line-toolbar-group line-smoothing">
|
||||||
|
<span className="line-section-label" title="指数加权移动平均:把线条磨平,看清趋势;原始数据以淡线垫在后面">平滑</span>
|
||||||
|
<Segmented<EwmaValue>
|
||||||
|
options={EWMA.map((x) => ({ value: x.value, label: x.label }))}
|
||||||
|
value={smooth.value}
|
||||||
|
onChange={(v) => update((q) => (v === 'off' ? q.delete('ewma') : q.set('ewma', v)))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Minimap
|
<Minimap
|
||||||
domainStart={period.domainStart}
|
domainStart={period.domainStart}
|
||||||
@@ -346,35 +310,62 @@ export function Line() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{s ? (
|
{s ? (
|
||||||
<div className="stack" style={{ marginTop: 24 }}>
|
<div className="stack line-metrics" style={{ marginTop: 16 }}>
|
||||||
<div className="tiles">
|
<section className="line-metric-surface">
|
||||||
<div className="tile scores-tile has-loading">
|
<header className="line-metric-header">
|
||||||
<LoadingBar active={loadingNow} />
|
<div className="line-metric-heading">
|
||||||
<span className="k">评分</span>
|
<h2>{GROUPS.find((group) => group.id === metricGroup)?.title ?? '线路指标'}</h2>
|
||||||
<ScoreChips metrics={m} />
|
{metricGroup === 'quic' ? <span>往返时延 · 双向丢包 · 抖动</span> : null}
|
||||||
|
</div>
|
||||||
|
<Segmented<MetricGroup> options={METRIC_GROUPS} value={metricGroup} onChange={setMetricGroup} />
|
||||||
|
</header>
|
||||||
|
{metricGroup === 'quic' ? <>
|
||||||
|
<div className="line-quic-summary">
|
||||||
|
<div className="line-combo-card">
|
||||||
|
<h3>QUIC RTT</h3>
|
||||||
|
<div className="line-combo-primary"><span>p50</span><strong>{fmtMs(m['rtt.p50'])}</strong></div>
|
||||||
|
<div className="line-combo-secondary"><span>p95 <b>{fmtMs(m['rtt.p95'])}</b></span><span className="combo-divider">|</span><span>p99 <b>{fmtMs(m['rtt.p99'])}</b></span></div>
|
||||||
|
</div>
|
||||||
|
<div className="line-combo-card">
|
||||||
|
<h3>QUIC Datagram 丢包</h3>
|
||||||
|
<div className="line-combo-primary"><span>总丢包</span><strong className={`combo-tone-${tone(m['loss.total'], 0.5, 3)}`}>{fmtPct(m['loss.total'])}</strong></div>
|
||||||
|
<div className="line-combo-secondary"><span>↑ {fmtPct(m['loss.up'])}</span><span>/</span><span>↓ {fmtPct(m['loss.down'])}</span></div>
|
||||||
|
<span className="line-combo-badge">最长连续 ↑ {fmtNum(m['loss.run.up_max'])} / ↓ {fmtNum(m['loss.run.down_max'])} 包</span>
|
||||||
|
</div>
|
||||||
|
<div className="line-combo-card">
|
||||||
|
<h3>QUIC 抖动 p95</h3>
|
||||||
|
<div className="line-jitter-pair"><span>↑ <b>{fmtMs(m['jitter.up.p95'])}</b></span><span>↓ <b>{fmtMs(m['jitter.down.p95'])}</b></span></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{TILES.filter((t) => !t.optional || m[t.key] != null).map((t) => (
|
<div className="line-session-bar" title="会话计数从 Probe 本次启动开始;传输疑似丢包率不等同于 Datagram 应用丢包率,连接迁移成功率暂不采集。">
|
||||||
|
<strong className="line-session-title">QUIC 会话与传输</strong>
|
||||||
|
{line.data?.quic_health ? (() => {
|
||||||
|
const h = line.data.quic_health!
|
||||||
|
return <>
|
||||||
|
<div className="line-session-items">
|
||||||
|
<span>握手 p95 <b>{fmtMs(h.handshake_p95_ms)}</b></span>
|
||||||
|
<span>成功率 <b>{fmtPct(h.handshake_success_rate)}</b></span>
|
||||||
|
<span>超时 <b>{fmtPct(h.handshake_timeout_rate)}</b></span>
|
||||||
|
<span>失败 <b>{fmtPct(h.handshake_failure_rate)}</b></span>
|
||||||
|
<span>传输疑似丢包 <b>{fmtPct(h.transport_packet_loss_rate)}</b></span>
|
||||||
|
<span>路径 MTU <b>{fmtNum(h.path_mtu)} B</b></span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
})() : <span className="line-session-empty">Probe 尚未上报会话数据</span>}
|
||||||
|
</div>
|
||||||
|
</> : null}
|
||||||
|
{metricGroup !== 'quic' ? <div className="tiles">
|
||||||
|
{metricGroup === 'score' ? <div className="tile scores-tile has-loading"><LoadingBar active={loadingNow} /><span className="k">评分</span><ScoreChips metrics={m} /></div> : null}
|
||||||
|
{TILES.filter((t) => t.group === metricGroup && (!t.optional || m[t.key] != null)).map((t) => (
|
||||||
<Stat key={t.key} label={t.label} value={t.fmt(m[t.key])} unit={t.unit} tone={t.good !== undefined ? tone(m[t.key], t.good, t.poor ?? Infinity) : ''} />
|
<Stat key={t.key} label={t.label} value={t.fmt(m[t.key])} unit={t.unit} tone={t.good !== undefined ? tone(m[t.key], t.good, t.poor ?? Infinity) : ''} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div> : null}
|
||||||
<p className="faint">
|
<div className="line-data-notes">
|
||||||
样本数:UDP {fmtNum(s.summary.samples['udp_echo'])} · TCP {fmtNum(s.summary.samples['tcp_echo'])}
|
<span>样本数:QUIC Datagram {fmtNum(s.summary.samples['udp_echo'])} · TCP {fmtNum(s.summary.samples['tcp_echo'])}</span>
|
||||||
</p>
|
{s.summary.coverage < 0.95 ? <span>数据覆盖率 {(s.summary.coverage * 100).toFixed(0)}%;阴影部分为无数据时段</span> : null}
|
||||||
{s.summary.coverage < 0.95 ? <p className="faint">这个时间段的数据覆盖率是 {(s.summary.coverage * 100).toFixed(0)}%,阴影部分是没有数据的时段。</p> : null}
|
|
||||||
|
|
||||||
<div className="row" style={{ justifyContent: 'flex-end', gap: 8 }}>
|
|
||||||
<span className="faint" title="指数加权移动平均:把线条磨平,看清趋势;原始数据以淡线垫在后面">
|
|
||||||
EWMA 平滑
|
|
||||||
</span>
|
|
||||||
<Segmented<EwmaValue>
|
|
||||||
options={EWMA.map((x) => ({ value: x.value, label: x.label }))}
|
|
||||||
value={smooth.value}
|
|
||||||
onChange={(v) => update((q) => (v === 'off' ? q.delete('ewma') : q.set('ewma', v)))}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{chartSections}
|
{chartSections}
|
||||||
|
</section>
|
||||||
<HostCard agentId={agentId} range={period.hostRange} />
|
|
||||||
|
|
||||||
{test.data && result ? (
|
{test.data && result ? (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@@ -392,22 +383,6 @@ export function Line() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{s.events.length > 0 ? (
|
|
||||||
<div className="card">
|
|
||||||
<div className="card-title">事件</div>
|
|
||||||
<div className="event-list">
|
|
||||||
{s.events.slice(0, 20).map((e) => (
|
|
||||||
<div key={e.id}>
|
|
||||||
<span className="faint num" style={{ minWidth: 120 }}>
|
|
||||||
{fmtTime(e.at)}
|
|
||||||
</span>
|
|
||||||
<span>{e.kind}</span>
|
|
||||||
{e.detail ? <span className="muted mono">{e.detail}</span> : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { PageError } from '../lib'
|
|||||||
import { IPV6_STATES, IpVersionPicker, ipVersionOf } from '@shared/ipVersions'
|
import { IPV6_STATES, IpVersionPicker, ipVersionOf } from '@shared/ipVersions'
|
||||||
import { LineStrips, OVERVIEW_RANGES, type OverviewRange } from '../LineStrips'
|
import { LineStrips, OVERVIEW_RANGES, type OverviewRange } from '../LineStrips'
|
||||||
|
|
||||||
const METRICS = ['rtt.p50', 'loss.total', 'loss.up', 'loss.down', 'tcp.rtt.p50', 'tcp.retrans'].join(',')
|
const METRICS = ['rtt.p50', 'rtt.p95', 'rtt.p99', 'loss.total', 'loss.up', 'loss.down', 'tcp.rtt.p50', 'tcp.retrans'].join(',')
|
||||||
|
|
||||||
type SortKey = 'name' | 'rtt' | 'loss'
|
type SortKey = 'name' | 'rtt' | 'loss'
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ function NodeCard({ node, labels, infos }: { node: HostItem; labels: string[]; i
|
|||||||
const stale = !node.online || node.stale
|
const stale = !node.online || node.stale
|
||||||
const info = infos.get(node.id)
|
const info = infos.get(node.id)
|
||||||
return (
|
return (
|
||||||
<div className={`card agent-card ${node.online ? '' : 'offline'}`}>
|
<Link to={`/host/${encodeURIComponent(node.id)}`} className={`card agent-card host-link ${node.online ? '' : 'offline'}`} aria-label={`查看 ${node.name} 主机状态`}>
|
||||||
<div className="agent-head">
|
<div className="agent-head">
|
||||||
{/* 有国旗就用国旗开头(在线、离线看右边的标签),没有再用状态点 */}
|
{/* 有国旗就用国旗开头(在线、离线看右边的标签),没有再用状态点 */}
|
||||||
{info?.country ? <Flag code={info.country} /> : <Dot tone={node.online ? 'ok' : 'bad'} />}
|
{info?.country ? <Flag code={info.country} /> : <Dot tone={node.online ? 'ok' : 'bad'} />}
|
||||||
@@ -133,11 +133,11 @@ function NodeCard({ node, labels, infos }: { node: HostItem; labels: string[]; i
|
|||||||
<div className="faint">这台 {KIND_NAME[node.kind]} 没有上报主机状态</div>
|
<div className="faint">这台 {KIND_NAME[node.kind]} 没有上报主机状态</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 一条线路(Probe → Agent):近一段时间的 UDP 丢包和延迟走势、TCP 的 RTT 和重传。 */
|
/** 一条线路(Probe → Agent):近一段时间的 QUIC Datagram 上下行丢包、RTT 和 TCP 重传。 */
|
||||||
function LineCard({ l, range, infos }: { l: OverviewLineDto; range: OverviewRange; infos: Map<string, NodeInfoDto> }) {
|
function LineCard({ l, range, infos }: { l: OverviewLineDto; range: OverviewRange; infos: Map<string, NodeInfoDto> }) {
|
||||||
const probeInfo = infos.get(l.probe_id)
|
const probeInfo = infos.get(l.probe_id)
|
||||||
const agentInfo = infos.get(l.agent_id)
|
const agentInfo = infos.get(l.agent_id)
|
||||||
@@ -153,7 +153,7 @@ function LineCard({ l, range, infos }: { l: OverviewLineDto; range: OverviewRang
|
|||||||
<span className="name">{l.name}</span>
|
<span className="name">{l.name}</span>
|
||||||
</span>
|
</span>
|
||||||
{!l.probe_connected && l.online ? (
|
{!l.probe_connected && l.online ? (
|
||||||
<span title="Agent 在线,但这个 Probe 没能和它建立测量会话(控制通道连不上或握手超时)。机器本身可能是通的、ping 也能通,这里说的是测量用的 TCP 和 UDP 通道,线路或运营商对它们的处理可能和 ping 不一样。">
|
<span title="Agent 在线,但这个 Probe 没能和它建立测量会话(控制通道连不上或握手超时)。机器本身可能是通的、ping 也能通,这里说的是测量用的 TCP 控制通道和 QUIC Datagram 数据通道,线路或运营商对它们的处理可能和 ping 不一样。">
|
||||||
<Chip tone="warn">测量通道未建立</Chip>
|
<Chip tone="warn">测量通道未建立</Chip>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export function usePeriod(lineId: string, ready: boolean, sinceMs: number | null
|
|||||||
from: new Date(Math.max(domainStart, sinceMs === null ? domainStart : dayBounds(sinceMs).start)).toISOString(),
|
from: new Date(Math.max(domainStart, sinceMs === null ? domainStart : dayBounds(sinceMs).start)).toISOString(),
|
||||||
to: new Date(domainEnd).toISOString(),
|
to: new Date(domainEnd).toISOString(),
|
||||||
step: wide ? '2m' : '1m',
|
step: wide ? '2m' : '1m',
|
||||||
preset: 'udp',
|
preset: 'quic',
|
||||||
metrics: 'rtt.p50,loss.total',
|
metrics: 'rtt.p50,loss.total',
|
||||||
})
|
})
|
||||||
: Promise.resolve(undefined),
|
: Promise.resolve(undefined),
|
||||||
@@ -171,10 +171,10 @@ export function usePeriod(lineId: string, ready: boolean, sinceMs: number | null
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 这一段的序列;今天的「最近一段」会定时刷新。 */
|
/** 这一段的序列;今天的「最近一段」会定时刷新。 */
|
||||||
export function usePeriodSeries(lineId: string, ready: boolean, period: Period) {
|
export function usePeriodSeries(lineId: string, ready: boolean, period: Period, preset = 'quic,tcp,connect,scores', metrics?: string) {
|
||||||
return useQuery(
|
return useQuery(
|
||||||
() => (ready ? api.get<SeriesDto>(`/api/public/lines/${encodeURIComponent(lineId)}/series`, { ...period.query, preset: 'udp,tcp,connect,scores' }) : Promise.resolve(undefined)),
|
() => (ready ? api.get<SeriesDto>(`/api/public/lines/${encodeURIComponent(lineId)}/series`, { ...period.query, ...(metrics ? { metrics } : { preset }) }) : Promise.resolve(undefined)),
|
||||||
[lineId, JSON.stringify(period.query), ready],
|
[lineId, JSON.stringify(period.query), ready, preset, metrics],
|
||||||
period.isToday ? (period.win === 'day' ? 60_000 : 10_000) : undefined,
|
period.isToday ? (period.win === 'day' ? 60_000 : 10_000) : undefined,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-10
@@ -107,11 +107,13 @@ interface Props {
|
|||||||
nowMs: number
|
nowMs: number
|
||||||
/** 这一天里数据的跨度(毫秒);未知或没有数据是 `null`。 */
|
/** 这一天里数据的跨度(毫秒);未知或没有数据是 `null`。 */
|
||||||
spanMs: number | null
|
spanMs: number | null
|
||||||
|
/** 跟时长快捷键和刻度选项放在同一行的辅助控件。 */
|
||||||
|
toolbar?: ReactNode
|
||||||
/** 放在日期和档位下面(缩略图)。 */
|
/** 放在日期和档位下面(缩略图)。 */
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RangePicker({ dayStart, fromMs, toMs, onChange, onWindow, scale, onScale, sinceMs, nowMs, spanMs, children }: Props) {
|
export function RangePicker({ dayStart, fromMs, toMs, onChange, onWindow, scale, onScale, sinceMs, nowMs, spanMs, toolbar, children }: Props) {
|
||||||
const picker = useRef<HTMLInputElement>(null)
|
const picker = useRef<HTMLInputElement>(null)
|
||||||
const today = dayBounds(nowMs).start
|
const today = dayBounds(nowMs).start
|
||||||
const firstDay = sinceMs === null ? today : dayBounds(sinceMs).start
|
const firstDay = sinceMs === null ? today : dayBounds(sinceMs).start
|
||||||
@@ -157,7 +159,7 @@ export function RangePicker({ dayStart, fromMs, toMs, onChange, onWindow, scale,
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
<div className="row wrap" style={{ marginTop: 8, justifyContent: 'space-between' }}>
|
<div className="row wrap range-toolbar" style={{ marginTop: 8, justifyContent: 'space-between' }}>
|
||||||
<Segmented
|
<Segmented
|
||||||
options={WINDOWS.map((w) => {
|
options={WINDOWS.map((w) => {
|
||||||
const ok = available.includes(w.value)
|
const ok = available.includes(w.value)
|
||||||
@@ -166,14 +168,17 @@ export function RangePicker({ dayStart, fromMs, toMs, onChange, onWindow, scale,
|
|||||||
value={active}
|
value={active}
|
||||||
onChange={onWindow}
|
onChange={onWindow}
|
||||||
/>
|
/>
|
||||||
<Segmented<'24' | '48'>
|
<div className="range-toolbar-right">
|
||||||
options={[
|
{toolbar}
|
||||||
{ value: '24', label: '24 小时刻度', title: '缩略图显示一天' },
|
<Segmented<'24' | '48'>
|
||||||
{ value: '48', label: '48 小时刻度', title: '缩略图显示连续两天,选区可以跨零点' },
|
options={[
|
||||||
]}
|
{ value: '24', label: '24 小时刻度', title: '缩略图显示一天' },
|
||||||
value={String(scale) as '24' | '48'}
|
{ value: '48', label: '48 小时刻度', title: '缩略图显示连续两天,选区可以跨零点' },
|
||||||
onChange={(v) => onScale(v === '48' ? 48 : 24)}
|
]}
|
||||||
/>
|
value={String(scale) as '24' | '48'}
|
||||||
|
onChange={(v) => onScale(v === '48' ? 48 : 24)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ export default defineConfig(({ mode }) => {
|
|||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
|
// WSL cannot reliably receive inotify events for source files on /mnt/c.
|
||||||
|
// Poll only in WSL so Windows edits trigger HMR without slowing native hosts.
|
||||||
|
watch: process.env.WSL_DISTRO_NAME ? { usePolling: true, interval: 500 } : undefined,
|
||||||
fs: { allow: ['..'] },
|
fs: { allow: ['..'] },
|
||||||
proxy: {
|
proxy: {
|
||||||
// 开发时管理端是另一个 Vite 服务(5175);和线上一样用同源的 /admin/ 访问它
|
// 开发时管理端是另一个 Vite 服务(5175);和线上一样用同源的 /admin/ 访问它
|
||||||
|
|||||||
@@ -1300,6 +1300,7 @@ export interface components {
|
|||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
probe_tcp_port: number | string;
|
probe_tcp_port: number | string;
|
||||||
probe_udp_ports: (number | string)[];
|
probe_udp_ports: (number | string)[];
|
||||||
|
probe_quic_ports: (number | string)[];
|
||||||
labels: string[];
|
labels: string[];
|
||||||
public_visible: boolean;
|
public_visible: boolean;
|
||||||
kinds: string[];
|
kinds: string[];
|
||||||
@@ -1694,6 +1695,7 @@ export interface components {
|
|||||||
ip_version: string;
|
ip_version: string;
|
||||||
/** @default false */
|
/** @default false */
|
||||||
ipv6_available: boolean;
|
ipv6_available: boolean;
|
||||||
|
quic_health?: null | components["schemas"]["QuicLineHealthDto"];
|
||||||
};
|
};
|
||||||
LinkDto: {
|
LinkDto: {
|
||||||
probe_id: string;
|
probe_id: string;
|
||||||
@@ -1828,6 +1830,7 @@ export interface components {
|
|||||||
last_test: null | components["schemas"]["TestSummaryDto"];
|
last_test: null | components["schemas"]["TestSummaryDto"];
|
||||||
/** @default ipv4 */
|
/** @default ipv4 */
|
||||||
ip_version: string;
|
ip_version: string;
|
||||||
|
quic_health?: null | components["schemas"]["QuicLineHealthDto"];
|
||||||
};
|
};
|
||||||
PageDtoOfAgentDto: {
|
PageDtoOfAgentDto: {
|
||||||
items: components["schemas"]["AgentDto"][];
|
items: components["schemas"]["AgentDto"][];
|
||||||
@@ -2037,6 +2040,39 @@ export interface components {
|
|||||||
sha256: string;
|
sha256: string;
|
||||||
command: string;
|
command: string;
|
||||||
};
|
};
|
||||||
|
QuicLineHealthDto: {
|
||||||
|
connected: boolean;
|
||||||
|
/** Format: int64 */
|
||||||
|
handshake_attempts: number | string;
|
||||||
|
/** Format: int64 */
|
||||||
|
handshake_successes: number | string;
|
||||||
|
/** Format: int64 */
|
||||||
|
handshake_failures: number | string;
|
||||||
|
/** Format: int64 */
|
||||||
|
handshake_timeouts: number | string;
|
||||||
|
/** Format: double */
|
||||||
|
handshake_success_rate: null | number | string;
|
||||||
|
/** Format: double */
|
||||||
|
handshake_failure_rate: null | number | string;
|
||||||
|
/** Format: double */
|
||||||
|
handshake_timeout_rate: null | number | string;
|
||||||
|
/** Format: double */
|
||||||
|
handshake_p95_ms: null | number | string;
|
||||||
|
/** Format: int64 */
|
||||||
|
handshake_sample_count: number | string;
|
||||||
|
/** Format: double */
|
||||||
|
transport_packet_loss_rate: null | number | string;
|
||||||
|
/** Format: uint64 */
|
||||||
|
transport_packets_sent: null | number | string;
|
||||||
|
/** Format: uint32 */
|
||||||
|
transport_congestion_events: null | number | string;
|
||||||
|
/** Format: uint32 */
|
||||||
|
transport_ecn_congestion_events: null | number | string;
|
||||||
|
/** Format: uint16 */
|
||||||
|
path_mtu: null | number | string;
|
||||||
|
/** Format: date-time */
|
||||||
|
at: unknown;
|
||||||
|
};
|
||||||
RawChunkMetaDto: {
|
RawChunkMetaDto: {
|
||||||
chunk_id: string;
|
chunk_id: string;
|
||||||
agent_id: string;
|
agent_id: string;
|
||||||
|
|||||||
@@ -160,6 +160,8 @@ export function TimeChart({ t, series, height = 220, unit = '', zero = true, gap
|
|||||||
(u) => {
|
(u) => {
|
||||||
const { ctx } = u
|
const { ctx } = u
|
||||||
ctx.save()
|
ctx.save()
|
||||||
|
// Coverage gaps are context only; keep the warm mask subtle so spikes stay prominent.
|
||||||
|
ctx.globalAlpha = 0.16
|
||||||
ctx.fillStyle = shade
|
ctx.fillStyle = shade
|
||||||
for (const g of latest.current.gaps) {
|
for (const g of latest.current.gaps) {
|
||||||
const x0 = Math.max(u.bbox.left, u.valToPos(g.from / 1000, 'x', true))
|
const x0 = Math.max(u.bbox.left, u.valToPos(g.from / 1000, 'x', true))
|
||||||
|
|||||||
Reference in new issue
Block a user