297 lines
16 KiB
C#
297 lines
16 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 Monitor.Backend.Analysis;
|
|
using Monitor.Backend.Application;
|
|
using Monitor.Protocol;
|
|
using Monitor.TestSupport;
|
|
|
|
namespace Monitor.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 == 2);
|
|
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 == 2));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MalformedAndUnknownMessages_AreIgnoredOrRefused_AndTheConnectionStaysUsable()
|
|
{
|
|
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
|
|
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
|
|
|
|
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])); // 没有注册的通道
|
|
|
|
// 节点不能调用任何“执行”类的方法:Fleet 根本没有这样的路由
|
|
foreach (string method in new[] { "fleet.exec", "fleet.shell", "monitor.exec", "monitor.write_file", "fleet.update" })
|
|
{
|
|
RpcMessage reply = await agent.RequestRawAsync(method);
|
|
Assert.Equal("fleet_unsupported", reply.Error!.Data!.Code);
|
|
}
|
|
|
|
// 连接仍然可用
|
|
ProbeAgentsResult list = await agent.RequestProbeAgentsAsync();
|
|
Assert.Contains(list.Agents, a => a.AgentId == agent.NodeId);
|
|
}
|
|
|
|
[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 == 2));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PendingNodes_CannotCallProductMethods()
|
|
{
|
|
await using AgentSimulator agent = await _backend.ConnectAgentAsync(autoApprove: false);
|
|
RpcMessage reply = await agent.RequestRawAsync(MonitorMethods.ProbeAgents, JsonSerializer.SerializeToElement(new ProbeAgentsParams(), MonitorJsonContext.Default.ProbeAgentsParams));
|
|
Assert.Equal("fleet_node_pending", reply.Error!.Data!.Code);
|
|
|
|
// 状态与事件也不会被接受:监控数据里没有它
|
|
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 == 2);
|
|
await using AgentSimulator second = await _backend.ReconnectAsync(first);
|
|
await second.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
|
|
|
|
// 旧连接被替换掉,但节点仍然在线(在线状态属于新连接)
|
|
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);
|
|
}
|
|
}
|