shared: add independent IPv4 and IPv6 collection [CI]
ci/woodpecker/pr/verify Pipeline was successful
ci/woodpecker/pr/web-build Pipeline was successful

This commit is contained in:
justme committed 2026-10-07 11:44:01 +08:00
1 parent c974a7dddb
commit 0eda0600cc
60 files changed
+699 -137

No files matched your search

@@ -30,7 +30,7 @@ public sealed class HostSamplerTests
var time = new Microsoft.Extensions.Time.Testing.FakeTimeProvider();
string stat = Stat1;
string dev = Dev1;
var sampler = new HostSampler(time, path => path switch { "/proc/stat" => stat, "/proc/net/dev" => dev, "/proc/uptime" => "100.0 1.0", _ => null }, () => (1000, 250), procRoot: "/proc", sysRoot: "/sys");
var sampler = new HostSampler(time, path => path.Replace('\\', '/') switch { "/proc/stat" => stat, "/proc/net/dev" => dev, "/proc/uptime" => "100.0 1.0", _ => null }, () => (1000, 250), procRoot: "/proc", sysRoot: "/sys");
HostSnapshot first = sampler.Sample();
Assert.Null(first.CpuPercent); // 第一次没有基线
Assert.Null(first.NetRxBps);
@@ -53,6 +53,7 @@ public sealed class HostSamplerTests
var reads = new List<string>();
var sampler = new HostSampler(TimeProvider.System, path =>
{
path = path.Replace('\\', '/'); // 测试里的 /proc 格式统一用斜杠,兼容 Windows 的 Path.Combine
reads.Add(path);
return path switch
{
@@ -0,0 +1,165 @@
using System.Net;
using System.Net.Sockets;
using System.Text.Json.Nodes;
using Microsoft.Extensions.DependencyInjection;
using MoShiro.Backend.Application;
using MoShiro.Protocol;
using MoShiro.TestSupport;
namespace MoShiro.Backend.Tests;
/// <summary>真实双栈回环:同一身份的两种会话、原始块、查询与测试结果必须各自独立。</summary>
[Collection("probe-e2e")]
public sealed class Ipv6Tests
{
private readonly BackendFixture _backend;
public Ipv6Tests(BackendFixture backend)
{
_backend = backend;
}
[Fact]
public async Task Queries_DifferentFamilyLoss_RemainsIndependentInHotPersistedAndCompareViews()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
long start = ((TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds() / 60_000) - 4) * 60_000;
for (int k = 0; k < 6; k++)
{
foreach (string version in new[] { IpVersions.V4, IpVersions.V6 })
{
RawChunk chunk = ChunkFactory.Echo(agent.NodeId, "udp_echo", start + k * 10_000, 100, 100,
i => new EchoPacket(false, version == IpVersions.V6 && i % 2 == 0, 20_000, 20_000));
chunk.Header.ProbeId = new ProbeId(_backend.DefaultProbeId);
chunk.Header.IpVersion = version;
Assert.True(await agent.SendChunkAsync(chunk));
}
}
string range = "from=" + DateTimeOffset.FromUnixTimeMilliseconds(start).UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ss.fffZ")
+ "&to=" + DateTimeOffset.FromUnixTimeMilliseconds(start + 60_000).UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ss.fffZ");
string v4 = _backend.Line(agent.NodeId);
string v6 = v4 + "~ipv6";
foreach (string step in new[] { "1s", "10s", "1m" })
{
foreach ((string line, double loss) in new[] { (v4, 0.0), (v6, 50.0) })
{
JsonNode series = null!;
await BackendFixture.EventuallyAsync(async () =>
{
series = await _backend.GetJsonAsync(_backend.Admin, $"/api/public/lines/{line}/series?{range}&step={step}&preset=udp");
return series["summary"]!["samples"]!["udp_echo"]?.GetValue<long>() == 600;
});
Assert.Equal(loss, series["summary"]!["metrics"]!["loss.total"]!.GetValue<double>(), 6);
}
}
JsonNode compare = await _backend.GetJsonAsync(_backend.Admin, $"/api/public/compare?lines={v4},{v6}&metric=loss.total&step=1m&{range}");
Assert.Equal(0, compare["summary"]![v4]!.GetValue<double>());
Assert.Equal(50, compare["summary"]![v6]!.GetValue<double>());
await using SseClient live = await SseClient.ConnectAsync(_backend.Admin, $"/api/public/live?topics=series:{v6}:10s:udp");
SseEvent snapshot = await live.WaitForAsync(e => e.Name == "series" && e.Data?["line_id"]?.GetValue<string>() == v6);
Assert.Equal("ipv6", snapshot.Data!["ip_version"]!.GetValue<string>());
}
[Fact]
public async Task Ipv6Configuration_RejectsWrongFamily_AndDoesNotArrangeIpv6ForALegacyAgent()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
using HttpResponseMessage invalid = await _backend.Admin.PatchAsync("/api/admin/agents/" + agent.NodeId.Value,
BackendFixture.Json(new JsonObject { ["ipv6_host"] = "127.0.0.1" }));
Assert.Equal(HttpStatusCode.BadRequest, invalid.StatusCode);
Assert.Contains("ipv6_host", await invalid.Content.ReadAsStringAsync(), StringComparison.Ordinal);
using HttpResponseMessage valid = await _backend.Admin.PatchAsync("/api/admin/agents/" + agent.NodeId.Value,
BackendFixture.Json(new JsonObject { ["ipv6_host"] = "::1" }));
Assert.True(valid.IsSuccessStatusCode);
ProbeAgentList endpoints = await _backend.Services.GetRequiredService<AgentService>().ProbeAgentsAsync(_backend.DefaultProbeId, default);
Assert.Null(Assert.Single(endpoints.Agents, a => a.AgentId == agent.NodeId).Ipv6Host);
JsonNode identity = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value);
Assert.Equal("::1", identity["ipv6_host"]!.GetValue<string>());
Assert.False(identity["ipv6_supported"]!.GetValue<bool>());
}
[Fact]
public async Task DualStack_CollectsBothFamilies_DisablingIpv6KeepsIpv4Running()
{
Assert.SkipUnless(Socket.OSSupportsIPv6, "IPv6 sockets are unavailable on this host");
await using AgentHarness agent = await AgentHarness.StartAsync(_backend, c => c.Listen.Bind = "::");
await agent.OverrideLineTasksAsync(rateHz: 30);
using HttpResponseMessage configured = await _backend.Admin.PatchAsync("/api/admin/agents/" + agent.Id,
BackendFixture.Json(new JsonObject { ["public_host"] = "127.0.0.1", ["ipv6_host"] = "::1" }));
Assert.True(configured.IsSuccessStatusCode, await configured.Content.ReadAsStringAsync());
await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend);
await BackendFixture.EventuallyAsync(() => Task.FromResult(probe.App.IsConnected(agent.Id) && probe.App.IsConnected(agent.Id, IpVersions.V6)));
await BackendFixture.EventuallyAsync(async () =>
{
JsonNode raw = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/raw/chunks?agent_id=" + agent.Id);
return new[] { "line:" + probe.ProbeId, "line:" + probe.ProbeId + ":ipv6" }.All(series =>
new[] { "udp_echo", "tcp_echo" }.All(kind => raw["items"]!.AsArray().Any(c => c!["series"]!.GetValue<string>() == series && c["kind"]!.GetValue<string>() == kind)));
}, TimeSpan.FromSeconds(45));
string v4 = agent.Id + "~" + probe.ProbeId;
string v6 = v4 + "~ipv6";
foreach (string step in new[] { "1s", "10s" })
{
foreach ((string line, string family) in new[] { (v4, IpVersions.V4), (v6, IpVersions.V6) })
{
JsonNode series = await _backend.GetJsonAsync(_backend.Admin, $"/api/public/lines/{line}/series?range=1m&step={step}&preset=udp,tcp");
Assert.Equal(family, series["ip_version"]!.GetValue<string>());
Assert.True(series["summary"]!["samples"]!["udp_echo"]!.GetValue<long>() > 0, series.ToJsonString());
Assert.True(series["summary"]!["samples"]!["tcp_echo"]!.GetValue<long>() > 0, series.ToJsonString());
}
}
JsonNode overview = await _backend.GetJsonAsync(_backend.Admin, "/api/public/overview?range=1m&ip_version=ipv6");
Assert.Contains(overview["lines"]!.AsArray(), l => l!["id"]!.GetValue<string>() == v6);
Assert.All(overview["lines"]!.AsArray(), l => Assert.Equal("ipv6", l!["ip_version"]!.GetValue<string>()));
var ipv4Connection = probe.App.Connection(agent.Id);
long previous = agent.App.Counters.Fragments;
using HttpResponseMessage disabled = await _backend.Admin.PatchAsync("/api/admin/agents/" + agent.Id, BackendFixture.Json(new JsonObject { ["ipv6_host"] = "" }));
Assert.True(disabled.IsSuccessStatusCode);
await BackendFixture.EventuallyAsync(() => Task.FromResult(probe.App.Connection(agent.Id, IpVersions.V6) is null));
Assert.Same(ipv4Connection, probe.App.Connection(agent.Id));
Assert.True(probe.App.IsConnected(agent.Id));
await BackendFixture.EventuallyAsync(() => Task.FromResult(agent.App.Counters.Fragments > previous), TimeSpan.FromSeconds(20));
Assert.Equal(0, agent.App.Counters.FragmentsDiscarded);
}
[Theory]
[InlineData("hy2_ladder")]
[InlineData("reality_session")]
public async Task Ipv6Test_UsesIpv6Session_AndDoesNotAppearInIpv4History(string plan)
{
Assert.SkipUnless(Socket.OSSupportsIPv6, "IPv6 sockets are unavailable on this host");
await using AgentHarness agent = await AgentHarness.StartAsync(_backend, c => c.Listen.Bind = "::");
await agent.OverrideLineTasksAsync();
using HttpResponseMessage configured = await _backend.Admin.PatchAsync("/api/admin/agents/" + agent.Id,
BackendFixture.Json(new JsonObject { ["ipv6_host"] = "::1" }));
Assert.True(configured.IsSuccessStatusCode);
await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend);
string v4 = agent.Id + "~" + probe.ProbeId;
string v6 = v4 + "~ipv6";
await BackendFixture.EventuallyAsync(async () =>
(await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.Id))["probes"]!.AsArray()
.Any(p => p!["probe_id"]!.GetValue<string>() == probe.ProbeId && p["ip_version"]!.GetValue<string>() == "ipv6"));
var parameters = plan == "hy2_ladder" ? new JsonObject { ["mbps"] = 1, ["duration_s"] = 1 } : new JsonObject { ["duration_s"] = 1 };
using HttpResponseMessage accepted = await _backend.Admin.PostAsync($"/api/admin/lines/{v6}/tests", BackendFixture.Json(new JsonObject { ["plan"] = plan, ["params"] = parameters }));
Assert.Equal(HttpStatusCode.Accepted, accepted.StatusCode);
string id = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue<string>();
JsonNode result = null!;
await BackendFixture.EventuallyAsync(async () =>
{
result = await _backend.GetJsonAsync(_backend.Admin, "/api/public/tests/" + id);
return result["status"]!.GetValue<string>() is "failed" or "aborted" || result["result"] is not null;
}, TimeSpan.FromSeconds(100));
Assert.Equal("succeeded", result["status"]!.GetValue<string>());
Assert.Equal("ipv6", result["ip_version"]!.GetValue<string>());
Assert.NotNull(result["result"]);
JsonNode history = await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/lines/{v4}/tests");
Assert.Empty(history["items"]!.AsArray());
JsonNode v6History = await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/lines/{v6}/tests");
Assert.Contains(v6History["items"]!.AsArray(), t => t!["id"]!.GetValue<string>() == id);
}
}