ci/woodpecker/push/main-amd64-build Pipeline was successful
Probes connect to the panel directly and get the agent list as desired state. CI signs the image digest of agent and probe releases, the panel forwards the manifest, and a host updater verifies it with a local public key, pulls by digest and rolls back when the new version is unhealthy.
293 lines
16 KiB
C#
293 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);
|
|
}
|
|
|
|
// 连接仍然可用:路由器照常回复
|
|
Assert.Equal("fleet_unsupported", (await agent.RequestRawAsync("fleet.exec")).Error!.Data!.Code);
|
|
}
|
|
|
|
[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);
|
|
// 状态与事件也不会被接受:监控数据里没有它
|
|
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);
|
|
}
|
|
}
|