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:
justme committed 2026-10-21 11:55:36 +08:00
1 parent d5ebf29e57
commit ad5efc62ff
206 files changed
+4202 -4964

No files matched your search

@@ -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>());
}
}