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 │
|
||||
│ 有公网 IP,监听 │ 先敲门再 TLS │ 家宽等出口,NAT 后 │
|
||||
└────────────────────┘ └──────────────────────┘
|
||||
测量会话:TLS 控制通道 + UDP/TCP 数据通道,由 Probe 主动拨出
|
||||
测量会话:TLS 控制通道 + QUIC Datagram/TCP 数据通道,由 Probe 主动拨出
|
||||
宿主机更新器(容器节点):用本机公钥验签 → 按 digest 拉镜像 → 重启,失败回滚
|
||||
```
|
||||
|
||||
@@ -75,7 +75,7 @@ Backend 用段文件保存原始数据,用 SQLite 保存索引、指标与配
|
||||
|
||||
- **控制链路(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 列表)。
|
||||
- **测量数据面**: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`,前端据此生成类型。
|
||||
|
||||
细节见 [`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
|
||||
Probe ──TLS 控制 / UDP、TCP 测量──> Agent ──长连接与数据上报──> Backend
|
||||
Probe ──TLS 控制 / QUIC Datagram、TCP 测量──> Agent ──长连接与数据上报──> Backend
|
||||
▲
|
||||
fleet-enroll 注册
|
||||
```
|
||||
|
||||
@@ -33,7 +33,7 @@ public sealed class AgentApp : IDisposable
|
||||
private readonly Outbox _outbox = new(64 << 20);
|
||||
private readonly RestartUpdater? _update;
|
||||
private readonly BackendUplink _uplink;
|
||||
private readonly UdpMux _udp;
|
||||
private readonly QuicDatagramTransport _datagrams;
|
||||
private readonly TcpAcceptor _tcp;
|
||||
private readonly PeerListener _peers;
|
||||
private readonly PeerHub _hub;
|
||||
@@ -62,7 +62,7 @@ public sealed class AgentApp : IDisposable
|
||||
|
||||
_ipv6Supported = bind.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6;
|
||||
_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
|
||||
_trust = TrustAnchor.Load(config.Fleet.IdentityDir, log);
|
||||
_knock = new KnockGate(identity.PeerIdentity, RosterKeys, time);
|
||||
@@ -74,7 +74,7 @@ public sealed class AgentApp : IDisposable
|
||||
Clock = _clock,
|
||||
Log = log,
|
||||
Session = () => null, // 每个 Probe 会话各用自己绑定的环境(BlockEnv.WithSession)
|
||||
Udp = _udp,
|
||||
Datagrams = _datagrams,
|
||||
TcpListen = _tcp,
|
||||
Admission = _admission,
|
||||
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 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);
|
||||
_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.OnPeersChanged = _hub.CloseNotIn;
|
||||
_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(
|
||||
new PeerListenerOptions
|
||||
@@ -113,13 +113,13 @@ public sealed class AgentApp : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_udp.Dispose();
|
||||
_datagrams.Dispose();
|
||||
_ownedRestart?.Dispose();
|
||||
}
|
||||
|
||||
internal StreamRegistry Streams => _streams;
|
||||
|
||||
public IReadOnlyList<int> UdpPorts => _udp.BoundPorts;
|
||||
public IReadOnlyList<int> QuicPorts => _datagrams.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);
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
// 数据面和控制端口先开始监听,再连接 Backend:hello 里上报的是实际端口
|
||||
_udp.Bind();
|
||||
_datagrams.Bind();
|
||||
_tcp.Bind();
|
||||
Task udp = _udp.RunAsync(linked.Token);
|
||||
Task quic = _datagrams.RunAsync(linked.Token);
|
||||
Task tcp = _tcp.RunAsync(linked.Token);
|
||||
Task peers = _peers.RunAsync(linked.Token);
|
||||
await Task.WhenAny(_peers.Started.Task, peers);
|
||||
if (peers.IsFaulted)
|
||||
{
|
||||
await linked.CancelAsync();
|
||||
await Task.WhenAll(udp, tcp, peers); // 抛出监听失败的原因(端口被占用等)
|
||||
await Task.WhenAll(quic, tcp, peers); // 抛出监听失败的原因(端口被占用等)
|
||||
}
|
||||
|
||||
_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
|
||||
{
|
||||
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
|
||||
{
|
||||
@@ -203,7 +203,7 @@ public sealed class AgentApp : IDisposable
|
||||
MaxTotalPps = _config.Limits.MaxTotalPps,
|
||||
AllowedTargetCidrs = _config.Limits.AllowedTargetCidrs,
|
||||
MaxConcurrentTests = _config.Limits.MaxConcurrentTests,
|
||||
AllowedUdpPorts = _udp.BoundPorts,
|
||||
AllowedQuicPorts = _datagrams.BoundPorts,
|
||||
AllowedTcpPorts = _tcp.BoundPorts,
|
||||
},
|
||||
BlockCatalog.AgentKinds);
|
||||
@@ -310,7 +310,8 @@ public sealed class AgentApp : IDisposable
|
||||
Platform = MoShiro.Measurement.PlatformDetector.Detect(),
|
||||
TlsPublicKey = Convert.ToBase64String(_identity.Certificate.PublicKey.ExportSubjectPublicKeyInfo()),
|
||||
ProbeTcpPort = ControlPort,
|
||||
ProbeUdpPorts = [.. UdpPorts],
|
||||
ProbeQuicPorts = [.. QuicPorts],
|
||||
LegacyProbeUdpPorts = [.. QuicPorts],
|
||||
LocalLimits = new LocalLimits
|
||||
{
|
||||
MaxTotalMbps = _config.Limits.MaxTotalMbps,
|
||||
|
||||
@@ -51,8 +51,15 @@ public sealed class ListenSection
|
||||
[JsonPropertyName("control_port")]
|
||||
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")]
|
||||
public List<int> UdpPorts { get; set; } = [7443];
|
||||
public List<int>? LegacyUdpPorts { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public IReadOnlyList<int> EffectiveQuicPorts => LegacyUdpPorts ?? QuicPorts;
|
||||
|
||||
[JsonPropertyName("tcp_ports")]
|
||||
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<ulong, byte> _nonces = new();
|
||||
private SocketAddress? _udpSource;
|
||||
private SocketAddress? _datagramSource;
|
||||
|
||||
public ProbeSession(PeerSessionInfo info)
|
||||
: base(info.Credentials.SessionIdValue, info.Credentials.Key)
|
||||
@@ -71,11 +71,11 @@ public sealed class ProbeSession : DataSession
|
||||
public RpcSession<PeerContext>? Rpc { get; set; }
|
||||
|
||||
/// <summary>最近一次认证通过的 UDP 包的源地址(Agent 把单向数据发往这里)。</summary>
|
||||
public override IPEndPoint? UdpEndpoint
|
||||
public override IPEndPoint? DatagramRemoteEndPoint
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ public sealed class ProbeSession : DataSession
|
||||
/// <summary>记录 UDP 源地址;NAT 映射变化后地址变了返回 true(上报 endpoint_changed)。第一次看到地址不算变化。</summary>
|
||||
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))
|
||||
{
|
||||
return false;
|
||||
@@ -96,7 +96,7 @@ public sealed class ProbeSession : DataSession
|
||||
copy[i] = source[i];
|
||||
}
|
||||
|
||||
Volatile.Write(ref _udpSource, copy);
|
||||
Volatile.Write(ref _datagramSource, copy);
|
||||
return previous is not null;
|
||||
}
|
||||
|
||||
@@ -312,7 +312,7 @@ public sealed class AgentAdmission : IAdmission
|
||||
/// <summary>NAT 映射变化,源地址变了:上报 <c>endpoint_changed</c>。</summary>
|
||||
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))
|
||||
{
|
||||
|
||||
@@ -24,7 +24,7 @@ public sealed class TaskManager
|
||||
private readonly StreamRegistry _streams;
|
||||
private readonly BlockEnv _env;
|
||||
private readonly TargetRunners _targets;
|
||||
private readonly Func<IReadOnlyList<int>> _udpPorts;
|
||||
private readonly Func<IReadOnlyList<int>> _quicPorts;
|
||||
private readonly Func<IReadOnlyList<int>> _tcpPorts;
|
||||
private readonly TrustAnchor _trust;
|
||||
private readonly Dictionary<ProbeSession, Dictionary<string, LineEntry>> _sessionLines = [];
|
||||
@@ -33,14 +33,14 @@ public sealed class TaskManager
|
||||
private long _version;
|
||||
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;
|
||||
_guardFactory = guardFactory;
|
||||
_streams = streams;
|
||||
_env = env;
|
||||
_targets = targets;
|
||||
_udpPorts = udpPorts;
|
||||
_quicPorts = quicPorts;
|
||||
_tcpPorts = tcpPorts;
|
||||
}
|
||||
|
||||
@@ -229,7 +229,8 @@ public sealed class TaskManager
|
||||
{
|
||||
Version = _version,
|
||||
Tasks = [.. entries.Values.Select(e => e.Task)],
|
||||
UdpPorts = [.. _udpPorts()],
|
||||
QuicPorts = [.. _quicPorts()],
|
||||
LegacyUdpPorts = [.. _quicPorts()],
|
||||
TcpPorts = [.. _tcpPorts()],
|
||||
};
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ public sealed class TestRunner
|
||||
|
||||
// 按需测试的数据端口由 Agent 决定:Backend 给的端口如果不是本机监听的端口(模板里是出厂默认值,每个 Agent 可以改,
|
||||
// 而 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);
|
||||
if (!listening)
|
||||
{
|
||||
|
||||
@@ -5174,6 +5174,7 @@
|
||||
"public_host",
|
||||
"probe_tcp_port",
|
||||
"probe_udp_ports",
|
||||
"probe_quic_ports",
|
||||
"labels",
|
||||
"public_visible",
|
||||
"kinds",
|
||||
@@ -5242,6 +5243,17 @@
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
"probe_quic_ports": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": [
|
||||
"integer",
|
||||
"string"
|
||||
],
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
"labels": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -6974,6 +6986,16 @@
|
||||
"ipv6_available": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"quic_health": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/QuicLineHealthDto"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -7601,6 +7623,16 @@
|
||||
"ip_version": {
|
||||
"type": "string",
|
||||
"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": {
|
||||
"required": [
|
||||
"chunk_id",
|
||||
|
||||
@@ -30,6 +30,14 @@ public sealed class AgentConnection
|
||||
|
||||
private readonly ConnectionEnv _env;
|
||||
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)
|
||||
{
|
||||
@@ -45,6 +53,38 @@ public sealed class AgentConnection
|
||||
|
||||
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)
|
||||
{
|
||||
// 重连的间隔别拉太长:每次尝试本来就要等好几秒(握手上限 30 秒),线路一恢复要尽快接上;带抖动,不会和别的节点撞在一起
|
||||
@@ -93,10 +133,22 @@ public sealed class AgentConnection
|
||||
byte[] agentKey = Convert.FromBase64String(Endpoint.PublicKey);
|
||||
(PeerSessionInfo info, TlsFrameTransport transport) = await PeerConnector.ConnectAsync(
|
||||
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);
|
||||
using var udp = new UdpMux(new SingleSession(data), null, _env.Clock, new DataPlaneCounters(), _env.Log, remote.AddressFamily == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, []);
|
||||
udp.Bind();
|
||||
var remote = (IPEndPoint)info.Remote!;
|
||||
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 clock = new ClockEstimator();
|
||||
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,
|
||||
Log = _env.Log,
|
||||
Session = () => data,
|
||||
Udp = udp,
|
||||
Datagrams = datagrams,
|
||||
PeerAddress = remote.Address,
|
||||
PeerSpkiSha256 = NodeIdentity.Fingerprint(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);
|
||||
rpc.OnBinary = (_, _) => ValueTask.CompletedTask;
|
||||
|
||||
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
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);
|
||||
try
|
||||
{
|
||||
// 先同步时钟(块头里要带偏移);Agent 随后推送线路任务集
|
||||
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();
|
||||
_session = session;
|
||||
_env.Log.Info("agent session up", ("agent", Endpoint.AgentId), ("offset_us", clock.OffsetUs));
|
||||
@@ -137,6 +227,7 @@ public sealed class AgentConnection
|
||||
finally
|
||||
{
|
||||
_session = null;
|
||||
Volatile.Write(ref _activeDatagrams, null);
|
||||
await cts.CancelAsync();
|
||||
await session.StopAllAsync();
|
||||
await Quiet(run);
|
||||
|
||||
@@ -73,14 +73,15 @@ public sealed class AgentDirectory
|
||||
_trust.Update(result.TrustChain);
|
||||
foreach (AgentEndpoint agent in result.Agents)
|
||||
{
|
||||
List<int> ports = agent.QuicPorts.Count > 0 ? agent.QuicPorts : agent.LegacyUdpPorts ?? [];
|
||||
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))
|
||||
{
|
||||
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)
|
||||
@@ -111,5 +112,5 @@ public sealed class AgentDirectory
|
||||
a.Count == b.Count && a.Zip(b).All(p => Equal(p.First, p.Second));
|
||||
|
||||
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,
|
||||
NodeVersion = Version,
|
||||
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),
|
||||
};
|
||||
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)
|
||||
{
|
||||
_log.Info("moshiro probe starting", MoShiro.Protocol.BuildInfo.Fields);
|
||||
|
||||
@@ -57,6 +57,15 @@ public sealed class KnownAgent
|
||||
[JsonPropertyName("tcp_port")]
|
||||
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>
|
||||
[JsonPropertyName("public_key")]
|
||||
public string PublicKey { get; set; } = "";
|
||||
|
||||
@@ -26,7 +26,7 @@ public static class BlockCatalog
|
||||
{
|
||||
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("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"]),
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace MoShiro.Measurement;
|
||||
internal sealed class EchoRows
|
||||
{
|
||||
/// <summary>UDP:没有在这段时间内回来的包就是丢了。</summary>
|
||||
public const long UdpGraceUs = 2_000_000;
|
||||
public const long DatagramGraceUs = 2_000_000;
|
||||
|
||||
/// <summary>
|
||||
/// TCP 不会丢包,只会延迟(重传、队头阻塞):一次几秒的中断之后,积压的回显会陆续回来,RTT 就是那几秒。
|
||||
@@ -65,26 +65,27 @@ internal sealed class EchoRows
|
||||
/// <summary>
|
||||
/// UDP 回显,拨号方(Probe):按声明的速率发包,t1 在发出时记录,回复到达时回填 t4。回复丢失的行 t4 缺失(Agent 一侧再补 t2/t3)。
|
||||
/// </summary>
|
||||
public sealed class UdpEchoDialer : BlockBase
|
||||
public sealed class DatagramEchoDialer : BlockBase
|
||||
{
|
||||
private readonly EchoRows _rows;
|
||||
|
||||
public UdpEchoDialer(BlockEnv env, BlockSpec spec)
|
||||
public DatagramEchoDialer(BlockEnv env, BlockSpec 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 async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
UdpMux udp = Env.Udp!;
|
||||
QuicDatagramTransport datagrams = Env.Datagrams!;
|
||||
DataSession session = Env.Session() ?? throw new BlockFaultException("no data session");
|
||||
await datagrams.WaitUntilConnectedAsync(ct);
|
||||
IPEndPoint target = PeerEndpoint(Spec.Port);
|
||||
int size = (int)Spec.Int("size");
|
||||
byte[] packet = new byte[size];
|
||||
udp.Register(Spec.StreamId, OnPacket);
|
||||
datagrams.Register(Spec.StreamId, OnPacket);
|
||||
try
|
||||
{
|
||||
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();
|
||||
uint seq = _rows.Add(size, t1);
|
||||
UdpPacket.Write(packet, new UdpHeader(UdpPacketType.EchoRequest, session.SessionId, Spec.StreamId, seq, t1), session.Key);
|
||||
udp.Send(target, packet);
|
||||
MeasurementDatagram.Write(packet, new MeasurementDatagramHeader(MeasurementDatagramType.EchoRequest, session.SessionId, Spec.StreamId, seq, t1), session.Key);
|
||||
datagrams.Send(session, target, packet);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
@@ -102,7 +103,7 @@ public sealed class UdpEchoDialer : BlockBase
|
||||
}
|
||||
finally
|
||||
{
|
||||
udp.Unregister(Spec.StreamId);
|
||||
datagrams.Unregister(Spec.StreamId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,9 +119,9 @@ public sealed class UdpEchoDialer : BlockBase
|
||||
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);
|
||||
}
|
||||
@@ -128,11 +129,11 @@ public sealed class UdpEchoDialer : BlockBase
|
||||
}
|
||||
|
||||
/// <summary>UDP 回显,监听方(Agent):校验后立即回复一个等长的包,记录 t2(到达)和 t3(回复发出),按序号留给合并。</summary>
|
||||
public sealed class UdpEchoListener : BlockBase
|
||||
public sealed class DatagramEchoListener : BlockBase
|
||||
{
|
||||
private readonly RowStore _records;
|
||||
|
||||
public UdpEchoListener(BlockEnv env, BlockSpec spec)
|
||||
public DatagramEchoListener(BlockEnv env, BlockSpec spec)
|
||||
: base(env, spec)
|
||||
{
|
||||
_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)
|
||||
{
|
||||
Env.Udp!.Register(Spec.StreamId, OnPacket);
|
||||
Env.Datagrams!.Register(Spec.StreamId, OnPacket);
|
||||
try
|
||||
{
|
||||
await WaitUntilCancelledAsync(ct);
|
||||
}
|
||||
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 映射和让准入策略学到源地址
|
||||
}
|
||||
|
||||
long t2 = delivery.ReceiveUs;
|
||||
delivery.ReplyAs(UdpPacketType.EchoReply);
|
||||
delivery.ReplyAs(MeasurementDatagramType.EchoReply);
|
||||
Span<long> row = [t2, Env.Clock.NowUs()];
|
||||
_records.SetRow(delivery.Header.Seq, row);
|
||||
}
|
||||
|
||||
@@ -30,11 +30,11 @@ public sealed class TrainListener : BlockBase
|
||||
public override async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
// Probe 开始前发来的探活包(Data)要被接受,准入策略才能据此学到它当前的源地址(NAT 映射);它们本身没有内容
|
||||
Env.Udp!.Register(Spec.StreamId, static (in UdpDelivery _) => { });
|
||||
Env.Datagrams!.Register(Spec.StreamId, static (in QuicDatagramDelivery _) => { });
|
||||
try
|
||||
{
|
||||
DataSession session = await WaitForPeerAsync(ct);
|
||||
IPEndPoint target = session.UdpEndpoint!;
|
||||
IPEndPoint target = session.DatagramRemoteEndPoint!;
|
||||
TimeSpan duration = TimeSpan.FromSeconds(Spec.Int("duration_s"));
|
||||
var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var thread = new Thread(() =>
|
||||
@@ -58,7 +58,7 @@ public sealed class TrainListener : BlockBase
|
||||
}
|
||||
finally
|
||||
{
|
||||
Env.Udp.Unregister(Spec.StreamId);
|
||||
Env.Datagrams.Unregister(Spec.StreamId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ public sealed class TrainListener : BlockBase
|
||||
while (true)
|
||||
{
|
||||
DataSession? session = Env.Session();
|
||||
if (session?.UdpEndpoint is not null)
|
||||
if (session?.DatagramRemoteEndPoint is not null)
|
||||
{
|
||||
return session;
|
||||
}
|
||||
@@ -120,8 +120,8 @@ public sealed class TrainListener : BlockBase
|
||||
for (; seq < due; seq++)
|
||||
{
|
||||
long sendUs = Env.Clock.NowUs();
|
||||
UdpPacket.Write(packet, new UdpHeader(UdpPacketType.Data, session.SessionId, Spec.StreamId, seq, sendUs), session.Key);
|
||||
Env.Udp!.Send(target, packet);
|
||||
MeasurementDatagram.Write(packet, new MeasurementDatagramHeader(MeasurementDatagramType.Data, session.SessionId, Spec.StreamId, seq, sendUs), session.Key);
|
||||
Env.Datagrams!.Send(session, target, packet);
|
||||
Span<long> row = [size, sendUs];
|
||||
_records.SetRow(seq, row);
|
||||
Interlocked.Exchange(ref _sent, seq + 1L);
|
||||
@@ -159,18 +159,19 @@ public sealed class TrainDialer : BlockBase
|
||||
|
||||
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");
|
||||
await datagrams.WaitUntilConnectedAsync(ct);
|
||||
_startUs = Env.Clock.NowUs();
|
||||
udp.Register(Spec.StreamId, OnPacket);
|
||||
datagrams.Register(Spec.StreamId, OnPacket);
|
||||
try
|
||||
{
|
||||
byte[] hello = new byte[UdpPacket.MinBytes + 20];
|
||||
byte[] hello = new byte[MeasurementDatagram.MinBytes + 20];
|
||||
IPEndPoint target = PeerEndpoint(Spec.Port);
|
||||
for (uint seq = 0; seq < 3; seq++)
|
||||
{
|
||||
UdpPacket.Write(hello, new UdpHeader(UdpPacketType.Data, session.SessionId, Spec.StreamId, seq, Env.Clock.NowUs()), session.Key);
|
||||
udp.Send(target, hello);
|
||||
MeasurementDatagram.Write(hello, new MeasurementDatagramHeader(MeasurementDatagramType.Data, session.SessionId, Spec.StreamId, seq, Env.Clock.NowUs()), session.Key);
|
||||
datagrams.Send(session, target, hello);
|
||||
}
|
||||
|
||||
await WaitUntilCancelledAsync(ct);
|
||||
@@ -178,7 +179,7 @@ public sealed class TrainDialer : BlockBase
|
||||
finally
|
||||
{
|
||||
_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) =>
|
||||
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;
|
||||
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); // 只记第一次到达(重复的包不覆盖)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ namespace MoShiro.Measurement;
|
||||
/// </summary>
|
||||
public class DataSession
|
||||
{
|
||||
private IDatagramConnection? _datagramConnection;
|
||||
|
||||
public DataSession(long sessionId, byte[] key)
|
||||
{
|
||||
SessionId = sessionId;
|
||||
@@ -21,13 +23,26 @@ public class DataSession
|
||||
|
||||
public byte[] Key { get; }
|
||||
|
||||
public IDatagramConnection? DatagramConnection => Volatile.Read(ref _datagramConnection);
|
||||
|
||||
public void BindDatagramConnection(IDatagramConnection connection) => Volatile.Write(ref _datagramConnection, connection);
|
||||
|
||||
/// <summary>对端最近一次通过认证的 UDP 源地址(监听方把单向数据发往这里;拨号方不需要)。</summary>
|
||||
public virtual IPEndPoint? UdpEndpoint => null;
|
||||
public virtual IPEndPoint? DatagramRemoteEndPoint => null;
|
||||
|
||||
/// <summary>TCP 首帧的随机数只能用一次。拨号方不接受连接,所以默认放行。</summary>
|
||||
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
|
||||
{
|
||||
DataSession? Find(long sessionId);
|
||||
@@ -60,7 +75,7 @@ public enum Admission
|
||||
public interface IAdmission
|
||||
{
|
||||
/// <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>
|
||||
bool AdmitMessage(DataSession session, uint streamId, long nowUs);
|
||||
@@ -201,7 +216,7 @@ public sealed class BlockEnv
|
||||
/// <summary>当前的数据面会话(拨号方固定;监听方是 Agent 当前的 Probe 会话,可能没有)。</summary>
|
||||
public required Func<DataSession?> Session { get; init; }
|
||||
|
||||
public UdpMux? Udp { get; init; }
|
||||
public QuicDatagramTransport? Datagrams { get; init; }
|
||||
|
||||
/// <summary>监听方的准入策略(限速等);拨号方为 null。</summary>
|
||||
public IAdmission? Admission { get; init; }
|
||||
@@ -238,7 +253,7 @@ public sealed class BlockEnv
|
||||
Clock = Clock,
|
||||
Log = Log,
|
||||
Session = () => session,
|
||||
Udp = Udp,
|
||||
Datagrams = Datagrams,
|
||||
Admission = Admission,
|
||||
Counters = Counters,
|
||||
TcpListen = TcpListen,
|
||||
|
||||
@@ -17,7 +17,7 @@ public sealed class NodeLimits
|
||||
public int MaxConcurrentTests { get; init; } = 1;
|
||||
|
||||
/// <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];
|
||||
}
|
||||
@@ -143,7 +143,7 @@ public sealed class BudgetGuard
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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(
|
||||
"udp_echo",
|
||||
"UDP echo",
|
||||
"Fixed-rate UDP echo with four timestamps per packet.",
|
||||
"QUIC Datagram echo",
|
||||
"Fixed-rate QUIC Datagram echo with four timestamps per packet.",
|
||||
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("size", 64, 44, 1400, "B"),
|
||||
@@ -41,10 +41,10 @@ public static class Kinds
|
||||
|
||||
public static readonly KindSpec UdpTrain = new(
|
||||
"udp_train",
|
||||
"UDP train",
|
||||
"Fixed-rate or laddered one-way UDP stream (Agent to Probe) for loss and jitter under load.",
|
||||
"QUIC Datagram train",
|
||||
"Fixed-rate or laddered one-way QUIC Datagram stream (Agent to Probe) for loss and delay variation under load.",
|
||||
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.Int("size", 1250, 200, 1400, "B"),
|
||||
|
||||
@@ -146,8 +146,11 @@ public sealed class AgentEndpoint
|
||||
[JsonPropertyName("tcp_port")]
|
||||
public int TcpPort { get; set; }
|
||||
|
||||
[JsonPropertyName("quic_ports")]
|
||||
public List<int> QuicPorts { get; set; } = [];
|
||||
|
||||
[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>
|
||||
[JsonPropertyName("public_key")]
|
||||
@@ -175,8 +178,11 @@ public sealed class AgentEnrollMeta
|
||||
[JsonPropertyName("probe_tcp_port")]
|
||||
public int ProbeTcpPort { get; set; } = 7443;
|
||||
|
||||
[JsonPropertyName("probe_quic_ports")]
|
||||
public List<int> ProbeQuicPorts { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("probe_udp_ports")]
|
||||
public List<int> ProbeUdpPorts { get; set; } = [7443];
|
||||
public List<int>? LegacyProbeUdpPorts { get; set; }
|
||||
|
||||
[JsonPropertyName("public_host")]
|
||||
public string? PublicHost { get; set; }
|
||||
@@ -296,6 +302,9 @@ public sealed class UpdateStatus
|
||||
/// <summary>Probe 的 <c>fleet.heartbeat</c> 附加字段:更新器的状态。</summary>
|
||||
public sealed class ProbeHeartbeatExtra
|
||||
{
|
||||
[JsonPropertyName("quic_links")]
|
||||
public List<ProbeQuicLinkHealth>? QuicLinks { get; set; }
|
||||
|
||||
[JsonPropertyName("ipv6")]
|
||||
public Ipv6Diagnostic? Ipv6 { get; set; }
|
||||
|
||||
@@ -303,6 +312,53 @@ public sealed class ProbeHeartbeatExtra
|
||||
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>
|
||||
public sealed class ProbeHelloMeta
|
||||
{
|
||||
@@ -345,8 +401,11 @@ public sealed class AgentHelloMeta
|
||||
[JsonPropertyName("probe_tcp_port")]
|
||||
public int? ProbeTcpPort { get; set; }
|
||||
|
||||
[JsonPropertyName("probe_quic_ports")]
|
||||
public List<int>? ProbeQuicPorts { get; set; }
|
||||
|
||||
[JsonPropertyName("probe_udp_ports")]
|
||||
public List<int>? ProbeUdpPorts { get; set; }
|
||||
public List<int>? LegacyProbeUdpPorts { get; set; }
|
||||
|
||||
[JsonPropertyName("local_limits")]
|
||||
public LocalLimits? LocalLimits { get; set; }
|
||||
@@ -482,8 +541,11 @@ public sealed class LineTaskSet
|
||||
public List<LineTask> Tasks { get; set; } = [];
|
||||
|
||||
/// <summary>Agent 的数据面端口(Probe 把包发到这里)。</summary>
|
||||
[JsonPropertyName("quic_ports")]
|
||||
public List<int> QuicPorts { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("udp_ports")]
|
||||
public List<int> UdpPorts { get; set; } = [];
|
||||
public List<int> LegacyUdpPorts { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("tcp_ports")]
|
||||
public List<int> TcpPorts { get; set; } = [];
|
||||
|
||||
+8
-8
@@ -3,7 +3,7 @@ using System.Security.Cryptography;
|
||||
|
||||
namespace MoShiro.Protocol;
|
||||
|
||||
public enum UdpPacketType : byte
|
||||
public enum MeasurementDatagramType : byte
|
||||
{
|
||||
EchoRequest = 1,
|
||||
EchoReply = 2,
|
||||
@@ -12,7 +12,7 @@ public enum UdpPacketType : byte
|
||||
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>
|
||||
/// 数据面的 UDP 包(Fleet 之外的线格式):
|
||||
@@ -20,7 +20,7 @@ public readonly record struct UdpHeader(UdpPacketType Type, long SessionId, uint
|
||||
/// mac = HMAC-SHA256(session_key, 前面全部字节) 截断到 16 字节。所有整数小端。
|
||||
/// 接收方对不合法的包静默丢弃并计数,不回复(防放大)。
|
||||
/// </summary>
|
||||
public static class UdpPacket
|
||||
public static class MeasurementDatagram
|
||||
{
|
||||
public const ushort Magic = 0x4D4E; // "MN"
|
||||
public const byte Version = 1;
|
||||
@@ -30,19 +30,19 @@ public static class UdpPacket
|
||||
public const int MaxBytes = 1472;
|
||||
|
||||
/// <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;
|
||||
if (packet.Length is < MinBytes or > MaxBytes
|
||||
|| BinaryPrimitives.ReadUInt16LittleEndian(packet) != Magic
|
||||
|| 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;
|
||||
}
|
||||
|
||||
header = new UdpHeader(
|
||||
(UdpPacketType)packet[3],
|
||||
header = new MeasurementDatagramHeader(
|
||||
(MeasurementDatagramType)packet[3],
|
||||
BinaryPrimitives.ReadInt64LittleEndian(packet[4..]),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(packet[12..]),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(packet[16..]),
|
||||
@@ -51,7 +51,7 @@ public static class UdpPacket
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
@@ -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)
|
||||
{
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(destination, UdpPacket.Magic);
|
||||
destination[2] = UdpPacket.Version;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(destination, MeasurementDatagram.Magic);
|
||||
destination[2] = MeasurementDatagram.Version;
|
||||
BinaryPrimitives.WriteInt64LittleEndian(destination[3..], sessionId);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(destination[11..], streamId);
|
||||
nonce[..NonceBytes].CopyTo(destination[15..]);
|
||||
@@ -35,7 +35,7 @@ public static class TcpWire
|
||||
sessionId = 0;
|
||||
streamId = 0;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ public sealed class MeasurementTests
|
||||
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]
|
||||
public void BudgetGuard_AcceptsDefaults_RejectsUnknownKindsBadParamsAndForeignPorts()
|
||||
@@ -34,7 +34,7 @@ public sealed class MeasurementTests
|
||||
[Fact]
|
||||
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;
|
||||
long pps = 0;
|
||||
Assert.True(guard.CheckPhase("udp_train", new Dictionary<string, JsonElement> { ["mbps"] = Json("10") }).Accepted);
|
||||
@@ -298,7 +298,7 @@ public sealed class MeasurementTests
|
||||
[Fact]
|
||||
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, 1_000_000);
|
||||
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);
|
||||
|
||||
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_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(
|
||||
"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 =
|
||||
[
|
||||
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,
|
||||
SessionMetrics.ConnectSuccess, SessionMetrics.ConnectTcp, SessionMetrics.ConnectTls,
|
||||
SessionMetrics.FailTimeout, SessionMetrics.FailReset, SessionMetrics.FailTls,
|
||||
@@ -22,7 +22,8 @@ public static class MetricRegistry
|
||||
/// <summary>指标分组:前端按分组展示,新增指标只需要把它加进分组。</summary>
|
||||
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("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"]),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace MoShiro.Backend.Analysis;
|
||||
|
||||
/// <summary>连接建立、长连接、UDP 压测(udp_train)的指标。</summary>
|
||||
/// <summary>TCP 建连、长连接、QUIC Datagram 压测(legacy kind udp_train)的指标。</summary>
|
||||
public static class SessionMetrics
|
||||
{
|
||||
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);
|
||||
|
||||
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(
|
||||
"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(
|
||||
"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(
|
||||
"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 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) =>
|
||||
Combine(
|
||||
@@ -24,7 +24,8 @@ public sealed class BaseModel : ServiceModelBase
|
||||
Dim(view, "loss.total", "Packet loss", 4, s_loss),
|
||||
Dim(view, "jitter.down.p95", "Jitter (down)", 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);
|
||||
}
|
||||
|
||||
@@ -273,7 +273,14 @@ internal static class JitterEmitter
|
||||
/// <summary>连续丢失段的长度(按发送顺序)。</summary>
|
||||
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)
|
||||
{
|
||||
@@ -292,7 +299,13 @@ internal sealed class LossRunsOp : MetricOp
|
||||
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)
|
||||
{
|
||||
@@ -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>
|
||||
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 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();
|
||||
|
||||
|
||||
@@ -31,7 +31,23 @@ public static class Mappers
|
||||
public static OverviewLineDto ToDto(OverviewLine line) =>
|
||||
new(
|
||||
line.Line.Key.Id, line.Line.Agent.Agent.Id.Value, line.Line.Probe.Id, line.Line.Probe.Name, line.Line.Agent.Agent.Name, line.Line.Agent.Agent.Labels,
|
||||
line.Line.Agent.Online, line.Line.Connected, line.Line.Agent.EndpointChanges, ToDto(line.Summary), Summary(line.LastTest), line.Line.IpVersion);
|
||||
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) =>
|
||||
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))
|
||||
: null;
|
||||
return new AgentDto(
|
||||
a.Id.Value, a.Name, StatusName(a.Status), view.Online, view.ProbeConnected, [.. view.Probes.Select(ToDto)], view.Host is null ? null : ToDto(view.Host), a.PublicHost, a.ProbeTcpPort, a.ProbeUdpPorts, a.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);
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ public sealed record AgentDto(
|
||||
string PublicHost,
|
||||
int ProbeTcpPort,
|
||||
IReadOnlyList<int> ProbeUdpPorts,
|
||||
IReadOnlyList<int> ProbeQuicPorts,
|
||||
IReadOnlyList<string> Labels,
|
||||
bool PublicVisible,
|
||||
IReadOnlyList<string> Kinds,
|
||||
|
||||
@@ -22,6 +22,7 @@ namespace MoShiro.Backend.Api;
|
||||
[JsonSerializable(typeof(DistributionDto))]
|
||||
[JsonSerializable(typeof(HeatmapDto))]
|
||||
[JsonSerializable(typeof(LineDto))]
|
||||
[JsonSerializable(typeof(QuicLineHealthDto))]
|
||||
[JsonSerializable(typeof(TestDto))]
|
||||
[JsonSerializable(typeof(PageDto<TestDto>))]
|
||||
[JsonSerializable(typeof(PageDto<KindDto>))]
|
||||
|
||||
@@ -28,7 +28,27 @@ public sealed record OverviewLineDto(
|
||||
int EndpointChanges,
|
||||
SummaryDto Summary,
|
||||
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);
|
||||
|
||||
@@ -90,7 +110,8 @@ public sealed record LineDto(
|
||||
IReadOnlyList<string> Kinds,
|
||||
TestSummaryDto? LastTest,
|
||||
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);
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ namespace MoShiro.Backend.Api;
|
||||
public static class QueryEndpoints
|
||||
{
|
||||
private const string Tag = "public";
|
||||
private const string DefaultOverviewPreset = "udp,tcp,scores";
|
||||
private const string DefaultSeriesPreset = "udp,tcp";
|
||||
private const string DefaultOverviewPreset = "quic,tcp,scores";
|
||||
private const string DefaultSeriesPreset = "quic,tcp";
|
||||
|
||||
public static void Map(RouteGroupBuilder group)
|
||||
{
|
||||
@@ -102,7 +102,7 @@ public static class QueryEndpoints
|
||||
return TypedResults.Json(
|
||||
new LineDto(
|
||||
line.Key.Id, view.Agent.Id.Value, line.Probe.Id, line.Probe.Name, view.Agent.Name, view.Agent.Labels, view.Online, line.Connected,
|
||||
Mappers.Time(await query.EarliestAsync(line.Key, ct)), view.Agent.Kinds, Mappers.Summary(last), line.IpVersion, view.Agent.Ipv6Host.Length > 0),
|
||||
Mappers.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);
|
||||
}
|
||||
|
||||
|
||||
@@ -173,7 +173,8 @@ public sealed partial class AgentService
|
||||
Host = agent.PublicHost,
|
||||
Ipv6Host = agent.Ipv6Supported && agent.Ipv6Host.Length > 0 ? agent.Ipv6Host : null,
|
||||
TcpPort = agent.ProbeTcpPort,
|
||||
UdpPorts = [.. agent.ProbeUdpPorts],
|
||||
QuicPorts = [.. agent.ProbeUdpPorts],
|
||||
LegacyUdpPorts = [.. agent.ProbeUdpPorts],
|
||||
PublicKey = Convert.ToBase64String(agent.TlsPublicKey!),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ public sealed class MoShiroEnrollmentHook : IEnrollmentHook
|
||||
// 注册请求带了 public_host 就用它;否则来源地址是公网地址时用来源地址(私网或隧道出口不用,等 hello 里的本机地址)
|
||||
string host = !string.IsNullOrWhiteSpace(meta.PublicHost) ? meta.PublicHost
|
||||
: 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)
|
||||
{
|
||||
(_, string? observedV6) = AgentAddressPolicy.Choose(enrolled, [], context.RemoteIp);
|
||||
@@ -70,10 +70,12 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
|
||||
private readonly ScheduleService _schedule;
|
||||
private readonly IProbeStore _probes;
|
||||
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;
|
||||
_quicRuntime = quicRuntime;
|
||||
_releases = releases;
|
||||
_schedule = schedule;
|
||||
_probes = probes;
|
||||
@@ -168,12 +170,17 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
|
||||
case NodeEventKind.Offline:
|
||||
_live.Publish(new NodeSignal("probe", id.Value, false, nodeEvent.AtMs));
|
||||
_releases.RecordStatus(id, null);
|
||||
_quicRuntime.MarkProbeOffline(id.Value, nodeEvent.AtMs);
|
||||
break;
|
||||
case NodeEventKind.Heartbeat when nodeEvent.Extra is { } extra:
|
||||
try
|
||||
{
|
||||
ProbeHeartbeatExtra? heartbeat = extra.Deserialize(MoShiroJsonContext.Default.ProbeHeartbeatExtra);
|
||||
_releases.RecordStatus(id, heartbeat?.Update);
|
||||
if (heartbeat?.QuicLinks is { } quicLinks)
|
||||
{
|
||||
_quicRuntime.Replace(id.Value, nodeEvent.AtMs, quicLinks);
|
||||
}
|
||||
if (heartbeat?.Ipv6 is { } ipv6)
|
||||
{
|
||||
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);
|
||||
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);
|
||||
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 上报的公网地址;手填的不动
|
||||
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.AddSingleton<AgentRuntime>();
|
||||
services.AddSingleton<ProbeQuicRuntime>();
|
||||
services.AddSingleton<LiveHub>();
|
||||
services.AddSingleton<HotLayer>();
|
||||
services.AddSingleton<BackendCounters>();
|
||||
|
||||
@@ -25,7 +25,7 @@ public readonly record struct LineKey(NodeId Agent, ProbeId Probe, string IpVers
|
||||
}
|
||||
|
||||
/// <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);
|
||||
|
||||
@@ -40,12 +40,14 @@ public sealed class LineService
|
||||
private readonly AgentService _agents;
|
||||
private readonly IProbeStore _probes;
|
||||
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;
|
||||
_agents = agents;
|
||||
_probes = probes;
|
||||
_quicRuntime = quicRuntime;
|
||||
}
|
||||
|
||||
/// <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)))
|
||||
{
|
||||
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)
|
||||
{
|
||||
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];
|
||||
}
|
||||
|
||||
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 可以改)
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -40,8 +40,8 @@ public sealed class ModelAndTestEvaluatorTests
|
||||
[Fact]
|
||||
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 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 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.up_max", 40), ("loss.run.down_max", 40))).Score!.Value;
|
||||
Assert.True(good > 95);
|
||||
Assert.True(bad < 10);
|
||||
}
|
||||
@@ -83,7 +83,7 @@ public sealed class ModelAndTestEvaluatorTests
|
||||
var ranges = new Dictionary<string, double[]>
|
||||
{
|
||||
["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.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],
|
||||
|
||||
@@ -90,6 +90,28 @@ public sealed class OpTests
|
||||
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]
|
||||
public void Reorder_CountsLateArrivals_AndIgnoresDuplicates()
|
||||
{
|
||||
|
||||
@@ -21,7 +21,7 @@ public sealed class AgentEndToEndTests
|
||||
AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
||||
await agent.OverrideLineTasksAsync(rateHz: 30);
|
||||
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"));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
await using ProbeSimulator second = first.Restarted();
|
||||
await second.ConnectAsync("127.0.0.1", agent.App.ControlPort, agent.TlsPublicKey, agent.App.UdpPorts[0], agent.NodeId);
|
||||
LineTaskSet set = await second.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo" && t.Port == agent.App.UdpPorts[0]));
|
||||
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.QuicPorts[0]));
|
||||
LineTask udp2 = set.Tasks.Single(t => t.Kind == "udp_echo");
|
||||
Assert.NotEqual(udp.StreamId, udp2.StreamId);
|
||||
|
||||
@@ -221,11 +221,11 @@ public sealed class AgentEndToEndTests
|
||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
||||
await agent.CreateProbeAsync();
|
||||
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 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]
|
||||
@@ -335,7 +335,7 @@ public sealed class AgentEndToEndTests
|
||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend, config => config.Limits.MaxTotalMbps = 20);
|
||||
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] };
|
||||
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
|
||||
{
|
||||
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);
|
||||
LoadedNodeIdentity identity = LoadedNodeIdentity.Load(directory);
|
||||
@@ -87,7 +87,7 @@ public sealed class AgentHarness : IAsyncDisposable
|
||||
public async Task<ProbeSimulator> ConnectProbeAsync()
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ public sealed class AgentHarness : IAsyncDisposable
|
||||
}
|
||||
|
||||
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 关掉它
|
||||
bool enabled = kind != "connect";
|
||||
if (rateHz is { } hz && kind != "connect")
|
||||
|
||||
@@ -97,6 +97,7 @@ public sealed class AgentSimulator : IAsyncDisposable
|
||||
["meta"] = new JsonObject
|
||||
{
|
||||
["probe_tcp_port"] = 7443,
|
||||
["probe_quic_ports"] = new JsonArray(7443),
|
||||
["probe_udp_ports"] = new JsonArray(7443),
|
||||
["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()),
|
||||
@@ -154,7 +155,8 @@ public sealed class AgentSimulator : IAsyncDisposable
|
||||
TlsPublicKey = Convert.ToBase64String(_tlsKey.PublicKeySpki),
|
||||
Addresses = Addresses is null ? null : [.. Addresses],
|
||||
ProbeTcpPort = 7443,
|
||||
ProbeUdpPorts = [7443],
|
||||
ProbeQuicPorts = [7443],
|
||||
LegacyProbeUdpPorts = [7443],
|
||||
LocalLimits = new LocalLimits { MaxTotalMbps = 200, MaxTotalPps = 20_000, MaxConcurrentTests = 1 },
|
||||
},
|
||||
MoShiroJsonContext.Default.AgentHelloMeta);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<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.Link\Fleet.Link.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -8,13 +8,14 @@ using Fleet.Core;
|
||||
using Fleet.Identity;
|
||||
using Fleet.Link;
|
||||
using Fleet.Rpc;
|
||||
using MoShiro.Measurement;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// 假 Probe:通过真实的点对点握手(TLS 固定证书 + 签名 + ECDH)连接真实的 Agent,接收 <c>line_task_set</c>,
|
||||
/// 在自己的 UDP 套接字上发回显包、接收单向数据流、估计时钟偏移,并把 Probe 一侧的列块经发件箱通道 17 交给 Agent。
|
||||
/// 通过 QUIC Datagram 发回显包、接收单向数据流、估计时钟偏移,并把 Probe 一侧的列块经发件箱通道 17 交给 Agent。
|
||||
/// 它实现 Probe 的协议职责,是将来真实 Probe 的参照。
|
||||
/// </summary>
|
||||
public sealed class ProbeSimulator : IAsyncDisposable
|
||||
@@ -30,8 +31,9 @@ public sealed class ProbeSimulator : IAsyncDisposable
|
||||
private readonly CancellationTokenSource _stop = new();
|
||||
private NodeId _agentId;
|
||||
private RpcSession<ProbeSimulator>? _rpc;
|
||||
private UdpClient? _udp;
|
||||
private IPEndPoint? _agentUdp;
|
||||
private QuicDatagramTransport? _quic;
|
||||
private DataSession? _dataSession;
|
||||
private IPEndPoint? _agentQuic;
|
||||
private PeerSessionInfo? _info;
|
||||
private Task? _receiver;
|
||||
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);
|
||||
_info = info;
|
||||
_agentKey = agentPublicKey;
|
||||
_udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0));
|
||||
_agentUdp = new IPEndPoint(IPAddress.Parse(host), udpPort);
|
||||
_dataSession = new DataSession(info.Credentials.SessionIdValue, info.Credentials.Key);
|
||||
_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.OnBinary = (_, _) => ValueTask.CompletedTask;
|
||||
_run = _rpc.RunAsync(_stop.Token);
|
||||
_receiver = Task.Run(ReceiveLoopAsync, _stop.Token);
|
||||
_receiver = _quic.RunAsync(_stop.Token);
|
||||
await SyncClockAsync();
|
||||
await _quic.ConnectAsync(ct);
|
||||
}
|
||||
|
||||
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();
|
||||
var collector = new EchoCollector();
|
||||
_echoes[task.StreamId] = collector;
|
||||
_quic!.Register(task.StreamId, OnDatagram);
|
||||
long startUtc = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
byte[] packet = new byte[size];
|
||||
for (uint seq = 0; seq < count; seq++)
|
||||
{
|
||||
long t1 = NowUs();
|
||||
collector.Sent[seq] = t1;
|
||||
UdpPacket.Write(packet, new UdpHeader(UdpPacketType.EchoRequest, _info!.Credentials.SessionIdValue, task.StreamId, seq, t1), _info.Credentials.Key);
|
||||
await _udp!.SendAsync(packet, packet.Length, _agentUdp!);
|
||||
MeasurementDatagram.Write(packet, new MeasurementDatagramHeader(MeasurementDatagramType.EchoRequest, _info!.Credentials.SessionIdValue, task.StreamId, seq, t1), _info.Credentials.Key);
|
||||
_quic.Send(_dataSession!, _agentQuic!, packet);
|
||||
await Task.Delay(interval, ct);
|
||||
}
|
||||
|
||||
await Task.Delay(300, ct); // 等最后的回复
|
||||
_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)]));
|
||||
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>
|
||||
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>
|
||||
public Task SendForgedEchoAsync(LineTask task, uint seq, byte[]? key = null)
|
||||
{
|
||||
byte[] packet = new byte[64];
|
||||
UdpPacket.Write(packet, new UdpHeader(UdpPacketType.EchoRequest, _info!.Credentials.SessionIdValue, task.StreamId, seq, NowUs()), key ?? new byte[32]);
|
||||
return _udp!.SendAsync(packet, packet.Length, _agentUdp!);
|
||||
MeasurementDatagram.Write(packet, new MeasurementDatagramHeader(MeasurementDatagramType.EchoRequest, _info!.Credentials.SessionIdValue, task.StreamId, seq, NowUs()), key ?? new byte[32]);
|
||||
_quic!.Send(_dataSession!, _agentQuic!, packet);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>重放:用同一个序号、同样的密钥再发一次。</summary>
|
||||
public Task SendEchoAsync(LineTask task, uint seq)
|
||||
{
|
||||
byte[] packet = new byte[64];
|
||||
UdpPacket.Write(packet, new UdpHeader(UdpPacketType.EchoRequest, _info!.Credentials.SessionIdValue, task.StreamId, seq, NowUs()), _info.Credentials.Key);
|
||||
return _udp!.SendAsync(packet, packet.Length, _agentUdp!);
|
||||
MeasurementDatagram.Write(packet, new MeasurementDatagramHeader(MeasurementDatagramType.EchoRequest, _info!.Credentials.SessionIdValue, task.StreamId, seq, NowUs()), _info.Credentials.Key);
|
||||
_quic!.Send(_dataSession!, _agentQuic!, packet);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
await _stop.CancelAsync();
|
||||
_udp?.Dispose();
|
||||
_quic?.Dispose();
|
||||
if (_rpc is not null)
|
||||
{
|
||||
await _rpc.DisposeAsync();
|
||||
@@ -277,41 +296,18 @@ public sealed class ProbeSimulator : IAsyncDisposable
|
||||
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;
|
||||
try
|
||||
{
|
||||
result = await _udp!.ReceiveAsync(_stop.Token);
|
||||
}
|
||||
catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException or SocketException)
|
||||
{
|
||||
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;
|
||||
}
|
||||
echo.Received.TryAdd(header.Seq, now);
|
||||
}
|
||||
else if (header.Type == MeasurementDatagramType.Data && _trains.TryGetValue(header.StreamId, out TrainCollector? train))
|
||||
{
|
||||
train.Received.TryAdd(header.Seq, now);
|
||||
train.Size = delivery.Payload.Length;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,33 +362,39 @@ public sealed class ProbeSimulator : IAsyncDisposable
|
||||
|
||||
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);
|
||||
if (RefusePhases)
|
||||
{
|
||||
return false;
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
_activePhases[start.StreamId] = start;
|
||||
if (start.Kind == "udp_train")
|
||||
{
|
||||
_trains[start.StreamId] = new TrainCollector { StartUtcMs = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds() };
|
||||
_quic!.Register(start.StreamId, OnDatagram);
|
||||
// 先发几个探活包,让 Agent 学到(最新的)源地址,再回复 accepted
|
||||
byte[] hello = new byte[64];
|
||||
for (uint seq = 0; seq < 3; seq++)
|
||||
{
|
||||
UdpPacket.Write(hello, new UdpHeader(UdpPacketType.Data, _info!.Credentials.SessionIdValue, start.StreamId, seq, NowUs()), _info.Credentials.Key);
|
||||
await _udp!.SendAsync(hello.AsMemory(), _agentUdp!, ct);
|
||||
MeasurementDatagram.Write(hello, new MeasurementDatagramHeader(MeasurementDatagramType.Data, _info!.Credentials.SessionIdValue, start.StreamId, seq, NowUs()), _info.Credentials.Key);
|
||||
_quic.Send(_dataSession!, _agentQuic!, hello);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
_ = ct;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
private async Task<int> EndPhaseAsync(TestPhaseEndParams end, CancellationToken ct)
|
||||
{
|
||||
_activePhases.TryRemove(end.StreamId, out TestPhaseStartParams? start);
|
||||
if (start is not null)
|
||||
{
|
||||
_quic!.Unregister(start.StreamId);
|
||||
}
|
||||
if (start is null || DropFragments)
|
||||
{
|
||||
_trains.TryRemove(end.StreamId, out _);
|
||||
@@ -456,4 +458,9 @@ public sealed class ProbeSimulator : IAsyncDisposable
|
||||
|
||||
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 │
|
||||
│ 有公网 IP,监听 │ 先敲门再 TLS │ 家宽等出口,NAT 后 │
|
||||
└────────────────────┘ └──────────────────────┘
|
||||
测量会话:TLS 控制通道 + UDP/TCP 数据通道,由 Probe 主动拨出
|
||||
测量会话:TLS 控制通道 + QUIC Datagram/TCP 数据通道,由 Probe 主动拨出(Agent 固定 QUIC 端口,Probe 本地源端口轮换)
|
||||
宿主机更新器(容器节点):用本机公钥验签 → 按 digest 拉镜像 → 重启,失败回滚
|
||||
```
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
| 任务模型 | `Task = {kind, target, params, schedule}`;持续采集、按需测试、目标任务统一建模;下发“期望任务集”,由节点自行调和 | [measurement.md](measurement.md) |
|
||||
| 采集扩展 | Kind 由积木组合声明;采集密度靠参数调整;原始块自描述 | [measurement.md](measurement.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) |
|
||||
| 展示查询 | 任意时间段(相对或绝对);自动步长(每条曲线最多约 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) |
|
||||
@@ -157,7 +157,7 @@ Kind 目录编译在二进制中,节点不接受远程下发代码(安全边
|
||||
| D15 | 注册独立为 `fleet-enroll` 工具;常驻进程不包含注册代码 | 常驻进程去掉 HttpClient,边界更纯 |
|
||||
| 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 |
|
||||
| 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 分钟这类视图足够精细且快 |
|
||||
| D20 | 时序响应采用列式格式(`t[]` + `values{}`,时间轴使用 Unix 毫秒) | 由用户确认;体积小,可以直接交给图表库 |
|
||||
| D21 | 国家和运营商只由 Backend 向 IP 归属服务查询(缺省 `https://ip.007995.xyz`,可关闭),只查公网地址;节点不访问任何外部服务;公开接口不返回 IP、城市、ASN | 客户的防火墙常常要审计节点的出站连接;访客拿不到敏感信息(有测试保证) |
|
||||
|
||||
@@ -145,10 +145,10 @@ MoShiro.Measurement/ 依赖:MoShiro.Protocol(仅 BCL,
|
||||
│ ├─ MonoClock.cs 单调时钟(微秒)与 UTC 锚点
|
||||
│ └─ RunningBlock.cs 一个运行中的积木(取消令牌 + 后台任务)
|
||||
├─ Net/
|
||||
│ ├─ UdpMux.cs UDP 数据面:按 stream 分发;校验顺序固定;准入策略(重放、限速)通过 IAdmission 注入
|
||||
│ ├─ QuicDatagramTransport.cs、MeasurementDatagramRouter.cs MsQuic 数据面与逐流认证分发
|
||||
│ └─ Tcp.cs TcpLink(拨号:TLS 按指纹固定 + 认证首帧)、TcpAcceptor(监听:TLS 自动识别 + 认证首帧)
|
||||
├─ Blocks/ 一个文件 = 一种采集方法的两个角色
|
||||
│ ├─ Echo.cs UdpEchoDialer/Listener、TcpEchoDialer/Listener
|
||||
│ ├─ Echo.cs DatagramEchoDialer/Listener、TcpEchoDialer/Listener
|
||||
│ ├─ Session.cs ConnectDialer、HoldDialer、IcmpEchoSolo(没有对端)、IdleListener
|
||||
│ ├─ Train.cs TrainDialer(接收,记 t_recv)、TrainListener(按码率发包,记发送行)
|
||||
│ └─ BlockCatalog.cs Kind → (拨号方, 监听方);capabilities、本地预算的支持列表都从这里推出来
|
||||
@@ -174,7 +174,7 @@ MoShiro.Measurement/ 依赖:MoShiro.Protocol(仅 BCL,
|
||||
| 用途 | 默认端口 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 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` 分发 |
|
||||
|
||||
- **默认不使用 443**,避免与 VPS 上已有的 REALITY 或 Hysteria2 服务冲突,也不需要特权端口权限。
|
||||
|
||||
@@ -5,14 +5,14 @@ MoShiro 的两条控制链路都建立在 Fleet 之上:
|
||||
- **Agent ↔ Backend**:Fleet 连接服务端(wss),见 [../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 能力的映射
|
||||
|
||||
| MoShiro 需要的能力 | 使用的 Fleet 机制 | MoShiro 提供的内容 |
|
||||
| --- | --- | --- |
|
||||
| 下发任务 | 期望状态 `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.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` |
|
||||
@@ -113,5 +113,5 @@ Probe 只记录自己一侧的列,Agent 在数据面记录另一侧,由 Agen
|
||||
- Fleet 层的兼容规则见 [../backend/fleet/README.md](../../backend/fleet/README.md)。
|
||||
- `moshiro.*` 方法:新增方法或可选字段不需要升级版本,接收方忽略未知字段。
|
||||
- RawChunk:块头自描述,新增列或新增 Kind 不需要升级 `container_version`;只有容器格式变化时才升级。
|
||||
- UDP 包和数据连接首帧的布局变化时,必须升级 `protocol_version`,并更新黄金字节测试。
|
||||
- QUIC Datagram 和 TCP 数据连接首帧的布局变化时,必须升级 `protocol_version`,并更新黄金字节测试。
|
||||
- Agent 同时支持当前版本和上一个版本的 Probe;Backend 能解码保留期内所有的 `container_version`。
|
||||
@@ -3,7 +3,7 @@
|
||||
## 1. 目标
|
||||
|
||||
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。
|
||||
4. **改一处,整条链路受益**:协议、原始数据格式、测试方案、指标、错误码、版本号、编译设置、代码风格都只有**一个真源**。改动之后,下游要么自动更新,要么在编译期或 CI 中直接报错。
|
||||
5. **模块化**:Probe/Agent 由可插拔的模块组成;Backend 按层分成多个项目,层之间的依赖在编译期强制。
|
||||
@@ -111,7 +111,7 @@ MoShiro.Protocol/
|
||||
│ └─ ParamSpec 参数定义(类型、默认值、上下限),节点与 Backend 共用同一套校验
|
||||
├─ Tasks/ TaskSet(Fleet 期望状态的文档类型)、调和结果
|
||||
├─ Rpc/ moshiro.* 方法名、params/result、二进制通道号(从 16 开始)
|
||||
├─ Wire/ UDP 包、数据连接首帧
|
||||
├─ Wire/ QUIC Datagram 测量包
|
||||
└─ Raw/ 自描述 RawChunk:头部、列类型、列编解码、Brotli、crc32c
|
||||
```
|
||||
|
||||
@@ -142,7 +142,7 @@ AgentApp Probe(ProbeApp)
|
||||
发件箱 RawChunk(通道 16) 心跳带更新器的状态;moshiro.update.* 通知
|
||||
心跳带更新器的状态;moshiro.update.* AgentDirectory → 每个 Agent 一个 AgentConnection(点对点会话)
|
||||
PeerListener / PeerHub(Probe 会话) 会话内:线路任务集 line_task_set 的调和、角色、列块发件箱(通道 17)
|
||||
UdpMux / TcpAcceptor(数据端口)
|
||||
QuicDatagramTransport / TcpAcceptor(数据端口)
|
||||
TaskManager / TestRunner / ChunkAssembler
|
||||
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. 构建与打包
|
||||
|
||||
@@ -12,7 +12,7 @@ Agent 和 Probe 在 hello 初始化及心跳中上报 IPv6 检测结果,包括
|
||||
|
||||
升级 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 不会给它安排第二条会话。
|
||||
|
||||
@@ -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)。
|
||||
|
||||
- **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/`)
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
├─ 用 Docker 或 Podman 起容器,身份和配置存在命名卷里;节点名字默认取主机名
|
||||
└─ 容器入口用代码注册: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(本地硬上限)
|
||||
下载压缩包的二进制安装(packaging/install.sh)做同样的注册,另外创建系统用户 moshiro、启用 moshiro-agent.service,见 deployment.md。
|
||||
|
||||
@@ -52,7 +52,7 @@ probe.json:{ fleet: { server, identity_dir }, restart_at?, limits? } ← lim
|
||||
|
||||
Probe 启动
|
||||
├─ 连接 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 注册、禁用、地址或证书变化),面板都重新推送同一种期望状态;
|
||||
Probe 用 Fleet.Hosting.ConfigStore 把最新列表写回 agents.json(这是配置,不是测量数据;面板暂时连不上时,Probe 靠它继续测量)
|
||||
@@ -67,7 +67,7 @@ Probe 启动
|
||||
```json
|
||||
{
|
||||
"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": {
|
||||
"max_total_mbps": 200,
|
||||
"max_total_pps": 20000,
|
||||
@@ -80,7 +80,7 @@ Probe 启动
|
||||
|
||||
- `limits` 是**本地硬上限**:期望任务集超出这些范围时,调和器拒绝执行,并在 `rejected[]` 中给出原因。
|
||||
- `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 默认值的基础上进一步收窄。
|
||||
|
||||
## 5. 相关 API
|
||||
|
||||
@@ -143,7 +143,7 @@ Agent、Backend、Probe 之间互相不信任。这份文档回答三个问题
|
||||
- 被攻破的 Probe 只能拿到 Backend 授权的会话,Agent 的速率上限由本地兜底;被攻破的 Agent 发来的数据只会影响它自己的线路。
|
||||
- **Probe 只拨公网地址**(G3):固定 SPKI 只能防止建立会话,防不了探测本身;要在 Probe 本地拒绝私网、回环、链路本地地址。
|
||||
- **名册里的主机名解析之后再检查地址**:签名保护的是字符串,不是解析结果;默认只放行公网地址,内网需要节点本地显式允许。
|
||||
- **UDP 会话迁移要做路径验证**:协议允许会话在 NAT 映射变化后跟随新的源地址,路径上的攻击者可以借此把会话牵到自己的地址。迁移前先做一次挑战应答,验证前限制发送量(不超过收到字节数的 3 倍),类似 QUIC 的 path validation。
|
||||
- **QUIC 会话迁移要做路径验证**:协议允许会话在 NAT 映射变化后跟随新的源地址,路径上的攻击者可以借此把会话牵到自己的地址。迁移前先做一次挑战应答,验证前限制发送量(不超过收到字节数的 3 倍),由 QUIC path validation 提供。
|
||||
- **不协商、不降级**:现在固定 ECDSA P-256 和固定的签名串格式,没有算法协商,保持这一点。
|
||||
- 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) |
|
||||
| 数据面逐包认证 | HMAC-SHA256 截断到 16 字节(`UdpPacket`、`TcpWire`) |
|
||||
| 数据面逐包认证 | HMAC-SHA256 截断到 16 字节(`MeasurementDatagram`、`TcpWire`) |
|
||||
| 会话密钥派生 | HKDF-SHA256(`SessionKeys`) |
|
||||
| 控制链路加密 | AES-256-GCM(每个方向一把密钥,严格递增的计数器) |
|
||||
| 令牌哈希、SPKI 指纹 | SHA-256 |
|
||||
|
||||
@@ -67,7 +67,7 @@ write_config() {
|
||||
cat >"$CONFIG/agent.json" <<JSON
|
||||
{
|
||||
"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 }
|
||||
}
|
||||
JSON
|
||||
|
||||
@@ -257,7 +257,7 @@ export function AgentDetail() {
|
||||
<dt>控制端口</dt>
|
||||
<dd className="mono">{a.probe_tcp_port}/tcp</dd>
|
||||
<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>
|
||||
<dd>{a.agent_version ?? '—'}</dd>
|
||||
<dt>展示页可见</dt>
|
||||
|
||||
@@ -17,8 +17,10 @@ import { OwnerPicker } from './OwnerPicker'
|
||||
*/
|
||||
function LinkMatrix() {
|
||||
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 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 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)
|
||||
@@ -58,12 +60,20 @@ function LinkMatrix() {
|
||||
{agents.map(([agentId]) => {
|
||||
const link = cell(probeId, agentId)
|
||||
if (!link) return <td key={agentId} />
|
||||
const probeOnline = onlineByProbe.get(probeId)
|
||||
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 (
|
||||
<td key={agentId}>
|
||||
<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>
|
||||
</button>
|
||||
{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 { Compare } from './pages/Compare'
|
||||
import { Line } from './pages/Line'
|
||||
import { Host } from './pages/Host'
|
||||
import { Overview } from './pages/Overview'
|
||||
import { useBackgroundTint } from './backgroundTint'
|
||||
import { useBackgroundParallax } from './parallax'
|
||||
@@ -51,6 +52,7 @@ export function App() {
|
||||
<Routes>
|
||||
<Route path="/" element={<Overview />} />
|
||||
<Route path="/line/:id" element={<LineRoute />} />
|
||||
<Route path="/host/:id" element={<Host />} />
|
||||
<Route path="/compare" element={<Compare />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</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 })
|
||||
|
||||
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 {
|
||||
lineId: string
|
||||
range: OverviewRange
|
||||
@@ -72,23 +26,23 @@ interface Props {
|
||||
m: Record<string, number | null | undefined>
|
||||
}
|
||||
|
||||
/** 一条线路近一段时间的 UDP 和 TCP:各是一排小格,共用一次请求。 */
|
||||
/** 一条线路的 QUIC/TCP 双行摘要,各自显示时延、质量点阵和丢包/重传率。 */
|
||||
export function LineStrips({ lineId, range, m }: Props) {
|
||||
const info = OVERVIEW_RANGES.find((r) => r.value === range) ?? OVERVIEW_RANGES[0]
|
||||
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],
|
||||
30_000,
|
||||
)
|
||||
const s = series.data
|
||||
const t = s?.t ?? []
|
||||
const col = (key: string): (number | null)[] => s?.values[key] ?? []
|
||||
// 悬停的那一格:UDP 和 TCP 的时间轴一样,所以同一格同时高亮;提示弹在卡片右侧,一次展示这段时间的 UDP 和 TCP
|
||||
// 悬停的那一格:QUIC 上下行和 TCP 共用时间轴。
|
||||
const [hover, setHover] = useState<number | null>(null)
|
||||
const last = useRef<number | null>(null) // 提示收回去的动画期间,内容还要留着
|
||||
if (hover !== null) last.current = hover
|
||||
const shown = hover ?? last.current
|
||||
// 整块线路区域都能触发:按鼠标横向位置对应到小格(UDP 和 TCP 的小格一样宽,量第一排就行),不用精确指到小格上
|
||||
// 整块线路区域都能触发:按鼠标横向位置对应到采样格,不用精确指到小格上。
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
const onMove = (e: MouseEvent<HTMLDivElement>) => {
|
||||
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 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 (
|
||||
<div className="strips" ref={box} onMouseMove={onMove} onMouseLeave={() => setHover(null)}>
|
||||
<Strip
|
||||
{...common}
|
||||
rttLabel="UDP 延迟 p50"
|
||||
rtt={m['rtt.p50']}
|
||||
badLabel="丢包"
|
||||
bad={m['loss.total']}
|
||||
badHint={`上行丢包 ${fmtPct(m['loss.up'])} 下行丢包 ${fmtPct(m['loss.down'])}`}
|
||||
badSeries={col('loss.total')}
|
||||
tipBad="丢包"
|
||||
/>
|
||||
<Strip {...common} rttLabel="TCP RTT p50" rtt={m['tcp.rtt.p50']} badLabel="重传" bad={m['tcp.retrans']} badSeries={col('tcp.retrans')} tipBad="重传" />
|
||||
<div className="line-mini-stack">
|
||||
<div className="line-mini-quic-head" title={`QUIC RTT p50 ${fmtMs(m['rtt.p50'])} · p95 ${fmtMs(m['rtt.p95'])} · p99 ${fmtMs(m['rtt.p99'])}`}>
|
||||
<span>QUIC</span><strong className="num">{fmtMs(m['rtt.p50'])}</strong><small>悬停看 p95 / p99</small>
|
||||
</div>
|
||||
<div className="line-mini-direction-row" title={`去程 Probe → Agent 丢包 ${fmtPct(up)}`}>
|
||||
<span>去程</span>
|
||||
<div className="bars" role="img" aria-label={`近 ${common.hoursText}每 ${common.stepText}的去程丢包`}>
|
||||
{(s ? lossUp : Array.from({ length: common.cells }, () => null)).map((value, i) => (
|
||||
<span key={`up-${t[i] ?? i}`} className={`bar ${s ? levelOf(value) : 'none'}${hover === i ? ' on' : ''}`} />
|
||||
))}
|
||||
</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}>
|
||||
{at !== null ? (
|
||||
<>
|
||||
@@ -118,12 +103,15 @@ export function LineStrips({ lineId, range, m }: Props) {
|
||||
{clock(t[at])}–{clock(t[at] + info.stepMin * 60_000)}
|
||||
</b>
|
||||
<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>
|
||||
丢包 <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>
|
||||
延迟 p50 <em>{fmtMs(col('rtt.p50')[at])}</em>
|
||||
回程丢包 <em className={levelOf(col('loss.down')[at])}>{fmtPct(col('loss.down')[at])}</em>
|
||||
</span>
|
||||
</div>
|
||||
<div className="pop-group">
|
||||
|
||||
@@ -209,7 +209,8 @@ export function Minimap({ domainStart, domainMin, t, values, fromMs, toMs, onSel
|
||||
<path d={path} />
|
||||
</svg>
|
||||
{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) => (
|
||||
<div key={x.frac} className="midnight" style={{ left: `${x.frac * 100}%` }} />
|
||||
))}
|
||||
|
||||
+613
-20
@@ -70,6 +70,523 @@
|
||||
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 {
|
||||
font-size: 40px;
|
||||
font-weight: 800;
|
||||
@@ -716,7 +1233,7 @@
|
||||
}
|
||||
|
||||
|
||||
/* ── 线路详情的图表:按 UDP、TCP、评分分组,宽图占满一行 ── */
|
||||
/* ── 线路详情的图表:按 QUIC、TCP、评分分组,宽图占满一行 ── */
|
||||
.chart-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -799,7 +1316,7 @@
|
||||
color: var(--app-ink-faint);
|
||||
}
|
||||
|
||||
/* ── 总览卡片里的 UDP:一排小格(每 5 分钟一格,按丢包着色)+ 延迟小走势,尽量轻 ── */
|
||||
/* ── 总览卡片里的 QUIC Datagram:按方向显示丢包小格 ── */
|
||||
.line-name {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
@@ -809,47 +1326,123 @@
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.udp-strip {
|
||||
position: relative;
|
||||
.host-link {
|
||||
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;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.udp-head {
|
||||
.line-mini-quic-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.udp-title {
|
||||
color: var(--app-ink-faint);
|
||||
.line-mini-quic-head > span,
|
||||
.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 {
|
||||
margin-left: 2px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
.line-mini-quic-head > strong,
|
||||
.line-mini-tcp-row > strong {
|
||||
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);
|
||||
}
|
||||
|
||||
.udp-title b.udp-rate.warn {
|
||||
.line-mini-badge.warn {
|
||||
background: var(--app-warn-container);
|
||||
color: var(--app-warn);
|
||||
}
|
||||
|
||||
.udp-title b.udp-rate.bad {
|
||||
.line-mini-badge.bad {
|
||||
background: var(--app-bad-container);
|
||||
color: var(--app-bad);
|
||||
}
|
||||
|
||||
.bars {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
height: 10px;
|
||||
min-width: 0;
|
||||
gap: 1.5px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.bar {
|
||||
@@ -875,7 +1468,7 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* UDP 和 TCP 两段之间留一点空 */
|
||||
/* QUIC 和 TCP 两段之间留一点空 */
|
||||
.strips {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -21,9 +21,11 @@ interface MetricInfo {
|
||||
const METRICS: MetricInfo[] = [
|
||||
{ id: 'rtt.p50', label: '延迟 p50', unit: 'ms', better: 'lower' },
|
||||
{ id: 'rtt.p95', label: '延迟 p95', unit: 'ms', better: 'lower' },
|
||||
{ id: 'loss.total', label: 'UDP 丢包', unit: '%', better: 'lower' },
|
||||
{ id: 'loss.up', label: 'UDP 上行丢包', unit: '%', better: 'lower' },
|
||||
{ id: 'loss.down', label: 'UDP 下行丢包', unit: '%', better: 'lower' },
|
||||
{ id: 'loss.total', label: 'QUIC 丢包', unit: '%', better: 'lower' },
|
||||
{ id: 'loss.up', label: 'QUIC 上行丢包', 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.down.p95', label: '下行抖动', 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 { useMemo } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { IP_VERSIONS, lineIdFor } from '@shared/ipVersions'
|
||||
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
||||
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 { fmtAgo, fmtMbps, fmtMs, fmtNum, fmtPct, fmtTime } from '@shared/format'
|
||||
import { NodeTag, useNodeInfos } from '@shared/nodes'
|
||||
import { fmtBytes, useHosts } from '../live'
|
||||
import { useQuery } from '@shared/hooks'
|
||||
import { Chip, Dot, LoadingBar, Segmented, Spinner, Stat } from '@shared/ui'
|
||||
import { PageError, ScoreChips } from '../lib'
|
||||
@@ -24,28 +23,25 @@ function tone(value: number | null | undefined, good: number, poor: number): 'ok
|
||||
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 }[] = [
|
||||
{ label: 'UDP 延迟 p50', key: 'rtt.p50', fmt: fmtMs },
|
||||
{ label: 'UDP 延迟 p95', key: 'rtt.p95', fmt: fmtMs },
|
||||
{ label: 'UDP 丢包', key: 'loss.total', fmt: fmtPct, good: 0.5, poor: 3 },
|
||||
{ label: 'UDP 上行丢包', key: 'loss.up', fmt: fmtPct, good: 0.5, poor: 3 },
|
||||
{ label: 'UDP 下行丢包', key: 'loss.down', fmt: fmtPct, good: 0.5, poor: 3 },
|
||||
{ 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 },
|
||||
{ label: 'TCP RTT p50', key: 'tcp.rtt.p50', fmt: fmtMs },
|
||||
{ label: 'TCP RTT p99', key: 'tcp.rtt.p99', fmt: fmtMs },
|
||||
{ label: 'TCP RTT 最大', key: 'tcp.rtt.max', fmt: fmtMs },
|
||||
{ label: 'TCP 重传率', key: 'tcp.retrans', fmt: fmtPct, good: 0.5, poor: 3 },
|
||||
{ label: 'TCP 建连 p95', key: 'connect.tcp_ms.p95', fmt: fmtMs, optional: true },
|
||||
{ label: 'TLS 握手 p95', key: 'connect.tls_ms.p95', fmt: fmtMs, optional: true },
|
||||
{ label: '建连成功率', key: 'connect.success', fmt: fmtPct, optional: true },
|
||||
type MetricGroup = 'quic' | 'tcp-connect' | 'score'
|
||||
|
||||
const METRIC_GROUPS: { value: MetricGroup; label: string }[] = [
|
||||
{ value: 'quic', label: 'QUIC' },
|
||||
{ value: 'tcp-connect', label: 'TCP' },
|
||||
{ value: 'score', label: '评分' },
|
||||
]
|
||||
|
||||
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'
|
||||
|
||||
const TILES: { group: Exclude<MetricGroup, 'score'>; label: string; key: string; fmt: (v: number | null | undefined) => string; good?: number; poor?: number; unit?: string; /** 这个线路没有数据时不显示(建连任务不是每条线路都开着)。 */ optional?: boolean }[] = [
|
||||
{ group: 'tcp-connect', label: 'TCP RTT p50', key: 'tcp.rtt.p50', fmt: fmtMs },
|
||||
{ group: 'tcp-connect', label: 'TCP RTT p99', key: 'tcp.rtt.p99', fmt: fmtMs },
|
||||
{ group: 'tcp-connect', label: 'TCP RTT 最大', key: 'tcp.rtt.max', fmt: fmtMs },
|
||||
{ group: 'tcp-connect', label: 'TCP 重传率', key: 'tcp.retrans', fmt: fmtPct, good: 0.5, poor: 3 },
|
||||
{ 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 {
|
||||
@@ -55,26 +51,26 @@ interface ChartDef {
|
||||
zero?: boolean
|
||||
fill?: boolean
|
||||
series: { key: string; label: string; color: string; dash?: number[] }[]
|
||||
group: 'udp' | 'tcp' | 'connect' | 'score'
|
||||
group: MetricGroup
|
||||
/** 占满一行。 */
|
||||
wide?: boolean
|
||||
}
|
||||
|
||||
const GROUPS = [
|
||||
{ id: 'udp', title: 'UDP(往返探测)' },
|
||||
{ id: 'tcp', title: 'TCP' },
|
||||
{ id: 'connect', title: '建连(TCP 握手、TLS 握手)' },
|
||||
{ id: 'quic', title: 'QUIC Datagram' },
|
||||
{ id: 'tcp-connect', title: 'TCP' },
|
||||
{ id: 'score', title: '评分' },
|
||||
] as const
|
||||
|
||||
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: '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: 'udp', unit: 'ms', series: [{ key: 'jitter.up.p95', label: '上行', color: '--chart-2' }, { key: 'jitter.down.p95', label: '下行', color: '--chart-1' }] },
|
||||
{ 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: 'retrans', title: '重传率', group: 'tcp', unit: '%', fill: true, series: [{ key: 'tcp.retrans', 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: 'connect-ok', title: '建连成功率', group: 'connect', unit: '%', zero: false, series: [{ key: 'connect.success', label: '成功率', 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: '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: '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: '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: '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: 'retrans', title: '重传率', group: 'tcp-connect', unit: '%', fill: true, series: [{ key: 'tcp.retrans', 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] }] },
|
||||
]
|
||||
|
||||
@@ -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() {
|
||||
const { id = '' } = useParams()
|
||||
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 infos = useNodeInfos()
|
||||
@@ -162,7 +110,13 @@ export function Line() {
|
||||
|
||||
const spec = readSpec(params)
|
||||
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 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 })
|
||||
})
|
||||
|
||||
const agentId = line.data?.agent_id
|
||||
const lastTestId = line.data?.last_test?.id
|
||||
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(
|
||||
() =>
|
||||
s
|
||||
? GROUPS.map((group) => {
|
||||
? GROUPS.filter((group) => group.id === metricGroup).map((group) => {
|
||||
const cards = CHARTS.filter((def) => def.group === group.id).map((def) => {
|
||||
const raw = def.series.map((x) => ({ x, values: s.values[x.key] ?? [] }))
|
||||
// 开了 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 }))
|
||||
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) }
|
||||
}).filter((g) => g.cards.length > 0)
|
||||
: [],
|
||||
[s, gaps, smooth.alpha],
|
||||
[s, gaps, smooth.alpha, metricGroup],
|
||||
)
|
||||
const loadingNow = !ready || series.busy
|
||||
const chartSections = charts.map(({ group, cards }) => (
|
||||
<section className="chart-section" key={group.id}>
|
||||
<h2>{group.title}</h2>
|
||||
<div className="grid cols-2">
|
||||
<div className={`grid cols-2 ${group.id === 'quic' ? 'quic-chart-grid' : ''}`}>
|
||||
{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} />
|
||||
<h3>{def.title}</h3>
|
||||
{node}
|
||||
@@ -250,7 +202,7 @@ export function Line() {
|
||||
const peak = result?.metrics?.['tcp.bulk.peak.max']
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<main className="page line-page">
|
||||
<div className="crumbs">
|
||||
<Link to={id.endsWith('~ipv6') ? '/?ip_version=ipv6' : '/'} className="row">
|
||||
<ChevronLeft size={16} />
|
||||
@@ -258,12 +210,13 @@ export function Line() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<section className="hero">
|
||||
<h1>
|
||||
{line.data?.name ?? '线路'}
|
||||
{line.data ? <span className="muted" style={{ fontWeight: 400 }}> → {line.data.probe_name}</span> : null}
|
||||
</h1>
|
||||
<div className="row" style={{ marginTop: 12 }}>
|
||||
<section className="hero line-header-surface">
|
||||
<div className="line-title-row">
|
||||
<h1>
|
||||
{line.data?.name ?? '线路'}
|
||||
{line.data ? <span className="muted line-peer-name"> → {line.data.probe_name}</span> : null}
|
||||
</h1>
|
||||
<div className="row line-version-switch">
|
||||
{IP_VERSIONS.map((v) => {
|
||||
const query = new URLSearchParams(params)
|
||||
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}
|
||||
</div>
|
||||
</div>
|
||||
{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'} />
|
||||
<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="faint">Agent</span>
|
||||
<NodeTag info={infos.get(line.data.agent_id)} />
|
||||
@@ -293,7 +247,7 @@ export function Line() {
|
||||
{series.loading && !s ? <Spinner /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<div className="line-range-picker">
|
||||
<RangePicker
|
||||
dayStart={period.dayStart}
|
||||
fromMs={period.fromMs}
|
||||
@@ -305,6 +259,16 @@ export function Line() {
|
||||
sinceMs={sinceMs}
|
||||
nowMs={nowMs}
|
||||
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
|
||||
domainStart={period.domainStart}
|
||||
@@ -346,35 +310,62 @@ export function Line() {
|
||||
) : null}
|
||||
|
||||
{s ? (
|
||||
<div className="stack" style={{ marginTop: 24 }}>
|
||||
<div className="tiles">
|
||||
<div className="tile scores-tile has-loading">
|
||||
<LoadingBar active={loadingNow} />
|
||||
<span className="k">评分</span>
|
||||
<ScoreChips metrics={m} />
|
||||
<div className="stack line-metrics" style={{ marginTop: 16 }}>
|
||||
<section className="line-metric-surface">
|
||||
<header className="line-metric-header">
|
||||
<div className="line-metric-heading">
|
||||
<h2>{GROUPS.find((group) => group.id === metricGroup)?.title ?? '线路指标'}</h2>
|
||||
{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>
|
||||
{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) : ''} />
|
||||
))}
|
||||
</div>
|
||||
<p className="faint">
|
||||
样本数:UDP {fmtNum(s.summary.samples['udp_echo'])} · TCP {fmtNum(s.summary.samples['tcp_echo'])}
|
||||
</p>
|
||||
{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> : null}
|
||||
<div className="line-data-notes">
|
||||
<span>样本数:QUIC Datagram {fmtNum(s.summary.samples['udp_echo'])} · TCP {fmtNum(s.summary.samples['tcp_echo'])}</span>
|
||||
{s.summary.coverage < 0.95 ? <span>数据覆盖率 {(s.summary.coverage * 100).toFixed(0)}%;阴影部分为无数据时段</span> : null}
|
||||
</div>
|
||||
{chartSections}
|
||||
|
||||
<HostCard agentId={agentId} range={period.hostRange} />
|
||||
</section>
|
||||
|
||||
{test.data && result ? (
|
||||
<div className="card">
|
||||
@@ -392,22 +383,6 @@ export function Line() {
|
||||
</div>
|
||||
) : 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>
|
||||
) : null}
|
||||
</main>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { PageError } from '../lib'
|
||||
import { IPV6_STATES, IpVersionPicker, ipVersionOf } from '@shared/ipVersions'
|
||||
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'
|
||||
|
||||
@@ -53,7 +53,7 @@ function NodeCard({ node, labels, infos }: { node: HostItem; labels: string[]; i
|
||||
const stale = !node.online || node.stale
|
||||
const info = infos.get(node.id)
|
||||
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">
|
||||
{/* 有国旗就用国旗开头(在线、离线看右边的标签),没有再用状态点 */}
|
||||
{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>
|
||||
</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> }) {
|
||||
const probeInfo = infos.get(l.probe_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>
|
||||
{!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>
|
||||
</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(),
|
||||
to: new Date(domainEnd).toISOString(),
|
||||
step: wide ? '2m' : '1m',
|
||||
preset: 'udp',
|
||||
preset: 'quic',
|
||||
metrics: 'rtt.p50,loss.total',
|
||||
})
|
||||
: 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(
|
||||
() => (ready ? api.get<SeriesDto>(`/api/public/lines/${encodeURIComponent(lineId)}/series`, { ...period.query, preset: 'udp,tcp,connect,scores' }) : Promise.resolve(undefined)),
|
||||
[lineId, JSON.stringify(period.query), ready],
|
||||
() => (ready ? api.get<SeriesDto>(`/api/public/lines/${encodeURIComponent(lineId)}/series`, { ...period.query, ...(metrics ? { metrics } : { preset }) }) : Promise.resolve(undefined)),
|
||||
[lineId, JSON.stringify(period.query), ready, preset, metrics],
|
||||
period.isToday ? (period.win === 'day' ? 60_000 : 10_000) : undefined,
|
||||
)
|
||||
}
|
||||
|
||||
+15
-10
@@ -107,11 +107,13 @@ interface Props {
|
||||
nowMs: number
|
||||
/** 这一天里数据的跨度(毫秒);未知或没有数据是 `null`。 */
|
||||
spanMs: number | null
|
||||
/** 跟时长快捷键和刻度选项放在同一行的辅助控件。 */
|
||||
toolbar?: 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 today = dayBounds(nowMs).start
|
||||
const firstDay = sinceMs === null ? today : dayBounds(sinceMs).start
|
||||
@@ -157,7 +159,7 @@ export function RangePicker({ dayStart, fromMs, toMs, onChange, onWindow, scale,
|
||||
) : null}
|
||||
</div>
|
||||
{children}
|
||||
<div className="row wrap" style={{ marginTop: 8, justifyContent: 'space-between' }}>
|
||||
<div className="row wrap range-toolbar" style={{ marginTop: 8, justifyContent: 'space-between' }}>
|
||||
<Segmented
|
||||
options={WINDOWS.map((w) => {
|
||||
const ok = available.includes(w.value)
|
||||
@@ -166,14 +168,17 @@ export function RangePicker({ dayStart, fromMs, toMs, onChange, onWindow, scale,
|
||||
value={active}
|
||||
onChange={onWindow}
|
||||
/>
|
||||
<Segmented<'24' | '48'>
|
||||
options={[
|
||||
{ value: '24', label: '24 小时刻度', title: '缩略图显示一天' },
|
||||
{ value: '48', label: '48 小时刻度', title: '缩略图显示连续两天,选区可以跨零点' },
|
||||
]}
|
||||
value={String(scale) as '24' | '48'}
|
||||
onChange={(v) => onScale(v === '48' ? 48 : 24)}
|
||||
/>
|
||||
<div className="range-toolbar-right">
|
||||
{toolbar}
|
||||
<Segmented<'24' | '48'>
|
||||
options={[
|
||||
{ value: '24', label: '24 小时刻度', title: '缩略图显示一天' },
|
||||
{ value: '48', label: '48 小时刻度', title: '缩略图显示连续两天,选区可以跨零点' },
|
||||
]}
|
||||
value={String(scale) as '24' | '48'}
|
||||
onChange={(v) => onScale(v === '48' ? 48 : 24)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -16,6 +16,9 @@ export default defineConfig(({ mode }) => {
|
||||
server: {
|
||||
port: 5173,
|
||||
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: ['..'] },
|
||||
proxy: {
|
||||
// 开发时管理端是另一个 Vite 服务(5175);和线上一样用同源的 /admin/ 访问它
|
||||
|
||||
@@ -1300,6 +1300,7 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
probe_tcp_port: number | string;
|
||||
probe_udp_ports: (number | string)[];
|
||||
probe_quic_ports: (number | string)[];
|
||||
labels: string[];
|
||||
public_visible: boolean;
|
||||
kinds: string[];
|
||||
@@ -1694,6 +1695,7 @@ export interface components {
|
||||
ip_version: string;
|
||||
/** @default false */
|
||||
ipv6_available: boolean;
|
||||
quic_health?: null | components["schemas"]["QuicLineHealthDto"];
|
||||
};
|
||||
LinkDto: {
|
||||
probe_id: string;
|
||||
@@ -1828,6 +1830,7 @@ export interface components {
|
||||
last_test: null | components["schemas"]["TestSummaryDto"];
|
||||
/** @default ipv4 */
|
||||
ip_version: string;
|
||||
quic_health?: null | components["schemas"]["QuicLineHealthDto"];
|
||||
};
|
||||
PageDtoOfAgentDto: {
|
||||
items: components["schemas"]["AgentDto"][];
|
||||
@@ -2037,6 +2040,39 @@ export interface components {
|
||||
sha256: 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: {
|
||||
chunk_id: string;
|
||||
agent_id: string;
|
||||
|
||||
@@ -160,6 +160,8 @@ export function TimeChart({ t, series, height = 220, unit = '', zero = true, gap
|
||||
(u) => {
|
||||
const { ctx } = u
|
||||
ctx.save()
|
||||
// Coverage gaps are context only; keep the warm mask subtle so spikes stay prominent.
|
||||
ctx.globalAlpha = 0.16
|
||||
ctx.fillStyle = shade
|
||||
for (const g of latest.current.gaps) {
|
||||
const x0 = Math.max(u.bbox.left, u.valToPos(g.from / 1000, 'x', true))
|
||||
|
||||
Reference in new issue
Block a user