diff --git a/backend/agent/Directory.Build.props b/backend/agent/Directory.Build.props
index 17e23af..8ddfaef 100644
--- a/backend/agent/Directory.Build.props
+++ b/backend/agent/Directory.Build.props
@@ -8,7 +8,7 @@
false
false
false
- false
+ true
false
true
false
diff --git a/backend/agent/src/MoShiro.Agent/AgentApp.cs b/backend/agent/src/MoShiro.Agent/AgentApp.cs
index fa8fc38..b80ffdf 100644
--- a/backend/agent/src/MoShiro.Agent/AgentApp.cs
+++ b/backend/agent/src/MoShiro.Agent/AgentApp.cs
@@ -74,10 +74,16 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
_ipv6Supported = _listening ? bind.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 : null;
_admission = new AgentAdmission(_streams);
- _datagrams = new QuicDatagramTransport(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.QuicPorts, identity.Certificate);
+ QuicSettings quic = config.Quic.ToSettings();
+ if (quic.Validate() is { } invalid)
+ {
+ throw new InvalidOperationException("agent.json quic: " + invalid);
+ }
+
+ _datagrams = new QuicDatagramTransport(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.QuicPorts, identity.Certificate, quic);
// 预握手敲门:控制端口和数据端口共用一个关卡(限速和去重一起算);名册就是期望状态里的 peers
_trust = TrustAnchor.Load(config.Fleet.IdentityDir, log);
- _dialer = new DialerHost(new DialEnv(identity.PeerIdentity, identity.NodeId.Value, config.Limits, new DialDirectory(new ConfigStore(dataDirectory), dataDirectory, log, _trust), _clock, time, log), time, log);
+ _dialer = new DialerHost(new DialEnv(identity.PeerIdentity, identity.NodeId.Value, config.Limits, quic, new DialDirectory(new ConfigStore(dataDirectory), dataDirectory, log, _trust), _clock, time, log), time, log);
_knock = new KnockGate(identity.PeerIdentity, RosterKeys, time);
_tcp = new TcpAcceptor(bind, config.Listen.TcpPorts, identity.Certificate, _sessions, _counters.Data, _knock);
diff --git a/backend/agent/src/MoShiro.Agent/AgentConfig.cs b/backend/agent/src/MoShiro.Agent/AgentConfig.cs
index 16d4447..6754f55 100644
--- a/backend/agent/src/MoShiro.Agent/AgentConfig.cs
+++ b/backend/agent/src/MoShiro.Agent/AgentConfig.cs
@@ -15,6 +15,9 @@ public sealed class AgentConfig
[JsonPropertyName("listen")]
public ListenSection Listen { get; set; } = new();
+ [JsonPropertyName("quic")]
+ public QuicSection Quic { get; set; } = new();
+
[JsonPropertyName("limits")]
public LimitsSection Limits { get; set; } = new();
@@ -66,6 +69,27 @@ public sealed class ListenSection
public List TcpPorts { get; set; } = [7444];
}
+/// 数据通道 QUIC 连接的传输参数;不写的项用 MsQuic 默认值。监听和拨出的连接都用这里的值。
+public sealed class QuicSection
+{
+ [JsonPropertyName("min_mtu")]
+ public int? MinMtu { get; set; }
+
+ [JsonPropertyName("max_mtu")]
+ public int? MaxMtu { get; set; }
+
+ [JsonPropertyName("pacing")]
+ public bool? Pacing { get; set; }
+
+ [JsonPropertyName("idle_timeout_ms")]
+ public int? IdleTimeoutMs { get; set; }
+
+ [JsonPropertyName("keep_alive_ms")]
+ public int? KeepAliveMs { get; set; }
+
+ public QuicSettings ToSettings() => new() { MinMtu = MinMtu, MaxMtu = MaxMtu, Pacing = Pacing, IdleTimeoutMs = IdleTimeoutMs, KeepAliveMs = KeepAliveMs };
+}
+
public sealed class LimitsSection
{
[JsonPropertyName("max_total_mbps")]
diff --git a/backend/agent/src/MoShiro.Agent/DataPlane/Registries.cs b/backend/agent/src/MoShiro.Agent/DataPlane/Registries.cs
index cabd503..5c061ab 100644
--- a/backend/agent/src/MoShiro.Agent/DataPlane/Registries.cs
+++ b/backend/agent/src/MoShiro.Agent/DataPlane/Registries.cs
@@ -217,8 +217,8 @@ public sealed class StreamInfo
KindSpec spec = Kinds.Find(kind)!;
DeclaredPps = kind switch
{
- "udp_echo" or "tcp_echo" => Kinds.PeakRateHz(kind, parameters),
- "udp_train" => parameters.GetNumber("mbps") * 1_000_000 / 8 / Math.Max(1, parameters.GetInt("size")),
+ "quic_ping" or "tcp_ping" => Kinds.PeakRateHz(kind, parameters),
+ "quic_stream" => parameters.GetNumber("mbps") * 1_000_000 / 8 / Math.Max(1, parameters.GetInt("size")),
_ => 20,
};
Port = spec.Params.Any(p => p.Name == "port") ? (int)parameters.GetInt("port") : 0;
diff --git a/backend/agent/src/MoShiro.Agent/Dial/DialConnection.cs b/backend/agent/src/MoShiro.Agent/Dial/DialConnection.cs
index 8161c2c..5d6dd6a 100644
--- a/backend/agent/src/MoShiro.Agent/Dial/DialConnection.cs
+++ b/backend/agent/src/MoShiro.Agent/Dial/DialConnection.cs
@@ -11,7 +11,7 @@ using MoShiro.Protocol;
namespace MoShiro.Agent;
/// 所有连接共用的东西。
-public sealed record DialEnv(NodeIdentity Identity, string NodeId, LimitsSection Limits, DialDirectory Directory, MonoClock Clock, TimeProvider Time, INodeLog Log);
+public sealed record DialEnv(NodeIdentity Identity, string NodeId, LimitsSection Limits, QuicSettings Quic, DialDirectory Directory, MonoClock Clock, TimeProvider Time, INodeLog Log);
///
/// 与一个 Agent 的点对点连接,分两层:
@@ -170,7 +170,8 @@ public sealed class DialConnection : IDisposable
new DataPlaneCounters(),
_attemptLog,
new IPEndPoint(remote.Address, quicPort),
- agentKey);
+ agentKey,
+ _env.Quic);
Volatile.Write(ref _activeDatagrams, datagrams);
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
diff --git a/backend/agent/src/MoShiro.Agent/Dial/DialSession.cs b/backend/agent/src/MoShiro.Agent/Dial/DialSession.cs
index adee1d9..af704a6 100644
--- a/backend/agent/src/MoShiro.Agent/Dial/DialSession.cs
+++ b/backend/agent/src/MoShiro.Agent/Dial/DialSession.cs
@@ -55,7 +55,7 @@ public sealed class DialSession
}
}
- private static bool IsTcpKind(string kind) => kind is "tcp_echo" or "connect" or "hold" or "tcp_bulk";
+ private static bool IsTcpKind(string kind) => kind is "tcp_ping" or "tcp_connect" or "tls_hold" or "tcp_transfer";
/// 调和线路任务:参数没有变化的积木继续运行,其余启动或停止。返回被拒绝的任务(不认识的 Kind、参数非法、超过上限)。
public async Task> ApplyAsync(LineTaskSet set)
@@ -137,7 +137,7 @@ public sealed class DialSession
return false;
}
- if (start.Kind == "udp_train")
+ if (start.Kind == "quic_stream")
{
double mbps = start.Params["mbps"].GetDouble();
long size = start.Params["size"].GetInt64();
@@ -167,7 +167,7 @@ public sealed class DialSession
}
// 最后一批包(单向数据流)、最后几个回显的回复还在途中
- await Task.Delay(TimeSpan.FromMilliseconds(phase.Kind == "udp_train" ? 200 : 300), BlockEnv.Time, ct);
+ await Task.Delay(TimeSpan.FromMilliseconds(phase.Kind == "quic_stream" ? 200 : 300), BlockEnv.Time, ct);
await phase.Running.StopAsync();
return phase.Running.Block.Flush(FlushMode.All, end.SentCount);
}
diff --git a/backend/agent/src/MoShiro.Agent/Tasks/TargetRunners.cs b/backend/agent/src/MoShiro.Agent/Tasks/TargetRunners.cs
index 45a8b17..6b14ded 100644
--- a/backend/agent/src/MoShiro.Agent/Tasks/TargetRunners.cs
+++ b/backend/agent/src/MoShiro.Agent/Tasks/TargetRunners.cs
@@ -7,14 +7,16 @@ using MoShiro.Protocol;
namespace MoShiro.Agent;
///
-/// 目标任务(Agent 自己探测一个地址,没有对端):用和线路任务同一套积木( 里的拨号方,例如 icmp_echo、connect),
-/// 只是列块不交给 Agent 合并,而是自己盖章、10 秒一块直接上传。目标地址必须在本地 allowed_target_cidrs 内
-/// (由 在接受任务之前检查)。
+/// 目标任务(Agent 自己探测一个地址,没有对端):用和线路任务同一套积木( 里的拨号方,例如 icmp_ping、connect),
+/// 只是列块不交给 Agent 合并,而是自己盖章、10 秒一块直接上传。目标可以是 IP、域名或(http)URL;域名在 Agent 上解析,
+/// 每个解析出的地址都必须在本地 allowed_target_cidrs 内(IP 目标由 在接受任务之前检查,域名在这里检查)。
///
public sealed class TargetRunners
{
private static readonly TimeSpan s_chunkSpan = TimeSpan.FromSeconds(ProtocolLimits.ChunkSeconds);
+ private static readonly TimeSpan s_resolveRetry = TimeSpan.FromMinutes(1);
+
private readonly Dictionary _running = [];
private readonly object _gate = new();
private readonly ChunkAssembler _chunks;
@@ -33,7 +35,7 @@ public sealed class TargetRunners
}
/// 让运行中的任务与期望集合一致:参数没有变化的保持,其余启动或停止。
- public void Reconcile(IReadOnlyList<(TaskSpec Spec, TaskDecision Decision)> desired)
+ public void Reconcile(IReadOnlyList<(TaskSpec Spec, TaskDecision Decision)> desired, Func? allow = null)
{
lock (_gate)
{
@@ -56,7 +58,7 @@ public sealed class TargetRunners
var cts = new CancellationTokenSource();
_running[id] = new Running(Signature(spec), cts);
- _ = Task.Run(() => RunAsync(spec, decision, cts.Token), CancellationToken.None);
+ _ = Task.Run(() => RunAsync(spec, decision, allow, cts.Token), CancellationToken.None);
}
}
}
@@ -64,23 +66,33 @@ public sealed class TargetRunners
private static string Signature(TaskSpec spec) =>
spec.Kind + "|" + spec.Target + "|" + string.Join(',', spec.Params.OrderBy(p => p.Key, StringComparer.Ordinal).Select(p => p.Key + "=" + p.Value.GetRawText()));
- private async Task RunAsync(TaskSpec spec, TaskDecision decision, CancellationToken ct)
+ private async Task RunAsync(TaskSpec spec, TaskDecision decision, Func? allow, CancellationToken ct)
{
try
{
- if (!TryResolve(spec.Target!, decision.Resolved!, spec.Kind, out IPAddress? address, out int port))
+ if (!TargetHost.TryParse(spec.Target, out string host, out int targetPort, out _))
{
- _log.Warn("target is not an address", ("task", spec.Id.Value));
+ _log.Warn("target is not valid", ("task", spec.Id.Value));
return;
}
+ // http 自己解析并检查每个地址;其余 Kind 在这里解析出一个允许的地址作为对端
+ IPAddress? address = null;
+ if (spec.Kind != TargetHost.HttpKind)
+ {
+ address = await ResolveAllowedAsync(spec, host, allow, ct);
+ }
+
+ int port = targetPort != 0 ? targetPort : spec.Kind == "tcp_connect" ? (int)decision.Resolved!.GetInt("port") : 0;
var env = new BlockEnv
{
Clock = _clock,
Log = _log,
Session = () => null,
PeerAddress = address,
- Fragments = new LocalSink(this, spec, address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 ? IpVersions.V6 : IpVersions.V4),
+ Target = spec.Target,
+ AllowAddress = allow,
+ Fragments = new LocalSink(this, spec, address?.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 ? IpVersions.V6 : IpVersions.V4),
};
IBlock block = BlockCatalog.Find(spec.Kind)!.Dialer(env, new BlockSpec(decision.Kind!, decision.Resolved!, 0, port, new FragmentScope(spec.Id, null, null)));
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
@@ -110,21 +122,29 @@ public sealed class TargetRunners
}
}
- private static bool TryResolve(string target, ParamValues values, string kind, out IPAddress address, out int port)
+ /// 解析目标,取第一个允许的地址;解析失败或没有允许的地址就每分钟重试(域名的解析结果会变)。
+ private async Task ResolveAllowedAsync(TaskSpec spec, string host, Func? allow, CancellationToken ct)
{
- port = kind == "connect" ? (int)values.GetInt("port") : 0;
- if (IPEndPoint.TryParse(target, out IPEndPoint? endpoint))
+ while (true)
{
- address = endpoint.Address;
- if (endpoint.Port != 0)
+ try
{
- port = endpoint.Port;
+ IPAddress[] addresses = IPAddress.TryParse(host, out IPAddress? literal) ? [literal] : await Dns.GetHostAddressesAsync(host, ct);
+ IPAddress? chosen = addresses.FirstOrDefault(a => allow?.Invoke(a) ?? true);
+ if (chosen is not null)
+ {
+ return chosen;
+ }
+
+ _log.Warn("no address of the target is allowed", ("task", spec.Id.Value), ("host", host));
+ }
+ catch (System.Net.Sockets.SocketException ex)
+ {
+ _log.Warn("target did not resolve", ("task", spec.Id.Value), ("host", host), ("err", ex.Message));
}
- return true;
+ await Task.Delay(s_resolveRetry, _time, ct);
}
-
- return IPAddress.TryParse(target, out address!);
}
/// 目标任务的行没有拨号方一侧可以合并:积木交出的就是完整的行,Agent 自己盖章(目标、时钟锚点)并放进发件箱。
diff --git a/backend/agent/src/MoShiro.Agent/Tasks/TaskManager.cs b/backend/agent/src/MoShiro.Agent/Tasks/TaskManager.cs
index f2ceaa2..a217b68 100644
--- a/backend/agent/src/MoShiro.Agent/Tasks/TaskManager.cs
+++ b/backend/agent/src/MoShiro.Agent/Tasks/TaskManager.cs
@@ -15,7 +15,7 @@ namespace MoShiro.Agent;
///
public sealed class TaskManager
{
- // 线路任务 = 有监听方积木、并且不是只用于按需测试的 Kind(udp_train、tcp_bulk 会占满链路);目标任务 = 可以由 Agent 自己运行的 Kind
+ // 线路任务 = 有监听方积木、并且不是只用于按需测试的 Kind(quic_stream、tcp_transfer 会占满链路);目标任务 = 可以由 Agent 自己运行的 Kind
private static readonly HashSet s_lineKinds = [.. BlockCatalog.All.Where(p => p.Listener is not null && !p.OnDemandOnly).Select(p => p.Kind)];
private static readonly HashSet s_targetKinds = [.. BlockCatalog.All.Where(p => p.RunsAsTarget).Select(p => p.Kind)];
@@ -151,7 +151,7 @@ public sealed class TaskManager
}
}
- _targets.Reconcile(targets);
+ _targets.Reconcile(targets, guard.AddressAllowed);
// 参数没有变化的任务沿用原来的流和积木,数据面上的会话(重放窗口、记录)不被打断
var retired = new List();
diff --git a/backend/agent/src/MoShiro.Agent/Tests/TestRunner.cs b/backend/agent/src/MoShiro.Agent/Tests/TestRunner.cs
index 236d574..7f8ad62 100644
--- a/backend/agent/src/MoShiro.Agent/Tests/TestRunner.cs
+++ b/backend/agent/src/MoShiro.Agent/Tests/TestRunner.cs
@@ -206,7 +206,7 @@ public sealed class TestRunner
}
// 单向数据流的发送端自己运行到结束(或失败);其他积木是反应式的,等够时长即可
- if (phase.Kind == "udp_train")
+ if (phase.Kind == "quic_stream")
{
try
{
diff --git a/backend/fleet/src/Fleet.Link/FleetLink.cs b/backend/fleet/src/Fleet.Link/FleetLink.cs
index d6c8248..fe9a21d 100644
--- a/backend/fleet/src/Fleet.Link/FleetLink.cs
+++ b/backend/fleet/src/Fleet.Link/FleetLink.cs
@@ -55,7 +55,7 @@ public sealed class FleetLinkOptions
/// 服务端至少要是哪个版本(每次握手重新取);空表示不要求。不够就关掉这条连接,等 再握手,服务端可能正在更新。
public Func? MinServerVersion { get; init; }
- /// 框架之外的能力,例如 moshiro.v1 与 moshiro.kinds:udp_echo,tcp_echo。
+ /// 框架之外的能力,例如 moshiro.v1 与 moshiro.kinds:quic_ping,tcp_ping。
public IReadOnlyList Capabilities { get; init; } = [];
/// 附加到 fleet.hello 的产品元数据。
diff --git a/backend/shared/MoShiro.Measurement/Blocks/BlockBase.cs b/backend/shared/MoShiro.Measurement/Blocks/BlockBase.cs
index 60e28e0..478b75b 100644
--- a/backend/shared/MoShiro.Measurement/Blocks/BlockBase.cs
+++ b/backend/shared/MoShiro.Measurement/Blocks/BlockBase.cs
@@ -76,7 +76,7 @@ public abstract class BlockBase : IBlock
}
}
- /// 读这个任务的自适应频率参数(udp_echo 和 tcp_echo 共用)。
+ /// 读这个任务的自适应频率参数(quic_ping 和 tcp_ping 共用)。
protected AdaptiveRate? CreateAdaptiveRate()
{
if (!Spec.Bool("adaptive"))
diff --git a/backend/shared/MoShiro.Measurement/Blocks/BlockCatalog.cs b/backend/shared/MoShiro.Measurement/Blocks/BlockCatalog.cs
index 3e4bf44..665bfd1 100644
--- a/backend/shared/MoShiro.Measurement/Blocks/BlockCatalog.cs
+++ b/backend/shared/MoShiro.Measurement/Blocks/BlockCatalog.cs
@@ -26,13 +26,14 @@ public static class BlockCatalog
{
public static readonly IReadOnlyList All =
[
- 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"]),
- new("connect", (e, s) => new ConnectDialer(e, s), (e, s) => new IdleListener(e, s), RunsAsTarget: true),
- new("hold", (e, s) => new HoldDialer(e, s), (e, s) => new TcpEchoListener(e, s, record: false)),
- new("icmp_echo", (e, s) => new IcmpEchoSolo(e, s), null, RunsAsTarget: true),
+ new("quic_ping", (e, s) => new DatagramEchoDialer(e, s), (e, s) => new DatagramEchoListener(e, s)),
+ new("tcp_ping", (e, s) => new TcpEchoDialer(e, s), (e, s) => new TcpEchoListener(e, s)),
+ new("quic_stream", (e, s) => new TrainDialer(e, s), (e, s) => new TrainListener(e, s), OnDemandOnly: true, Directions: ["rev"]),
+ new("tcp_transfer", (e, s) => new TcpBulkDialer(e, s), (e, s) => new TcpBulkListener(e, s), OnDemandOnly: true, Directions: ["fwd", "rev"]),
+ new("tcp_connect", (e, s) => new ConnectDialer(e, s), (e, s) => new IdleListener(e, s), RunsAsTarget: true),
+ new("tls_hold", (e, s) => new HoldDialer(e, s), (e, s) => new TcpEchoListener(e, s, record: false)),
+ new("http_request", (e, s) => new HttpDialer(e, s), null, RunsAsTarget: true),
+ new("icmp_ping", (e, s) => new IcmpEchoSolo(e, s), null, RunsAsTarget: true),
];
public static BlockPair? Find(string kind) => All.FirstOrDefault(p => p.Kind == kind);
diff --git a/backend/shared/MoShiro.Measurement/Blocks/Http.cs b/backend/shared/MoShiro.Measurement/Blocks/Http.cs
new file mode 100644
index 0000000..1a2c630
--- /dev/null
+++ b/backend/shared/MoShiro.Measurement/Blocks/Http.cs
@@ -0,0 +1,382 @@
+using System.Net;
+using System.Net.Http;
+using System.Net.Quic;
+using System.Net.Security;
+using System.Net.Sockets;
+using System.Security.Authentication;
+using Fleet.Core;
+using MoShiro.Protocol;
+
+namespace MoShiro.Measurement;
+
+/// http 请求的结果,编码与 Backend 的 HttpOutcome 一致。
+internal static class HttpOutcomes
+{
+ public const int Ok = 0;
+ public const int Timeout = 1;
+ public const int Reset = 2;
+ public const int Tls = 3;
+ public const int Other = 4;
+ public const int Dns = 5;
+ public const int BadStatus = 6;
+ public const int Unsupported = 7;
+ public const int NotAllowed = 8;
+}
+
+/// 解析出的地址都不在本地允许的网段内。
+internal sealed class TargetNotAllowedException : Exception
+{
+ public TargetNotAllowedException(string host)
+ : base("no address of " + host + " is allowed by allowed_target_cidrs")
+ {
+ }
+}
+
+///
+/// 单个 Agent 去请求一个 URL(HTTP/1.1、HTTP/2 或 HTTP/3,不回退),记录 DNS、TCP 建连、首字节、总耗时和状态码。
+/// 每次请求新建连接,所以每次都包含完整的建连过程;频率按失败率自适应:目标一直应答就降到证明得了成功率的最低频率,
+/// 出现失败升到最高频率,全部失败时指数退避。
+///
+public sealed class HttpDialer : BlockBase
+{
+ private readonly DriverLog _log;
+ private readonly int _attempt;
+ private readonly int _ts;
+ private readonly int _w;
+ private readonly int _dns;
+ private readonly int _tcp;
+ private readonly int _ttfb;
+ private readonly int _total;
+ private readonly int _status;
+ private readonly int _proto;
+ private readonly int _outcome;
+ private readonly HashSet _loggedOutcomes = [];
+ private long _next;
+
+ public HttpDialer(BlockEnv env, BlockSpec spec)
+ : base(env, spec)
+ {
+ _log = new DriverLog(spec.Kind.FindStream("req")!, Side.Dialer, 1024);
+ _attempt = _log.Store.ColumnIndex("attempt");
+ _ts = _log.Store.ColumnIndex("ts");
+ _w = _log.Store.ColumnIndex("w");
+ _dns = _log.Store.ColumnIndex("dns_us");
+ _tcp = _log.Store.ColumnIndex("tcp_us");
+ _ttfb = _log.Store.ColumnIndex("ttfb_us");
+ _total = _log.Store.ColumnIndex("total_us");
+ _status = _log.Store.ColumnIndex("status");
+ _proto = _log.Store.ColumnIndex("proto");
+ _outcome = _log.Store.ColumnIndex("outcome");
+ }
+
+ public override long Produced => _log.Count;
+
+ public override async Task RunAsync(CancellationToken ct)
+ {
+ if (!TargetHost.TryParse(Env.Target, out _, out _, out Uri? url) || url is null)
+ {
+ Env.Log.Warn("http target is not a URL", ("task", Spec.Scope.Task?.Value));
+ await WaitUntilCancelledAsync(ct);
+ return;
+ }
+
+ AdaptiveRate? rate = CreateRequestRate();
+ try
+ {
+ if (rate is not null)
+ {
+ await PaceAsync(rate, async () =>
+ {
+ int weight = rate.Weight;
+ long sent = Env.Clock.NowUs();
+ rate.OnSend(sent);
+ bool ok = await RequestAsync(url, weight, ct);
+ if (ok)
+ {
+ long now = Env.Clock.NowUs();
+ rate.OnReply(sent, now, (now - sent) / 1000.0);
+ }
+ }, ct);
+ }
+ else
+ {
+ TimeSpan interval = TimeSpan.FromSeconds(60.0 / Spec.Int("per_minute"));
+ while (!ct.IsCancellationRequested)
+ {
+ long start = Env.Clock.NowUs();
+ await RequestAsync(url, 1, ct);
+ TimeSpan wait = interval - TimeSpan.FromMicroseconds(Env.Clock.NowUs() - start);
+ if (wait > TimeSpan.Zero)
+ {
+ await Task.Delay(wait, Env.Time, ct);
+ }
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // 积木被停止
+ }
+ }
+
+ public override int Flush(FlushMode mode, long peerProduced = 0)
+ {
+ RawStream? stream = _log.Take(null, all: true);
+ if (stream is null)
+ {
+ return 0;
+ }
+
+ Ship(stream, "ts");
+ return 1;
+ }
+
+ ///
+ /// 频率以「次/分钟」声明,换算成 Hz 交给 。请求间隔以秒到分钟计,所以窗口按分钟级放宽,
+ /// 并且不用 RTT 分位数的精度保护(样本太少,估计不出来):平稳时只由成功率的上界决定频率。
+ ///
+ private AdaptiveRate? CreateRequestRate()
+ {
+ if (!Spec.Bool("adaptive"))
+ {
+ return null;
+ }
+
+ double max = Math.Max(Spec.Int("rate_max_per_min"), Spec.Int("rate_min_per_min")) / 60.0;
+ return new AdaptiveRate(new AdaptiveRateOptions
+ {
+ MinHz = Spec.Int("rate_min_per_min") / 60.0,
+ MaxHz = max,
+ TauPct = Spec.Number("tau_pct"),
+ RttGuard = false,
+ CertifySeconds = 900,
+ QuietCertifySeconds = 1200,
+ GraceSeconds = Math.Ceiling(Spec.Int("timeout_ms") / 1000.0) + 1,
+ DeadWindowSeconds = 120,
+ DeadMinSent = 3,
+ DeadMaxIntervalSeconds = 300,
+ ActiveLossCount = 2,
+ Prior = Env.LossPrior,
+ });
+ }
+
+ /// 发一次请求并记一行;返回这次算不算成功(有应答且状态码合格)。
+ private async Task RequestAsync(Uri url, int weight, CancellationToken ct)
+ {
+ string version = Spec.Text("version");
+ bool verify = Spec.Bool("tls_verify");
+ TimeSpan timeout = TimeSpan.FromMilliseconds(Spec.Int("timeout_ms"));
+ var phases = new Phases();
+ long start = Env.Clock.NowUs();
+ int outcome = HttpOutcomes.Other;
+ int status = 0;
+ int proto = 0;
+ long? ttfb = null;
+ long? total = null;
+ using var timeoutCts = new CancellationTokenSource(timeout, Env.Time);
+ using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
+ try
+ {
+ if (version == "h3" && !QuicConnection.IsSupported)
+ {
+ outcome = HttpOutcomes.Unsupported;
+ LogFirstFailure(outcome, url, version, null);
+ }
+ else
+ {
+ if (version == "h3")
+ {
+ await ResolveForQuicAsync(url.IdnHost, phases, linked.Token);
+ }
+
+ using HttpMessageInvoker invoker = CreateInvoker(version, verify, phases);
+ using var request = new HttpRequestMessage(new HttpMethod(Spec.Text("method")), url)
+ {
+ Version = version switch { "h1" => HttpVersion.Version11, "h2" => HttpVersion.Version20, _ => HttpVersion.Version30 },
+ VersionPolicy = HttpVersionPolicy.RequestVersionExact,
+ };
+ using HttpResponseMessage response = await invoker.SendAsync(request, linked.Token);
+ ttfb = Env.Clock.NowUs() - start;
+ status = (int)response.StatusCode;
+ proto = response.Version.Major switch { 3 => 3, 2 => 2, _ => 1 };
+ await ReadBodyAsync(response, (int)Spec.Int("read_kb") * 1024, linked.Token);
+ total = Env.Clock.NowUs() - start;
+ long expected = Spec.Int("ok_status");
+ outcome = (expected == 0 ? status < 400 : status == expected) ? HttpOutcomes.Ok : HttpOutcomes.BadStatus;
+ }
+ }
+ catch (OperationCanceledException) when (!ct.IsCancellationRequested)
+ {
+ outcome = HttpOutcomes.Timeout;
+ }
+ catch (OperationCanceledException)
+ {
+ return false;
+ }
+ catch (Exception ex) when (ex is HttpRequestException or IOException or SocketException or AuthenticationException or TargetNotAllowedException or QuicException)
+ {
+ outcome = Classify(ex);
+ LogFirstFailure(outcome, url, version, ex);
+ }
+
+ Record(start, weight, phases, ttfb, total, status, proto, outcome);
+ return outcome == HttpOutcomes.Ok;
+ }
+
+ private HttpMessageInvoker CreateInvoker(string version, bool verify, Phases phases)
+ {
+ var handler = new SocketsHttpHandler
+ {
+ UseProxy = false,
+ AllowAutoRedirect = false,
+ UseCookies = false,
+ AutomaticDecompression = DecompressionMethods.None,
+ };
+
+ // 设置了 ConnectCallback 的处理器不能用 HTTP/3(运行时用自己的 QUIC 连接,不经过回调):h3 只能靠请求前的解析和网段检查
+ if (version != "h3")
+ {
+ handler.ConnectCallback = (context, token) => ConnectAsync(context.DnsEndPoint, phases, token);
+ }
+
+ if (!verify)
+ {
+#pragma warning disable CA5359 // 操作员明确关闭证书校验(tls_verify=false):只测可达性和耗时
+ handler.SslOptions = new SslClientAuthenticationOptions { RemoteCertificateValidationCallback = (_, _, _, _) => true };
+#pragma warning restore CA5359
+ }
+
+ return new HttpMessageInvoker(handler, disposeHandler: true);
+ }
+
+ /// 自己解析和连接:每个地址都要在允许的网段里,DNS 和 TCP 建连的耗时分别记下来。
+ private async ValueTask ConnectAsync(DnsEndPoint endpoint, Phases phases, CancellationToken ct)
+ {
+ IPAddress[] addresses = await ResolveAsync(endpoint.Host, phases, ct);
+ long start = Env.Clock.NowUs();
+ Exception? last = null;
+ foreach (IPAddress address in addresses)
+ {
+ var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
+ try
+ {
+ await socket.ConnectAsync(new IPEndPoint(address, endpoint.Port), ct);
+ phases.TcpUs = Env.Clock.NowUs() - start;
+ return new NetworkStream(socket, ownsSocket: true);
+ }
+ catch (SocketException ex)
+ {
+ socket.Dispose();
+ last = ex;
+ }
+ }
+
+ throw last ?? new SocketException((int)SocketError.HostNotFound);
+ }
+
+ /// HTTP/3 由运行时自己建 UDP 连接,没法接管;先解析并检查一遍,再让它去连。
+ private async Task ResolveForQuicAsync(string host, Phases phases, CancellationToken ct) => _ = await ResolveAsync(host, phases, ct);
+
+ private async Task ResolveAsync(string host, Phases phases, CancellationToken ct)
+ {
+ long start = Env.Clock.NowUs();
+ IPAddress[] all = IPAddress.TryParse(host, out IPAddress? literal) ? [literal] : await Dns.GetHostAddressesAsync(host, ct);
+ phases.DnsUs = Env.Clock.NowUs() - start;
+ IPAddress[] allowed = [.. all.Where(a => Env.AllowAddress?.Invoke(a) ?? true)];
+ return allowed.Length > 0 ? allowed : throw new TargetNotAllowedException(host);
+ }
+
+ private static async Task ReadBodyAsync(HttpResponseMessage response, int limit, CancellationToken ct)
+ {
+ if (limit <= 0)
+ {
+ return;
+ }
+
+ await using Stream body = await response.Content.ReadAsStreamAsync(ct);
+ byte[] buffer = new byte[Math.Min(limit, 16 * 1024)];
+ int read = 0;
+ while (read < limit)
+ {
+ int n = await body.ReadAsync(buffer.AsMemory(0, Math.Min(buffer.Length, limit - read)), ct);
+ if (n == 0)
+ {
+ break;
+ }
+
+ read += n;
+ }
+ }
+
+ /// 每种失败结果只记第一次(带原因),保留给事后审计,不会随请求频率刷屏。
+ private void LogFirstFailure(int outcome, Uri url, string version, Exception? ex)
+ {
+ if (!_loggedOutcomes.Add(outcome))
+ {
+ return;
+ }
+
+ Env.Log.Warn("http request failed", ("task", Spec.Scope.Task?.Value), ("outcome", outcome), ("version", version), ("host", url.Host),
+ ("quic_supported", version == "h3" ? QuicConnection.IsSupported : null), ("err", ex is null ? "System.Net.Quic is not available" : ex.GetType().Name + ": " + (ex.InnerException?.Message ?? ex.Message)));
+ }
+
+ private static int Classify(Exception ex)
+ {
+ for (Exception? e = ex; e is not null; e = e.InnerException)
+ {
+ switch (e)
+ {
+ case TargetNotAllowedException:
+ return HttpOutcomes.NotAllowed;
+ case AuthenticationException:
+ return HttpOutcomes.Tls;
+ case SocketException { SocketErrorCode: SocketError.HostNotFound or SocketError.TryAgain or SocketError.NoData }:
+ return HttpOutcomes.Dns;
+ case SocketException { SocketErrorCode: SocketError.ConnectionRefused or SocketError.ConnectionReset }:
+ return HttpOutcomes.Reset;
+ case HttpRequestException { HttpRequestError: HttpRequestError.NameResolutionError }:
+ return HttpOutcomes.Dns;
+ case HttpRequestException { HttpRequestError: HttpRequestError.SecureConnectionError }:
+ return HttpOutcomes.Tls;
+ case HttpRequestException { HttpRequestError: HttpRequestError.VersionNegotiationError }:
+ return HttpOutcomes.Unsupported;
+ }
+ }
+
+ return HttpOutcomes.Other;
+ }
+
+ private void Record(long startUs, int weight, Phases phases, long? ttfb, long? total, int status, int proto, int outcome)
+ {
+ long key = _log.Store.Append();
+ _log.Store.Set(key, _attempt, _next++);
+ _log.Store.Set(key, _ts, startUs);
+ _log.Store.Set(key, _w, Math.Clamp(weight, 1, 65535));
+ _log.Store.Set(key, _outcome, outcome);
+ SetIf(key, _dns, phases.DnsUs);
+ SetIf(key, _tcp, phases.TcpUs);
+ SetIf(key, _ttfb, ttfb);
+ SetIf(key, _total, total);
+ if (status != 0)
+ {
+ _log.Store.Set(key, _status, status);
+ _log.Store.Set(key, _proto, proto);
+ }
+ }
+
+ private void SetIf(long key, int column, long? value)
+ {
+ if (value is { } v)
+ {
+ _log.Store.Set(key, column, Math.Clamp(v, 0, uint.MaxValue));
+ }
+ }
+
+ /// 一次请求里 DNS 和 TCP 建连用了多久(连接回调里填)。
+ private sealed class Phases
+ {
+ public long? DnsUs { get; set; }
+
+ public long? TcpUs { get; set; }
+ }
+}
diff --git a/backend/shared/MoShiro.Measurement/Blocks/Train.cs b/backend/shared/MoShiro.Measurement/Blocks/Train.cs
index cafd7da..2bca408 100644
--- a/backend/shared/MoShiro.Measurement/Blocks/Train.cs
+++ b/backend/shared/MoShiro.Measurement/Blocks/Train.cs
@@ -219,7 +219,7 @@ public sealed class TrainDialer : BlockBase
[FragmentKeys.SeqTo] = Number(to),
};
Env.Fragments!.Ship(
- "udp_train",
+ "quic_stream",
Spec.Scope,
fragments == 0 ? [pkt, _step.Cut(0, 1, includeKey: false)] : [pkt],
parameters,
diff --git a/backend/shared/MoShiro.Measurement/Core/AdaptiveRate.cs b/backend/shared/MoShiro.Measurement/Core/AdaptiveRate.cs
index b7eb0bc..19dd130 100644
--- a/backend/shared/MoShiro.Measurement/Core/AdaptiveRate.cs
+++ b/backend/shared/MoShiro.Measurement/Core/AdaptiveRate.cs
@@ -65,6 +65,9 @@ public sealed record AdaptiveRateOptions
public double RttFloorMs { get; init; } = 5;
+ /// 用 RTT p99 的置信区间限制降频。样本稀少的任务(每分钟几次)估不出区间,关掉后平稳频率只由丢包上界决定。
+ public bool RttGuard { get; init; } = true;
+
/// 先验:时间 → 这个时刻丢包的历史概率(0 到 1);null 表示没有历史。
public Func? Prior { get; init; }
@@ -285,7 +288,7 @@ public sealed class AdaptiveRate
UpdateGuard(nowUs);
double hz = next switch
{
- ProbeState.Quiet => Quantize(Math.Max(QuietHz(QuietWindowSeconds(nowUs)), double.IsNaN(_guardHz) ? _options.MaxHz : _guardHz)),
+ ProbeState.Quiet => Quantize(Math.Max(QuietHz(QuietWindowSeconds(nowUs)), !_options.RttGuard ? _options.MinHz : double.IsNaN(_guardHz) ? _options.MaxHz : _guardHz)),
ProbeState.Watch or ProbeState.Active => _options.MaxHz,
_ => Math.Max(_options.MinHz / 4, 1.0 / _options.DeadMaxIntervalSeconds),
};
diff --git a/backend/shared/MoShiro.Measurement/Core/Contracts.cs b/backend/shared/MoShiro.Measurement/Core/Contracts.cs
index dd1fd9d..44519c5 100644
--- a/backend/shared/MoShiro.Measurement/Core/Contracts.cs
+++ b/backend/shared/MoShiro.Measurement/Core/Contracts.cs
@@ -190,6 +190,8 @@ public sealed class BlockSpec
public bool Bool(string name) => Params.GetBool(name);
+ public string Text(string name) => Params.GetString(name);
+
/// 参数或端口变化时调和器重启这个积木(不含流编号:Agent 的流编号每次重建都会重新分配)。
public string ConfigSignature => KindId + "|" + Port + "|" +
string.Join(',', Params.Values.OrderBy(p => p.Key, StringComparer.Ordinal).Select(p => p.Key + "=" + p.Value.GetRawText()));
@@ -237,6 +239,12 @@ public sealed class BlockEnv
/// 拨号方:对端(Agent,或目标任务的目标地址)。
public IPAddress? PeerAddress { get; init; }
+ /// 单个 Agent 任务的目标原文(http 是 URL,其余是主机[:端口]);线路任务为 null。
+ public string? Target { get; init; }
+
+ /// 单个 Agent 任务:解析出来的地址能不能连(本地 allowed_target_cidrs);null 表示不限制。
+ public Func? AllowAddress { get; init; }
+
/// 拨号方:对端 TLS 证书的 SPKI 指纹;null 表示不校验证书(目标任务,目标是任意地址)。
public byte[]? PeerSpkiSha256 { get; init; }
@@ -246,7 +254,7 @@ public sealed class BlockEnv
/// 拨号方:列块出口。
public IFragmentSink? Fragments { get; init; }
- /// 本地硬上限:批量传输(tcp_bulk)发送端的码率(Mbit/s)。批量传输会尽可能占满链路,这是防止把小机器打满的保险。
+ /// 本地硬上限:批量传输(tcp_transfer)发送端的码率(Mbit/s)。批量传输会尽可能占满链路,这是防止把小机器打满的保险。
public double MaxBulkMbps { get; init; } = 100;
/// 本地硬上限:单向数据流接收缓冲的包速率上界。
@@ -267,6 +275,8 @@ public sealed class BlockEnv
Counters = Counters,
TcpListen = TcpListen,
PeerAddress = PeerAddress,
+ Target = Target,
+ AllowAddress = AllowAddress,
PeerSpkiSha256 = PeerSpkiSha256,
KnockKey = KnockKey,
Fragments = Fragments,
diff --git a/backend/shared/MoShiro.Measurement/Execution/BudgetGuard.cs b/backend/shared/MoShiro.Measurement/Execution/BudgetGuard.cs
index 06bd324..f589594 100644
--- a/backend/shared/MoShiro.Measurement/Execution/BudgetGuard.cs
+++ b/backend/shared/MoShiro.Measurement/Execution/BudgetGuard.cs
@@ -106,22 +106,25 @@ public sealed class BudgetGuard
return Check(new TaskSpec { Id = new TaskId("tsk_phase"), Scope = "line", Kind = kindId, Params = new Dictionary(parameters) }, ref mbps, ref pps);
}
+ ///
+ /// 目标在静态上可不可以接受:IP 字面量按 allowed_target_cidrs 检查;域名(包括 URL 里的)要解析之后才知道,
+ /// 这里只要求配置过白名单,运行时每次解析出的地址再用 检查。
+ ///
public bool TargetAllowed(string? target)
{
- if (target is null)
+ if (!TargetHost.TryParse(target, out string? host, out _, out _))
{
return false;
}
- string host = target;
- if (IPEndPoint.TryParse(target, out IPEndPoint? endpoint))
- {
- host = endpoint.Address.ToString();
- }
+ return IPAddress.TryParse(host, out IPAddress? address) ? AddressAllowed(address) : _cidrs.Count > 0;
+ }
- if (!IPAddress.TryParse(host, out IPAddress? address))
+ public bool AddressAllowed(IPAddress address)
+ {
+ if (address.IsIPv4MappedToIPv6)
{
- return false;
+ address = address.MapToIPv4();
}
foreach ((IPAddress network, int prefix) in _cidrs)
@@ -148,10 +151,11 @@ public sealed class BudgetGuard
private static long EstimatePps(string kindId, ParamValues values) => kindId switch
{
- "udp_echo" or "tcp_echo" => Kinds.PeakRateHz(kindId, values) * 2,
- "icmp_echo" => values.GetInt("rate_hz") * 2,
- "udp_train" => (long)(values.GetNumber("mbps") * 1_000_000 / 8 / Math.Max(1, values.GetInt("size"))),
- "connect" => values.GetInt("per_minute") * 6 / 60,
+ "quic_ping" or "tcp_ping" => Kinds.PeakRateHz(kindId, values) * 2,
+ "icmp_ping" => values.GetInt("rate_hz") * 2,
+ "quic_stream" => (long)(values.GetNumber("mbps") * 1_000_000 / 8 / Math.Max(1, values.GetInt("size"))),
+ "tcp_connect" => values.GetInt("per_minute") * 6 / 60,
+ "http_request" => Math.Max(1, values.GetInt("rate_max_per_min") * 20 / 60),
_ => 1,
};
diff --git a/backend/shared/MoShiro.Measurement/Net/MsQuicApi.cs b/backend/shared/MoShiro.Measurement/Net/MsQuicApi.cs
index e997628..2bddd21 100644
--- a/backend/shared/MoShiro.Measurement/Net/MsQuicApi.cs
+++ b/backend/shared/MoShiro.Measurement/Net/MsQuicApi.cs
@@ -109,14 +109,16 @@ internal static unsafe class MsQuicApi
public static void RegistrationClose(nint handle) => s_registrationClose(handle);
- public static nint ConfigurationOpen(nint registration, byte[] alpn)
+ public static nint ConfigurationOpen(nint registration, byte[] alpn, QuicSettings? settings = null)
{
+ byte[] native = settings is { IsDefault: false } ? settings.ToNative() : [];
unsafe
{
fixed (byte* alpnBytes = alpn)
+ fixed (byte* settingsBytes = native)
{
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);
+ uint status = s_configurationOpen(registration, ref buffer, 1, native.Length == 0 ? 0 : (nint)settingsBytes, (uint)native.Length, 0, out nint configuration);
Check(status, "ConfigurationOpen");
return configuration;
}
diff --git a/backend/shared/MoShiro.Measurement/Net/MsQuicConnection.cs b/backend/shared/MoShiro.Measurement/Net/MsQuicConnection.cs
index 45ef668..db9bbe6 100644
--- a/backend/shared/MoShiro.Measurement/Net/MsQuicConnection.cs
+++ b/backend/shared/MoShiro.Measurement/Net/MsQuicConnection.cs
@@ -156,7 +156,8 @@ internal sealed class MsQuicConnection : IAsyncDisposable, IDatagramConnection
byte[] expectedPeerSpki,
MsQuicDatagramCallback datagramHandler,
INodeLog log,
- CancellationToken ct)
+ CancellationToken ct,
+ QuicSettings? settings = null)
{
nint registration = 0;
nint configuration = 0;
@@ -165,7 +166,7 @@ internal sealed class MsQuicConnection : IAsyncDisposable, IDatagramConnection
try
{
registration = MsQuicApi.RegistrationOpen("moshiro-dialer");
- configuration = MsQuicApi.ConfigurationOpen(registration, QuicAlpn.Value);
+ configuration = MsQuicApi.ConfigurationOpen(registration, QuicAlpn.Value, settings);
LoadClientCredentials(configuration);
var endpoint = new IPEndPoint(address.AddressFamily == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, localPort);
SocketAddress serialized = endpoint.Serialize();
diff --git a/backend/shared/MoShiro.Measurement/Net/MsQuicListener.cs b/backend/shared/MoShiro.Measurement/Net/MsQuicListener.cs
index 6201f29..3922b45 100644
--- a/backend/shared/MoShiro.Measurement/Net/MsQuicListener.cs
+++ b/backend/shared/MoShiro.Measurement/Net/MsQuicListener.cs
@@ -21,6 +21,7 @@ internal sealed class MsQuicListener : IAsyncDisposable
private readonly INodeLog _log;
private readonly Action? _peerAddressChanged;
private readonly X509Certificate2 _certificate;
+ private readonly QuicSettings? _settings;
private readonly GCHandle _self;
private readonly ConcurrentDictionary _connections = new();
private readonly List _listeners = [];
@@ -34,8 +35,10 @@ internal sealed class MsQuicListener : IAsyncDisposable
X509Certificate2 certificate,
MsQuicDatagramCallback datagramHandler,
INodeLog log,
- Action? peerAddressChanged)
+ Action? peerAddressChanged,
+ QuicSettings? settings = null)
{
+ _settings = settings;
// MsQuic opens dual-mode wildcard sockets, but filters incoming connections using
// the address family supplied to ListenerStart. An IPv6Any listener alone rejects
// IPv4-mapped connections with TLS alert 120 (no_application_protocol).
@@ -62,7 +65,7 @@ internal sealed class MsQuicListener : IAsyncDisposable
_registration = MsQuicApi.RegistrationOpen("moshiro-agent");
try
{
- _configuration = MsQuicApi.ConfigurationOpen(_registration, QuicAlpn.Value);
+ _configuration = MsQuicApi.ConfigurationOpen(_registration, QuicAlpn.Value, _settings);
MsQuicConnection.LoadServerCredentials(_configuration, _certificate);
var bound = new List(_ports.Count * _bindAddresses.Count);
foreach (int port in _ports)
diff --git a/backend/shared/MoShiro.Measurement/Net/QuicDatagramTransport.cs b/backend/shared/MoShiro.Measurement/Net/QuicDatagramTransport.cs
index 66910dd..aabaf16 100644
--- a/backend/shared/MoShiro.Measurement/Net/QuicDatagramTransport.cs
+++ b/backend/shared/MoShiro.Measurement/Net/QuicDatagramTransport.cs
@@ -21,6 +21,7 @@ public sealed class QuicDatagramTransport : IDisposable, IAsyncDisposable
private readonly DataSession? _clientSession;
private readonly IPEndPoint? _remote;
private readonly byte[]? _peerSpki;
+ private readonly QuicSettings? _settings;
private int _previousSourcePort;
private readonly TaskCompletionSource _clientReady = new(TaskCreationOptions.RunContinuationsAsynchronously);
private MsQuicListener? _listener;
@@ -28,8 +29,9 @@ public sealed class QuicDatagramTransport : IDisposable, IAsyncDisposable
private int _disposed;
/// Constructs an Agent-side QUIC listener.
- public QuicDatagramTransport(IDataSessions sessions, IAdmission? admission, MonoClock clock, DataPlaneCounters counters, INodeLog log, IPAddress bind, IReadOnlyList ports, X509Certificate2 serverCertificate)
+ public QuicDatagramTransport(IDataSessions sessions, IAdmission? admission, MonoClock clock, DataPlaneCounters counters, INodeLog log, IPAddress bind, IReadOnlyList ports, X509Certificate2 serverCertificate, QuicSettings? settings = null)
{
+ _settings = settings;
_router = new MeasurementDatagramRouter(sessions, admission, clock, counters, log);
_log = log;
_onDatagram = _router.Dispatch;
@@ -39,8 +41,9 @@ public sealed class QuicDatagramTransport : IDisposable, IAsyncDisposable
}
/// Constructs a 拨号方-side QUIC dialer for one Agent session.
- public QuicDatagramTransport(DataSession session, MonoClock clock, DataPlaneCounters counters, INodeLog log, IPEndPoint remote, byte[] peerSpki)
+ public QuicDatagramTransport(DataSession session, MonoClock clock, DataPlaneCounters counters, INodeLog log, IPEndPoint remote, byte[] peerSpki, QuicSettings? settings = null)
{
+ _settings = settings;
_router = new MeasurementDatagramRouter(new SingleSession(session), null, clock, counters, log);
_log = log;
_onDatagram = _router.Dispatch;
@@ -73,7 +76,7 @@ public sealed class QuicDatagramTransport : IDisposable, IAsyncDisposable
throw new InvalidOperationException("QUIC listener is already bound.");
}
- _listener = new MsQuicListener(_bind, _ports, _serverCertificate!, _onDatagram, _log, null);
+ _listener = new MsQuicListener(_bind, _ports, _serverCertificate!, _onDatagram, _log, null, _settings);
_listener.Start();
BoundPorts = _listener.BoundPorts;
}
@@ -92,7 +95,7 @@ public sealed class QuicDatagramTransport : IDisposable, IAsyncDisposable
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
await CloseClientAsync().ConfigureAwait(false);
int sourcePort = SelectSourcePort(_remote.AddressFamily, _previousSourcePort);
- MsQuicConnection client = await MsQuicConnection.ConnectAsync(_remote.Address, _remote.Port, sourcePort, _peerSpki, _onDatagram, _log, ct).ConfigureAwait(false);
+ MsQuicConnection client = await MsQuicConnection.ConnectAsync(_remote.Address, _remote.Port, sourcePort, _peerSpki, _onDatagram, _log, ct, _settings).ConfigureAwait(false);
_client = client;
_clientSession.BindDatagramConnection(client);
BoundPorts = client.LocalEndPoint is { } local ? [local.Port] : [sourcePort];
diff --git a/backend/shared/MoShiro.Measurement/Net/QuicSettings.cs b/backend/shared/MoShiro.Measurement/Net/QuicSettings.cs
new file mode 100644
index 0000000..6eee9a8
--- /dev/null
+++ b/backend/shared/MoShiro.Measurement/Net/QuicSettings.cs
@@ -0,0 +1,110 @@
+using System.Buffers.Binary;
+
+namespace MoShiro.Measurement;
+
+///
+/// 数据通道 QUIC 连接的传输参数(agent.json 的 quic 段);null 的项用 MsQuic 默认值。
+/// 一条 QUIC 连接由这个 Agent 与对端之间的所有任务共用,所以这些参数属于节点,不属于某个任务。
+/// 监听方用它接受连接,拨号方用它发起连接,两边各自生效。
+///
+public sealed record QuicSettings
+{
+ /// MsQuic 默认的最小、最大 MTU 是 1248 和 1500(IP 包大小,含 IP 和 UDP 头)。
+ public const int MinAllowedMtu = 1200;
+
+ public const int MaxAllowedMtu = 65535;
+
+ /// 路径 MTU 探测的起点;链路的真实 MTU 更小时探测会往下收缩,更大时往上探测到 。
+ public int? MinMtu { get; init; }
+
+ /// 路径 MTU 探测的上限。设成链路的真实 MTU 可以省掉探测,设小可以避开会丢大包的路径。
+ public int? MaxMtu { get; init; }
+
+ public bool? Pacing { get; init; }
+
+ public int? IdleTimeoutMs { get; init; }
+
+ public int? KeepAliveMs { get; init; }
+
+ public bool IsDefault => MinMtu is null && MaxMtu is null && Pacing is null && IdleTimeoutMs is null && KeepAliveMs is null;
+
+ /// 参数是否合法;不合法返回原因。
+ public string? Validate()
+ {
+ if (MinMtu is { } min && min is < MinAllowedMtu or > MaxAllowedMtu)
+ {
+ return $"min_mtu must be {MinAllowedMtu}..{MaxAllowedMtu}";
+ }
+
+ if (MaxMtu is { } max && max is < MinAllowedMtu or > MaxAllowedMtu)
+ {
+ return $"max_mtu must be {MinAllowedMtu}..{MaxAllowedMtu}";
+ }
+
+ if (MinMtu is { } lo && MaxMtu is { } hi && lo > hi)
+ {
+ return "min_mtu must not exceed max_mtu";
+ }
+
+ if (IdleTimeoutMs is < 0)
+ {
+ return "idle_timeout_ms must not be negative";
+ }
+
+ return KeepAliveMs is < 0 ? "keep_alive_ms must not be negative" : null;
+ }
+
+ // QUIC_SETTINGS(msquic.h)的布局:IsSetFlags(u64) 后面依次是 u64、u32、u16 各一组字段,再是位域字节。
+ // 这里只写用到的几项,其余字段保持 0 且不置位。长度只算到 MtuDiscoveryMissingProbeCount,更靠后的字段 MsQuic 不会读。
+ private const int Size = 112;
+ private const int IdleTimeoutOffset = 24;
+ private const int KeepAliveOffset = 88;
+ private const int MinMtuOffset = 102;
+ private const int MaxMtuOffset = 104;
+ private const int BitFieldOffset = 106;
+ private const int PacingBit = 1 << 1;
+
+ private const int IdleTimeoutFlag = 2;
+ private const int KeepAliveFlag = 16;
+ private const int MinMtuFlag = 22;
+ private const int MaxMtuFlag = 23;
+ private const int PacingFlag = 25;
+
+ internal byte[] ToNative()
+ {
+ byte[] native = new byte[Size];
+ ulong isSet = 0;
+ if (IdleTimeoutMs is { } idle)
+ {
+ BinaryPrimitives.WriteUInt64LittleEndian(native.AsSpan(IdleTimeoutOffset), (ulong)idle);
+ isSet |= 1UL << IdleTimeoutFlag;
+ }
+
+ if (KeepAliveMs is { } keepAlive)
+ {
+ BinaryPrimitives.WriteUInt32LittleEndian(native.AsSpan(KeepAliveOffset), (uint)keepAlive);
+ isSet |= 1UL << KeepAliveFlag;
+ }
+
+ if (MinMtu is { } min)
+ {
+ BinaryPrimitives.WriteUInt16LittleEndian(native.AsSpan(MinMtuOffset), (ushort)min);
+ isSet |= 1UL << MinMtuFlag;
+ }
+
+ if (MaxMtu is { } max)
+ {
+ BinaryPrimitives.WriteUInt16LittleEndian(native.AsSpan(MaxMtuOffset), (ushort)max);
+ isSet |= 1UL << MaxMtuFlag;
+ }
+
+ if (Pacing is { } pacing)
+ {
+ native[BitFieldOffset] = (byte)(pacing ? PacingBit : 0);
+ isSet |= 1UL << PacingFlag;
+ }
+
+ BinaryPrimitives.WriteUInt64LittleEndian(native, isSet);
+ return native;
+ }
+}
diff --git a/backend/shared/MoShiro.Protocol/Catalog/Kinds/Kinds.cs b/backend/shared/MoShiro.Protocol/Catalog/Kinds/Kinds.cs
index f20d420..4e4288c 100644
--- a/backend/shared/MoShiro.Protocol/Catalog/Kinds/Kinds.cs
+++ b/backend/shared/MoShiro.Protocol/Catalog/Kinds/Kinds.cs
@@ -10,7 +10,7 @@ public static class Kinds
/// 这个任务可能达到的最高探测频率(Hz):自适应时是 rate_max_hz,否则是固定的 rate_hz。预算、限速和准入都按它算。
public static long PeakRateHz(string kindId, ParamValues values) =>
- (kindId is "udp_echo" or "tcp_echo") && values.GetBool("adaptive") ? Math.Max(values.GetInt("rate_max_hz"), values.GetInt("rate_min_hz")) : values.GetInt("rate_hz");
+ (kindId is "quic_ping" or "tcp_ping") && values.GetBool("adaptive") ? Math.Max(values.GetInt("rate_max_hz"), values.GetInt("rate_min_hz")) : values.GetInt("rate_hz");
private static readonly StreamSpec s_echoMsg = new(
"msg",
@@ -30,8 +30,8 @@ public static class Kinds
],
Union: true);
- public static readonly KindSpec UdpEcho = new(
- "udp_echo",
+ public static readonly KindSpec QuicPing = new(
+ "quic_ping",
"QUIC Datagram echo",
"Fixed-rate QUIC Datagram echo with four timestamps per packet.",
Scope.Line,
@@ -47,10 +47,10 @@ public static class Kinds
P.Int("port", 7443, 1, 65535),
],
[s_echoMsg],
- p => Bandwidth.UdpEcho(PeakRateHz("udp_echo", p), p.GetInt("size")));
+ p => Bandwidth.UdpEcho(PeakRateHz("quic_ping", p), p.GetInt("size")));
- public static readonly KindSpec UdpTrain = new(
- "udp_train",
+ public static readonly KindSpec QuicStream = new(
+ "quic_stream",
"QUIC Datagram train",
"Fixed-rate or laddered one-way QUIC Datagram stream (Agent to 拨号方) for loss and delay variation under load.",
Scope.Line,
@@ -68,8 +68,8 @@ public static class Kinds
],
p => (long)(p.GetNumber("mbps") * 1_000_000));
- public static readonly KindSpec TcpEcho = new(
- "tcp_echo",
+ public static readonly KindSpec TcpPing = new(
+ "tcp_ping",
"TCP echo",
"Small TCP messages echoed with four timestamps, plus periodic kernel tcp_info samples.",
Scope.Line,
@@ -88,10 +88,10 @@ public static class Kinds
P.Bool("tls", false),
],
[s_echoMsg, s_tcpInfo],
- p => Bandwidth.TcpEcho(PeakRateHz("tcp_echo", p), p.GetInt("max")));
+ p => Bandwidth.TcpEcho(PeakRateHz("tcp_ping", p), p.GetInt("max")));
- public static readonly KindSpec TcpBulk = new(
- "tcp_bulk",
+ public static readonly KindSpec TcpTransfer = new(
+ "tcp_transfer",
"TCP bulk",
"Bulk TCP transfer on several streams with periodic byte counters and tcp_info.",
Scope.Line,
@@ -110,8 +110,8 @@ public static class Kinds
],
_ => 0);
- public static readonly KindSpec Connect = new(
- "connect",
+ public static readonly KindSpec TcpConnect = new(
+ "tcp_connect",
"Connect",
"Repeated TCP and TLS connection establishment with per-phase timings and outcome.",
Scope.Line | Scope.Target,
@@ -129,8 +129,8 @@ public static class Kinds
],
p => p.GetInt("per_minute") * 4000 / 60);
- public static readonly KindSpec Hold = new(
- "hold",
+ public static readonly KindSpec TlsHold = new(
+ "tls_hold",
"Hold",
"A long-lived TLS connection with periodic heartbeats; records resets and lifetime.",
Scope.Line,
@@ -144,8 +144,38 @@ public static class Kinds
],
p => 2000 / Math.Max(1, p.GetInt("heartbeat_s")) * 8);
- public static readonly KindSpec IcmpEcho = new(
- "icmp_echo",
+ public static readonly KindSpec HttpRequest = new(
+ "http_request",
+ "HTTP request",
+ "One Agent requests a URL over HTTP/1.1, HTTP/2 or HTTP/3 and records DNS, TCP, time to first byte, total time and status. Target is the URL.",
+ Scope.Target,
+ new BlockSet("http", "fixed", "fixed", "request", []),
+ [
+ P.Enum("version", "h2", ["h1", "h2", "h3"], "HTTP version; the request fails instead of falling back."),
+ P.Enum("method", "GET", ["GET", "HEAD"]),
+ P.Bool("adaptive", true, "Adapt the request rate to the failure rate: low while the target answers, up to rate_max_per_min when it fails, exponential back-off while it is unreachable."),
+ P.Int("per_minute", 6, 1, 120, "/min", "Fixed rate, used when adaptive is false."),
+ P.Int("rate_min_per_min", 1, 1, 120, "/min"),
+ P.Int("rate_max_per_min", 12, 1, 120, "/min", "Highest rate, and the reference grid for sample weights."),
+ P.Number("tau_pct", 5, 0.1, 50, "%", "Failure rate a quiet target has to be able to prove below, at 95% confidence."),
+ P.Int("timeout_ms", 10000, 500, 60000, "ms"),
+ P.Int("read_kb", 16, 0, 1024, "KiB", "Body bytes to read before the request counts as finished (0: headers only)."),
+ P.Int("ok_status", 0, 0, 599, null, "Status that counts as success; 0 means any status below 400."),
+ P.Bool("tls_verify", true),
+ ],
+ [
+ new StreamSpec(
+ "req",
+ [
+ Col.Seq("attempt"), Col.TsDialer("ts"), Col.U16("w"),
+ Col.U32("dns_us", nullable: true), Col.U32("tcp_us", nullable: true), Col.U32("ttfb_us", nullable: true), Col.U32("total_us", nullable: true),
+ Col.U16("status", nullable: true), Col.U8("proto", nullable: true), Col.U8("outcome"),
+ ]),
+ ],
+ p => p.GetInt("rate_max_per_min") * (2000 + p.GetInt("read_kb") * 1024) * 8 / 60);
+
+ public static readonly KindSpec IcmpPing = new(
+ "icmp_ping",
"ICMP echo",
"拨号方 or Agent side ICMP echo to a target address.",
Scope.Target,
@@ -159,7 +189,7 @@ public static class Kinds
],
p => Bandwidth.UdpEcho(p.GetInt("rate_hz"), p.GetInt("size")));
- public static readonly IReadOnlyList All = [UdpEcho, UdpTrain, TcpEcho, TcpBulk, Connect, Hold, IcmpEcho];
+ public static readonly IReadOnlyList All = [QuicPing, QuicStream, TcpPing, TcpTransfer, TcpConnect, TlsHold, HttpRequest, IcmpPing];
public static KindSpec? Find(string id)
{
diff --git a/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs b/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs
index ee50931..129b238 100644
--- a/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs
+++ b/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs
@@ -488,7 +488,7 @@ public sealed class ControlHealth
/// 任务层:测量自己使用的链路。TCP 类积木自带套接字和重拨,QUIC 数据通道由拨号方单独维护。
public sealed class TaskHealth
{
- /// 正在运行的 TCP 类线路积木数(tcp_echo、connect、hold、tcp_bulk)。
+ /// 正在运行的 TCP 类线路积木数(tcp_ping、connect、hold、tcp_transfer)。
[JsonPropertyName("tcp_blocks")]
public int TcpBlocks { get; set; }
@@ -496,7 +496,7 @@ public sealed class TaskHealth
public QuicHealth Quic { get; set; } = new();
}
-/// QUIC 数据通道(udp_echo、udp_train 使用)的状态、握手统计和传输层统计。
+/// QUIC 数据通道(quic_ping、quic_stream 使用)的状态、握手统计和传输层统计。
public sealed class QuicHealth
{
[JsonPropertyName("state")]
diff --git a/backend/shared/MoShiro.Protocol/Tasks/TargetHost.cs b/backend/shared/MoShiro.Protocol/Tasks/TargetHost.cs
new file mode 100644
index 0000000..cfb5eaa
--- /dev/null
+++ b/backend/shared/MoShiro.Protocol/Tasks/TargetHost.cs
@@ -0,0 +1,104 @@
+using System.Globalization;
+using System.Net;
+
+namespace MoShiro.Protocol;
+
+///
+/// 单个 Agent 任务的目标:http 是 http(s)://主机[:端口][/路径],其余 Kind 是 主机[:端口](IP 或域名,IPv6 带端口时用方括号)。
+/// Backend 只检查格式,不做 DNS 解析;解析在 Agent 上进行,每个解析出的地址都要在 allowed_target_cidrs 里。
+///
+public static class TargetHost
+{
+ public const string HttpKind = "http_request";
+
+ /// 解析目标; 为 0 表示没有写端口, 只在 http(s) URL 时有值。
+ public static bool TryParse(string? target, out string host, out int port, out Uri? url)
+ {
+ host = "";
+ port = 0;
+ url = null;
+ if (string.IsNullOrWhiteSpace(target) || target.Length > 2048 || target.Any(char.IsWhiteSpace))
+ {
+ return false;
+ }
+
+ if (target.Contains("://", StringComparison.Ordinal))
+ {
+ if (!Uri.TryCreate(target, UriKind.Absolute, out Uri? uri) || uri.Scheme is not ("http" or "https") || uri.UserInfo.Length > 0 || !ValidHost(uri.IdnHost))
+ {
+ return false;
+ }
+
+ url = uri;
+ host = uri.IdnHost.Trim('[', ']');
+ port = uri.IsDefaultPort ? 0 : uri.Port;
+ return true;
+ }
+
+ // IPAddress.TryParse 会把「[v6]:端口」的端口丢掉,带方括号的先按端点解析
+ if (!target.StartsWith('[') && IPAddress.TryParse(target, out IPAddress? ip))
+ {
+ host = ip.ToString();
+ return true;
+ }
+
+ if (IPEndPoint.TryParse(target, out IPEndPoint? endpoint))
+ {
+ if (endpoint.Port is 0)
+ {
+ return false;
+ }
+
+ host = endpoint.Address.ToString();
+ port = endpoint.Port;
+ return true;
+ }
+
+ int colon = target.LastIndexOf(':');
+ string name = colon < 0 ? target : target[..colon];
+ if (colon >= 0 && (!int.TryParse(target.AsSpan(colon + 1), NumberStyles.None, CultureInfo.InvariantCulture, out port) || port is < 1 or > 65535))
+ {
+ return false;
+ }
+
+ if (!ValidHost(name))
+ {
+ return false;
+ }
+
+ host = name.ToLowerInvariant();
+ return true;
+ }
+
+ /// 按 Kind 检查并规范化:http 必须是 http(s) URL,其余 Kind 必须是主机[:端口];不合格返回 null。
+ public static string? Normalize(string kindId, string? target)
+ {
+ if (!TryParse(target, out string host, out int port, out Uri? url) || (url is not null) != (kindId == HttpKind))
+ {
+ return null;
+ }
+
+ if (url is not null)
+ {
+ return url.AbsoluteUri;
+ }
+
+ string shown = IPAddress.TryParse(host, out IPAddress? ip) && ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 && port != 0 ? "[" + host + "]" : host;
+ return port == 0 ? host : shown + ":" + port.ToString(CultureInfo.InvariantCulture);
+ }
+
+ private static bool ValidHost(string name)
+ {
+ if (IPAddress.TryParse(name.Trim('[', ']'), out _))
+ {
+ return true;
+ }
+
+ if (name.Length is 0 or > 253 || name[^1] == '-' || name[0] is '-' or '.')
+ {
+ return false;
+ }
+
+ return name.TrimEnd('.').Split('.').All(label => label.Length is > 0 and <= 63 && label.All(c => char.IsAsciiLetterOrDigit(c) || c == '-'));
+ }
+}
diff --git a/backend/shared/MoShiro.Protocol/Wire/MeasurementDatagram.cs b/backend/shared/MoShiro.Protocol/Wire/MeasurementDatagram.cs
index 11a44e5..b662387 100644
--- a/backend/shared/MoShiro.Protocol/Wire/MeasurementDatagram.cs
+++ b/backend/shared/MoShiro.Protocol/Wire/MeasurementDatagram.cs
@@ -8,7 +8,7 @@ public enum MeasurementDatagramType : byte
EchoRequest = 1,
EchoReply = 2,
- /// 单向数据(udp_train),以及 拨号方 为了保持 NAT 映射和让 Agent 学到源地址而发的探活包。
+ /// 单向数据(quic_stream),以及 拨号方 为了保持 NAT 映射和让 Agent 学到源地址而发的探活包。
Data = 3,
}
diff --git a/backend/shared/tests/MoShiro.Measurement.Tests/MeasurementTests.cs b/backend/shared/tests/MoShiro.Measurement.Tests/MeasurementTests.cs
index 64b3e46..fe5d99a 100644
--- a/backend/shared/tests/MoShiro.Measurement.Tests/MeasurementTests.cs
+++ b/backend/shared/tests/MoShiro.Measurement.Tests/MeasurementTests.cs
@@ -1,3 +1,4 @@
+using System.Net;
using System.Text.Json;
using Fleet.Core;
using MoShiro.Protocol;
@@ -24,11 +25,11 @@ public sealed class MeasurementTests
BudgetGuard guard = Guard();
double mbps = 0;
long pps = 0;
- Assert.True(guard.Check(Line("udp_echo"), ref mbps, ref pps).Accepted);
+ Assert.True(guard.Check(Line("quic_ping"), ref mbps, ref pps).Accepted);
Assert.Equal(RejectReasons.Unsupported, guard.Check(Line("no_such_kind"), ref mbps, ref pps).Reason);
- Assert.Equal(RejectReasons.InvalidParams, guard.Check(Line("udp_echo", ("rate_hz", "100000")), ref mbps, ref pps).Reason);
- Assert.Equal(RejectReasons.InvalidParams, guard.Check(Line("udp_echo", ("nope", "1")), ref mbps, ref pps).Reason);
- Assert.Equal(RejectReasons.PolicyDenied, guard.Check(Line("udp_echo", ("port", "9999")), ref mbps, ref pps).Reason);
+ Assert.Equal(RejectReasons.InvalidParams, guard.Check(Line("quic_ping", ("rate_hz", "100000")), ref mbps, ref pps).Reason);
+ Assert.Equal(RejectReasons.InvalidParams, guard.Check(Line("quic_ping", ("nope", "1")), ref mbps, ref pps).Reason);
+ Assert.Equal(RejectReasons.PolicyDenied, guard.Check(Line("quic_ping", ("port", "9999")), ref mbps, ref pps).Reason);
}
[Fact]
@@ -37,11 +38,11 @@ public sealed class MeasurementTests
BudgetGuard guard = Guard(new NodeLimits { MaxTotalMbps = 15, AllowedQuicPorts = [7443] });
double mbps = 0;
long pps = 0;
- Assert.True(guard.CheckPhase("udp_train", new Dictionary { ["mbps"] = Json("10") }).Accepted);
- Assert.Equal(RejectReasons.BudgetExceeded, guard.CheckPhase("udp_train", new Dictionary { ["mbps"] = Json("20") }).Reason);
+ Assert.True(guard.CheckPhase("quic_stream", new Dictionary { ["mbps"] = Json("10") }).Accepted);
+ Assert.Equal(RejectReasons.BudgetExceeded, guard.CheckPhase("quic_stream", new Dictionary { ["mbps"] = Json("20") }).Reason);
- Assert.True(guard.Check(Line("udp_train", ("mbps", "10")), ref mbps, ref pps).Accepted);
- Assert.Equal(RejectReasons.BudgetExceeded, guard.Check(Line("udp_train", ("mbps", "10")), ref mbps, ref pps).Reason);
+ Assert.True(guard.Check(Line("quic_stream", ("mbps", "10")), ref mbps, ref pps).Accepted);
+ Assert.Equal(RejectReasons.BudgetExceeded, guard.Check(Line("quic_stream", ("mbps", "10")), ref mbps, ref pps).Reason);
}
[Fact]
@@ -54,8 +55,15 @@ public sealed class MeasurementTests
Assert.True(some.TargetAllowed("192.0.2.17:443"));
Assert.True(some.TargetAllowed("2001:db8::1"));
Assert.False(some.TargetAllowed("192.0.3.1"));
- Assert.False(some.TargetAllowed("example.com"));
Assert.False(some.TargetAllowed(null));
+
+ // 域名要解析之后才知道地址:配置过白名单就先接受,每个解析出的地址再由 AddressAllowed 检查
+ Assert.True(some.TargetAllowed("example.com:443"));
+ Assert.True(some.TargetAllowed("https://example.com/health"));
+ Assert.False(Guard().TargetAllowed("example.com"));
+ Assert.True(some.AddressAllowed(IPAddress.Parse("192.0.2.9")));
+ Assert.False(some.AddressAllowed(IPAddress.Parse("10.0.0.1")));
+ Assert.False(some.AddressAllowed(IPAddress.Parse("::ffff:10.0.0.1")));
}
// ---- RowStore ----
@@ -63,7 +71,7 @@ public sealed class MeasurementTests
[Fact]
public void RowStore_RecordsOnlyItsOwnSidesColumns_AndCutsExistingRowsInKeyOrder()
{
- StreamSpec msg = Kinds.UdpEcho.FindStream("msg")!;
+ StreamSpec msg = Kinds.QuicPing.FindStream("msg")!;
var dialer = new RowStore(msg, Side.Dialer, 64);
Assert.Equal(["size", "w", "t1", "t4"], msg.ColumnsOf(Side.Dialer).Select(c => c.Name));
Assert.Equal(["t2", "t3"], msg.ColumnsOf(Side.Listener).Select(c => c.Name));
@@ -88,7 +96,7 @@ public sealed class MeasurementTests
[Fact]
public void RowStore_RingOverwritesOldRows_AndNeverReturnsAStaleOne()
{
- var store = new RowStore(Kinds.UdpEcho.FindStream("msg")!, Side.Listener, 16);
+ var store = new RowStore(Kinds.QuicPing.FindStream("msg")!, Side.Listener, 16);
store.SetRow(3, [30, 31]);
Assert.True(store.TryGet(3, 0, out long t2));
Assert.Equal(30, t2);
@@ -117,19 +125,19 @@ public sealed class MeasurementTests
msg.Add(seq, 64, 1, t1, t4);
}
- return new RawChunk(new RawChunkHeader { Kind = "udp_echo", Clock = new ChunkClock { DialerOffsetUs = 1000 } }, [msg.Build()]);
+ return new RawChunk(new RawChunkHeader { Kind = "quic_ping", Clock = new ChunkClock { DialerOffsetUs = 1000 } }, [msg.Build()]);
}
[Fact]
public void FragmentMerger_DialerDriven_AddsTheAgentColumnsByKey_AndLeavesMissingRowsEmpty()
{
- var agent = new RowStore(Kinds.UdpEcho.FindStream("msg")!, Side.Listener, 64);
+ var agent = new RowStore(Kinds.QuicPing.FindStream("msg")!, Side.Listener, 64);
agent.SetRow(0, [2000, 2050]);
agent.SetRow(2, [4000, 4040]);
var source = new Source { Stores = { ["msg"] = agent } };
RawChunk fragment = EchoFragment((0, 1000, 3000), (1, 2000, null), (2, 3000, 5000));
- Result> merged = FragmentMerger.Merge(Kinds.UdpEcho, fragment, 1, source);
+ Result> merged = FragmentMerger.Merge(Kinds.QuicPing, fragment, 1, source);
RawStream msg = Assert.Single(merged.Value);
Assert.Equal(["seq", "size", "w", "t1", "t2", "t3", "t4"], msg.Columns.Select(c => c.Name));
Assert.Equal([true, false, true], Enumerable.Range(0, 3).Select(i => msg.Find("t2")!.IsValid(i)));
@@ -141,7 +149,7 @@ public sealed class MeasurementTests
public void FragmentMerger_DialerDriven_WithoutAnyAgentRecords_KeepsTheDialerRows()
{
RawChunk fragment = EchoFragment((0, 1000, 3000), (1, 2000, null));
- RawStream msg = Assert.Single(FragmentMerger.Merge(Kinds.UdpEcho, fragment, 1, new Source()).Value);
+ RawStream msg = Assert.Single(FragmentMerger.Merge(Kinds.QuicPing, fragment, 1, new Source()).Value);
Assert.Equal(2, msg.Count);
Assert.False(msg.Find("t2")!.IsValid(0));
Assert.Equal(3000, msg.Find("t4")!.Values[0]);
@@ -152,14 +160,14 @@ public sealed class MeasurementTests
{
var bad = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq")]));
bad.Add(0);
- var fragment = new RawChunk(new RawChunkHeader { Kind = "udp_echo" }, [bad.Build()]);
- Assert.False(FragmentMerger.Merge(Kinds.UdpEcho, fragment, 1, new Source()).IsSuccess);
+ var fragment = new RawChunk(new RawChunkHeader { Kind = "quic_ping" }, [bad.Build()]);
+ Assert.False(FragmentMerger.Merge(Kinds.QuicPing, fragment, 1, new Source()).IsSuccess);
}
[Fact]
public void FragmentMerger_AgentDriven_TakesRowsFromTheAgent_AndMarksUnreceivedPacketsAsLost()
{
- var agent = new RowStore(Kinds.UdpTrain.FindStream("pkt")!, Side.Listener, 64);
+ var agent = new RowStore(Kinds.QuicStream.FindStream("pkt")!, Side.Listener, 64);
for (uint seq = 0; seq < 5; seq++)
{
agent.SetRow(seq, [1000, 100 + seq]);
@@ -167,12 +175,12 @@ public sealed class MeasurementTests
var pkt = new StreamBuilder(new StreamSpec("pkt", [Col.Seq("seq"), Col.TsDialer("t_recv")]));
pkt.Add(1, 5000).Add(1, 9999).Add(3, 6000); // sequence 1 arrives twice (the first one counts); 0, 2 and 4 are lost
- var header = new RawChunkHeader { Kind = "udp_train" };
+ var header = new RawChunkHeader { Kind = "quic_stream" };
header.Params[FragmentKeys.SeqFrom] = Json("0");
header.Params[FragmentKeys.SeqTo] = Json("5");
var source = new Source { Stores = { ["pkt"] = agent } };
- RawStream merged = Assert.Single(FragmentMerger.Merge(Kinds.UdpTrain, new RawChunk(header, [pkt.Build()]), 1, source).Value);
+ RawStream merged = Assert.Single(FragmentMerger.Merge(Kinds.QuicStream, new RawChunk(header, [pkt.Build()]), 1, source).Value);
Assert.Equal(5, merged.Count);
Assert.Equal(["seq", "size", "t_send", "t_recv"], merged.Columns.Select(c => c.Name));
RawColumn recv = merged.Find("t_recv")!;
@@ -181,7 +189,7 @@ public sealed class MeasurementTests
Assert.Equal(103, merged.Find("t_send")!.Values[3]);
// A one-way fragment that does not declare its sequence range is invalid
- Assert.False(FragmentMerger.Merge(Kinds.UdpTrain, new RawChunk(new RawChunkHeader { Kind = "udp_train" }, [pkt.Build()]), 1, source).IsSuccess);
+ Assert.False(FragmentMerger.Merge(Kinds.QuicStream, new RawChunk(new RawChunkHeader { Kind = "quic_stream" }, [pkt.Build()]), 1, source).IsSuccess);
}
[Fact]
@@ -189,9 +197,9 @@ public sealed class MeasurementTests
{
var conn = new StreamBuilder(new StreamSpec("conn", [Col.U8("outcome"), Col.Seq("attempt"), Col.TsDialer("ts")]));
conn.Add(0, 0, 100);
- var fragment = new RawChunk(new RawChunkHeader { Kind = "connect" }, [conn.Build()]);
+ var fragment = new RawChunk(new RawChunkHeader { Kind = "tcp_connect" }, [conn.Build()]);
- RawStream merged = Assert.Single(FragmentMerger.Merge(Kinds.Connect, fragment, 1, new Source()).Value);
+ RawStream merged = Assert.Single(FragmentMerger.Merge(Kinds.TcpConnect, fragment, 1, new Source()).Value);
Assert.Equal(["attempt", "ts", "tcp_us", "tls_us", "outcome"], merged.Columns.Select(c => c.Name));
Assert.False(merged.Find("tcp_us")!.IsValid(0));
}
@@ -212,7 +220,7 @@ public sealed class MeasurementTests
[Fact]
public void FragmentMerger_UnionStreams_ConcatenateBothSidesRows_AndEachAgentRowIsDeliveredOnce()
{
- StreamSpec info = Kinds.TcpEcho.FindStream("tcp_info")!;
+ StreamSpec info = Kinds.TcpPing.FindStream("tcp_info")!;
Assert.Equal(info.Columns.Count, info.ColumnsOf(Side.Listener).Count); // each side owns every column
var agent = new RowStore(info, Side.Listener, 64);
var recorder = new Source { Stores = { ["tcp_info"] = agent } };
@@ -222,7 +230,7 @@ public sealed class MeasurementTests
var msg = EchoFragment((0, 1000, 3000));
var fragment = new RawChunk(msg.Header, [msg.Streams[0], InfoRows(info, (100, 0, 1))]);
- IReadOnlyList first = FragmentMerger.Merge(Kinds.TcpEcho, fragment, 1, recorder).Value;
+ IReadOnlyList first = FragmentMerger.Merge(Kinds.TcpPing, fragment, 1, recorder).Value;
RawStream merged = first.Single(x => x.Name == "tcp_info");
Assert.Equal(3, merged.Count);
Assert.Equal([0L, 1L, 1L], merged.Find("side")!.Values);
@@ -231,20 +239,20 @@ public sealed class MeasurementTests
Assert.Equal(1, merged.Find("conn")!.Values[1]);
// The agent's rows were consumed: the next chunk carries only the dialer's own rows
- IReadOnlyList second = FragmentMerger.Merge(Kinds.TcpEcho, fragment, 1, recorder).Value;
+ IReadOnlyList second = FragmentMerger.Merge(Kinds.TcpPing, fragment, 1, recorder).Value;
Assert.Equal(1, second.Single(x => x.Name == "tcp_info").Count);
}
[Fact]
public void FragmentMerger_UnionStreams_AgentRowsAreDeliveredEvenWhenTheDialerHasNone()
{
- StreamSpec info = Kinds.TcpEcho.FindStream("tcp_info")!;
+ StreamSpec info = Kinds.TcpPing.FindStream("tcp_info")!;
var agent = new RowStore(info, Side.Listener, 64);
agent.SetRow(agent.Append(), [500, 1, 40_000, 5_000, 10, 7, 100, 1_000, 1]);
// A dialer on a platform without kernel sampling sends the echo rows only
RawChunk fragment = EchoFragment((0, 1000, 3000));
- IReadOnlyList merged = FragmentMerger.Merge(Kinds.TcpEcho, fragment, 1, new Source { Stores = { ["tcp_info"] = agent } }).Value;
+ IReadOnlyList merged = FragmentMerger.Merge(Kinds.TcpPing, fragment, 1, new Source { Stores = { ["tcp_info"] = agent } }).Value;
RawStream rows = merged.Single(x => x.Name == "tcp_info");
Assert.Equal(1, rows.Count);
Assert.Equal(7, rows.Find("retrans_total")!.Values[0]);
@@ -253,11 +261,11 @@ public sealed class MeasurementTests
[Fact]
public void FragmentMerger_LegacyTcpSamples_KeepConnectionIdentityUnknown()
{
- StreamSpec info = Kinds.TcpEcho.FindStream("tcp_info")!;
+ StreamSpec info = Kinds.TcpPing.FindStream("tcp_info")!;
RawStream sample = InfoRows(info, (100, 0, 1));
var legacy = new RawStream(sample.Name, sample.Count, sample.Columns.Where(c => c.Name != "conn").ToArray());
- var fragment = new RawChunk(new RawChunkHeader { Kind = "tcp_echo" }, [legacy]);
- RawStream merged = Assert.Single(FragmentMerger.Merge(Kinds.TcpEcho, fragment, 1, new Source()).Value);
+ var fragment = new RawChunk(new RawChunkHeader { Kind = "tcp_ping" }, [legacy]);
+ RawStream merged = Assert.Single(FragmentMerger.Merge(Kinds.TcpPing, fragment, 1, new Source()).Value);
Assert.False(merged.Find("conn")!.IsValid(0));
Assert.Equal(1, merged.Find("retrans_total")!.Values[0]);
}
@@ -282,7 +290,7 @@ public sealed class MeasurementTests
[Fact]
public void EchoRows_Tcp_WaitsForTheReplyInsteadOfCallingASeveralSecondStallALoss()
{
- var rows = new EchoRows(Kinds.TcpEcho, 64, EchoRows.TcpGraceUs, settleOnReply: true);
+ var rows = new EchoRows(Kinds.TcpPing, 64, EchoRows.TcpGraceUs, settleOnReply: true);
uint a = rows.Add(64, 0);
uint b = rows.Add(64, 100_000);
uint c = rows.Add(64, 200_000);
@@ -312,7 +320,7 @@ public sealed class MeasurementTests
[Fact]
public void EchoRows_Udp_GivesUpOnAMissingReplyAfterTwoSeconds()
{
- var rows = new EchoRows(Kinds.UdpEcho, 64, EchoRows.DatagramGraceUs, settleOnReply: false);
+ var rows = new EchoRows(Kinds.QuicPing, 64, EchoRows.DatagramGraceUs, settleOnReply: false);
rows.Add(64, 0);
rows.Add(64, 1_000_000);
RawStream cut = rows.Take(2_500_000, all: false)!;
@@ -324,7 +332,7 @@ public sealed class MeasurementTests
[Fact]
public void EchoRows_DiscardedLocalSendIsNotReportedAsNetworkLoss()
{
- var rows = new EchoRows(Kinds.UdpEcho, 64, EchoRows.DatagramGraceUs, settleOnReply: false);
+ var rows = new EchoRows(Kinds.QuicPing, 64, EchoRows.DatagramGraceUs, settleOnReply: false);
uint unsent = rows.Add(64, 0);
Assert.True(rows.Discard(unsent));
@@ -369,7 +377,7 @@ public sealed class MeasurementTests
public void BlockSpec_ConfigSignatureIgnoresTheStreamId_ButSignatureDoesNot()
{
BlockSpec Create(string json, uint stream = 1, int port = 7443) => BlockSpec.Create(
- "udp_echo",
+ "quic_ping",
JsonDocument.Parse(json).RootElement.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.Clone()),
stream,
port,
@@ -381,7 +389,7 @@ public sealed class MeasurementTests
Assert.NotEqual(baseline.Signature, Create("""{"rate_hz":10,"size":64}""", stream: 2).Signature);
Assert.Equal(baseline.ConfigSignature, Create("""{"rate_hz":10,"size":64}""", stream: 2).ConfigSignature);
Assert.NotEqual(baseline.ConfigSignature, Create("""{"rate_hz":10,"size":64}""", port: 9000).ConfigSignature);
- Assert.Null(BlockSpec.Create("udp_echo", new Dictionary { ["rate_hz"] = Json("100000") }, 1, 7443, new FragmentScope(null, null, null)));
+ Assert.Null(BlockSpec.Create("quic_ping", new Dictionary { ["rate_hz"] = Json("100000") }, 1, 7443, new FragmentScope(null, null, null)));
}
// ---- 起止时间 ----
@@ -389,13 +397,13 @@ public sealed class MeasurementTests
[Fact]
public void ChunkFinalizer_TakesStartAndDurationFromTheDataTimestamps_NotFromTheDeliveryTime()
{
- var agent = new RowStore(Kinds.UdpEcho.FindStream("msg")!, Side.Listener, 64);
+ var agent = new RowStore(Kinds.QuicPing.FindStream("msg")!, Side.Listener, 64);
agent.SetRow(0, [2000, 2050]);
RawChunk fragment = EchoFragment((0, 1000, 3000));
- IReadOnlyList streams = FragmentMerger.Merge(Kinds.UdpEcho, fragment, 1, new Source { Stores = { ["msg"] = agent } }).Value;
+ IReadOnlyList streams = FragmentMerger.Merge(Kinds.QuicPing, fragment, 1, new Source { Stores = { ["msg"] = agent } }).Value;
var anchor = new ClockAnchor(ListenerMonoUs: 1_000_000, UtcMs: 1_700_000_000_000);
- RawChunk chunk = ChunkFinalizer.Finalize(Kinds.UdpEcho, fragment, streams, new NodeId("agt_x"), anchor, new Dictionary { ["port"] = Json("7443") }, 7443, new ChunkId(Guid.NewGuid()));
+ RawChunk chunk = ChunkFinalizer.Finalize(Kinds.QuicPing, fragment, streams, new NodeId("agt_x"), anchor, new Dictionary { ["port"] = Json("7443") }, 7443, new ChunkId(Guid.NewGuid()));
// t1 = 1000 us (拨号方 clock) + offset 1000 = 2000 us on the Agent clock, which is 998 ms before the anchor
Assert.Equal(1_700_000_000_000 - 998, chunk.Header.StartUtcMs);
diff --git a/backend/shared/tests/MoShiro.Measurement.Tests/QuicSettingsTests.cs b/backend/shared/tests/MoShiro.Measurement.Tests/QuicSettingsTests.cs
new file mode 100644
index 0000000..4e6159b
--- /dev/null
+++ b/backend/shared/tests/MoShiro.Measurement.Tests/QuicSettingsTests.cs
@@ -0,0 +1,57 @@
+using System.Net;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using Fleet.Core;
+
+namespace MoShiro.Measurement.Tests;
+
+public sealed class QuicSettingsTests
+{
+ [Theory]
+ [InlineData(1100, null, false)]
+ [InlineData(null, 70_000, false)]
+ [InlineData(1400, 1300, false)]
+ [InlineData(1280, 1500, true)]
+ [InlineData(null, null, true)]
+ public void Validate_RejectsMtuOutsideTheSupportedRangeOrOutOfOrder(int? min, int? max, bool valid) =>
+ Assert.Equal(valid, new QuicSettings { MinMtu = min, MaxMtu = max }.Validate() is null);
+
+ /// 设置真的写进了 MsQuic:最大 MTU 压低后数据报上限随之变小,最小 MTU 抬高后一连上就是大包(不用等路径探测)。
+ [Theory]
+ [InlineData(1200, 1280, 1200, 1280)]
+ [InlineData(1500, 1500, 1400, 1500)]
+ public async Task Settings_ChangeTheDatagramLimitOfTheConnection(int min, int max, int atLeast, int atMost)
+ {
+ var settings = new QuicSettings { MinMtu = min, MaxMtu = max };
+ var log = new NullLog();
+ using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
+ var request = new CertificateRequest("CN=localhost", key, HashAlgorithmName.SHA256);
+ using X509Certificate2 certificate = request.CreateSelfSigned(DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch.AddYears(100));
+ await using var listener = new MsQuicListener(IPAddress.Loopback, [0], certificate, (_, _) => { }, log, null, settings);
+ listener.Start();
+ using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
+ MsQuicConnection client = await MsQuicConnection.ConnectAsync(IPAddress.Loopback, listener.BoundPorts[0], 0, key.ExportSubjectPublicKeyInfo(), (_, _) => { }, log, timeout.Token, settings);
+ try
+ {
+ while (!client.IsReady)
+ {
+ await Task.Delay(10, timeout.Token);
+ }
+
+ // 路径 MTU 探测还可能在往上走,给它一点时间;上限始终不能超过设置的最大 MTU
+ await Task.Delay(1500, timeout.Token);
+ Assert.InRange(client.MaxDatagramBytes, atLeast - 100, atMost);
+ }
+ finally
+ {
+ await client.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(3));
+ }
+ }
+
+ private sealed class NullLog : INodeLog
+ {
+ public void Write(NodeLogLevel level, string message, params (string Key, object? Value)[] fields)
+ {
+ }
+ }
+}
diff --git a/backend/shared/tests/MoShiro.Protocol.Tests/CatalogTests.cs b/backend/shared/tests/MoShiro.Protocol.Tests/CatalogTests.cs
index 22e8e51..7f9fa65 100644
--- a/backend/shared/tests/MoShiro.Protocol.Tests/CatalogTests.cs
+++ b/backend/shared/tests/MoShiro.Protocol.Tests/CatalogTests.cs
@@ -35,7 +35,7 @@ public sealed class CatalogTests
public void DefaultLineTemplates_StayWithinTheLineBudget()
{
long total = 0;
- foreach (KindSpec kind in new[] { Kinds.UdpEcho, Kinds.TcpEcho })
+ foreach (KindSpec kind in new[] { Kinds.QuicPing, Kinds.TcpPing })
{
total += kind.BudgetBitsPerSecond(ParamResolver.Resolve(kind.Params, null).Values!);
}
@@ -52,7 +52,7 @@ public sealed class CatalogTests
["size"] = Json("\"big\""),
["bogus"] = Json("1"),
};
- ParamResolution result = ParamResolver.Resolve(Kinds.UdpEcho.Params, input);
+ ParamResolution result = ParamResolver.Resolve(Kinds.QuicPing.Params, input);
Assert.False(result.IsValid);
Assert.Contains("between 1 and 200", result.Errors["rate_hz"]);
@@ -64,13 +64,13 @@ public sealed class CatalogTests
[Fact]
public void Resolve_AcceptsBoundariesAndRejectsJustOutside()
{
- Assert.True(ParamResolver.Resolve(Kinds.UdpEcho.Params, new Dictionary { ["rate_hz"] = Json("1") }).IsValid);
- Assert.True(ParamResolver.Resolve(Kinds.UdpEcho.Params, new Dictionary { ["rate_hz"] = Json("200") }).IsValid);
- Assert.False(ParamResolver.Resolve(Kinds.UdpEcho.Params, new Dictionary { ["rate_hz"] = Json("0") }).IsValid);
- Assert.False(ParamResolver.Resolve(Kinds.UdpEcho.Params, new Dictionary { ["rate_hz"] = Json("201") }).IsValid);
- Assert.False(ParamResolver.Resolve(Kinds.UdpEcho.Params, new Dictionary { ["rate_hz"] = Json("1.5") }).IsValid);
- Assert.False(ParamResolver.Resolve(Kinds.UdpTrain.Params, new Dictionary { ["direction"] = Json("\"sideways\"") }).IsValid);
- Assert.False(ParamResolver.Resolve(Kinds.TcpEcho.Params, new Dictionary { ["tls"] = Json("1") }).IsValid);
+ Assert.True(ParamResolver.Resolve(Kinds.QuicPing.Params, new Dictionary { ["rate_hz"] = Json("1") }).IsValid);
+ Assert.True(ParamResolver.Resolve(Kinds.QuicPing.Params, new Dictionary { ["rate_hz"] = Json("200") }).IsValid);
+ Assert.False(ParamResolver.Resolve(Kinds.QuicPing.Params, new Dictionary { ["rate_hz"] = Json("0") }).IsValid);
+ Assert.False(ParamResolver.Resolve(Kinds.QuicPing.Params, new Dictionary { ["rate_hz"] = Json("201") }).IsValid);
+ Assert.False(ParamResolver.Resolve(Kinds.QuicPing.Params, new Dictionary { ["rate_hz"] = Json("1.5") }).IsValid);
+ Assert.False(ParamResolver.Resolve(Kinds.QuicStream.Params, new Dictionary { ["direction"] = Json("\"sideways\"") }).IsValid);
+ Assert.False(ParamResolver.Resolve(Kinds.TcpPing.Params, new Dictionary { ["tls"] = Json("1") }).IsValid);
}
[Fact]
@@ -98,7 +98,7 @@ public sealed class CatalogTests
var set = new TaskSet
{
Version = 7,
- Tasks = [new TaskSpec { Id = new TaskId("tsk_1"), Kind = "udp_echo", Params = new Dictionary { ["rate_hz"] = Json("10") } }],
+ Tasks = [new TaskSpec { Id = new TaskId("tsk_1"), Kind = "quic_ping", Params = new Dictionary { ["rate_hz"] = Json("10") } }],
Peers = [new PeerSpec { NodeId = new Fleet.Core.NodeId("agt_1"), PublicKey = "AAAA" }],
};
string text = JsonSerializer.Serialize(set, MoShiroJsonContext.Default.TaskSet);
@@ -114,9 +114,9 @@ public sealed class CatalogTests
[Fact]
public void NodeEnrollMeta_ToleratesUnknownFields_AndKeepsDefaults()
{
- NodeEnrollMeta meta = JsonSerializer.Deserialize("{\"kinds\":[\"udp_echo\"],\"future\":1}", MoShiroJsonContext.Default.NodeEnrollMeta)!;
+ NodeEnrollMeta meta = JsonSerializer.Deserialize("{\"kinds\":[\"quic_ping\"],\"future\":1}", MoShiroJsonContext.Default.NodeEnrollMeta)!;
Assert.Equal(0, meta.TcpPort); // 没有端口表示这个节点不监听
- Assert.Equal(["udp_echo"], meta.Kinds);
+ Assert.Equal(["quic_ping"], meta.Kinds);
}
[Fact]
diff --git a/backend/shared/tests/MoShiro.Protocol.Tests/RawChunkCodecTests.cs b/backend/shared/tests/MoShiro.Protocol.Tests/RawChunkCodecTests.cs
index 54015b3..8c73f5b 100644
--- a/backend/shared/tests/MoShiro.Protocol.Tests/RawChunkCodecTests.cs
+++ b/backend/shared/tests/MoShiro.Protocol.Tests/RawChunkCodecTests.cs
@@ -28,7 +28,7 @@ public sealed class RawChunkCodecTests
private static RawChunk SampleChunk()
{
RawChunk chunk = ChunkFactory.Echo(
- s_agent, "udp_echo", 1_700_000_000_000, 6, 100,
+ s_agent, "quic_ping", 1_700_000_000_000, 6, 100,
i => new EchoPacket(LostUp: i == 2, LostDown: i == 4, 20_000 + (i * 100), 21_000));
chunk.Header.ChunkId = new ChunkId(Guid.Parse("01890000-0000-7000-8000-000000000001"));
return chunk;
@@ -41,7 +41,7 @@ public sealed class RawChunkCodecTests
RawChunk decoded = RawChunkCodec.Decode(RawChunkCodec.Encode(original)).Value;
Assert.Equal(original.Header.ChunkId, decoded.Header.ChunkId);
- Assert.Equal("udp_echo", decoded.Header.Kind);
+ Assert.Equal("quic_ping", decoded.Header.Kind);
Assert.Equal(original.Header.Clock.DialerOffsetUs, decoded.Header.Clock.DialerOffsetUs);
RawStream before = original.Find("msg")!;
RawStream after = decoded.Find("msg")!;
@@ -100,7 +100,7 @@ public sealed class RawChunkCodecTests
string columns = Convert.ToHexStringLower(body.AsSpan(4 + (int)headerLength));
string header = System.Text.Encoding.UTF8.GetString(body.AsSpan(4, (int)headerLength));
- Assert.StartsWith("{\"chunk_id\":\"01890000-0000-7000-8000-000000000001\",\"container_version\":1,\"kind\":\"udp_echo\"", header);
+ Assert.StartsWith("{\"chunk_id\":\"01890000-0000-7000-8000-000000000001\",\"container_version\":1,\"kind\":\"quic_ping\"", header);
Assert.Contains("\"streams\":[{\"name\":\"msg\",\"count\":6,\"columns\":[{\"name\":\"seq\",\"type\":\"u32\",\"enc\":\"delta\",\"nullable\":false},", header);
Assert.Equal(GoldenColumns, columns);
}
@@ -181,7 +181,7 @@ public sealed class RawChunkCodecTests
[Fact]
public void Encode_IsSmallerThanTheNaiveRepresentation()
{
- // 10 Hz 的 udp_echo 一个 10 秒块应当远小于每行 6×8 字节
+ // 10 Hz 的 quic_ping 一个 10 秒块应当远小于每行 6×8 字节
RawChunk chunk = ChunkFactory.UdpEchoClean(s_agent, 1_700_000_000_000);
Assert.True(RawChunkCodec.Encode(chunk).Length < 100 * 6 * 8 / 4);
}
diff --git a/backend/shared/tests/MoShiro.Protocol.Tests/TargetHostTests.cs b/backend/shared/tests/MoShiro.Protocol.Tests/TargetHostTests.cs
new file mode 100644
index 0000000..fdf6c93
--- /dev/null
+++ b/backend/shared/tests/MoShiro.Protocol.Tests/TargetHostTests.cs
@@ -0,0 +1,28 @@
+using MoShiro.Protocol;
+
+namespace MoShiro.Protocol.Tests;
+
+public sealed class TargetHostTests
+{
+ [Theory]
+ [InlineData("http_request", "https://Example.com/health?x=1", "https://example.com/health?x=1")]
+ [InlineData("http_request", "http://192.0.2.1:8080", "http://192.0.2.1:8080/")]
+ [InlineData("tcp_connect", "Example.com:443", "example.com:443")]
+ [InlineData("tcp_connect", "192.0.2.1", "192.0.2.1")]
+ [InlineData("tcp_connect", "[2001:db8::1]:443", "[2001:db8::1]:443")]
+ [InlineData("icmp_ping", "2001:db8::1", "2001:db8::1")]
+ public void Normalize_AcceptsTheTargetFormOfTheKind(string kind, string target, string expected) =>
+ Assert.Equal(expected, TargetHost.Normalize(kind, target));
+
+ [Theory]
+ [InlineData("http_request", "example.com")] // http 必须是 URL
+ [InlineData("http_request", "ftp://example.com/")]
+ [InlineData("http_request", "https://user:pw@example.com/")]
+ [InlineData("tcp_connect", "https://example.com/")] // 其余 Kind 不能是 URL
+ [InlineData("tcp_connect", "example.com:0")]
+ [InlineData("tcp_connect", "exa mple.com")]
+ [InlineData("tcp_connect", "-bad.example.com")]
+ [InlineData("tcp_connect", "")]
+ public void Normalize_RejectsTheWrongFormForTheKind(string kind, string target) =>
+ Assert.Null(TargetHost.Normalize(kind, target));
+}