Files
MoShiro/backend/tests/MoShiro.Backend.Tests/Ipv6Tests.cs
T
justme ad5efc62ff agent: unify listening and dialing with directed mutual measurements
Replace Probe with Agent across node runtime, Fleet contracts, backend state, API and storage. Report incoming sessions and outgoing link health through each Agent uplink, and retain directed link controls and retry.

Update protocol fixtures and end-to-end coverage for the unified model; accept requested cancellation during the Fleet timeout test cleanup.
2026-10-21 11:55:36 +08:00

169 lines
10 KiB
C#

using System.Net;
using System.Net.Sockets;
using System.Text.Json.Nodes;
using Fleet.Core;
using Microsoft.Extensions.DependencyInjection;
using MoShiro.Backend.Application;
using MoShiro.Protocol;
using MoShiro.TestSupport;
namespace MoShiro.Backend.Tests;
/// <summary>真实双栈回环:同一身份的两种会话、原始块、查询与测试结果必须各自独立。</summary>
// 慢的端到端测试(真实时间的阶梯、要等测量块出来):CI 里单独一批跑(Category=Slow),不拦着出镜像,见 ci/verify-backend.sh
[Trait("Category", "Slow")]
public sealed class Ipv6Tests : IClassFixture<BackendFixture>
{
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.DialerId = new NodeId(_backend.DefaultDialerId);
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);
AgentRecord dialer = (await _backend.Services.GetRequiredService<IAgentStore>().GetAsync(new NodeId(_backend.DefaultDialerId), default))!;
DialTargets endpoints = await _backend.Services.GetRequiredService<AgentService>().DialTargetsAsync(dialer, default);
Assert.Null(Assert.Single(endpoints.Targets, t => t.NodeId == 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 DialerHarness dialer = await DialerHarness.StartAsync(_backend);
await BackendFixture.EventuallyAsync(() => Task.FromResult(dialer.App.Dialer.IsConnected(agent.Id) && dialer.App.Dialer.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:" + dialer.DialerId, "line:" + dialer.DialerId + ":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 + "~" + dialer.DialerId;
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 = dialer.App.Dialer.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(dialer.App.Dialer.Connection(agent.Id, IpVersions.V6) is null));
Assert.Same(ipv4Connection, dialer.App.Dialer.Connection(agent.Id));
Assert.True(dialer.App.Dialer.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 DialerHarness dialer = await DialerHarness.StartAsync(_backend);
string v4 = agent.Id + "~" + dialer.DialerId;
string v6 = v4 + "~ipv6";
await BackendFixture.EventuallyAsync(async () =>
(await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.Id))["dialers"]!.AsArray()
.Any(p => p!["dialer_id"]!.GetValue<string>() == dialer.DialerId && 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);
}
}