3 Commits
Author SHA1 Message Date
justme e2e5e437cb docs(deploy): document QUIC runtime and port requirements [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/verify Pipeline failed
ci/woodpecker/push/dev-image unknown status
2026-10-10 03:00:47 +08:00
justme b25b111134 feat(display): add compact QUIC and host health views 2026-10-10 03:00:23 +08:00
justme b964dc2bbc feat(quic): replace probe UDP data plane with QUIC datagrams 2026-10-10 02:59:56 +08:00
77 changed files with 3359 additions and 687 deletions

No files matched your search

+2 -2
View File
@@ -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)。
+1 -1
View File
@@ -7,7 +7,7 @@ Agent 仅开放配置的控制和数据端口。它不提供远程终端、命
## 工作流程
```text
Probe ──TLS 控制 / UDP、TCP 测量──> Agent ──长连接与数据上报──> Backend
Probe ──TLS 控制 / QUIC Datagram、TCP 测量──> Agent ──长连接与数据上报──> Backend
▲
fleet-enroll 注册
```
+16 -15
View File
@@ -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)
{
+182
View File
@@ -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;
}
+9 -1
View File
@@ -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; } = [];
@@ -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) { }
}
}
+3 -3
View File
@@ -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 | 客户的防火墙常常要审计节点的出站连接;访客拿不到敏感信息(有测试保证) |
+3 -3
View File
@@ -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 服务冲突,也不需要特权端口权限。
+3 -3
View File
@@ -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 -3
View File
@@ -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
```
+3 -3
View File
@@ -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/`)
+4 -4
View File
@@ -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
+2 -2
View File
@@ -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 |
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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>
+12 -2
View File
@@ -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 ? (
+2
View File
@@ -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>
+52 -64
View File
@@ -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">
+2 -1
View File
@@ -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
View File
@@ -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;
+5 -3
View File
@@ -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' },
+89
View File
@@ -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
View File
@@ -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>
+5 -5
View File
@@ -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>
) : (
+4 -4
View File
@@ -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
View File
@@ -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>
)
+3
View File
@@ -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/ 访问它
+36
View File
@@ -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;
+2
View File
@@ -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))