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.
This commit is contained in:
1 parent
d5ebf29e57
commit
ad5efc62ff
206 files changed
+4202
-4964
No files matched your search
@@ -1,3 +1,4 @@
|
||||
using Fleet.Core;
|
||||
using System.Net;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MoShiro.Backend.Application;
|
||||
@@ -91,12 +92,13 @@ public sealed class AddressTests : IClassFixture<BackendFixture>
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProbeAgentList_CarriesTheCertificatePublicKeyTheAgentReported_ForPinningAndKnocking()
|
||||
public async Task DialTargets_CarryTheCertificatePublicKeyTheNodeEnrolledWith_ForPinningAndKnocking()
|
||||
{
|
||||
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
|
||||
AgentService service = _backend.Services.GetRequiredService<AgentService>();
|
||||
ProbeAgentList list = await service.ProbeAgentsAsync(_backend.DefaultProbeId, default);
|
||||
AgentEndpoint endpoint = Assert.Single(list.Agents, a => a.AgentId == agent.NodeId);
|
||||
AgentRecord dialer = (await _backend.Services.GetRequiredService<IAgentStore>().GetAsync(new NodeId(_backend.DefaultDialerId), default))!;
|
||||
DialTargets list = await service.DialTargetsAsync(dialer, default);
|
||||
DialTarget endpoint = Assert.Single(list.Targets, t => t.NodeId == agent.NodeId);
|
||||
Assert.Equal(Convert.ToBase64String(agent.TlsPublicKey), endpoint.PublicKey);
|
||||
}
|
||||
}
|
||||
@@ -17,21 +17,21 @@ public sealed class AgentDataPlaneTests : IClassFixture<BackendFixture>
|
||||
_backend = backend;
|
||||
}
|
||||
|
||||
private async Task<(AgentHarness Agent, ProbeSimulator Probe, LineTask Udp)> LineAsync()
|
||||
private async Task<(AgentHarness Agent, DialerSimulator Dialer, LineTask Udp)> LineAsync()
|
||||
{
|
||||
AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
||||
await agent.OverrideLineTasksAsync(rateHz: 30);
|
||||
ProbeSimulator probe = await agent.ConnectProbeAsync();
|
||||
LineTaskSet set = await probe.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo" && t.Port == agent.App.QuicPorts[0]));
|
||||
return (agent, probe, set.Tasks.Single(t => t.Kind == "udp_echo"));
|
||||
DialerSimulator dialer = await agent.ConnectDialerAsync();
|
||||
LineTaskSet set = await dialer.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo" && t.Port == agent.App.QuicPorts[0]));
|
||||
return (agent, dialer, set.Tasks.Single(t => t.Kind == "udp_echo"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TcpDataPlane_ClosesConnectionsWithoutAValidFirstFrame()
|
||||
{
|
||||
(AgentHarness agent, ProbeSimulator probe, _) = await LineAsync();
|
||||
(AgentHarness agent, DialerSimulator dialer, _) = await LineAsync();
|
||||
await using (agent)
|
||||
await using (probe)
|
||||
await using (dialer)
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, agent.App.TcpPorts[0]);
|
||||
@@ -45,21 +45,21 @@ public sealed class AgentDataPlaneTests : IClassFixture<BackendFixture>
|
||||
[Fact]
|
||||
public async Task DataPlane_DropsForgedReplayedAndGarbagePackets_WithoutAnswering()
|
||||
{
|
||||
(AgentHarness agent, ProbeSimulator probe, LineTask udp) = await LineAsync();
|
||||
(AgentHarness agent, DialerSimulator dialer, LineTask udp) = await LineAsync();
|
||||
await using (agent)
|
||||
await using (probe)
|
||||
await using (dialer)
|
||||
{
|
||||
long before = agent.App.Counters.DroppedInvalid;
|
||||
await probe.SendRawUdpAsync([1, 2, 3]);
|
||||
await probe.SendRawUdpAsync(new byte[64]);
|
||||
await probe.SendForgedEchoAsync(udp, 1);
|
||||
await dialer.SendRawUdpAsync([1, 2, 3]);
|
||||
await dialer.SendRawUdpAsync(new byte[64]);
|
||||
await dialer.SendForgedEchoAsync(udp, 1);
|
||||
await BackendFixture.EventuallyAsync(() => Task.FromResult(agent.App.Counters.DroppedInvalid - before >= 3), because: "garbage and forged packets are counted");
|
||||
|
||||
// 重放:同一个序号发两次,只有第一次被接受
|
||||
await probe.SendEchoAsync(udp, 500);
|
||||
await dialer.SendEchoAsync(udp, 500);
|
||||
await BackendFixture.EventuallyAsync(() => Task.FromResult(agent.App.Counters.PacketsOut >= 1));
|
||||
long dropped = agent.App.Counters.DroppedInvalid;
|
||||
await probe.SendEchoAsync(udp, 500);
|
||||
await dialer.SendEchoAsync(udp, 500);
|
||||
await BackendFixture.EventuallyAsync(() => Task.FromResult(agent.App.Counters.DroppedInvalid > dropped), because: "a replayed packet is dropped");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Fleet.Core;
|
||||
using System.Text.Json.Nodes;
|
||||
using MoShiro.Measurement;
|
||||
using MoShiro.Protocol;
|
||||
@@ -5,7 +6,7 @@ using MoShiro.TestSupport;
|
||||
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <summary>真实的后端 + 真实的 Agent(进程内)+ 假 Probe:覆盖 Agent 与 Backend 的链路、Probe 与 Agent 的点对点会话和数据面。</summary>
|
||||
/// <summary>真实的后端 + 真实的 Agent(进程内)+ 拨号侧模拟器:覆盖 Agent 与 Backend 的链路、Dialer 与 Agent 的点对点会话和数据面。</summary>
|
||||
// 使用真实 Agent/QUIC/TCP 会话的端到端验证放在慢测集合,避免和大量 API/单元测试争抢本机套接字。
|
||||
[Trait("Category", "Slow")]
|
||||
public sealed class AgentEndToEndTests : IClassFixture<BackendFixture>
|
||||
@@ -17,30 +18,17 @@ public sealed class AgentEndToEndTests : IClassFixture<BackendFixture>
|
||||
_backend = backend;
|
||||
}
|
||||
|
||||
private async Task<(AgentHarness Agent, ProbeSimulator Probe, LineTask Udp)> LineAsync()
|
||||
private async Task<(AgentHarness Agent, DialerSimulator Dialer, LineTask Udp)> LineAsync()
|
||||
{
|
||||
AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
||||
await agent.OverrideLineTasksAsync(rateHz: 30);
|
||||
ProbeSimulator probe = await agent.ConnectProbeAsync();
|
||||
LineTaskSet set = await probe.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo" && t.Port == agent.App.QuicPorts[0]));
|
||||
return (agent, probe, set.Tasks.Single(t => t.Kind == "udp_echo"));
|
||||
DialerSimulator dialer = await agent.ConnectDialerAsync();
|
||||
LineTaskSet set = await dialer.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo" && t.Port == agent.App.QuicPorts[0]));
|
||||
return (agent, dialer, set.Tasks.Single(t => t.Kind == "udp_echo"));
|
||||
}
|
||||
|
||||
private async Task<JsonNode> AgentViewAsync(AgentHarness agent) => await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.Id);
|
||||
|
||||
[Fact]
|
||||
public async Task Agent_ConnectsAndReportsItsRealPorts_AndTheProbeSessionIsVisibleToTheBackend()
|
||||
{
|
||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
||||
JsonNode view = await AgentViewAsync(agent);
|
||||
Assert.Equal("active", view["status"]!.GetValue<string>());
|
||||
Assert.False(view["probe_connected"]!.GetValue<bool>());
|
||||
|
||||
await using ProbeSimulator probe = await agent.ConnectProbeAsync();
|
||||
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probe_connected"]!.GetValue<bool>());
|
||||
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["runtime_status"]!.GetValue<string>() == "running");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Agent_OrderlyShutdownIsReportedAsStoppingRatherThanOffline()
|
||||
{
|
||||
@@ -57,11 +45,11 @@ public sealed class AgentEndToEndTests : IClassFixture<BackendFixture>
|
||||
[Fact]
|
||||
public async Task UdpEcho_EndToEnd_FragmentsAreMergedWithAgentTimestamps_AndStoredByTheBackend()
|
||||
{
|
||||
(AgentHarness agent, ProbeSimulator probe, LineTask udp) = await LineAsync();
|
||||
(AgentHarness agent, DialerSimulator dialer, LineTask udp) = await LineAsync();
|
||||
await using (agent)
|
||||
await using (probe)
|
||||
await using (dialer)
|
||||
{
|
||||
(int sent, int received) = await probe.RunUdpEchoAsync(udp, 40, TimeSpan.FromMilliseconds(40));
|
||||
(int sent, int received) = await dialer.RunUdpEchoAsync(udp, 40, TimeSpan.FromMilliseconds(40));
|
||||
Assert.Equal(40, sent);
|
||||
Assert.Equal(40, received);
|
||||
|
||||
@@ -84,12 +72,12 @@ public sealed class AgentEndToEndTests : IClassFixture<BackendFixture>
|
||||
[InlineData(true)]
|
||||
public async Task TcpEcho_EndToEnd_AuthenticatedFirstFrame_PlainOrTls(bool tls)
|
||||
{
|
||||
(AgentHarness agent, ProbeSimulator probe, _) = await LineAsync();
|
||||
(AgentHarness agent, DialerSimulator dialer, _) = await LineAsync();
|
||||
await using (agent)
|
||||
await using (probe)
|
||||
await using (dialer)
|
||||
{
|
||||
LineTask tcp = (await probe.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "tcp_echo" && t.Port == agent.App.TcpPorts[0]))).Tasks.Single(t => t.Kind == "tcp_echo");
|
||||
(int sent, int received) = await probe.RunTcpEchoAsync(tcp, agent.App.TcpPorts[0], 15, TimeSpan.FromMilliseconds(60), tls);
|
||||
LineTask tcp = (await dialer.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "tcp_echo" && t.Port == agent.App.TcpPorts[0]))).Tasks.Single(t => t.Kind == "tcp_echo");
|
||||
(int sent, int received) = await dialer.RunTcpEchoAsync(tcp, agent.App.TcpPorts[0], 15, TimeSpan.FromMilliseconds(60), tls);
|
||||
Assert.Equal(15, sent);
|
||||
Assert.Equal(15, received);
|
||||
await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.Id}"))["items"]!.AsArray().Any(c => c!["kind"]!.GetValue<string>() == "tcp_echo"));
|
||||
@@ -97,9 +85,9 @@ public sealed class AgentEndToEndTests : IClassFixture<BackendFixture>
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ARestartedProbe_NeverInheritsTheAgentRecordsOfItsPreviousSession()
|
||||
public async Task ARestartedDialer_NeverInheritsTheAgentRecordsOfItsPreviousSession()
|
||||
{
|
||||
(AgentHarness agent, ProbeSimulator first, LineTask udp) = await LineAsync();
|
||||
(AgentHarness agent, DialerSimulator first, LineTask udp) = await LineAsync();
|
||||
await using (agent)
|
||||
{
|
||||
await first.RunUdpEchoAsync(udp, 20, TimeSpan.FromMilliseconds(40));
|
||||
@@ -107,8 +95,8 @@ public sealed class AgentEndToEndTests : IClassFixture<BackendFixture>
|
||||
Assert.True(records.Contains(5), "the agent recorded t2/t3 for the first session's packets");
|
||||
await first.DisposeAsync();
|
||||
|
||||
// The probe process restarts: a new session with its own streams; sequence numbers start from 0 again
|
||||
await using ProbeSimulator second = first.Restarted();
|
||||
// The dialer process restarts: a new session with its own streams; sequence numbers start from 0 again
|
||||
await using DialerSimulator second = first.Restarted();
|
||||
await second.ConnectAsync("127.0.0.1", agent.App.ControlPort, agent.TlsPublicKey, agent.App.QuicPorts[0], agent.NodeId);
|
||||
LineTaskSet set = await second.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo" && t.Port == agent.App.QuicPorts[0]));
|
||||
LineTask udp2 = set.Tasks.Single(t => t.Kind == "udp_echo");
|
||||
@@ -125,26 +113,26 @@ public sealed class AgentEndToEndTests : IClassFixture<BackendFixture>
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeveralProbes_AreReportedTogether_CanBeDroppedOneByOne_AndARevokedOneIsKickedOut()
|
||||
public async Task SeveralDialers_AreReportedTogether_CanBeDroppedOneByOne_AndADisabledOneIsKickedOut()
|
||||
{
|
||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
||||
await agent.OverrideLineTasksAsync(rateHz: 30);
|
||||
await using ProbeSimulator a = await agent.ConnectProbeAsync();
|
||||
await using ProbeSimulator b = await agent.ConnectProbeAsync();
|
||||
await using DialerSimulator a = await agent.ConnectDialerAsync();
|
||||
await using DialerSimulator b = await agent.ConnectDialerAsync();
|
||||
|
||||
// The agent reports both sessions with version and address; the probe view shows the agent it is connected to
|
||||
// The agent reports both sessions with version and address; the dialer view shows the agent it is connected to
|
||||
JsonNode view = null!;
|
||||
await BackendFixture.EventuallyAsync(
|
||||
async () =>
|
||||
{
|
||||
view = await AgentViewAsync(agent);
|
||||
return view["probes"]!.AsArray().Count == 2;
|
||||
return view["dialers"]!.AsArray().Count == 2;
|
||||
},
|
||||
because: "the agent reports every connected probe");
|
||||
Assert.All(view["probes"]!.AsArray(), p => Assert.Equal("0.0.0-sim", p!["version"]!.GetValue<string>()));
|
||||
Assert.Equal("127.0.0.1", view["probes"]![0]!["remote"]!.GetValue<string>());
|
||||
JsonNode probeView = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/probes/" + a.ProbeId);
|
||||
Assert.Equal(agent.Id, probeView["connections"]![0]!["agent_id"]!.GetValue<string>());
|
||||
because: "the agent reports every connected dialer");
|
||||
Assert.All(view["dialers"]!.AsArray(), p => Assert.Equal("0.0.0-sim", p!["version"]!.GetValue<string>()));
|
||||
Assert.Equal("127.0.0.1", view["dialers"]![0]!["remote"]!.GetValue<string>());
|
||||
JsonNode dialerView = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + a.DialerId);
|
||||
Assert.Equal(agent.Id, dialerView["outgoing"]![0]!["listener_id"]!.GetValue<string>());
|
||||
|
||||
// Both get their own task set (own streams)
|
||||
LineTaskSet setA = await a.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo"));
|
||||
@@ -152,56 +140,44 @@ public sealed class AgentEndToEndTests : IClassFixture<BackendFixture>
|
||||
Assert.NotEqual(setA.Tasks.Single(t => t.Kind == "udp_echo").StreamId, setB.Tasks.Single(t => t.Kind == "udp_echo").StreamId);
|
||||
|
||||
// Drop one: the other stays
|
||||
using HttpResponseMessage dropped = await _backend.Admin.PostAsync($"/api/admin/agents/{agent.Id}/probes/{a.ProbeId}/disconnect", null);
|
||||
using HttpResponseMessage dropped = await _backend.Admin.PostAsync($"/api/admin/agents/{agent.Id}/dialers/{a.DialerId}/disconnect", null);
|
||||
Assert.Equal(System.Net.HttpStatusCode.NoContent, dropped.StatusCode);
|
||||
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probes"]!.AsArray().Count == 1, because: "only the dropped probe leaves");
|
||||
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["dialers"]!.AsArray().Count == 1, because: "only the dropped dialer leaves");
|
||||
Assert.True(b.IsConnected);
|
||||
using HttpResponseMessage again = await _backend.Admin.PostAsync($"/api/admin/agents/{agent.Id}/probes/{a.ProbeId}/disconnect", null);
|
||||
using HttpResponseMessage again = await _backend.Admin.PostAsync($"/api/admin/agents/{agent.Id}/dialers/{a.DialerId}/disconnect", null);
|
||||
Assert.Equal(System.Net.HttpStatusCode.Conflict, again.StatusCode);
|
||||
|
||||
// Revoke the remaining one: the agent closes its session
|
||||
using HttpResponseMessage revoked = await _backend.Admin.PostAsync($"/api/admin/probes/{b.ProbeId}/revoke", null);
|
||||
// Disable the remaining Agent: the listener closes its session
|
||||
using HttpResponseMessage revoked = await _backend.Admin.PostAsync($"/api/admin/agents/{b.DialerId}/disable", null);
|
||||
revoked.EnsureSuccessStatusCode();
|
||||
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probes"]!.AsArray().Count == 0, because: "a revoked probe is disconnected");
|
||||
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["dialers"]!.AsArray().Count == 0, because: "a disabled Agent is disconnected");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PeerHandshake_RejectsAProbeWhoseKeyTheBackendNeverIssued()
|
||||
public async Task PeerHandshake_RejectsADialerWhoseKeyTheBackendNeverIssued()
|
||||
{
|
||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
||||
await agent.CreateProbeAsync();
|
||||
await using var stranger = new ProbeSimulator("prb_stranger", Fleet.Identity.NodeIdentity.Generate());
|
||||
await agent.CreateDialerAsync();
|
||||
await using var stranger = new DialerSimulator("agt_stranger", Fleet.Identity.NodeIdentity.Generate());
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => stranger.ConnectAsync("127.0.0.1", agent.App.ControlPort, agent.TlsPublicKey, agent.App.QuicPorts[0], agent.NodeId));
|
||||
|
||||
// 证书公钥不对(中间人)也不会连上
|
||||
await using ProbeSimulator probe = await agent.CreateProbeAsync();
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => probe.ConnectAsync("127.0.0.1", agent.App.ControlPort, Fleet.Identity.NodeIdentity.Generate().PublicKeySpki, agent.App.QuicPorts[0], agent.NodeId));
|
||||
await using DialerSimulator dialer = await agent.CreateDialerAsync();
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => dialer.ConnectAsync("127.0.0.1", agent.App.ControlPort, Fleet.Identity.NodeIdentity.Generate().PublicKeySpki, agent.App.QuicPorts[0], agent.NodeId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProbeDisconnect_IsReportedToTheBackend()
|
||||
{
|
||||
(AgentHarness agent, ProbeSimulator probe, _) = await LineAsync();
|
||||
await using (agent)
|
||||
{
|
||||
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probe_connected"]!.GetValue<bool>());
|
||||
await probe.DisposeAsync();
|
||||
await BackendFixture.EventuallyAsync(async () => !(await AgentViewAsync(agent))["probe_connected"]!.GetValue<bool>());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_IsRefusedWhenNoProbeIsConnected_AndWhenAPhaseExceedsTheLocalLimits()
|
||||
public async Task Test_IsRefusedWhenNoDialerIsConnected_AndWhenAPhaseExceedsTheLocalLimits()
|
||||
{
|
||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend, config => config.Limits.MaxTotalMbps = 20);
|
||||
await agent.OverrideLineTasksAsync();
|
||||
|
||||
var phase = new TestPhase { Kind = "udp_train", DurationSeconds = 1, Params = { ["port"] = System.Text.Json.JsonSerializer.SerializeToElement(agent.App.QuicPorts[0]) } };
|
||||
var run = new TestRunParams { TestId = new TestId("tst_x"), ProbeId = new ProbeId("prb_nobody"), Plan = "x", Phases = [phase] };
|
||||
Assert.Equal("probe_offline", agent.App.Tests.Start(run).Error.Code.Value);
|
||||
var run = new TestRunParams { TestId = new TestId("tst_x"), DialerId = new NodeId("agt_nobody"), Plan = "x", Phases = [phase] };
|
||||
Assert.Equal("dialer_offline", agent.App.Tests.Start(run).Error.Code.Value);
|
||||
|
||||
await using ProbeSimulator probe = await agent.ConnectProbeAsync();
|
||||
run.ProbeId = new ProbeId(probe.ProbeId);
|
||||
await using DialerSimulator dialer = await agent.ConnectDialerAsync();
|
||||
run.DialerId = new NodeId(dialer.DialerId);
|
||||
phase.Params["mbps"] = System.Text.Json.JsonSerializer.SerializeToElement(50);
|
||||
Assert.Equal("budget_exceeded", agent.App.Tests.Start(run).Error.Code.Value);
|
||||
}
|
||||
|
||||
@@ -64,31 +64,37 @@ public sealed class AgentHarness : IAsyncDisposable
|
||||
LoadedNodeIdentity identity = LoadedNodeIdentity.Load(directory);
|
||||
var app = new AgentApp(config, identity, directory, TimeProvider.System, NullNodeLog.Instance);
|
||||
var harness = new AgentHarness(backend, directory, identity, app);
|
||||
await app.Ready.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
await Task.WhenAny(app.Ready, harness._run).WaitAsync(TimeSpan.FromSeconds(10));
|
||||
if (harness._run.IsCompleted)
|
||||
{
|
||||
await harness._run;
|
||||
}
|
||||
|
||||
await app.Ready;
|
||||
await BackendFixture.EventuallyAsync(() => Task.FromResult(app.Link.IsConnected), because: "the agent should connect to the backend");
|
||||
return harness;
|
||||
}
|
||||
|
||||
/// <summary>注册一个 Probe(密钥由它自己生成),并构造连到这个 Agent 的假 Probe:对等面用它的对等面密钥。</summary>
|
||||
public async Task<ProbeSimulator> CreateProbeAsync()
|
||||
/// <summary>注册一个拨号侧 Agent(密钥由它自己生成),并构造连到这个 Agent 的拨号侧模拟器:对等面用它的对等面密钥。</summary>
|
||||
public async Task<DialerSimulator> CreateDialerAsync()
|
||||
{
|
||||
using EnrolledProbe probe = await Backend.CreateProbeAsync();
|
||||
// 假 Probe 要在注册目录被释放之后继续用这把密钥:导出再导入一份
|
||||
NodeIdentity key = NodeIdentity.FromPkcs8(probe.Identity.PeerIdentity.ExportPkcs8());
|
||||
using EnrolledDialer dialer = await Backend.CreateDialerAsync();
|
||||
// 拨号侧模拟器 要在注册目录被释放之后继续用这把密钥:导出再导入一份
|
||||
NodeIdentity key = NodeIdentity.FromPkcs8(dialer.Identity.PeerIdentity.ExportPkcs8());
|
||||
string publicKey = Convert.ToBase64String(key.PublicKeySpki);
|
||||
|
||||
// 等 Agent 的期望状态里有了这把公钥(否则握手会被拒绝)
|
||||
await BackendFixture.EventuallyAsync(
|
||||
() => Task.FromResult(App.IsPeerKnown(probe.ProbeId, publicKey)),
|
||||
because: "the agent should learn the probe's peer key");
|
||||
return new ProbeSimulator(probe.ProbeId, key);
|
||||
() => Task.FromResult(App.IsPeerKnown(dialer.DialerId, publicKey)),
|
||||
because: "the agent should learn the dialer's peer key");
|
||||
return new DialerSimulator(dialer.DialerId, key);
|
||||
}
|
||||
|
||||
public async Task<ProbeSimulator> ConnectProbeAsync()
|
||||
public async Task<DialerSimulator> ConnectDialerAsync()
|
||||
{
|
||||
ProbeSimulator probe = await CreateProbeAsync();
|
||||
await probe.ConnectAsync("127.0.0.1", App.ControlPort, TlsPublicKey, App.QuicPorts[0], NodeId);
|
||||
return probe;
|
||||
DialerSimulator dialer = await CreateDialerAsync();
|
||||
await dialer.ConnectAsync("127.0.0.1", App.ControlPort, TlsPublicKey, App.QuicPorts[0], NodeId);
|
||||
return dialer;
|
||||
}
|
||||
|
||||
/// <summary>为这个 Agent 创建覆盖任务,让线路任务使用它实际监听的端口(默认模板里是 7443/7444,不在本地允许的端口里)。</summary>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using System.Net;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
[Trait("Category", "Slow")]
|
||||
public sealed class AgentMeshTests : IClassFixture<BackendFixture>
|
||||
{
|
||||
private readonly BackendFixture _backend;
|
||||
|
||||
public AgentMeshTests(BackendFixture backend) => _backend = backend;
|
||||
|
||||
[Fact]
|
||||
public async Task ListeningAgents_MeasureBothDirections_AndReportThroughTheirBackendLinks()
|
||||
{
|
||||
await using AgentHarness a = await AgentHarness.StartAsync(_backend);
|
||||
await using AgentHarness b = await AgentHarness.StartAsync(_backend);
|
||||
await a.OverrideLineTasksAsync(rateHz: 30);
|
||||
await b.OverrideLineTasksAsync(rateHz: 30);
|
||||
await BackendFixture.EventuallyAsync(
|
||||
() => Task.FromResult(a.App.Dialer.IsConnected(b.Id) && b.App.Dialer.IsConnected(a.Id)),
|
||||
TimeSpan.FromSeconds(30), "both listening agents should establish outgoing measurement sessions");
|
||||
|
||||
using HttpResponseMessage retry = await _backend.Admin.PostAsync($"/api/admin/links/{a.Id}/{b.Id}/retry", null);
|
||||
Assert.Equal(HttpStatusCode.Accepted, retry.StatusCode);
|
||||
JsonNode result = JsonNode.Parse(await retry.Content.ReadAsStringAsync())!;
|
||||
Assert.True(result["queued"]!.GetValue<bool>());
|
||||
|
||||
await BackendFixture.EventuallyAsync(async () =>
|
||||
{
|
||||
JsonNode matrix = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/links");
|
||||
return matrix["items"]!.AsArray().Count(i =>
|
||||
((i!["dialer_id"]!.GetValue<string>() == a.Id && i["listener_id"]!.GetValue<string>() == b.Id)
|
||||
|| (i["dialer_id"]!.GetValue<string>() == b.Id && i["listener_id"]!.GetValue<string>() == a.Id))
|
||||
&& i["connected"]!.GetValue<bool>()) == 2;
|
||||
}, TimeSpan.FromSeconds(30), "each agent heartbeat should report its outgoing connection");
|
||||
|
||||
await BackendFixture.EventuallyAsync(async () =>
|
||||
{
|
||||
JsonNode left = await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={a.Id}");
|
||||
JsonNode right = await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={b.Id}");
|
||||
return left["items"]!.AsArray().Any(c => c!["kind"]!.GetValue<string>() == "udp_echo" && c["series"]!.GetValue<string>() == "line:" + b.Id)
|
||||
&& right["items"]!.AsArray().Any(c => c!["kind"]!.GetValue<string>() == "udp_echo" && c["series"]!.GetValue<string>() == "line:" + a.Id);
|
||||
}, TimeSpan.FromSeconds(40), "both directed lines should persist measured packet data");
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ using MoShiro.TestSupport;
|
||||
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <summary>Exercises the complete Backend → Agent → Probe desired task update with an isolated Backend fixture.</summary>
|
||||
/// <summary>Exercises the complete Backend → Agent → Dialer desired task update with an isolated Backend fixture.</summary>
|
||||
[Trait("Category", "Slow")]
|
||||
public sealed class AgentTaskSetEndToEndTests : IClassFixture<BackendFixture>
|
||||
{
|
||||
@@ -17,12 +17,12 @@ public sealed class AgentTaskSetEndToEndTests : IClassFixture<BackendFixture>
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LineTaskSet_FollowsTheDesiredState_AndIsPushedToTheProbe()
|
||||
public async Task LineTaskSet_FollowsTheDesiredState_AndIsPushedToTheDialer()
|
||||
{
|
||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
||||
await agent.OverrideLineTasksAsync(rateHz: 30);
|
||||
await using ProbeSimulator probe = await agent.ConnectProbeAsync();
|
||||
LineTask udp = (await probe.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo" && t.Port == agent.App.QuicPorts[0])))
|
||||
await using DialerSimulator dialer = await agent.ConnectDialerAsync();
|
||||
LineTask udp = (await dialer.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo" && t.Port == agent.App.QuicPorts[0])))
|
||||
.Tasks.Single(t => t.Kind == "udp_echo");
|
||||
|
||||
JsonNode tasks = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/tasks");
|
||||
@@ -30,7 +30,7 @@ public sealed class AgentTaskSetEndToEndTests : IClassFixture<BackendFixture>
|
||||
using HttpResponseMessage disabled = await _backend.Admin.PatchAsync("/api/admin/tasks/" + mine["id"]!.GetValue<string>(), BackendFixture.Json(new JsonObject { ["enabled"] = false }, "application/merge-patch+json"));
|
||||
disabled.EnsureSuccessStatusCode();
|
||||
|
||||
LineTaskSet next = await probe.WaitForTasksAsync(s => s.Tasks.All(t => t.Kind != "udp_echo") && s.Version > 0);
|
||||
LineTaskSet next = await dialer.WaitForTasksAsync(s => s.Tasks.All(t => t.Kind != "udp_echo") && s.Version > 0);
|
||||
Assert.DoesNotContain(next.Tasks, t => t.Id == udp.Id);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using Fleet.Core;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json.Nodes;
|
||||
using MoShiro.Protocol;
|
||||
@@ -37,15 +38,15 @@ public sealed class AotSmokeTests
|
||||
|
||||
using HttpResponseMessage settingsResponse = await http.PatchAsync("/api/admin/settings", BackendFixture.Json(new JsonObject { ["site.public"] = true }, "application/merge-patch+json"));
|
||||
settingsResponse.EnsureSuccessStatusCode();
|
||||
// Probe:probe 角色的加入令牌 → 节点自己生成密钥并注册
|
||||
using HttpResponseMessage probeJoin = await http.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "probe", ["max_uses"] = 1 }));
|
||||
probeJoin.EnsureSuccessStatusCode();
|
||||
string probeToken = JsonNode.Parse(await probeJoin.Content.ReadAsStringAsync())!["plaintext"]!.GetValue<string>();
|
||||
using var probeHttp = new HttpClient { Timeout = TimeSpan.FromSeconds(15) };
|
||||
Fleet.Core.Result<Fleet.Enrollment.Client.EnrollmentResult> probeEnrolled = await Fleet.Enrollment.Client.EnrollmentClient.EnrollAsync(
|
||||
probeHttp, baseAddress, probeToken, "aot-probe-" + Guid.NewGuid().ToString("N")[..6], Path.Combine(Path.GetTempPath(), "moshiro-aot-probe-" + Guid.NewGuid().ToString("N")), "0.0.0", null, TimeProvider.System, default);
|
||||
Assert.True(probeEnrolled.IsSuccess, probeEnrolled.IsSuccess ? "" : probeEnrolled.Error.ToString());
|
||||
string probeId = probeEnrolled.Value.NodeId;
|
||||
// 拨号侧 Agent:统一加入令牌 → 节点自己生成密钥并注册
|
||||
using HttpResponseMessage dialerJoin = await http.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "agent", ["max_uses"] = 1 }));
|
||||
dialerJoin.EnsureSuccessStatusCode();
|
||||
string dialerToken = JsonNode.Parse(await dialerJoin.Content.ReadAsStringAsync())!["plaintext"]!.GetValue<string>();
|
||||
using var dialerHttp = new HttpClient { Timeout = TimeSpan.FromSeconds(15) };
|
||||
Fleet.Core.Result<Fleet.Enrollment.Client.EnrollmentResult> dialerEnrolled = await Fleet.Enrollment.Client.EnrollmentClient.EnrollAsync(
|
||||
dialerHttp, baseAddress, dialerToken, "aot-dialer-" + Guid.NewGuid().ToString("N")[..6], Path.Combine(Path.GetTempPath(), "moshiro-aot-dialer-" + Guid.NewGuid().ToString("N")), "0.0.0", null, TimeProvider.System, default);
|
||||
Assert.True(dialerEnrolled.IsSuccess, dialerEnrolled.IsSuccess ? "" : dialerEnrolled.Error.ToString());
|
||||
string dialerId = dialerEnrolled.Value.NodeId;
|
||||
|
||||
// Agent:加入令牌 → 注册(ECDSA 持有证明)→ WebSocket 签名认证 → 期望状态
|
||||
using HttpResponseMessage joinResponse = await http.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["max_uses"] = 1 }));
|
||||
@@ -53,8 +54,8 @@ public sealed class AotSmokeTests
|
||||
string joinToken = JsonNode.Parse(await joinResponse.Content.ReadAsStringAsync())!["plaintext"]!.GetValue<string>();
|
||||
using var anonymous = new HttpClient { BaseAddress = baseAddress, Timeout = TimeSpan.FromSeconds(15) };
|
||||
await using AgentSimulator agent = await AgentSimulator.EnrollAsync(anonymous, joinToken, "aot-" + Guid.NewGuid().ToString("N")[..6]);
|
||||
agent.ProbeForChunks = new ProbeId(probeId);
|
||||
string lineId = $"{agent.NodeId.Value}~{probeId}";
|
||||
agent.DialerForChunks = new NodeId(dialerId);
|
||||
string lineId = $"{agent.NodeId.Value}~{dialerId}";
|
||||
await agent.ConnectAsync(baseAddress);
|
||||
TaskSet tasks = await agent.WaitForTaskSetAsync(s => s.Tasks.Count >= 2);
|
||||
Assert.Contains(tasks.Tasks, t => t.Kind == "udp_echo");
|
||||
@@ -98,8 +99,8 @@ public sealed class AotSmokeTests
|
||||
await using SseClient sse = await SseClient.ConnectAsync(http, $"/api/public/live?topics=nodes,series:{lineId}:1s:udp");
|
||||
await sse.WaitForAsync(e => e.Name == "series");
|
||||
|
||||
// 按需测试:Probe 事件 → 下发 → 状态
|
||||
await agent.SendEventAsync(AgentEventKinds.ProbeConnected, probeId);
|
||||
// 按需测试:Dialer 事件 → 下发 → 状态
|
||||
await agent.SendEventAsync(AgentEventKinds.DialerConnected, dialerId);
|
||||
JsonNode started = null!;
|
||||
long begun = TimeProvider.System.GetTimestamp();
|
||||
while (TimeProvider.System.GetElapsedTime(begun) < TimeSpan.FromSeconds(10))
|
||||
|
||||
@@ -99,16 +99,12 @@ public sealed class ArchitectureTests
|
||||
Assert.DoesNotContain(agent.ProjectReferences, r => r.StartsWith("MoShiro.Backend", StringComparison.Ordinal) || r.StartsWith("Fleet.Server", StringComparison.Ordinal) || r.StartsWith("Fleet.Enrollment", StringComparison.Ordinal));
|
||||
Assert.Equal(["Fleet.Hosting", "Fleet.Link", "MoShiro.Measurement"], agent.ProjectReferences.Order().ToArray());
|
||||
|
||||
// Probe 同理,并且不认识 Backend 和 Agent
|
||||
Project probe = Get("MoShiro.Probe");
|
||||
Assert.Equal(["Fleet.Hosting", "Fleet.Link", "MoShiro.Measurement"], probe.ProjectReferences.Order().ToArray());
|
||||
|
||||
// 两个节点的共享库只依赖协议
|
||||
// 测量库在 TCP 数据面上做预握手敲门,所以直接引用 Fleet.Identity(节点侧库)
|
||||
Assert.Equal(["Fleet.Identity", "MoShiro.Protocol"], Get("MoShiro.Measurement").ProjectReferences.Order().ToArray());
|
||||
|
||||
// 节点一侧的库没有运行时依赖包,并使用节点禁用列表(不能用 HttpClient、Process、写文件;唯一的写文件豁免在 ConfigStore)
|
||||
foreach (string name in new[] { "Fleet.Link", "Fleet.Hosting", "MoShiro.Measurement", "MoShiro.Agent", "MoShiro.Probe" })
|
||||
foreach (string name in new[] { "Fleet.Link", "Fleet.Hosting", "MoShiro.Measurement", "MoShiro.Agent" })
|
||||
{
|
||||
Project project = Get(name);
|
||||
Assert.All(project.Packages, package => Assert.Contains(package, s_allowedAnalyzers));
|
||||
@@ -126,9 +122,9 @@ public sealed class ArchitectureTests
|
||||
[Fact]
|
||||
public void CollectionLayer_KnowsNoBusinessProtocolsOrPlans_OnlyMechanismsAndRawColumns()
|
||||
{
|
||||
// 采集层(协议、积木、Agent、Probe、Fleet)只认识机制(传输、节奏、方向、时间戳)和原始列;
|
||||
// 采集层(协议、积木、Agent、Fleet)只认识机制(传输、节奏、方向、时间戳)和原始列;
|
||||
// REALITY、Hysteria2、Minecraft 这些业务概念以及由原语编排成的测试方案只存在于 Backend。
|
||||
string[] collectionRoots = ["shared/MoShiro.Protocol", "shared/MoShiro.Measurement", "agent/src", "probe/src", "fleet/src"];
|
||||
string[] collectionRoots = ["shared/MoShiro.Protocol", "shared/MoShiro.Measurement", "agent/src", "fleet/src"];
|
||||
string[] forbidden = ["reality", "hysteria", "hy2", "minecraft", "brutal", "PlanSpec", "PhaseSpec"];
|
||||
foreach (string root in collectionRoots)
|
||||
{
|
||||
|
||||
@@ -34,7 +34,7 @@ public sealed class AuditTests : IClassFixture<BackendFixture>
|
||||
public async Task WriteActions_AreRecorded_WithWhoAndTheDetail()
|
||||
{
|
||||
string note = "audit-" + Guid.NewGuid().ToString("N")[..8];
|
||||
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "probe", ["note"] = note }));
|
||||
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "agent", ["note"] = note }));
|
||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
|
||||
JsonNode? row = await FindAsync("limit=100", x => x["detail"]?.GetValue<string>().Contains(note, StringComparison.Ordinal) == true);
|
||||
|
||||
@@ -61,34 +61,34 @@ public class BackendFixture : IAsyncLifetime
|
||||
|
||||
public IServiceProvider Services => App.Services;
|
||||
|
||||
/// <summary>夹具创建的默认 Probe:合成块(<see cref="ChunkFactory"/>)和单线路的查询都用它。</summary>
|
||||
public string DefaultProbeId { get; private set; } = "";
|
||||
/// <summary>夹具创建的默认拨号侧 Agent:合成块(<see cref="ChunkFactory"/>)和单线路的查询都用它。</summary>
|
||||
public string DefaultDialerId { get; private set; } = "";
|
||||
|
||||
/// <summary>默认 Probe 与这个 Agent 组成的线路 id(<c>agent~probe</c>)。</summary>
|
||||
public string Line(Fleet.Core.NodeId agent) => $"{agent.Value}~{DefaultProbeId}";
|
||||
/// <summary>默认拨号侧 Agent 与这个 Agent 组成的线路 id(<c>agent~dialer</c>)。</summary>
|
||||
public string Line(Fleet.Core.NodeId agent) => $"{agent.Value}~{DefaultDialerId}";
|
||||
|
||||
public string Line(string agentId) => $"{agentId}~{DefaultProbeId}";
|
||||
public string Line(string agentId) => $"{agentId}~{DefaultDialerId}";
|
||||
|
||||
/// <summary>
|
||||
/// 注册一个新的 Probe:和真实部署一样,先在管理端创建 <c>probe</c> 角色的加入令牌,再由节点自己生成密钥、用 <c>fleet-enroll</c> 同样的流程注册。
|
||||
/// 注册一个新的 Agent:和真实部署一样,先在管理端创建 <c>agent</c> 加入令牌,再由节点自己生成密钥、用 <c>fleet-enroll</c> 同样的流程注册。
|
||||
/// 返回的身份目录里有两把密钥:连面板的 <c>Identity</c> 和对等面的 <c>PeerIdentity</c>。调用方负责释放。
|
||||
/// </summary>
|
||||
public async Task<EnrolledProbe> CreateProbeAsync(string? name = null)
|
||||
public async Task<EnrolledDialer> CreateDialerAsync(string? name = null)
|
||||
{
|
||||
string token = await CreateJoinTokenAsync(role: "probe");
|
||||
string directory = Path.Combine(Path.GetTempPath(), "moshiro-probe-id-" + Guid.NewGuid().ToString("N"));
|
||||
string token = await CreateJoinTokenAsync(role: "agent");
|
||||
string directory = Path.Combine(Path.GetTempPath(), "moshiro-dialer-id-" + Guid.NewGuid().ToString("N"));
|
||||
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) };
|
||||
Fleet.Core.Result<Fleet.Enrollment.Client.EnrollmentResult> enrolled = await Fleet.Enrollment.Client.EnrollmentClient.EnrollAsync(
|
||||
http, BaseAddress, token, name ?? UniqueName("probe"), directory, "0.0.0-test", null, TimeProvider.System, default);
|
||||
http, BaseAddress, token, name ?? UniqueName("dialer"), directory, "0.0.0-test", null, TimeProvider.System, default);
|
||||
Assert.True(enrolled.IsSuccess, enrolled.IsSuccess ? "" : enrolled.Error.ToString());
|
||||
return new EnrolledProbe(enrolled.Value.NodeId, directory, Fleet.Hosting.LoadedNodeIdentity.Load(directory));
|
||||
return new EnrolledDialer(enrolled.Value.NodeId, directory, Fleet.Hosting.LoadedNodeIdentity.Load(directory));
|
||||
}
|
||||
|
||||
/// <summary>注册一个 Probe,只要它的 id(密钥用完即释放)。</summary>
|
||||
public async Task<string> CreateProbeIdAsync(string? name = null)
|
||||
/// <summary>注册一个拨号侧 Agent,只要它的 id(密钥用完即释放)。</summary>
|
||||
public async Task<string> CreateDialerIdAsync(string? name = null)
|
||||
{
|
||||
using EnrolledProbe probe = await CreateProbeAsync(name);
|
||||
return probe.ProbeId;
|
||||
using EnrolledDialer dialer = await CreateDialerAsync(name);
|
||||
return dialer.DialerId;
|
||||
}
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
@@ -97,9 +97,9 @@ public class BackendFixture : IAsyncLifetime
|
||||
await StartAsync();
|
||||
_adminToken = await CreateAdminTokenAsync();
|
||||
CreateClients();
|
||||
using (EnrolledProbe probe = await CreateProbeAsync("default"))
|
||||
using (EnrolledDialer dialer = await CreateDialerAsync("default"))
|
||||
{
|
||||
DefaultProbeId = probe.ProbeId;
|
||||
DefaultDialerId = dialer.DialerId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ public class BackendFixture : IAsyncLifetime
|
||||
public async Task<AgentSimulator> ReconnectAsync(AgentSimulator previous)
|
||||
{
|
||||
AgentSimulator agent = AgentSimulator.Restore(previous.ExportedKey, previous.NodeId);
|
||||
agent.ProbeForChunks = new MoShiro.Protocol.ProbeId(DefaultProbeId);
|
||||
agent.DialerForChunks = new Fleet.Core.NodeId(DefaultDialerId);
|
||||
_agents.Add(agent);
|
||||
await agent.ConnectAsync(BaseAddress);
|
||||
return agent;
|
||||
@@ -207,7 +207,7 @@ public class BackendFixture : IAsyncLifetime
|
||||
{
|
||||
string token = await CreateJoinTokenAsync(autoApprove);
|
||||
AgentSimulator agent = await AgentSimulator.EnrollAsync(Anonymous, token, name ?? UniqueName("agent"), kinds);
|
||||
agent.ProbeForChunks = new MoShiro.Protocol.ProbeId(DefaultProbeId);
|
||||
agent.DialerForChunks = new Fleet.Core.NodeId(DefaultDialerId);
|
||||
_agents.Add(agent);
|
||||
await agent.ConnectAsync(BaseAddress, hello, kinds);
|
||||
return agent;
|
||||
@@ -340,8 +340,8 @@ public sealed class StrictLoginFixture : BackendFixture
|
||||
protected override int LoginAttemptsPerMinute => 10;
|
||||
}
|
||||
|
||||
/// <summary>注册好的 Probe:身份目录(节点自己生成的两把密钥)和加载出来的身份。</summary>
|
||||
public sealed record EnrolledProbe(string ProbeId, string Directory, Fleet.Hosting.LoadedNodeIdentity Identity) : IDisposable
|
||||
/// <summary>注册好的拨号侧 Agent:身份目录(节点自己生成的两把密钥)和加载出来的身份。</summary>
|
||||
public sealed record EnrolledDialer(string DialerId, string Directory, Fleet.Hosting.LoadedNodeIdentity Identity) : IDisposable
|
||||
{
|
||||
public void Dispose() => Identity.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <summary>真实的后端 + 真实的 Agent + 真实的拨号侧 Agent(都在进程内,回环网络)。</summary>
|
||||
// 慢的端到端测试(真实时间的阶梯、要等测量块出来):CI 里单独一批跑(Category=Slow),不拦着出镜像,见 ci/verify-backend.sh
|
||||
[Trait("Category", "Slow")]
|
||||
public sealed class DialerEndToEndTests : IClassFixture<BackendFixture>
|
||||
{
|
||||
private readonly BackendFixture _backend;
|
||||
|
||||
public DialerEndToEndTests(BackendFixture backend)
|
||||
{
|
||||
_backend = backend;
|
||||
}
|
||||
|
||||
private async Task<JsonNode> AgentViewAsync(AgentHarness agent) => await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.Id);
|
||||
|
||||
private async Task<int> ChunkCountAsync(AgentHarness agent, string kind) =>
|
||||
(await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.Id}"))["items"]!.AsArray().Count(c => c!["kind"]!.GetValue<string>() == kind);
|
||||
|
||||
[Fact]
|
||||
public async Task Dialer_FollowsTheAgentListThePanelKnows_AndSavesItForTheNextStart()
|
||||
{
|
||||
await using AgentHarness first = await AgentHarness.StartAsync(_backend);
|
||||
await using DialerHarness dialer = await DialerHarness.StartAsync(_backend);
|
||||
await BackendFixture.EventuallyAsync(() => Task.FromResult(dialer.App.Dialer.IsConnected(first.Id)));
|
||||
|
||||
await using AgentHarness second = await AgentHarness.StartAsync(_backend);
|
||||
await BackendFixture.EventuallyAsync(() => Task.FromResult(dialer.App.Dialer.IsConnected(second.Id)), TimeSpan.FromSeconds(30), "a new Agent should arrive through dial_targets state sync: " + string.Join(";", dialer.App.Dialer.Directory.Targets.Select(a => $"{a.NodeId}@{a.Host}:{a.TcpPort}")) + " v" + dialer.App.Dialer.Directory.Version + " want " + second.Id);
|
||||
Assert.Contains(dialer.App.Dialer.Directory.Targets, a => a.NodeId == second.Id);
|
||||
Assert.True(File.Exists(Path.Combine(dialer.Directory, "dial_targets.json")));
|
||||
|
||||
// 停用一个 Agent:Dialer 断开它
|
||||
using HttpResponseMessage disabled = await _backend.Admin.PostAsync($"/api/admin/agents/{second.Id}/disable", BackendFixture.Json(new JsonObject()));
|
||||
Assert.True(disabled.IsSuccessStatusCode, await disabled.Content.ReadAsStringAsync());
|
||||
await BackendFixture.EventuallyAsync(() => Task.FromResult(!dialer.App.Dialer.IsConnected(second.Id)), TimeSpan.FromSeconds(30));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Hy2Ladder_WithTheRealDialer_Succeeds_AndEveryFragmentIsStored()
|
||||
{
|
||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
||||
await agent.OverrideLineTasksAsync();
|
||||
await using DialerHarness dialer = await DialerHarness.StartAsync(_backend);
|
||||
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["dialer_connected"]!.GetValue<bool>());
|
||||
|
||||
using HttpResponseMessage accepted = await _backend.Admin.PostAsync($"/api/admin/lines/{agent.Id}~{dialer.DialerId}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "hy2_ladder", ["params"] = new JsonObject { ["size"] = 1000, ["duration_s"] = 5 } }));
|
||||
Assert.True(accepted.StatusCode == System.Net.HttpStatusCode.Accepted, await accepted.Content.ReadAsStringAsync());
|
||||
string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue<string>();
|
||||
|
||||
JsonNode test = null!;
|
||||
await BackendFixture.EventuallyAsync(
|
||||
async () =>
|
||||
{
|
||||
test = await _backend.GetJsonAsync(_backend.Admin, "/api/public/tests/" + testId);
|
||||
return test["status"]!.GetValue<string>() is "succeeded" or "failed" or "aborted";
|
||||
},
|
||||
TimeSpan.FromSeconds(90));
|
||||
Assert.True(test["status"]!.GetValue<string>() == "succeeded", test.ToJsonString());
|
||||
|
||||
await BackendFixture.EventuallyAsync(async () => await ChunkCountAsync(agent, "udp_train") >= 5, because: "the 100 Mbit/s phase (5 s of 1000 B packets, over 50000 records) is split into two fragments");
|
||||
Assert.Equal(0, agent.App.Counters.FragmentsDiscarded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RealitySession_WithTheRealDialer_RunsTcpBulkAndStoresBytesAndKernelRows()
|
||||
{
|
||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
||||
await agent.OverrideLineTasksAsync();
|
||||
await using DialerHarness dialer = await DialerHarness.StartAsync(_backend);
|
||||
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["dialer_connected"]!.GetValue<bool>());
|
||||
|
||||
using HttpResponseMessage accepted = await _backend.Admin.PostAsync($"/api/admin/lines/{agent.Id}~{dialer.DialerId}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "reality_session" }));
|
||||
Assert.True(accepted.StatusCode == System.Net.HttpStatusCode.Accepted, await accepted.Content.ReadAsStringAsync());
|
||||
string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue<string>();
|
||||
|
||||
JsonNode test = null!;
|
||||
await BackendFixture.EventuallyAsync(
|
||||
async () =>
|
||||
{
|
||||
test = await _backend.GetJsonAsync(_backend.Admin, "/api/public/tests/" + testId);
|
||||
return test["status"]!.GetValue<string>() is "succeeded" or "failed" or "aborted";
|
||||
},
|
||||
TimeSpan.FromSeconds(150));
|
||||
Assert.True(test["status"]!.GetValue<string>() == "succeeded", test.ToJsonString());
|
||||
await BackendFixture.EventuallyAsync(async () => await ChunkCountAsync(agent, "tcp_bulk") >= 1, because: "the bulk phase stores its fragments");
|
||||
Assert.Equal(0, agent.App.Counters.FragmentsDiscarded);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using Fleet.Core;
|
||||
using Fleet.Hosting;
|
||||
using MoShiro.Agent;
|
||||
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <summary>进程内的真实拨号节点:用加入令牌注册出来的身份启动 <see cref="AgentApp"/>,监听关闭(<c>listen.enabled = false</c>),只向 Backend 下发的目标拨出。</summary>
|
||||
public sealed class DialerHarness : IAsyncDisposable
|
||||
{
|
||||
private readonly CancellationTokenSource _stop = new();
|
||||
private readonly string _directory;
|
||||
private readonly Task _run;
|
||||
private readonly EnrolledDialer _enrolled;
|
||||
|
||||
private DialerHarness(string directory, AgentApp app, EnrolledDialer enrolled)
|
||||
{
|
||||
_directory = directory;
|
||||
_enrolled = enrolled;
|
||||
App = app;
|
||||
_run = Task.Run(() => app.RunAsync(_stop.Token), CancellationToken.None);
|
||||
}
|
||||
|
||||
public AgentApp App { get; }
|
||||
|
||||
public string Directory => _directory;
|
||||
|
||||
public string DialerId { get; init; } = "";
|
||||
|
||||
public static async Task<DialerHarness> StartAsync(BackendFixture backend, Action<AgentConfig>? configure = null)
|
||||
{
|
||||
EnrolledDialer enrolled = await backend.CreateDialerAsync();
|
||||
var config = new AgentConfig { Fleet = { Server = backend.BaseAddress.ToString(), IdentityDir = enrolled.Directory }, Listen = { Enabled = false } };
|
||||
configure?.Invoke(config);
|
||||
var app = new AgentApp(config, enrolled.Identity, enrolled.Directory, TimeProvider.System, NullNodeLog.Instance);
|
||||
return new DialerHarness(enrolled.Directory, app, enrolled) { DialerId = enrolled.DialerId };
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _stop.CancelAsync();
|
||||
try
|
||||
{
|
||||
await _run.WaitAsync(TimeSpan.FromSeconds(15));
|
||||
}
|
||||
catch (Exception ex) when (ex is OperationCanceledException or TimeoutException)
|
||||
{
|
||||
// 停止中
|
||||
}
|
||||
|
||||
await App.DisposeAsync();
|
||||
_enrolled.Dispose();
|
||||
_stop.Dispose();
|
||||
try
|
||||
{
|
||||
System.IO.Directory.Delete(_directory, recursive: true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// 临时目录清理失败不影响测试结果
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,7 +95,7 @@ public sealed class EnrollmentAndTaskSetTests : IClassFixture<BackendFixture>
|
||||
[Fact]
|
||||
public async Task Enrollment_RejectsATlsPublicKeyThatDoesNotMatchItsFingerprint_AndAnUnknownRole()
|
||||
{
|
||||
string token = await _backend.CreateJoinTokenAsync(role: "probe");
|
||||
string token = await _backend.CreateJoinTokenAsync(role: "agent");
|
||||
using var mine = Fleet.Identity.NodeIdentity.Generate();
|
||||
using var tls = Fleet.Identity.NodeIdentity.Generate();
|
||||
using var impostor = Fleet.Identity.NodeIdentity.Generate();
|
||||
@@ -352,55 +352,41 @@ public sealed class EnrollmentAndTaskSetTests : IClassFixture<BackendFixture>
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProbeLifecycle_EnrollRenameRevokeDelete_IsReflectedInEveryAgentsPeerList()
|
||||
public async Task DialOnlyNode_EnrollDisableDelete_IsReflectedInTheListenersPeerList()
|
||||
{
|
||||
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
|
||||
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount);
|
||||
await using AgentSimulator listener = await _backend.ConnectAgentAsync();
|
||||
await listener.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount);
|
||||
|
||||
// 创建 Probe 就是创建一个 probe 角色的加入令牌;密钥由节点自己生成,Backend 从不接触私钥
|
||||
string name = _backend.UniqueName("home");
|
||||
using EnrolledProbe probe = await _backend.CreateProbeAsync(name);
|
||||
string id = probe.ProbeId;
|
||||
Assert.StartsWith(ProbeId.Prefix, id, StringComparison.Ordinal);
|
||||
string peerKey = Convert.ToBase64String(probe.Identity.PeerIdentity.PublicKeySpki);
|
||||
string backendKey = Convert.ToBase64String(probe.Identity.Identity.PublicKeySpki);
|
||||
Assert.NotEqual(backendKey, peerKey); // 连面板的和连 Agent 的是两把独立的密钥
|
||||
using EnrolledDialer dialer = await _backend.CreateDialerAsync(name);
|
||||
string id = dialer.DialerId;
|
||||
Assert.StartsWith(AgentIds.Prefix, id, StringComparison.Ordinal);
|
||||
string peerKey = Convert.ToBase64String(dialer.Identity.PeerIdentity.PublicKeySpki);
|
||||
string backendKey = Convert.ToBase64String(dialer.Identity.Identity.PublicKeySpki);
|
||||
Assert.NotEqual(backendKey, peerKey); // 连面板的和连监听方的是两把独立的密钥
|
||||
|
||||
// Agent 的名单里放的是对等面的公钥,不是连面板的那把
|
||||
TaskSet withProbe = await agent.WaitForTaskSetAsync(s => s.Peers.Any(p => p.ProbeId.Value == id && p.PublicKey == peerKey));
|
||||
Assert.DoesNotContain(withProbe.Peers, p => p.PublicKey == backendKey);
|
||||
// 监听方的名单里放的是对等面的公钥,不是连面板的那把
|
||||
TaskSet withDialer = await listener.WaitForTaskSetAsync(s => s.Peers.Any(p => p.NodeId.Value == id && p.PublicKey == peerKey));
|
||||
Assert.DoesNotContain(withDialer.Peers, p => p.PublicKey == backendKey);
|
||||
|
||||
// 令牌带角色;面板能列出这个 Probe;重名的注册被拒绝
|
||||
JsonNode tokens = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/join-tokens");
|
||||
Assert.Contains(tokens["items"]!.AsArray(), t => t!["role"]!.GetValue<string>() == "probe");
|
||||
JsonNode listed = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/probes");
|
||||
Assert.Contains(listed["items"]!.AsArray(), p => p!["id"]!.GetValue<string>() == id && !p["revoked"]!.GetValue<bool>());
|
||||
string duplicateToken = await _backend.CreateJoinTokenAsync(role: "probe");
|
||||
// 重名节点进入待审批状态,尚未获得任务
|
||||
string duplicateToken = await _backend.CreateJoinTokenAsync(role: "agent");
|
||||
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) };
|
||||
Fleet.Core.Result<Fleet.Enrollment.Client.EnrollmentResult> duplicate = await Fleet.Enrollment.Client.EnrollmentClient.EnrollAsync(
|
||||
http, _backend.BaseAddress, duplicateToken, name, Path.Combine(Path.GetTempPath(), "moshiro-dup-" + Guid.NewGuid().ToString("N")), "0.0.0", null, TimeProvider.System, default);
|
||||
Assert.False(duplicate.IsSuccess);
|
||||
Assert.Equal("fleet_name_conflict", duplicate.Error.Code.Value);
|
||||
Assert.True(duplicate.IsSuccess);
|
||||
JsonNode pending = await AgentAsync(duplicate.Value.NodeId);
|
||||
Assert.Equal("pending", pending["status"]!.GetValue<string>());
|
||||
|
||||
// 创建身份和轮换密钥的接口已经没有了:换密钥就是吊销之后重新注册
|
||||
using HttpResponseMessage oldCreate = await _backend.Admin.PostAsync("/api/admin/probes", BackendFixture.Json(new JsonObject { ["name"] = "x" }));
|
||||
Assert.True(oldCreate.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.MethodNotAllowed);
|
||||
using HttpResponseMessage oldRotate = await _backend.Admin.PostAsync($"/api/admin/probes/{id}/rotate", null);
|
||||
Assert.True(oldRotate.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.MethodNotAllowed);
|
||||
|
||||
// 改名
|
||||
using HttpResponseMessage renamed = await _backend.Admin.PatchAsync("/api/admin/probes/" + id, BackendFixture.Json(new JsonObject { ["name"] = name + "-b" }));
|
||||
Assert.Equal(name + "-b", JsonNode.Parse(await renamed.Content.ReadAsStringAsync())!["name"]!.GetValue<string>());
|
||||
|
||||
// 吊销:从名单里消失,面板里留着记录
|
||||
using HttpResponseMessage revoked = await _backend.Admin.PostAsync($"/api/admin/probes/{id}/revoke", null);
|
||||
Assert.True(JsonNode.Parse(await revoked.Content.ReadAsStringAsync())!["revoked"]!.GetValue<bool>());
|
||||
await agent.WaitForTaskSetAsync(s => s.Peers.All(p => p.ProbeId.Value != id));
|
||||
// 停用:从名单里消失,记录还在
|
||||
using HttpResponseMessage disabled = await _backend.Admin.PostAsync($"/api/admin/agents/{id}/disable", BackendFixture.Json(new JsonObject()));
|
||||
Assert.True(disabled.IsSuccessStatusCode);
|
||||
await listener.WaitForTaskSetAsync(s => s.Peers.All(p => p.NodeId.Value != id));
|
||||
|
||||
// 删除
|
||||
using HttpResponseMessage deleted = await _backend.Admin.DeleteAsync("/api/admin/probes/" + id);
|
||||
using HttpResponseMessage deleted = await _backend.Admin.DeleteAsync("/api/admin/agents/" + id);
|
||||
Assert.Equal(HttpStatusCode.NoContent, deleted.StatusCode);
|
||||
using HttpResponseMessage gone = await _backend.Admin.GetAsync("/api/admin/probes/" + id);
|
||||
using HttpResponseMessage gone = await _backend.Admin.GetAsync("/api/admin/agents/" + id);
|
||||
Assert.Equal(HttpStatusCode.NotFound, gone.StatusCode);
|
||||
}
|
||||
|
||||
|
||||
@@ -141,14 +141,12 @@ public sealed class HostStatusTests : IClassFixture<BackendFixture>
|
||||
await using SseClient sse = await SseClient.ConnectAsync(_backend.Anonymous, "/api/public/live?topics=hosts");
|
||||
await agent.SendHostAsync(Snapshot(18.5));
|
||||
SseEvent live = await sse.WaitForAsync(e => e.Name == "host" && e.Data!["id"]!.GetValue<string>() == id);
|
||||
Assert.Equal("agent", live.Data!["kind"]!.GetValue<string>());
|
||||
Assert.Equal(18.5, live.Data!["host"]!["cpu_percent"]!.GetValue<double>());
|
||||
Assert.True(live.Data["online"]!.GetValue<bool>());
|
||||
|
||||
// REST:最新值,没有过期
|
||||
JsonNode list = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/hosts");
|
||||
JsonNode item = list["items"]!.AsArray().Single(i => i!["id"]!.GetValue<string>() == id)!;
|
||||
Assert.Equal("agent", item["kind"]!.GetValue<string>());
|
||||
Assert.False(item["stale"]!.GetValue<bool>());
|
||||
Assert.Equal(40L << 30, item["host"]!["disk_total"]!.GetValue<long>());
|
||||
Assert.Equal(1000, item["host"]!["net_link_mbps"]!.GetValue<long>());
|
||||
@@ -160,10 +158,10 @@ public sealed class HostStatusTests : IClassFixture<BackendFixture>
|
||||
Assert.DoesNotContain(forbidden, text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// 线路详情也不泄漏 Probe 的地址和版本
|
||||
// 线路详情也不泄漏 Dialer 的地址和版本
|
||||
string line = (await _backend.GetJsonAsync(_backend.Anonymous, $"/api/public/lines/{_backend.Line(id)}")).ToJsonString();
|
||||
Assert.DoesNotContain("remote", line, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("probe_version", line, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("dialer_version", line, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// 历史:每分钟一个样本
|
||||
JsonNode history = null!;
|
||||
@@ -185,47 +183,10 @@ public sealed class HostStatusTests : IClassFixture<BackendFixture>
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Slow")]
|
||||
public async Task ARealProbeReportsItsHostStatus_LikeAnAgent_NeverCarriesModelsOrAddresses_AndDisappearsWhenRevoked()
|
||||
public async Task ADialerThatTurnsReportingOff_SendsNothing()
|
||||
{
|
||||
await _backend.SiteMustBePublicAsync();
|
||||
await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend, file => file.Reporting.HostIntervalSeconds = 1);
|
||||
|
||||
JsonNode? item = null;
|
||||
await BackendFixture.EventuallyAsync(
|
||||
async () =>
|
||||
{
|
||||
JsonNode list = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/hosts");
|
||||
item = list["items"]!.AsArray().SingleOrDefault(i => i!["id"]!.GetValue<string>() == probe.ProbeId);
|
||||
return item?["host"] is not null;
|
||||
},
|
||||
TimeSpan.FromSeconds(30),
|
||||
"the probe's host status shows up");
|
||||
Assert.Equal("probe", item!["kind"]!.GetValue<string>());
|
||||
Assert.NotNull(item["host"]!["uptime_seconds"]); // 任何平台都读得到运行时长
|
||||
foreach (string forbidden in new[] { "model", "hostname", "\"ip\"", "address", "remote", "mac", "iface", "public_host" })
|
||||
{
|
||||
Assert.DoesNotContain(forbidden, item.ToJsonString(), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// 历史也按节点 id 取
|
||||
using HttpResponseMessage history = await _backend.Anonymous.GetAsync($"/api/public/hosts/{probe.ProbeId}?range=1h");
|
||||
Assert.Equal(System.Net.HttpStatusCode.OK, history.StatusCode);
|
||||
|
||||
// 吊销之后展示页就看不到它了
|
||||
using HttpResponseMessage revoked = await _backend.Admin.PostAsync($"/api/admin/probes/{probe.ProbeId}/revoke", null);
|
||||
Assert.True(revoked.IsSuccessStatusCode);
|
||||
JsonNode after = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/hosts");
|
||||
Assert.DoesNotContain(after["items"]!.AsArray(), i => i!["id"]!.GetValue<string>() == probe.ProbeId);
|
||||
using HttpResponseMessage gone = await _backend.Anonymous.GetAsync($"/api/public/hosts/{probe.ProbeId}");
|
||||
Assert.Equal(System.Net.HttpStatusCode.NotFound, gone.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Slow")]
|
||||
public async Task AProbeThatTurnsReportingOff_SendsNothing()
|
||||
{
|
||||
await _backend.SiteMustBePublicAsync();
|
||||
await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend, file =>
|
||||
await using DialerHarness dialer = await DialerHarness.StartAsync(_backend, file =>
|
||||
{
|
||||
file.Reporting.HostStats = false;
|
||||
file.Reporting.HostIntervalSeconds = 1;
|
||||
@@ -234,7 +195,7 @@ public sealed class HostStatusTests : IClassFixture<BackendFixture>
|
||||
await Task.Delay(TimeSpan.FromSeconds(4));
|
||||
|
||||
JsonNode list = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/hosts");
|
||||
JsonNode item = list["items"]!.AsArray().Single(i => i!["id"]!.GetValue<string>() == probe.ProbeId)!;
|
||||
JsonNode item = list["items"]!.AsArray().Single(i => i!["id"]!.GetValue<string>() == dialer.DialerId)!;
|
||||
Assert.Null(item["host"]); // 节点还在,但一个字也没上报
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,8 @@ public sealed class IngestAndQueryTests : IClassFixture<BackendFixture>
|
||||
Assert.True(await agent.SendChunkAsync(udp));
|
||||
if (tcp)
|
||||
{
|
||||
long probeBase = ChunkFactory.ProbeUs(0);
|
||||
RawStream info = ChunkFactory.TcpInfo(Enumerable.Range(0, 10).Select(s => (probeBase + (s * 1_000_000L), (byte)0, 45_000u, (uint)(s * 2), (uint)(s * 400))));
|
||||
long dialerBase = ChunkFactory.DialerUs(0);
|
||||
RawStream info = ChunkFactory.TcpInfo(Enumerable.Range(0, 10).Select(s => (dialerBase + (s * 1_000_000L), (byte)0, 45_000u, (uint)(s * 2), (uint)(s * 400))));
|
||||
RawChunk tcpChunk = ChunkFactory.Echo(
|
||||
agent.NodeId, "tcp_echo", start, 200, 50, _ => new EchoPacket(false, false, (long)(owdMs * 1000), (long)(owdMs * 1000)), extraStreams: [info]);
|
||||
Assert.True(await agent.SendChunkAsync(tcpChunk));
|
||||
|
||||
@@ -4,7 +4,7 @@ using System.Text.Json.Nodes;
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 一行安装:Agent 和 Probe 共用同一份<b>静态</b>脚本(所有人拿到的字节都一样,所以哈希固定、可以先核对再执行),
|
||||
/// 一行安装:监听侧和拨号侧 Agent 共用同一份<b>静态</b>脚本(所有人拿到的字节都一样,所以哈希固定、可以先核对再执行),
|
||||
/// 面板地址、角色、一次性代码都走命令里的环境变量;脚本再向面板取 CI 签名的发布清单来验证镜像。
|
||||
/// </summary>
|
||||
public sealed class InstallTests : IClassFixture<BackendFixture>
|
||||
@@ -37,11 +37,10 @@ public sealed class InstallTests : IClassFixture<BackendFixture>
|
||||
Assert.Equal("", agent["install_commands"]!["openwrt"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("agent")]
|
||||
[InlineData("probe")]
|
||||
public async Task Commands_VerifyTheScriptHash_ThenRunItWithThePublicValues(string role)
|
||||
[Fact]
|
||||
public async Task Commands_VerifyTheScriptHash_ThenRunItWithThePublicValues()
|
||||
{
|
||||
const string role = "agent";
|
||||
JsonNode created = await CreateAsync(role);
|
||||
string code = created["plaintext"]!.GetValue<string>();
|
||||
JsonNode commands = created["install_commands"]!;
|
||||
@@ -62,11 +61,10 @@ public sealed class InstallTests : IClassFixture<BackendFixture>
|
||||
Assert.DoesNotContain("<", linux + macos + windows, StringComparison.Ordinal); // 没有要用户自己替换的占位符
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("agent")]
|
||||
[InlineData("probe")]
|
||||
public async Task ComposeInstall_WritesThePublicValuesToAnEnvFile_AndChecksTheComposeHash(string role)
|
||||
[Fact]
|
||||
public async Task ComposeInstall_WritesThePublicValuesToAnEnvFile_AndChecksTheComposeHash()
|
||||
{
|
||||
const string role = "agent";
|
||||
JsonNode created = await CreateAsync(role);
|
||||
string code = created["plaintext"]!.GetValue<string>();
|
||||
JsonNode compose = created["compose"]!;
|
||||
@@ -91,28 +89,14 @@ public sealed class InstallTests : IClassFixture<BackendFixture>
|
||||
byte[] body = await served.Content.ReadAsByteArrayAsync();
|
||||
Assert.Equal(compose["sha256"]!.GetValue<string>(), Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(body)));
|
||||
|
||||
if (role == "probe")
|
||||
{
|
||||
// Docker Desktop 没有宿主网络:macOS 和 Windows 的 .env 里改成 bridge
|
||||
string macos = compose["macos"]!.GetValue<string>();
|
||||
string windows = compose["windows"]!.GetValue<string>();
|
||||
Assert.Contains("MOSHIRO_NETWORK_MODE=bridge", macos + windows, StringComparison.Ordinal);
|
||||
Assert.Contains("docker compose up -d", macos, StringComparison.Ordinal);
|
||||
Assert.Contains("Get-FileHash compose.yaml", windows, StringComparison.Ordinal);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Agent 需要 Linux 的宿主网络
|
||||
Assert.Equal(string.Empty, compose["macos"]!.GetValue<string>());
|
||||
Assert.Equal(string.Empty, compose["windows"]!.GetValue<string>());
|
||||
}
|
||||
Assert.Equal(string.Empty, compose["macos"]!.GetValue<string>());
|
||||
Assert.Equal(string.Empty, compose["windows"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("agent")]
|
||||
[InlineData("probe")]
|
||||
public async Task QuadletInstall_ChecksTheUnitHash_PointsTheImageAtTheConfiguredRepository_AndLeavesAutoUpdateOn(string role)
|
||||
[Fact]
|
||||
public async Task QuadletInstall_ChecksTheUnitHash_PointsTheImageAtTheConfiguredRepository_AndLeavesAutoUpdateOn()
|
||||
{
|
||||
const string role = "agent";
|
||||
JsonNode created = await CreateAsync(role);
|
||||
JsonNode quadlet = created["quadlet"]!;
|
||||
string env = quadlet["env"]!.GetValue<string>();
|
||||
@@ -148,7 +132,7 @@ public sealed class InstallTests : IClassFixture<BackendFixture>
|
||||
[InlineData("evil.container")]
|
||||
[InlineData("backend.container")]
|
||||
[InlineData("agent")]
|
||||
public async Task OnlyTheTwoQuadletUnits_AreServed(string file)
|
||||
public async Task UnknownQuadletUnits_AreRejected(string file)
|
||||
{
|
||||
using HttpResponseMessage response = await _backend.Anonymous.GetAsync("/install/quadlet/" + file);
|
||||
|
||||
@@ -157,7 +141,6 @@ public sealed class InstallTests : IClassFixture<BackendFixture>
|
||||
|
||||
[Theory]
|
||||
[InlineData("agent.yaml")]
|
||||
[InlineData("probe.yaml")]
|
||||
[InlineData("backend.yaml")]
|
||||
public async Task TheComposeFiles_AreStatic_PinNothingToABuild_AndKeepTheNodeHardening(string file)
|
||||
{
|
||||
@@ -177,7 +160,7 @@ public sealed class InstallTests : IClassFixture<BackendFixture>
|
||||
[InlineData("evil.yaml")]
|
||||
[InlineData("agent")]
|
||||
[InlineData("..%2Finstall.sh")]
|
||||
public async Task OnlyTheThreeComposeFiles_AreServed(string file)
|
||||
public async Task UnknownComposeFiles_AreRejected(string file)
|
||||
{
|
||||
using HttpResponseMessage response = await _backend.Anonymous.GetAsync("/install/compose/" + file);
|
||||
|
||||
@@ -220,15 +203,15 @@ public sealed class InstallTests : IClassFixture<BackendFixture>
|
||||
[Fact]
|
||||
public async Task TheScript_GetsTheSignedManifestOfItsRole_ByteForByte()
|
||||
{
|
||||
string manifest = """{"schema":1,"component":"probe","channel":"dev","image":"registry.example/moshiro-dev/moshiro-probe","digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000","tag":"dev-1","version":"0.1.1","commit":"abc","built_at":"2026-10-06T00:00:00Z","seq":1}""";
|
||||
string manifest = """{"schema":1,"component":"agent","channel":"dev","image":"registry.example/moshiro-dev/moshiro-agent","digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000","tag":"dev-1","version":"0.1.1","commit":"abc","built_at":"2026-10-06T00:00:00Z","seq":1}""";
|
||||
using HttpResponseMessage published = await _backend.Anonymous.PostAsync("/api/release/offer", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = TestReleaseKey.Sign(manifest) }));
|
||||
Assert.True(published.StatusCode is HttpStatusCode.Created or HttpStatusCode.Conflict, await published.Content.ReadAsStringAsync());
|
||||
|
||||
string code = (await CreateAsync("probe"))["plaintext"]!.GetValue<string>();
|
||||
string code = (await CreateAsync("agent"))["plaintext"]!.GetValue<string>();
|
||||
using HttpResponseMessage served = await _backend.Anonymous.GetAsync("/install/manifest?code=" + code);
|
||||
Assert.Equal(HttpStatusCode.OK, served.StatusCode);
|
||||
Assert.Equal("no-store", served.Headers.CacheControl?.ToString());
|
||||
Assert.Contains("\"component\":\"probe\"", await served.Content.ReadAsStringAsync(), StringComparison.Ordinal); // 签名对象是原始字节,面板一个字节也不改
|
||||
Assert.Contains("\"component\":\"agent\"", await served.Content.ReadAsStringAsync(), StringComparison.Ordinal); // 签名对象是原始字节,面板一个字节也不改
|
||||
using HttpResponseMessage signature = await _backend.Anonymous.GetAsync("/install/manifest.sig?code=" + code);
|
||||
Assert.Equal(HttpStatusCode.OK, signature.StatusCode);
|
||||
Assert.False(string.IsNullOrWhiteSpace(await signature.Content.ReadAsStringAsync()));
|
||||
@@ -243,14 +226,14 @@ public sealed class InstallTests : IClassFixture<BackendFixture>
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
JsonNode revoked = await CreateAsync("probe");
|
||||
JsonNode revoked = await CreateAsync("agent");
|
||||
using HttpResponseMessage delete = await _backend.Admin.DeleteAsync("/api/admin/join-tokens/" + revoked["token"]!["id"]!.GetValue<string>());
|
||||
Assert.True(delete.IsSuccessStatusCode);
|
||||
using HttpResponseMessage gone = await _backend.Anonymous.GetAsync("/install/manifest?code=" + revoked["plaintext"]!.GetValue<string>());
|
||||
Assert.Equal(HttpStatusCode.NotFound, gone.StatusCode);
|
||||
|
||||
// 用完了(一次性令牌已经注册过一个节点):脚本在动手之前就能从这里知道,给出明确的提示
|
||||
JsonNode used = await CreateAsync("probe");
|
||||
JsonNode used = await CreateAsync("agent");
|
||||
string code = used["plaintext"]!.GetValue<string>();
|
||||
await using (await MoShiro.TestSupport.AgentSimulator.EnrollAsync(_backend.Anonymous, code, _backend.UniqueName("used"))) { }
|
||||
using HttpResponseMessage after = await _backend.Anonymous.GetAsync("/install/manifest?code=" + code);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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;
|
||||
@@ -31,7 +32,7 @@ public sealed class Ipv6Tests : IClassFixture<BackendFixture>
|
||||
{
|
||||
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.DialerId = new NodeId(_backend.DefaultDialerId);
|
||||
chunk.Header.IpVersion = version;
|
||||
Assert.True(await agent.SendChunkAsync(chunk));
|
||||
}
|
||||
@@ -75,8 +76,9 @@ public sealed class Ipv6Tests : IClassFixture<BackendFixture>
|
||||
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);
|
||||
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>());
|
||||
@@ -91,16 +93,16 @@ public sealed class Ipv6Tests : IClassFixture<BackendFixture>
|
||||
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 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:" + probe.ProbeId, "line:" + probe.ProbeId + ":ipv6" }.All(series =>
|
||||
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 + "~" + probe.ProbeId;
|
||||
string v4 = agent.Id + "~" + dialer.DialerId;
|
||||
string v6 = v4 + "~ipv6";
|
||||
foreach (string step in new[] { "1s", "10s" })
|
||||
{
|
||||
@@ -117,13 +119,13 @@ public sealed class Ipv6Tests : IClassFixture<BackendFixture>
|
||||
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);
|
||||
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(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(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);
|
||||
}
|
||||
@@ -139,12 +141,12 @@ public sealed class Ipv6Tests : IClassFixture<BackendFixture>
|
||||
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;
|
||||
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))["probes"]!.AsArray()
|
||||
.Any(p => p!["probe_id"]!.GetValue<string>() == probe.ProbeId && p["ip_version"]!.GetValue<string>() == "ipv6"));
|
||||
(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);
|
||||
|
||||
@@ -13,8 +13,8 @@ public sealed class LinkTests : IClassFixture<BackendFixture>
|
||||
_backend = backend;
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> SetLinkAsync(string probe, string agent, bool? enabled) =>
|
||||
await _backend.Admin.PutAsync($"/api/admin/links/{probe}/{agent}", BackendFixture.Json(new JsonObject { ["enabled"] = enabled }));
|
||||
private async Task<HttpResponseMessage> SetLinkAsync(string dialer, string agent, bool? enabled) =>
|
||||
await _backend.Admin.PutAsync($"/api/admin/links/{dialer}/{agent}", BackendFixture.Json(new JsonObject { ["enabled"] = enabled }));
|
||||
|
||||
private async Task SetAutoLinkAsync(bool on)
|
||||
{
|
||||
@@ -22,47 +22,68 @@ public sealed class LinkTests : IClassFixture<BackendFixture>
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private async Task<JsonNode?> LinkAsync(string probe, string agent) =>
|
||||
(await _backend.GetJsonAsync(_backend.Admin, "/api/admin/links"))["items"]!.AsArray().FirstOrDefault(i => i!["probe_id"]!.GetValue<string>() == probe && i["agent_id"]!.GetValue<string>() == agent);
|
||||
private async Task<JsonNode?> LinkAsync(string dialer, string agent) =>
|
||||
(await _backend.GetJsonAsync(_backend.Admin, "/api/admin/links"))["items"]!.AsArray().FirstOrDefault(i => i!["dialer_id"]!.GetValue<string>() == dialer && i["listener_id"]!.GetValue<string>() == agent);
|
||||
|
||||
[Fact]
|
||||
public async Task ListeningAgents_AutomaticallyLinkBothDirections_WithoutSelfLinks()
|
||||
{
|
||||
await using AgentSimulator first = await _backend.ConnectAgentAsync();
|
||||
await using AgentSimulator second = await _backend.ConnectAgentAsync();
|
||||
string a = first.NodeId.Value;
|
||||
string b = second.NodeId.Value;
|
||||
Assert.True((await LinkAsync(a, b))!["linked"]!.GetValue<bool>());
|
||||
Assert.True((await LinkAsync(b, a))!["linked"]!.GetValue<bool>());
|
||||
Assert.Null(await LinkAsync(a, a));
|
||||
using HttpResponseMessage self = await SetLinkAsync(a, a, true);
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, self.StatusCode);
|
||||
await first.WaitForTaskSetAsync(s => s.Peers.Any(p => p.NodeId.Value == b));
|
||||
await second.WaitForTaskSetAsync(s => s.Peers.Any(p => p.NodeId.Value == a));
|
||||
|
||||
using HttpResponseMessage off = await SetLinkAsync(a, b, false);
|
||||
off.EnsureSuccessStatusCode();
|
||||
Assert.False((await LinkAsync(a, b))!["linked"]!.GetValue<bool>());
|
||||
Assert.True((await LinkAsync(b, a))!["linked"]!.GetValue<bool>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Links_FollowTheAutomaticSwitch_AndManualChoicesAlwaysWin()
|
||||
{
|
||||
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
|
||||
string probe = await _backend.CreateProbeIdAsync();
|
||||
string dialer = await _backend.CreateDialerIdAsync();
|
||||
string agentId = agent.NodeId.Value;
|
||||
try
|
||||
{
|
||||
// 自动连接(默认开):新 Probe 自动进入这个 Agent 允许的名单
|
||||
await agent.WaitForTaskSetAsync(s => s.Peers.Any(p => p.ProbeId.Value == probe));
|
||||
JsonNode link = (await LinkAsync(probe, agentId))!;
|
||||
// 自动连接(默认开):新 Dialer 自动进入这个 Agent 允许的名单
|
||||
await agent.WaitForTaskSetAsync(s => s.Peers.Any(p => p.NodeId.Value == dialer));
|
||||
JsonNode link = (await LinkAsync(dialer, agentId))!;
|
||||
Assert.True(link["linked"]!.GetValue<bool>());
|
||||
Assert.Null(link["override"]);
|
||||
|
||||
// 手动关掉这一对:自动连接开着也不连
|
||||
using HttpResponseMessage off = await SetLinkAsync(probe, agentId, false);
|
||||
using HttpResponseMessage off = await SetLinkAsync(dialer, agentId, false);
|
||||
Assert.Equal(HttpStatusCode.OK, off.StatusCode);
|
||||
await agent.WaitForTaskSetAsync(s => s.Peers.All(p => p.ProbeId.Value != probe));
|
||||
Assert.False((await LinkAsync(probe, agentId))!["linked"]!.GetValue<bool>());
|
||||
await agent.WaitForTaskSetAsync(s => s.Peers.All(p => p.NodeId.Value != dialer));
|
||||
Assert.False((await LinkAsync(dialer, agentId))!["linked"]!.GetValue<bool>());
|
||||
|
||||
// 取消手动设置:回到自动连接
|
||||
using HttpResponseMessage reset = await SetLinkAsync(probe, agentId, null);
|
||||
using HttpResponseMessage reset = await SetLinkAsync(dialer, agentId, null);
|
||||
Assert.Equal(HttpStatusCode.OK, reset.StatusCode);
|
||||
await agent.WaitForTaskSetAsync(s => s.Peers.Any(p => p.ProbeId.Value == probe));
|
||||
await agent.WaitForTaskSetAsync(s => s.Peers.Any(p => p.NodeId.Value == dialer));
|
||||
|
||||
// 关掉自动连接:没有手动选中的就都不连
|
||||
await SetAutoLinkAsync(false);
|
||||
await agent.WaitForTaskSetAsync(s => s.Peers.All(p => p.ProbeId.Value != probe));
|
||||
await agent.WaitForTaskSetAsync(s => s.Peers.All(p => p.NodeId.Value != dialer));
|
||||
Assert.False((await _backend.GetJsonAsync(_backend.Admin, "/api/admin/links"))["auto_link"]!.GetValue<bool>());
|
||||
|
||||
// 自动连接关着,手动选中的照样连
|
||||
await SetLinkAsync(probe, agentId, true);
|
||||
await agent.WaitForTaskSetAsync(s => s.Peers.Any(p => p.ProbeId.Value == probe));
|
||||
Assert.True((await LinkAsync(probe, agentId))!["override"]!.GetValue<bool>());
|
||||
await SetLinkAsync(dialer, agentId, true);
|
||||
await agent.WaitForTaskSetAsync(s => s.Peers.Any(p => p.NodeId.Value == dialer));
|
||||
Assert.True((await LinkAsync(dialer, agentId))!["override"]!.GetValue<bool>());
|
||||
}
|
||||
finally
|
||||
{
|
||||
await SetLinkAsync(probe, agentId, null);
|
||||
await SetLinkAsync(dialer, agentId, null);
|
||||
await SetAutoLinkAsync(true); // 共用的后端:恢复默认,别影响别的测试
|
||||
}
|
||||
}
|
||||
@@ -71,25 +92,25 @@ public sealed class LinkTests : IClassFixture<BackendFixture>
|
||||
public async Task Links_RefuseUnknownNodes()
|
||||
{
|
||||
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
|
||||
string probe = await _backend.CreateProbeIdAsync();
|
||||
using HttpResponseMessage noProbe = await SetLinkAsync("prb_missing", agent.NodeId.Value, false);
|
||||
Assert.Equal(HttpStatusCode.NotFound, noProbe.StatusCode);
|
||||
using HttpResponseMessage noAgent = await SetLinkAsync(probe, "agt_missing", false);
|
||||
string dialer = await _backend.CreateDialerIdAsync();
|
||||
using HttpResponseMessage noDialer = await SetLinkAsync("agt_missing", agent.NodeId.Value, false);
|
||||
Assert.Equal(HttpStatusCode.NotFound, noDialer.StatusCode);
|
||||
using HttpResponseMessage noAgent = await SetLinkAsync(dialer, "agt_missing", false);
|
||||
Assert.Equal(HttpStatusCode.NotFound, noAgent.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Links_RetryEnablesPairAndReportsWhenProbeIsOffline()
|
||||
public async Task Links_RetryEnablesPairAndReportsWhenDialerIsOffline()
|
||||
{
|
||||
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
|
||||
string probe = await _backend.CreateProbeIdAsync();
|
||||
string dialer = await _backend.CreateDialerIdAsync();
|
||||
|
||||
using HttpResponseMessage response = await _backend.Admin.PostAsync($"/api/admin/links/{probe}/{agent.NodeId.Value}/retry", BackendFixture.Json(new JsonObject()));
|
||||
using HttpResponseMessage response = await _backend.Admin.PostAsync($"/api/admin/links/{dialer}/{agent.NodeId.Value}/retry", BackendFixture.Json(new JsonObject()));
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
JsonNode body = JsonNode.Parse(await response.Content.ReadAsStringAsync())!;
|
||||
Assert.False(body["queued"]!.GetValue<bool>());
|
||||
Assert.True(body["link"]!["linked"]!.GetValue<bool>());
|
||||
Assert.Equal("probe_unavailable", body["link"]!["connection_state"]!.GetValue<string>());
|
||||
Assert.Contains("Probe 状态", body["link"]!["connection_reason"]!.GetValue<string>());
|
||||
Assert.Equal("dialer_unavailable", body["link"]!["connection_state"]!.GetValue<string>());
|
||||
Assert.Contains("拨号节点状态", body["link"]!["connection_reason"]!.GetValue<string>());
|
||||
}
|
||||
}
|
||||
@@ -52,8 +52,8 @@ public sealed class LiveTests : IClassFixture<BackendFixture>
|
||||
{
|
||||
await _backend.SiteMustBePublicAsync();
|
||||
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
|
||||
await agent.SendEventAsync(AgentEventKinds.ProbeConnected, _backend.DefaultProbeId);
|
||||
await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value))["probe_connected"]!.GetValue<bool>());
|
||||
await agent.SendEventAsync(AgentEventKinds.DialerConnected, _backend.DefaultDialerId);
|
||||
await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value))["dialer_connected"]!.GetValue<bool>());
|
||||
using HttpResponseMessage accepted = await _backend.Admin.PostAsync(
|
||||
$"/api/admin/lines/{_backend.Line(agent.NodeId.Value)}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "reality_session" }));
|
||||
string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue<string>();
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
<ProjectReference Include="..\MoShiro.TestSupport\MoShiro.TestSupport.csproj" />
|
||||
<ProjectReference Include="..\..\agent\src\MoShiro.Agent\MoShiro.Agent.csproj" />
|
||||
<ProjectReference Include="..\..\fleet\src\Fleet.Enrollment.Client\Fleet.Enrollment.Client.csproj" />
|
||||
<ProjectReference Include="..\..\probe\src\MoShiro.Probe\MoShiro.Probe.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
|
||||
@@ -64,12 +64,12 @@ public sealed partial class NodeInfoTests
|
||||
var time = new FakeTimeProvider(DateTimeOffset.Parse("2026-10-06T00:00:00Z"));
|
||||
var service = new NodeInfoService(store, geo, time);
|
||||
|
||||
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "10.0.0.5", CancellationToken.None);
|
||||
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "127.0.0.1", CancellationToken.None);
|
||||
await service.ObserveAddressAsync("agt_1", "10.0.0.5", CancellationToken.None);
|
||||
await service.ObserveAddressAsync("agt_1", "127.0.0.1", CancellationToken.None);
|
||||
Assert.Empty(geo.Calls);
|
||||
|
||||
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "223.73.168.45", CancellationToken.None);
|
||||
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "223.73.168.45", CancellationToken.None);
|
||||
await service.ObserveAddressAsync("agt_1", "223.73.168.45", CancellationToken.None);
|
||||
await service.ObserveAddressAsync("agt_1", "223.73.168.45", CancellationToken.None);
|
||||
Assert.Single(geo.Calls);
|
||||
|
||||
NodeInfo info = Assert.Single(await service.ListAsync(CancellationToken.None));
|
||||
@@ -79,10 +79,10 @@ public sealed partial class NodeInfoTests
|
||||
Assert.DoesNotMatch(Ipv4(), info.ToString());
|
||||
|
||||
// 地址变了才重新查;过了一周也重新查
|
||||
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "223.5.5.5", CancellationToken.None);
|
||||
await service.ObserveAddressAsync("agt_1", "223.5.5.5", CancellationToken.None);
|
||||
Assert.Equal(2, geo.Calls.Count);
|
||||
time.Advance(TimeSpan.FromDays(8));
|
||||
await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "223.5.5.5", CancellationToken.None);
|
||||
await service.ObserveAddressAsync("agt_1", "223.5.5.5", CancellationToken.None);
|
||||
Assert.Equal(3, geo.Calls.Count);
|
||||
}
|
||||
|
||||
@@ -93,11 +93,11 @@ public sealed partial class NodeInfoTests
|
||||
var time = new FakeTimeProvider(DateTimeOffset.Parse("2026-10-06T00:00:00Z"));
|
||||
var service = new NodeInfoService(new MemoryStore(), geo, time);
|
||||
|
||||
await service.ObserveAddressAsync("agt_1", NodeKinds.Agent, "8.8.8.8", CancellationToken.None);
|
||||
await service.ObserveAddressAsync("agt_1", NodeKinds.Agent, "8.8.8.8", CancellationToken.None);
|
||||
await service.ObserveAddressAsync("agt_1", "8.8.8.8", CancellationToken.None);
|
||||
await service.ObserveAddressAsync("agt_1", "8.8.8.8", CancellationToken.None);
|
||||
Assert.Single(geo.Calls);
|
||||
time.Advance(TimeSpan.FromMinutes(11));
|
||||
await service.ObserveAddressAsync("agt_1", NodeKinds.Agent, "8.8.8.8", CancellationToken.None);
|
||||
await service.ObserveAddressAsync("agt_1", "8.8.8.8", CancellationToken.None);
|
||||
Assert.Equal(2, geo.Calls.Count);
|
||||
Assert.Null(Assert.Single(await service.ListAsync(CancellationToken.None)).Country);
|
||||
}
|
||||
@@ -109,13 +109,13 @@ public sealed partial class NodeInfoTests
|
||||
var service = new NodeInfoService(new MemoryStore(), new FakeGeo(null), time);
|
||||
|
||||
Assert.True(service.ShouldRetryGeo("agt_1"));
|
||||
await service.ObserveAddressAsync("agt_1", NodeKinds.Agent, "", CancellationToken.None);
|
||||
await service.ObserveAddressAsync("agt_1", "", CancellationToken.None);
|
||||
Assert.False(service.ShouldRetryGeo("agt_1"));
|
||||
time.Advance(TimeSpan.FromMinutes(11));
|
||||
Assert.True(service.ShouldRetryGeo("agt_1"));
|
||||
|
||||
var found = new NodeInfoService(new MemoryStore(), new FakeGeo(new GeoInfo("jp", 1, "Example", false)), time);
|
||||
await found.ObserveAddressAsync("agt_2", NodeKinds.Agent, "8.8.8.8", CancellationToken.None);
|
||||
await found.ObserveAddressAsync("agt_2", "8.8.8.8", CancellationToken.None);
|
||||
time.Advance(TimeSpan.FromMinutes(11));
|
||||
Assert.False(found.ShouldRetryGeo("agt_2"));
|
||||
}
|
||||
@@ -125,7 +125,7 @@ public sealed partial class NodeInfoTests
|
||||
{
|
||||
var store = new MemoryStore();
|
||||
var first = new NodeInfoService(store, new FakeGeo(null), TimeProvider.System);
|
||||
await first.SetPlatformAsync("agt_1", NodeKinds.Agent, new PlatformInfo { Os = "linux", Name = "Debian 12", Arch = "arm64" }, CancellationToken.None);
|
||||
await first.SetPlatformAsync("agt_1", new PlatformInfo { Os = "linux", Name = "Debian 12", Arch = "arm64" }, CancellationToken.None);
|
||||
|
||||
var second = new NodeInfoService(store, new FakeGeo(null), TimeProvider.System);
|
||||
NodeInfo info = Assert.Single(await second.ListAsync(CancellationToken.None));
|
||||
@@ -190,7 +190,7 @@ public sealed partial class NodeInfoTests
|
||||
|
||||
/// <summary>
|
||||
/// 访客能拿到的东西里不能有 IP 等敏感信息:公开接口的整个响应里不出现 IP 形状的字符串,也没有 ip、city、asn、经纬度这些字段。
|
||||
/// Agent 和 Probe 这里是真的连上了(Probe 的来源地址是 127.0.0.1),所以如果哪里把地址带出来了,这里一定能发现。
|
||||
/// 监听侧和拨号侧 Agent 这里是真的连上了(Dialer 的来源地址是 127.0.0.1),所以如果哪里把地址带出来了,这里一定能发现。
|
||||
/// </summary>
|
||||
[Trait("Category", "Slow")]
|
||||
public sealed partial class NodePrivacyTests : IClassFixture<BackendFixture>
|
||||
@@ -210,31 +210,29 @@ public sealed partial class NodePrivacyTests : IClassFixture<BackendFixture>
|
||||
await _backend.SiteMustBePublicAsync();
|
||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
||||
await agent.OverrideLineTasksAsync();
|
||||
await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend);
|
||||
await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.Id))["probe_connected"]!.GetValue<bool>());
|
||||
await using DialerHarness dialer = await DialerHarness.StartAsync(_backend);
|
||||
await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.Id))["dialer_connected"]!.GetValue<bool>());
|
||||
|
||||
// 两端都自报了系统和架构(Agent 在 hello 里,Probe 随列表同步)
|
||||
// 两个 Agent 都通过各自的 Backend hello 上报系统和架构
|
||||
JsonNode? agentNode = null;
|
||||
JsonNode? probeNode = null;
|
||||
JsonNode? dialerNode = null;
|
||||
await BackendFixture.EventuallyAsync(
|
||||
async () =>
|
||||
{
|
||||
JsonNode nodes = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/nodes");
|
||||
agentNode = nodes["items"]!.AsArray().FirstOrDefault(n => n!["id"]!.GetValue<string>() == agent.Id);
|
||||
probeNode = nodes["items"]!.AsArray().FirstOrDefault(n => n!["id"]!.GetValue<string>() == probe.ProbeId);
|
||||
return agentNode?["os"] is not null && probeNode?["os"] is not null && agentNode["arch"] is not null && probeNode["arch"] is not null;
|
||||
dialerNode = nodes["items"]!.AsArray().FirstOrDefault(n => n!["id"]!.GetValue<string>() == dialer.DialerId);
|
||||
return agentNode?["os"] is not null && dialerNode?["os"] is not null && agentNode["arch"] is not null && dialerNode["arch"] is not null;
|
||||
},
|
||||
TimeSpan.FromSeconds(40),
|
||||
"the agent (hello) and the probe (agent list sync) both report their platform");
|
||||
Assert.Equal("agent", agentNode!["kind"]!.GetValue<string>());
|
||||
Assert.Equal("probe", probeNode!["kind"]!.GetValue<string>());
|
||||
"both agents report their platform through their own Backend hello");
|
||||
|
||||
string[] paths =
|
||||
[
|
||||
"/api/public/nodes",
|
||||
"/api/public/hosts",
|
||||
"/api/public/overview?range=1h&metrics=rtt.p50",
|
||||
$"/api/public/lines/{Uri.EscapeDataString(agent.Id + "~" + probe.ProbeId)}",
|
||||
$"/api/public/lines/{Uri.EscapeDataString(agent.Id + "~" + dialer.DialerId)}",
|
||||
"/api/public/catalog/plans",
|
||||
];
|
||||
foreach (string path in paths)
|
||||
|
||||
@@ -10,7 +10,7 @@ public sealed class NodeRuntimeLogTests
|
||||
public void DiagnosticFieldsSurviveUpload_WhileSecretsAndOversizedFieldsRemainBounded()
|
||||
{
|
||||
var service = new NodeRuntimeLogService(TimeProvider.System);
|
||||
var node = new NodeId("prb_diagnostics");
|
||||
var node = new NodeId("agt_diagnostics");
|
||||
Dictionary<string, string> fields = Enumerable.Range(0, 18).ToDictionary(i => "field_" + i, _ => "value");
|
||||
fields.Add("api_token", "must-not-survive");
|
||||
fields.Add("detail", new string('x', 500));
|
||||
|
||||
@@ -21,29 +21,29 @@ public sealed class OnDemandTestsTests : IClassFixture<BackendFixture>
|
||||
private static JsonObject Plan(string plan, JsonObject? parameters = null) =>
|
||||
parameters is null ? new JsonObject { ["plan"] = plan } : new JsonObject { ["plan"] = plan, ["params"] = parameters };
|
||||
|
||||
private async Task<AgentSimulator> AgentWithProbeAsync()
|
||||
private async Task<AgentSimulator> AgentWithDialerAsync()
|
||||
{
|
||||
AgentSimulator agent = await _backend.ConnectAgentAsync();
|
||||
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount);
|
||||
await agent.SendEventAsync(AgentEventKinds.ProbeConnected, _backend.DefaultProbeId);
|
||||
await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value))["probe_connected"]!.GetValue<bool>());
|
||||
await agent.SendEventAsync(AgentEventKinds.DialerConnected, _backend.DefaultDialerId);
|
||||
await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value))["dialer_connected"]!.GetValue<bool>());
|
||||
return agent;
|
||||
}
|
||||
|
||||
private async Task<JsonNode> TestAsync(string id) => await _backend.GetJsonAsync(_backend.Admin, "/api/public/tests/" + id);
|
||||
|
||||
[Fact]
|
||||
public async Task Start_RequiresAProbeSession_APlanThatExists_AndValidParams()
|
||||
public async Task Start_RequiresADialerSession_APlanThatExists_AndValidParams()
|
||||
{
|
||||
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
|
||||
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount);
|
||||
|
||||
using HttpResponseMessage offline = await StartAsync(agent, Plan("hy2_ladder"));
|
||||
Assert.Equal(HttpStatusCode.Conflict, offline.StatusCode);
|
||||
Assert.Equal("probe_offline", JsonNode.Parse(await offline.Content.ReadAsStringAsync())!["code"]!.GetValue<string>());
|
||||
Assert.Equal("dialer_offline", JsonNode.Parse(await offline.Content.ReadAsStringAsync())!["code"]!.GetValue<string>());
|
||||
|
||||
await agent.SendEventAsync(AgentEventKinds.ProbeConnected, _backend.DefaultProbeId);
|
||||
await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value))["probe_connected"]!.GetValue<bool>());
|
||||
await agent.SendEventAsync(AgentEventKinds.DialerConnected, _backend.DefaultDialerId);
|
||||
await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value))["dialer_connected"]!.GetValue<bool>());
|
||||
|
||||
using HttpResponseMessage unknownPlan = await StartAsync(agent, Plan("warp_ladder"));
|
||||
Assert.Equal(HttpStatusCode.NotFound, unknownPlan.StatusCode);
|
||||
@@ -69,7 +69,7 @@ public sealed class OnDemandTestsTests : IClassFixture<BackendFixture>
|
||||
[Trait("Category", "Slow")]
|
||||
public async Task Hy2Ladder_RunsEndToEnd_AndProducesAResultWithScores()
|
||||
{
|
||||
await using AgentSimulator agent = await AgentWithProbeAsync();
|
||||
await using AgentSimulator agent = await AgentWithDialerAsync();
|
||||
using HttpResponseMessage accepted = await StartAsync(agent, Plan("hy2_ladder"));
|
||||
Assert.Equal(HttpStatusCode.Accepted, accepted.StatusCode);
|
||||
JsonNode started = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!;
|
||||
@@ -140,7 +140,7 @@ public sealed class OnDemandTestsTests : IClassFixture<BackendFixture>
|
||||
[Fact]
|
||||
public async Task AgentRefusal_IsSurfacedAndMarksTheTestFailed()
|
||||
{
|
||||
await using AgentSimulator agent = await AgentWithProbeAsync();
|
||||
await using AgentSimulator agent = await AgentWithDialerAsync();
|
||||
agent.TestRunHandler = _ => (false, "test_busy");
|
||||
using HttpResponseMessage response = await StartAsync(agent, Plan("hy2_ladder"));
|
||||
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
|
||||
@@ -160,7 +160,7 @@ public sealed class OnDemandTestsTests : IClassFixture<BackendFixture>
|
||||
[Fact]
|
||||
public async Task Cancel_ReachesTheAgent_AndOnlyTheOwningAgentCanReportStatus()
|
||||
{
|
||||
await using AgentSimulator agent = await AgentWithProbeAsync();
|
||||
await using AgentSimulator agent = await AgentWithDialerAsync();
|
||||
await using AgentSimulator other = await _backend.ConnectAgentAsync();
|
||||
using HttpResponseMessage accepted = await StartAsync(agent, Plan("reality_session"));
|
||||
Assert.Equal(HttpStatusCode.Accepted, accepted.StatusCode);
|
||||
@@ -190,7 +190,7 @@ public sealed class OnDemandTestsTests : IClassFixture<BackendFixture>
|
||||
[Fact]
|
||||
public async Task CancellingATestOfAnOfflineAgent_AbortsItImmediately()
|
||||
{
|
||||
AgentSimulator agent = await AgentWithProbeAsync();
|
||||
AgentSimulator agent = await AgentWithDialerAsync();
|
||||
using HttpResponseMessage accepted = await StartAsync(agent, Plan("reality_session"));
|
||||
string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue<string>();
|
||||
await agent.DisposeAsync();
|
||||
@@ -205,7 +205,7 @@ public sealed class OnDemandTestsTests : IClassFixture<BackendFixture>
|
||||
public async Task TestsOfHiddenLines_AreNotFoundForAnonymousVisitors()
|
||||
{
|
||||
await _backend.SiteMustBePublicAsync();
|
||||
await using AgentSimulator agent = await AgentWithProbeAsync();
|
||||
await using AgentSimulator agent = await AgentWithDialerAsync();
|
||||
using HttpResponseMessage accepted = await StartAsync(agent, Plan("reality_session"));
|
||||
string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue<string>();
|
||||
|
||||
@@ -230,7 +230,7 @@ public sealed class OnDemandTestsTests : IClassFixture<BackendFixture>
|
||||
JsonNode list = await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.NodeId.Value}&kind=udp_echo");
|
||||
JsonNode item = list["items"]!.AsArray().Single()!;
|
||||
Assert.Equal(chunk.Header.ChunkId.ToString(), item["chunk_id"]!.GetValue<string>());
|
||||
Assert.Equal("line:" + _backend.DefaultProbeId, item["series"]!.GetValue<string>());
|
||||
Assert.Equal("line:" + _backend.DefaultDialerId, item["series"]!.GetValue<string>());
|
||||
|
||||
using HttpResponseMessage download = await _backend.Admin.GetAsync("/api/admin/raw/chunks/" + chunk.Header.ChunkId);
|
||||
Assert.Equal("application/vnd.moshiro.rawchunk", download.Content.Headers.ContentType!.MediaType);
|
||||
|
||||
@@ -57,7 +57,7 @@ public sealed class OperationsAndSecurityTests : IClassFixture<BackendFixture>
|
||||
|
||||
// 模拟“这个指标是新增的(或者它的口径变了)”:抹掉各层里已有的 rtt,并让登记的指纹对不上
|
||||
var store = Service<IMetricStore>();
|
||||
foreach (MetricRow row in await store.ReadAsync(MetricLevel.M1, agent.NodeId, "line:" + _backend.DefaultProbeId, baseMs, baseMs + (2 * Minute), default))
|
||||
foreach (MetricRow row in await store.ReadAsync(MetricLevel.M1, agent.NodeId, "line:" + _backend.DefaultDialerId, baseMs, baseMs + (2 * Minute), default))
|
||||
{
|
||||
await store.PatchAsync(MetricLevel.M1, [row with { Metrics = AccumulatorCodec.Encode(new MetricSet()) }], new HashSet<string> { "rtt" }, default);
|
||||
}
|
||||
@@ -190,7 +190,7 @@ public sealed class OperationsAndSecurityTests : IClassFixture<BackendFixture>
|
||||
Assert.Equal("fleet_protocol_violation", (await agent.RequestRawAsync(MoShiroMethods.TestRun)).Error!.Data!.Code);
|
||||
Assert.Equal("fleet_protocol_violation", (await agent.RequestRawAsync("fleet.state.sync")).Error!.Data!.Code);
|
||||
|
||||
// 一个 Probe 都不是的 Agent 发 Probe 才有的通道:节点面的列块通道(17)没有声明,不会进收件箱
|
||||
// Agent 向 Backend 发送仅用于节点对等会话的列块通道(17):节点面未声明此方向,不会进入收件箱
|
||||
await agent.SendBinaryAsync(OutboxFrame.Build(MoShiroChannels.ColumnBlock, Guid.NewGuid(), [1]));
|
||||
|
||||
// 反复违规:超过上限(默认 20 次)之后连接被服务端关闭
|
||||
@@ -226,10 +226,10 @@ public sealed class OperationsAndSecurityTests : IClassFixture<BackendFixture>
|
||||
{
|
||||
await using AgentSimulator agent = await _backend.ConnectAgentAsync(autoApprove: false);
|
||||
// 状态与事件也不会被接受:监控数据里没有它
|
||||
await agent.SendEventAsync(AgentEventKinds.ProbeConnected, _backend.DefaultProbeId);
|
||||
await agent.SendEventAsync(AgentEventKinds.DialerConnected, _backend.DefaultDialerId);
|
||||
await Task.Delay(200);
|
||||
JsonNode view = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value);
|
||||
Assert.False(view["probe_connected"]!.GetValue<bool>());
|
||||
Assert.False(view["dialer_connected"]!.GetValue<bool>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -9,7 +9,7 @@ using MoShiro.Backend.Application;
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 每个 Agent 和 Probe 都必须有属主(passkey):属主是节点敏感内容(extension 会话、加密数据)绑定的人,装上时固定在节点本地。
|
||||
/// 每个 监听侧和拨号侧 Agent 都必须有属主(passkey):属主是节点敏感内容(extension 会话、加密数据)绑定的人,装上时固定在节点本地。
|
||||
/// 没有信任根、没选属主、选了操作员集合之外的凭证,都不能创建加入码;安装脚本和容器入口缺了属主也不装。
|
||||
/// </summary>
|
||||
public sealed class OwnerPolicyTests
|
||||
@@ -65,7 +65,7 @@ public sealed class OwnerEndToEndTests : IDisposable, IClassFixture<BackendFixtu
|
||||
Assert.Equal(HttpStatusCode.BadRequest, none.StatusCode);
|
||||
Assert.Contains("owner", await none.Content.ReadAsStringAsync(), StringComparison.Ordinal);
|
||||
|
||||
using HttpResponseMessage stranger = await session.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "probe", ["controllers"] = new JsonArray("not-an-operator") }));
|
||||
using HttpResponseMessage stranger = await session.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "agent", ["controllers"] = new JsonArray("not-an-operator") }));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, stranger.StatusCode);
|
||||
|
||||
// 明确选了一把,或者不指定(默认是全部操作员):可以,命令里带着创世指纹和属主
|
||||
@@ -76,7 +76,7 @@ public sealed class OwnerEndToEndTests : IDisposable, IClassFixture<BackendFixtu
|
||||
Assert.Contains("MOSHIRO_CONTROLLERS=", linux, StringComparison.Ordinal);
|
||||
Assert.Contains(known, linux, StringComparison.Ordinal);
|
||||
|
||||
using HttpResponseMessage defaults = await session.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "probe" }));
|
||||
using HttpResponseMessage defaults = await session.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "agent" }));
|
||||
Assert.Equal(HttpStatusCode.Created, defaults.StatusCode);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <summary>真实的后端 + 真实的 Agent + 真实的 Probe(都在进程内,回环网络)。</summary>
|
||||
// 慢的端到端测试(真实时间的阶梯、要等测量块出来):CI 里单独一批跑(Category=Slow),不拦着出镜像,见 ci/verify-backend.sh
|
||||
[Trait("Category", "Slow")]
|
||||
public sealed class ProbeEndToEndTests : IClassFixture<BackendFixture>
|
||||
{
|
||||
private readonly BackendFixture _backend;
|
||||
|
||||
public ProbeEndToEndTests(BackendFixture backend)
|
||||
{
|
||||
_backend = backend;
|
||||
}
|
||||
|
||||
private async Task<JsonNode> AgentViewAsync(AgentHarness agent) => await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.Id);
|
||||
|
||||
private async Task<int> ChunkCountAsync(AgentHarness agent, string kind) =>
|
||||
(await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.Id}"))["items"]!.AsArray().Count(c => c!["kind"]!.GetValue<string>() == kind);
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_GetsTheAgentListFromThePanel_RunsTheLineTasks_AndTheBackendStoresMergedChunks()
|
||||
{
|
||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
||||
await agent.OverrideLineTasksAsync(rateHz: 30);
|
||||
await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend);
|
||||
|
||||
await BackendFixture.EventuallyAsync(() => Task.FromResult(probe.App.IsConnected(agent.Id)), because: "the panel sends the agent list to the probe, which then connects");
|
||||
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probe_connected"]!.GetValue<bool>());
|
||||
await BackendFixture.EventuallyAsync(() => Task.FromResult(probe.App.Connection(agent.Id)!.RoleCount == 2), because: "udp_echo and tcp_echo");
|
||||
|
||||
// 每 10 秒切一块,回显类再等 2 秒宽限
|
||||
await BackendFixture.EventuallyAsync(async () => await ChunkCountAsync(agent, "udp_echo") >= 1 && await ChunkCountAsync(agent, "tcp_echo") >= 1, TimeSpan.FromSeconds(40));
|
||||
Assert.True(agent.App.Counters.Fragments >= 2);
|
||||
Assert.Equal(0, agent.App.Counters.FragmentsDiscarded);
|
||||
|
||||
// 时钟偏移已经估计出来:进程内两端共用同一个单调时钟,所以偏移应当接近 0(真实线路上是两台机器各自的时钟原点之差)
|
||||
Assert.InRange(probe.App.Connection(agent.Id)!.ClockOffsetUs, -20_000, 20_000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_IntroducesItselfToThePanel_AndARevokedProbeIsLockedOut()
|
||||
{
|
||||
await _backend.SiteMustBePublicAsync();
|
||||
await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend);
|
||||
string path = "/api/admin/probes/" + probe.ProbeId;
|
||||
|
||||
// 直连面板:在线,hello 里的系统、架构和版本都被记下来了
|
||||
await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, path))["online"]!.GetValue<bool>(), because: "the probe links to the panel");
|
||||
await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, path))["runtime_status"]!.GetValue<string>() == "probe_paused", because: "a Probe without any enabled Agent endpoints reports paused probing");
|
||||
await BackendFixture.EventuallyAsync(
|
||||
async () => (await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/nodes"))["items"]!.AsArray().Any(n => n!["id"]!.GetValue<string>() == probe.ProbeId && n["os"] is not null && n["arch"] is not null),
|
||||
because: "the platform comes from the probe's own hello");
|
||||
|
||||
// 吊销:面板立刻断开它,之后连不上
|
||||
using HttpResponseMessage revoked = await _backend.Admin.PostAsync(path + "/revoke", null);
|
||||
revoked.EnsureSuccessStatusCode();
|
||||
await BackendFixture.EventuallyAsync(async () =>
|
||||
!(await _backend.GetJsonAsync(_backend.Admin, path))["online"]!.GetValue<bool>() && !probe.App.Link.IsConnected,
|
||||
because: "a revoked probe is disconnected and cannot come back");
|
||||
Assert.Equal("maintenance", (await _backend.GetJsonAsync(_backend.Admin, path))["runtime_status"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_FollowsTheAgentListThePanelKnows_AndSavesItForTheNextStart()
|
||||
{
|
||||
await using AgentHarness first = await AgentHarness.StartAsync(_backend);
|
||||
await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend);
|
||||
await BackendFixture.EventuallyAsync(() => Task.FromResult(probe.App.IsConnected(first.Id)));
|
||||
|
||||
await using AgentHarness second = await AgentHarness.StartAsync(_backend);
|
||||
await BackendFixture.EventuallyAsync(() => Task.FromResult(probe.App.IsConnected(second.Id)), TimeSpan.FromSeconds(30), "a new agent should be picked up through agents_changed: " + string.Join(";", probe.App.Directory.Agents.Select(a => $"{a.AgentId}@{a.Host}:{a.TcpPort}")) + " v" + probe.App.Directory.Version + " want " + second.Id);
|
||||
Assert.Contains(probe.App.Directory.Agents, a => a.AgentId == second.Id);
|
||||
Assert.True(File.Exists(Path.Combine(probe.Directory, "agents.json")));
|
||||
|
||||
// 停用一个 Agent:Probe 断开它
|
||||
using HttpResponseMessage disabled = await _backend.Admin.PostAsync($"/api/admin/agents/{second.Id}/disable", BackendFixture.Json(new JsonObject()));
|
||||
Assert.True(disabled.IsSuccessStatusCode, await disabled.Content.ReadAsStringAsync());
|
||||
await BackendFixture.EventuallyAsync(() => Task.FromResult(!probe.App.IsConnected(second.Id)), TimeSpan.FromSeconds(30));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Hy2Ladder_WithTheRealProbe_Succeeds_AndEveryFragmentIsStored()
|
||||
{
|
||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
||||
await agent.OverrideLineTasksAsync();
|
||||
await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend);
|
||||
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probe_connected"]!.GetValue<bool>());
|
||||
|
||||
using HttpResponseMessage accepted = await _backend.Admin.PostAsync($"/api/admin/lines/{agent.Id}~{probe.ProbeId}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "hy2_ladder", ["params"] = new JsonObject { ["size"] = 1000, ["duration_s"] = 5 } }));
|
||||
Assert.True(accepted.StatusCode == System.Net.HttpStatusCode.Accepted, await accepted.Content.ReadAsStringAsync());
|
||||
string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue<string>();
|
||||
|
||||
JsonNode test = null!;
|
||||
await BackendFixture.EventuallyAsync(
|
||||
async () =>
|
||||
{
|
||||
test = await _backend.GetJsonAsync(_backend.Admin, "/api/public/tests/" + testId);
|
||||
return test["status"]!.GetValue<string>() is "succeeded" or "failed" or "aborted";
|
||||
},
|
||||
TimeSpan.FromSeconds(90));
|
||||
Assert.True(test["status"]!.GetValue<string>() == "succeeded", test.ToJsonString());
|
||||
|
||||
await BackendFixture.EventuallyAsync(async () => await ChunkCountAsync(agent, "udp_train") >= 5, because: "the 100 Mbit/s phase (5 s of 1000 B packets, over 50000 records) is split into two fragments");
|
||||
Assert.Equal(0, agent.App.Counters.FragmentsDiscarded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RealitySession_WithTheRealProbe_RunsTcpBulkAndStoresBytesAndKernelRows()
|
||||
{
|
||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
||||
await agent.OverrideLineTasksAsync();
|
||||
await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend);
|
||||
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probe_connected"]!.GetValue<bool>());
|
||||
|
||||
using HttpResponseMessage accepted = await _backend.Admin.PostAsync($"/api/admin/lines/{agent.Id}~{probe.ProbeId}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "reality_session" }));
|
||||
Assert.True(accepted.StatusCode == System.Net.HttpStatusCode.Accepted, await accepted.Content.ReadAsStringAsync());
|
||||
string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue<string>();
|
||||
|
||||
JsonNode test = null!;
|
||||
await BackendFixture.EventuallyAsync(
|
||||
async () =>
|
||||
{
|
||||
test = await _backend.GetJsonAsync(_backend.Admin, "/api/public/tests/" + testId);
|
||||
return test["status"]!.GetValue<string>() is "succeeded" or "failed" or "aborted";
|
||||
},
|
||||
TimeSpan.FromSeconds(150));
|
||||
Assert.True(test["status"]!.GetValue<string>() == "succeeded", test.ToJsonString());
|
||||
await BackendFixture.EventuallyAsync(async () => await ChunkCountAsync(agent, "tcp_bulk") >= 1, because: "the bulk phase stores its fragments");
|
||||
Assert.Equal(0, agent.App.Counters.FragmentsDiscarded);
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using Fleet.Core;
|
||||
using MoShiro.Probe;
|
||||
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <summary>进程内的真实 Probe:用加入令牌注册出来的身份(节点自己生成的两把密钥)启动 <see cref="ProbeApp"/>。</summary>
|
||||
public sealed class ProbeHarness : IAsyncDisposable
|
||||
{
|
||||
private readonly CancellationTokenSource _stop = new();
|
||||
private readonly string _directory;
|
||||
private readonly Task _run;
|
||||
private readonly EnrolledProbe _enrolled;
|
||||
|
||||
private ProbeHarness(string directory, ProbeApp app, EnrolledProbe enrolled)
|
||||
{
|
||||
_directory = directory;
|
||||
_enrolled = enrolled;
|
||||
App = app;
|
||||
_run = Task.Run(() => app.RunAsync(_stop.Token), CancellationToken.None);
|
||||
}
|
||||
|
||||
public ProbeApp App { get; }
|
||||
|
||||
public string Directory => _directory;
|
||||
|
||||
public string ProbeId { get; init; } = "";
|
||||
|
||||
/// <summary>用加入令牌注册(节点自己生成密钥),把面板地址和身份目录写进 <c>probe.json</c>,然后启动。</summary>
|
||||
public static async Task<ProbeHarness> StartAsync(BackendFixture backend, Action<ProbeFile>? configure = null)
|
||||
{
|
||||
EnrolledProbe enrolled = await backend.CreateProbeAsync();
|
||||
string directory = enrolled.Directory;
|
||||
string path = Path.Combine(directory, "probe.json");
|
||||
var written = new ProbeFile { Fleet = { Server = backend.BaseAddress.ToString(), IdentityDir = directory } };
|
||||
await File.WriteAllTextAsync(path, System.Text.Json.JsonSerializer.Serialize(written, ProbeConfigContext.Default.ProbeFile), Encoding.UTF8);
|
||||
|
||||
ProbeFile file = Fleet.Hosting.NodeConfig.Load(path, ProbeConfigContext.Default.ProbeFile);
|
||||
configure?.Invoke(file);
|
||||
var app = new ProbeApp(file, enrolled.Identity, directory, TimeProvider.System, NullNodeLog.Instance);
|
||||
return new ProbeHarness(directory, app, enrolled) { ProbeId = enrolled.ProbeId };
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _stop.CancelAsync();
|
||||
try
|
||||
{
|
||||
await _run.WaitAsync(TimeSpan.FromSeconds(15));
|
||||
}
|
||||
catch (Exception ex) when (ex is OperationCanceledException or TimeoutException)
|
||||
{
|
||||
// 停止中
|
||||
}
|
||||
|
||||
App.Dispose();
|
||||
_enrolled.Dispose();
|
||||
_stop.Dispose();
|
||||
try
|
||||
{
|
||||
System.IO.Directory.Delete(_directory, recursive: true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// 临时目录清理失败不影响测试结果
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ using System.Reflection;
|
||||
using Fleet.Rpc;
|
||||
using Fleet.Server;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MoShiro.Probe;
|
||||
using MoShiro.Agent;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Tests;
|
||||
@@ -55,29 +55,13 @@ public sealed class ProtocolDeclarationTests : IClassFixture<BackendFixture>
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Agent_HandlesEveryMethodItShould_OnBothPlanes()
|
||||
public async Task Node_HandlesEveryMethodItShould_OnBothPlanes()
|
||||
{
|
||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
||||
Assert.Empty(s_table.MissingHandlers(RpcSide.Node, NodeRoles.Agent, agent.App.Link.Router.Methods));
|
||||
// 对等面:Agent 是监听方,要处理 Probe 发来的方法
|
||||
Assert.Empty(s_table.MissingHandlers(RpcSide.Node, null, agent.App.Link.Router.Methods));
|
||||
// 对等面:节点作为监听方处理拨号方发来的方法,作为拨号方处理监听方发来的方法
|
||||
Assert.Empty(s_table.MissingHandlers(RpcSide.Listener, null, agent.App.PeerRouter.Methods));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_HandlesEveryMethodItShould_OnBothPlanes()
|
||||
{
|
||||
await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend);
|
||||
Assert.Empty(s_table.MissingHandlers(RpcSide.Node, NodeRoles.Probe, probe.App.Link.Router.Methods));
|
||||
// 对等面:Probe 是拨号方,要处理 Agent 发来的方法
|
||||
Assert.Empty(s_table.MissingHandlers(RpcSide.Dialer, null, AgentConnection.Router.Methods));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RolesOfNodeIds_FollowThePrefix()
|
||||
{
|
||||
Assert.Equal(NodeRoles.Agent, NodeRoles.Of(AgentIds.Prefix + "_01ABC"));
|
||||
Assert.Equal(NodeRoles.Probe, NodeRoles.Of(ProbeId.Prefix + "_01ABC"));
|
||||
Assert.Null(NodeRoles.Of("usr_01ABC"));
|
||||
Assert.Empty(s_table.MissingHandlers(RpcSide.Dialer, null, DialConnection.Router.Methods));
|
||||
}
|
||||
|
||||
private static string FindRoot()
|
||||
|
||||
@@ -58,7 +58,7 @@ public sealed class ReleaseFeedTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("probe", "dev")] // 仓库里没有这个组件的清单
|
||||
[InlineData("unsupported", "dev")] // 仓库里没有这个组件的清单
|
||||
[InlineData("agent", "release")] // 没有这个通道
|
||||
public async Task NothingThere_MeansNull_NotAnException(string component, string channel)
|
||||
{
|
||||
|
||||
@@ -76,7 +76,7 @@ public sealed class ReleaseOfferTests : IClassFixture<BackendFixture>
|
||||
[Fact]
|
||||
public async Task OfferWithAValidSignature_IsAccepted_WithoutAnyToken()
|
||||
{
|
||||
long seq = TimeProvider.System.GetUtcNow().ToUnixTimeSeconds() * 1000; // backend 的 release 通道:不和别的用例抢 agent、probe 的 seq
|
||||
long seq = TimeProvider.System.GetUtcNow().ToUnixTimeSeconds() * 1000; // backend 的 release 通道:不和别的用例抢 agent 的 seq
|
||||
string manifest = $$"""{"schema":1,"component":"backend","channel":"release","image":"registry.example/moshiro/moshiro-backend","digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000","tag":"dev-x","version":"0.1.1","seq":{{seq}}}""";
|
||||
using HttpClient anonymous = _backend.NewClient();
|
||||
|
||||
|
||||
@@ -256,7 +256,7 @@ public sealed class StorageTests : IDisposable
|
||||
// ───────────────────────── 节点、令牌、设置、事件 ─────────────────────────
|
||||
|
||||
private static NewNode Node(string id, string name, NodeStatus status = NodeStatus.Active, byte fill = 1) =>
|
||||
new(new NodeId(id), name, status, new byte[] { fill, 2, 3 }, new byte[32], ["lab"], """{"k":1}""", "1.0", 1_000);
|
||||
new(new NodeId(id), name, status, new byte[] { fill, 2, 3 }, new byte[32], ["lab"], """{"k":1}""", "1.0", 1_000, TlsPublicKey: new byte[] { fill, 2, 3 });
|
||||
|
||||
[Fact]
|
||||
public async Task Agents_NamesAreUniqueAmongEstablishedNodes_PendingOnesMayRepeat()
|
||||
|
||||
@@ -33,7 +33,7 @@ public sealed class TrustTests : IClassFixture<BackendFixture>
|
||||
private static JsonObject Sign(SoftwarePasskey passkey, string statement) => SharedOperator.Sign(passkey, statement);
|
||||
|
||||
/// <summary>创建一个加入码(普通 API key 就行,不需要 passkey),返回响应里的码和三条安装命令。</summary>
|
||||
private async Task<(string Code, JsonNode Commands, HttpStatusCode Status)> CreateCodeAsync(string role = "probe", string[]? controllers = null)
|
||||
private async Task<(string Code, JsonNode Commands, HttpStatusCode Status)> CreateCodeAsync(string role = "agent", string[]? controllers = null)
|
||||
{
|
||||
var body = new JsonObject { ["role"] = role, ["note"] = "trust test" };
|
||||
if (controllers is not null)
|
||||
|
||||
@@ -51,11 +51,11 @@ public sealed class UpdateTests : IClassFixture<BackendFixture>
|
||||
await using AgentSimulator late = await _backend.ConnectAgentAsync();
|
||||
await BackendFixture.EventuallyAsync(() => Task.FromResult(late.UpdateOffers.ToArray().Any(o => o.Manifest == manifest)), because: "a node that connects later still gets the latest offer");
|
||||
|
||||
// 另一个组件(probe)的清单不会发给 Agent
|
||||
using HttpResponseMessage probeRelease = await PublishAsync(Manifest("probe", seq));
|
||||
Assert.Equal(HttpStatusCode.Created, probeRelease.StatusCode);
|
||||
// 另一个组件(backend)的清单不会发给 Agent
|
||||
using HttpResponseMessage backendRelease = await PublishAsync(Manifest("backend", seq));
|
||||
Assert.Equal(HttpStatusCode.Created, backendRelease.StatusCode);
|
||||
await Task.Delay(300);
|
||||
Assert.DoesNotContain(online.UpdateOffers.ToArray(), o => o.Manifest.Contains("\"component\":\"probe\"", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(online.UpdateOffers.ToArray(), o => o.Manifest.Contains("\"component\":\"backend\"", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in new issue
Block a user