ci/woodpecker/push/main-amd64-build Pipeline was successful
Methods, roles and binary channels are declared in one table per plane (node and peer). RpcRouter checks direction, kind and role when handlers are registered, when messages arrive (fleet_protocol_violation, disconnect after repeated violations) and before sending. NodeConnection is split by responsibility.
323 lines
17 KiB
C#
323 lines
17 KiB
C#
using System.Net;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using Fleet.Core;
|
|
using Fleet.Rpc;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Hosting;
|
|
using MoShiro.Backend.Analysis;
|
|
using MoShiro.Backend.Application;
|
|
using MoShiro.Protocol;
|
|
using MoShiro.TestSupport;
|
|
|
|
namespace MoShiro.Backend.Tests;
|
|
|
|
[Collection("backend")]
|
|
public sealed class OperationsAndSecurityTests
|
|
{
|
|
private const long Minute = 60_000;
|
|
|
|
private readonly BackendFixture _backend;
|
|
|
|
public OperationsAndSecurityTests(BackendFixture backend)
|
|
{
|
|
_backend = backend;
|
|
}
|
|
|
|
private static long Now() => TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds();
|
|
|
|
private static string Iso(long ms) => DateTimeOffset.FromUnixTimeMilliseconds(ms).UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'");
|
|
|
|
private T Service<T>()
|
|
where T : class => _backend.Services.GetRequiredService<T>();
|
|
|
|
private T HostedService<T>()
|
|
where T : class => _backend.Services.GetServices<IHostedService>().OfType<T>().Single();
|
|
|
|
// ───────────────────────── 指标版本与回填 ─────────────────────────
|
|
|
|
[Fact]
|
|
public async Task ANewOrChangedMetric_IsBackfilledOverTheRetainedRawData()
|
|
{
|
|
long baseMs = ((Now() / Minute) - 4) * Minute;
|
|
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
|
|
for (int k = 0; k < 12; k++)
|
|
{
|
|
Assert.True(await agent.SendChunkAsync(ChunkFactory.Echo(agent.NodeId, "udp_echo", baseMs + (k * 10_000L), 100, 100, _ => new EchoPacket(false, false, 30_000, 30_000))));
|
|
}
|
|
|
|
string url = $"/api/public/lines/{_backend.Line(agent.NodeId.Value)}/series?from={Iso(baseMs)}&to={Iso(baseMs + (2 * Minute))}&step=1m&metrics=rtt.p95,loss.total";
|
|
JsonNode before = null!;
|
|
await BackendFixture.EventuallyAsync(async () =>
|
|
{
|
|
before = await _backend.GetJsonAsync(_backend.Admin, url);
|
|
return before["summary"]!["samples"]!["udp_echo"]?.GetValue<long>() == 1200 && before["source"]!.GetValue<string>() == "1m";
|
|
});
|
|
double rtt = before["summary"]!["metrics"]!["rtt.p95"]!.GetValue<double>();
|
|
Assert.InRange(rtt, 60 * 0.955, 60 * 1.045);
|
|
|
|
// 模拟“这个指标是新增的(或者它的口径变了)”:抹掉各层里已有的 rtt,并让登记的指纹对不上
|
|
var store = Service<IMetricStore>();
|
|
foreach (MetricRow row in await store.ReadAsync(MetricLevel.M1, agent.NodeId, "line:" + _backend.DefaultProbeId, baseMs, baseMs + (2 * Minute), default))
|
|
{
|
|
await store.PatchAsync(MetricLevel.M1, [row with { Metrics = AccumulatorCodec.Encode(new MetricSet()) }], new HashSet<string> { "rtt" }, default);
|
|
}
|
|
|
|
await store.SetVersionAsync(new MetricVersionRecord("rtt", "stale-fingerprint", null, null), default);
|
|
JsonNode erased = await _backend.GetJsonAsync(_backend.Admin, url);
|
|
Assert.Null(erased["summary"]!["metrics"]!["rtt.p95"]);
|
|
Assert.NotNull(erased["summary"]!["metrics"]!["loss.total"]); // 其他指标不受影响
|
|
|
|
// 启动时的指纹比对发现 rtt 变了:在保留期内的原始块上重算它(限速、低优先级)
|
|
await HostedService<MetricRecompute>().RunAsync(default);
|
|
RecomputeProgress progress = HostedService<MetricRecompute>().Progress;
|
|
Assert.Equal("done", progress.State);
|
|
Assert.Equal(["rtt"], progress.Metrics.ToArray());
|
|
Assert.True(progress.WindowsTotal >= 2);
|
|
Assert.Equal(progress.WindowsTotal, progress.WindowsDone);
|
|
|
|
JsonNode after = await _backend.GetJsonAsync(_backend.Admin, url);
|
|
Assert.Equal(rtt, after["summary"]!["metrics"]!["rtt.p95"]!.GetValue<double>(), 9);
|
|
Assert.Equal(MetricRegistry.Find("rtt")!.Fingerprint, (await store.GetVersionsAsync(default)).Single(v => v.MetricId == "rtt").Fingerprint);
|
|
|
|
// 完成之后再比对:没有变化,不再重算
|
|
await HostedService<MetricRecompute>().RunAsync(default);
|
|
Assert.Equal("idle", HostedService<MetricRecompute>().Progress.State);
|
|
|
|
JsonNode api = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/analysis/recompute");
|
|
Assert.Equal("idle", api["state"]!.GetValue<string>());
|
|
}
|
|
|
|
// ───────────────────────── 保留 ─────────────────────────
|
|
|
|
[Fact]
|
|
public async Task Retention_RemovesRawChunksPastTheConfiguredPeriod_AndTheSettingTakesEffectImmediately()
|
|
{
|
|
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
|
|
RawChunk old = ChunkFactory.UdpEchoClean(agent.NodeId, Now() - (45L * 24 * 60 * Minute));
|
|
RawChunk recent = ChunkFactory.UdpEchoClean(agent.NodeId, Now() - (2L * 24 * 60 * Minute));
|
|
RawChunk fresh = ChunkFactory.UdpEchoClean(agent.NodeId, Now() - (5 * Minute));
|
|
foreach (RawChunk chunk in new[] { old, recent, fresh })
|
|
{
|
|
Assert.True(await agent.SendChunkAsync(chunk));
|
|
}
|
|
|
|
async Task<int> CountAsync() =>
|
|
(await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.NodeId.Value}"))["items"]!.AsArray().Count;
|
|
|
|
Assert.Equal(3, await CountAsync());
|
|
await HostedService<RetentionService>().RunOnceAsync(default);
|
|
Assert.Equal(2, await CountAsync()); // 默认保留 30 天:45 天前的块被删除
|
|
|
|
using HttpResponseMessage patch = await _backend.Admin.PatchAsync(
|
|
"/api/admin/settings", BackendFixture.Json(new JsonObject { ["retention.raw_days"] = 1 }, "application/merge-patch+json"));
|
|
Assert.Equal(HttpStatusCode.OK, patch.StatusCode);
|
|
await HostedService<RetentionService>().RunOnceAsync(default);
|
|
Assert.Equal(1, await CountAsync());
|
|
|
|
using HttpResponseMessage restore = await _backend.Admin.PatchAsync(
|
|
"/api/admin/settings", BackendFixture.Json(new JsonObject { ["retention.raw_days"] = 30 }, "application/merge-patch+json"));
|
|
Assert.Equal(HttpStatusCode.OK, restore.StatusCode);
|
|
}
|
|
|
|
// ───────────────────────── 节点连接的安全边界 ─────────────────────────
|
|
|
|
[Fact]
|
|
public async Task AnAuthSignatureCannotBeReplayedOnAnotherConnection()
|
|
{
|
|
await using AgentSimulator agent = await _backend.EnrollOnlyAsync();
|
|
byte[]? captured = null;
|
|
AuthAttempt first = await AgentWire.AuthenticateAsync(_backend.BaseAddress, agent.NodeId, nonce => captured = agent.SignAuth(agent.NodeId, nonce));
|
|
Assert.True(first.Ok);
|
|
|
|
AuthAttempt replay = await AgentWire.AuthenticateAsync(_backend.BaseAddress, agent.NodeId, _ => captured!);
|
|
Assert.False(replay.Ok);
|
|
Assert.Equal("fleet_unauthorized", replay.ErrorCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OversizedFrames_CloseTheConnection_WithoutHarmingTheServer()
|
|
{
|
|
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
|
|
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount);
|
|
try
|
|
{
|
|
await agent.SendBinaryAsync(new byte[17 * 1024 * 1024]);
|
|
}
|
|
catch (System.Net.WebSockets.WebSocketException)
|
|
{
|
|
// 服务端可能在发送完成之前就关闭了连接
|
|
}
|
|
|
|
await BackendFixture.EventuallyAsync(async () => !(await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value))["online"]!.GetValue<bool>());
|
|
|
|
// 服务端仍然正常
|
|
await using AgentSimulator another = await _backend.ConnectAgentAsync();
|
|
Assert.NotNull(await another.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MalformedAndUnknownMessages_AreIgnoredOrRefused_AndTheConnectionStaysUsable()
|
|
{
|
|
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
|
|
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount);
|
|
|
|
await agent.SendTextAsync("{not json");
|
|
await agent.SendTextAsync("[]");
|
|
await agent.SendBinaryAsync([]);
|
|
await agent.SendBinaryAsync([200, 1, 2, 3]); // 未知的二进制通道,长度不够一个完整的头部
|
|
await agent.SendBinaryAsync(OutboxFrame.Build(99, Guid.NewGuid(), [1])); // 没有注册的通道
|
|
|
|
// 节点不能调用任何“执行”类的方法:这些方法没有声明,是协议违规,直接拒绝
|
|
foreach (string method in new[] { "fleet.exec", "fleet.shell", "moshiro.exec", "moshiro.write_file", "fleet.update" })
|
|
{
|
|
RpcMessage reply = await agent.RequestRawAsync(method);
|
|
Assert.Equal("fleet_protocol_violation", reply.Error!.Data!.Code);
|
|
}
|
|
|
|
// 连接仍然可用:违规次数还没到上限,路由器照常回复
|
|
Assert.Equal("fleet_protocol_violation", (await agent.RequestRawAsync("fleet.exec")).Error!.Data!.Code);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ARepeatOffender_IsDisconnected_AndANodeCannotInitiateWhatOnlyTheBackendMay()
|
|
{
|
|
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
|
|
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount);
|
|
|
|
// 节点冒充 Backend 发起只有 Backend 才能发的方法:违规,不是「不支持」
|
|
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)没有声明,不会进收件箱
|
|
await agent.SendBinaryAsync(OutboxFrame.Build(MoShiroChannels.ColumnBlock, Guid.NewGuid(), [1]));
|
|
|
|
// 反复违规:超过上限(默认 20 次)之后连接被服务端关闭
|
|
for (int i = 0; i < 40 && agent.CloseReason is null; i++)
|
|
{
|
|
try
|
|
{
|
|
await agent.RequestRawAsync("fleet.exec");
|
|
}
|
|
catch (Exception ex) when (ex is TimeoutException or OperationCanceledException or System.Net.WebSockets.WebSocketException or InvalidOperationException)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
await BackendFixture.EventuallyAsync(() => Task.FromResult(agent.CloseReason is not null), because: "a node that keeps violating the protocol is disconnected");
|
|
Assert.Equal("protocol violations", agent.CloseReason);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WithoutHello_NoStateIsPushed_UntilTheNodeIntroducesItself()
|
|
{
|
|
await using AgentSimulator agent = await _backend.ConnectAgentAsync(hello: false);
|
|
await Task.Delay(400);
|
|
Assert.Empty(agent.TaskSets);
|
|
|
|
await agent.HelloAsync();
|
|
Assert.NotNull(await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PendingNodes_CannotCallProductMethods()
|
|
{
|
|
await using AgentSimulator agent = await _backend.ConnectAgentAsync(autoApprove: false);
|
|
// 状态与事件也不会被接受:监控数据里没有它
|
|
await agent.SendEventAsync(AgentEventKinds.ProbeConnected, _backend.DefaultProbeId);
|
|
await Task.Delay(200);
|
|
JsonNode view = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value);
|
|
Assert.False(view["probe_connected"]!.GetValue<bool>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReconnectingTheSameNode_ReplacesTheOldConnection()
|
|
{
|
|
AgentSimulator first = await _backend.ConnectAgentAsync();
|
|
await first.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount);
|
|
await using AgentSimulator second = await _backend.ReconnectAsync(first);
|
|
await second.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount);
|
|
|
|
// 旧连接被替换掉,但节点仍然在线(在线状态属于新连接)
|
|
await first.WaitUntilAsync(() => first.CloseReason is not null);
|
|
JsonNode view = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + first.NodeId.Value);
|
|
Assert.True(view["online"]!.GetValue<bool>());
|
|
}
|
|
|
|
// ───────────────────────── 用户、会话与令牌 ─────────────────────────
|
|
|
|
[Fact]
|
|
public async Task Users_CanBeCreated_PasswordChangeEndsTheirSessions_AndLogoutInvalidatesTheCookie()
|
|
{
|
|
string name = "user" + Guid.NewGuid().ToString("N")[..8];
|
|
using HttpResponseMessage weak = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = "short" }));
|
|
Assert.Equal(HttpStatusCode.BadRequest, weak.StatusCode);
|
|
Assert.NotNull(JsonNode.Parse(await weak.Content.ReadAsStringAsync())!["errors"]!["password"]);
|
|
|
|
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = "first password" }));
|
|
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
|
string userId = JsonNode.Parse(await created.Content.ReadAsStringAsync())!["id"]!.GetValue<string>();
|
|
using HttpResponseMessage duplicate = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = "another password" }));
|
|
Assert.Equal(HttpStatusCode.Conflict, duplicate.StatusCode);
|
|
|
|
using HttpClient client = _backend.NewClient();
|
|
(string cookie, string csrf) = await LoginAsync(client, name, "first password");
|
|
Assert.Equal(HttpStatusCode.OK, (await Send(client, HttpMethod.Get, "/api/admin/auth/me", cookie)).StatusCode);
|
|
|
|
using HttpResponseMessage change = await _backend.Admin.PatchAsync($"/api/admin/users/{userId}", BackendFixture.Json(new JsonObject { ["password"] = "second password" }));
|
|
Assert.Equal(HttpStatusCode.NoContent, change.StatusCode);
|
|
Assert.Equal(HttpStatusCode.Unauthorized, (await Send(client, HttpMethod.Get, "/api/admin/auth/me", cookie)).StatusCode); // 旧会话失效
|
|
|
|
using HttpResponseMessage oldPassword = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = "first password" }));
|
|
Assert.Equal(HttpStatusCode.Unauthorized, oldPassword.StatusCode);
|
|
(cookie, csrf) = await LoginAsync(client, name, "second password");
|
|
|
|
using HttpResponseMessage logout = await Send(client, HttpMethod.Post, "/api/admin/auth/logout", cookie, csrf);
|
|
Assert.Equal(HttpStatusCode.NoContent, logout.StatusCode);
|
|
Assert.Equal(HttpStatusCode.Unauthorized, (await Send(client, HttpMethod.Get, "/api/admin/auth/me", cookie)).StatusCode);
|
|
|
|
JsonNode users = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/users");
|
|
Assert.DoesNotContain("password", users.ToJsonString(), StringComparison.OrdinalIgnoreCase); // 响应里没有任何口令信息
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApiTokens_CanBeRevoked_AndTheirPlaintextIsNeverListed()
|
|
{
|
|
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "temp", ["scope"] = "read" }));
|
|
JsonNode body = JsonNode.Parse(await created.Content.ReadAsStringAsync())!;
|
|
string plaintext = body["plaintext"]!.GetValue<string>();
|
|
string id = body["token"]!["id"]!.GetValue<string>();
|
|
|
|
using HttpClient client = _backend.NewClient();
|
|
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", plaintext);
|
|
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/public/catalog/kinds")).StatusCode);
|
|
|
|
string listing = (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/api-tokens")).ToJsonString();
|
|
Assert.DoesNotContain(plaintext, listing, StringComparison.Ordinal);
|
|
|
|
using HttpResponseMessage revoke = await _backend.Admin.DeleteAsync("/api/admin/api-tokens/" + id);
|
|
Assert.Equal(HttpStatusCode.NoContent, revoke.StatusCode);
|
|
Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/api/admin/agents")).StatusCode);
|
|
}
|
|
|
|
private static async Task<(string Cookie, string Csrf)> LoginAsync(HttpClient client, string user, string password)
|
|
{
|
|
using HttpResponseMessage response = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = user, ["password"] = password }));
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
return (response.Headers.GetValues("Set-Cookie").Single().Split(';')[0], JsonNode.Parse(await response.Content.ReadAsStringAsync())!["csrf_token"]!.GetValue<string>());
|
|
}
|
|
|
|
private static Task<HttpResponseMessage> Send(HttpClient client, HttpMethod method, string url, string cookie, string? csrf = null)
|
|
{
|
|
var request = new HttpRequestMessage(method, url);
|
|
request.Headers.Add("Cookie", cookie);
|
|
if (csrf is not null)
|
|
{
|
|
request.Headers.Add("X-CSRF-Token", csrf);
|
|
}
|
|
|
|
return client.SendAsync(request);
|
|
}
|
|
}
|