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; /// 真实双栈回环:同一身份的两种会话、原始块、查询与测试结果必须各自独立。 [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() == 600; }); Assert.Equal(loss, series["summary"]!["metrics"]!["loss.total"]!.GetValue(), 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()); Assert.Equal(50, compare["summary"]![v6]!.GetValue()); 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() == v6); Assert.Equal("ipv6", snapshot.Data!["ip_version"]!.GetValue()); } [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().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()); Assert.False(identity["ipv6_supported"]!.GetValue()); } [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() == series && c["kind"]!.GetValue() == 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()); Assert.True(series["summary"]!["samples"]!["udp_echo"]!.GetValue() > 0, series.ToJsonString()); Assert.True(series["summary"]!["samples"]!["tcp_echo"]!.GetValue() > 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() == v6); Assert.All(overview["lines"]!.AsArray(), l => Assert.Equal("ipv6", l!["ip_version"]!.GetValue())); 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() == probe.ProbeId && p["ip_version"]!.GetValue() == "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(); JsonNode result = null!; await BackendFixture.EventuallyAsync(async () => { result = await _backend.GetJsonAsync(_backend.Admin, "/api/public/tests/" + id); return result["status"]!.GetValue() is "failed" or "aborted" || result["result"] is not null; }, TimeSpan.FromSeconds(100)); Assert.Equal("succeeded", result["status"]!.GetValue()); Assert.Equal("ipv6", result["ip_version"]!.GetValue()); 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() == id); } }