feat: daily node restart controlled from the admin, connect timings as a default line task [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful

Agents and Probes exit in order at a configured time (default 04:30 UTC+8, switch and time zone in the admin settings) and the service manager starts them again; a local restart_at or MOSHIRO_RESTART_AT wins. The connect kind (TCP connect and TLS handshake timings) is now a default continuous line template, with a section on the line page.
This commit is contained in:
justme committed 2026-10-07 14:56:50 +08:00
1 parent 388619b8b6
commit 9bcef56497
37 files changed
+928 -101

No files matched your search

@@ -44,7 +44,7 @@ public sealed class AgentEndToEndTests
{
// 默认模板的端口(7443/7444)不在这个 Agent 实际监听的端口里:任务被拒绝,原因是 policy_denied
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["rejected"]!.AsArray().Count == 2);
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["rejected"]!.AsArray().Count == Defaults.TaskCount);
JsonNode rejected = (await AgentViewAsync(agent))["rejected"]!;
Assert.All(rejected.AsArray(), item => Assert.Equal("policy_denied", item!["reason"]!.GetValue<string>()));
@@ -88,16 +88,19 @@ public sealed class AgentHarness : IAsyncDisposable
public async Task OverrideLineTasksAsync(int? rateHz = null)
{
JsonNode tasks = await Backend.GetJsonAsync(Backend.Admin, "/api/admin/tasks");
foreach (JsonNode? task in tasks["items"]!.AsArray())
// 先处理 connect(关掉),再改 udp_echo / tcp_echo 的速率:否则中途的带宽预算会多出 connect 那一份
foreach (JsonNode? task in tasks["items"]!.AsArray().OrderBy(t => t!["kind"]!.GetValue<string>() == "connect" ? 0 : 1))
{
if (task!["scope"]!.GetValue<string>() != "line" || task["agent_id"] is not null || task["kind"]!.GetValue<string>() is not ("udp_echo" or "tcp_echo"))
if (task!["scope"]!.GetValue<string>() != "line" || task["agent_id"] is not null || task["kind"]!.GetValue<string>() is not ("udp_echo" or "tcp_echo" or "connect"))
{
continue;
}
string kind = task["kind"]!.GetValue<string>();
var parameters = new JsonObject { ["port"] = kind == "udp_echo" ? App.UdpPorts[0] : App.TcpPorts[0] };
if (rateHz is { } hz)
// 默认的 connect 模板在这些用例里不需要(也占预算):对这个 Agent 关掉它
bool enabled = kind != "connect";
if (rateHz is { } hz && kind != "connect")
{
parameters["rate_hz"] = hz;
}
@@ -109,6 +112,7 @@ public sealed class AgentHarness : IAsyncDisposable
["agent_id"] = Id,
["base_task_id"] = task["id"]!.GetValue<string>(),
["params"] = parameters,
["enabled"] = enabled,
}));
Assert.True(created.StatusCode == System.Net.HttpStatusCode.Created, await created.Content.ReadAsStringAsync());
}
@@ -10,6 +10,12 @@ using MoShiro.TestSupport;
namespace MoShiro.Backend.Tests;
/// <summary>首次启动种入的默认线路任务模板数:udp_echo、tcp_echo、connect。</summary>
internal static class Defaults
{
public const int TaskCount = 3;
}
/// <summary>
/// 在真实的 Kestrel 端口上启动整个后端(真实的 SQLite 与段文件,只是用临时目录和较快的轮询),
/// 并提供管理员客户端(Bearer 令牌)和假 Agent。
@@ -24,8 +24,8 @@ public sealed class EnrollmentAndTaskSetTests
Assert.Equal("active", agent.EnrollmentStatus);
Assert.Equal("active", agent.AuthStatus);
TaskSet set = await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
Assert.Equal(["tcp_echo", "udp_echo"], set.Tasks.Select(t => t.Kind).Order().ToArray());
TaskSet set = await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount);
Assert.Equal(["connect", "tcp_echo", "udp_echo"], set.Tasks.Select(t => t.Kind).Order().ToArray());
Assert.All(set.Tasks, t =>
{
Assert.Equal("line", t.Scope);
@@ -56,7 +56,7 @@ public sealed class EnrollmentAndTaskSetTests
public async Task Reconnecting_AlwaysGetsTheCurrentSnapshotAgain()
{
AgentSimulator first = await _backend.ConnectAgentAsync();
TaskSet before = await first.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
TaskSet before = await first.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount);
await first.DisposeAsync();
// 同一个身份重新连接:新连接的服务端状态为空,所以会重新推送当前版本
@@ -157,7 +157,7 @@ public sealed class EnrollmentAndTaskSetTests
using HttpResponseMessage approve = await _backend.Admin.PostAsync($"/api/admin/agents/{agent.NodeId.Value}/approve", null);
Assert.Equal(HttpStatusCode.OK, approve.StatusCode);
TaskSet set = await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
TaskSet set = await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount);
Assert.NotNull(set);
}
@@ -165,7 +165,7 @@ public sealed class EnrollmentAndTaskSetTests
public async Task DisablingAConnectedAgent_RevokesItAndRefusesFutureConnections()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount);
using HttpResponseMessage disable = await _backend.Admin.PostAsync($"/api/admin/agents/{agent.NodeId.Value}/disable", null);
Assert.Equal(HttpStatusCode.OK, disable.StatusCode);
@@ -239,7 +239,7 @@ public sealed class EnrollmentAndTaskSetTests
public async Task TaskOverride_ChangesTheDesiredStateAndBumpsTheVersion()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
TaskSet initial = await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
TaskSet initial = await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount);
TaskSpec udp = initial.Tasks.Single(t => t.Kind == "udp_echo");
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject
@@ -254,7 +254,7 @@ public sealed class EnrollmentAndTaskSetTests
Assert.StartsWith("/api/admin/tasks/", created.Headers.Location!.ToString());
TaskSet updated = await agent.WaitForTaskSetAsync(s => s.Version > initial.Version && s.Tasks.Any(t => t.Kind == "udp_echo" && t.Params["rate_hz"].GetInt64() == 20));
Assert.Equal(2, updated.Tasks.Count);
Assert.Equal(Defaults.TaskCount, updated.Tasks.Count);
Assert.Equal(7443, updated.Tasks.Single(t => t.Kind == "udp_echo").Params["port"].GetInt64());
// 停用覆盖 → 该 Agent 不再运行这条任务(模板对其他 Agent 不受影响)
@@ -262,7 +262,7 @@ public sealed class EnrollmentAndTaskSetTests
using HttpResponseMessage disable = await _backend.Admin.PatchAsync("/api/admin/tasks/" + overrideId, BackendFixture.Json(new JsonObject { ["enabled"] = false }, "application/merge-patch+json"));
Assert.Equal(HttpStatusCode.OK, disable.StatusCode);
TaskSet disabled = await agent.WaitForTaskSetAsync(s => s.Version > updated.Version && s.Tasks.All(t => t.Kind != "udp_echo"));
Assert.Single(disabled.Tasks);
Assert.Equal(Defaults.TaskCount - 1, disabled.Tasks.Count);
}
[Fact]
@@ -314,7 +314,7 @@ public sealed class EnrollmentAndTaskSetTests
public async Task TaskSet_RejectedItemsReportedByTheAgent_AreVisibleToTheAdmin()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount);
agent.RejectNextState = [new Fleet.Rpc.RejectedItem { Id = "tsk_x", Reason = "policy_denied" }];
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject
@@ -330,7 +330,7 @@ public sealed class EnrollmentAndTaskSetTests
public async Task ProbeLifecycle_CreateRotateRevokeDelete_IsReflectedInEveryAgentsPeerList()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount);
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/probes", BackendFixture.Json(new JsonObject { ["name"] = _backend.UniqueName("home") }));
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
@@ -25,7 +25,7 @@ public sealed class OnDemandTestsTests
private async Task<AgentSimulator> AgentWithProbeAsync()
{
AgentSimulator agent = await _backend.ConnectAgentAsync();
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
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>());
return agent;
@@ -37,7 +37,7 @@ public sealed class OnDemandTestsTests
public async Task Start_RequiresAProbeSession_APlanThatExists_AndValidParams()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount);
using HttpResponseMessage offline = await StartAsync(agent, Plan("hy2_ladder"));
Assert.Equal(HttpStatusCode.Conflict, offline.StatusCode);
@@ -139,7 +139,7 @@ public sealed class OperationsAndSecurityTests
public async Task OversizedFrames_CloseTheConnection_WithoutHarmingTheServer()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount);
try
{
await agent.SendBinaryAsync(new byte[17 * 1024 * 1024]);
@@ -153,14 +153,14 @@ public sealed class OperationsAndSecurityTests
// 服务端仍然正常
await using AgentSimulator another = await _backend.ConnectAgentAsync();
Assert.NotNull(await another.WaitForTaskSetAsync(s => s.Tasks.Count == 2));
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 == 2);
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount);
await agent.SendTextAsync("{not json");
await agent.SendTextAsync("[]");
@@ -187,7 +187,7 @@ public sealed class OperationsAndSecurityTests
Assert.Empty(agent.TaskSets);
await agent.HelloAsync();
Assert.NotNull(await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2));
Assert.NotNull(await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount));
}
[Fact]
@@ -205,9 +205,9 @@ public sealed class OperationsAndSecurityTests
public async Task ReconnectingTheSameNode_ReplacesTheOldConnection()
{
AgentSimulator first = await _backend.ConnectAgentAsync();
await first.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
await first.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount);
await using AgentSimulator second = await _backend.ReconnectAsync(first);
await second.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
await second.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount);
// 旧连接被替换掉,但节点仍然在线(在线状态属于新连接)
await first.WaitUntilAsync(() => first.CloseReason is not null);
@@ -57,7 +57,7 @@ public sealed class PersistenceTests : IAsyncLifetime
// 同一个身份可以重新连接,并拿回当前版本的期望状态
await using AgentSimulator again = await _backend.ReconnectAsync(agent);
Assert.NotNull(await again.WaitForTaskSetAsync(s => s.Tasks.Count == 2));
Assert.NotNull(await again.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount));
// 已经落盘的块在重启后仍然可以按 ID 下载
JsonNode chunks = await _backend.GetJsonAsync(admin, $"/api/admin/raw/chunks?agent_id={id}&limit=1");
@@ -0,0 +1,80 @@
using System.Net;
using System.Text.Json.Nodes;
using MoShiro.Backend.Application;
using MoShiro.Protocol;
using MoShiro.TestSupport;
namespace MoShiro.Backend.Tests;
/// <summary>定时重启:默认开、北京时间 04:30;节点连上时补发,管理端改了设置马上群发,关掉就发空。</summary>
[Collection("probe-e2e")]
public sealed class ScheduleTests
{
private readonly BackendFixture _backend;
public ScheduleTests(BackendFixture backend)
{
_backend = backend;
}
private async Task PatchAsync(JsonObject settings)
{
using HttpResponseMessage response = await _backend.Admin.PatchAsync("/api/admin/settings", BackendFixture.Json(settings, "application/merge-patch+json"));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
private static Task<bool> LastIs(AgentSimulator agent, string? restartAt) =>
Task.FromResult(agent.Schedules.ToArray() is { Length: > 0 } all && all[^1].RestartAt == restartAt);
[Fact]
public void Format_WritesTheOffsetTheNodeParses()
{
Assert.Equal("04:30+08:00", ScheduleService.Format(4, 30, 480));
Assert.Equal("00:00+00:00", ScheduleService.Format(0, 0, 0));
Assert.Equal("23:05-05:30", ScheduleService.Format(23, 5, -330));
}
[Fact]
public async Task ANewNode_GetsTheDefaultSchedule_AndLaterChangesAreBroadcast()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
await BackendFixture.EventuallyAsync(() => LastIs(agent, "04:30+08:00"), because: "defaults: on, 04:30 Beijing time, sent right after hello");
try
{
await PatchAsync(new JsonObject { ["fleet.restart_hour"] = 3, ["fleet.restart_minute"] = 5, ["fleet.restart_utc_offset_minutes"] = 0 });
await BackendFixture.EventuallyAsync(() => LastIs(agent, "03:05+00:00"), because: "a changed time is pushed to online nodes");
await PatchAsync(new JsonObject { ["fleet.restart_enabled"] = false });
await BackendFixture.EventuallyAsync(() => LastIs(agent, null), because: "switching it off sends an empty schedule");
// 关着的时候新连上的节点也拿到「关」
await using AgentSimulator late = await _backend.ConnectAgentAsync();
await BackendFixture.EventuallyAsync(() => LastIs(late, null), because: "a node connecting while it is off is told so");
}
finally
{
await PatchAsync(new JsonObject { ["fleet.restart_enabled"] = true, ["fleet.restart_hour"] = 4, ["fleet.restart_minute"] = 30, ["fleet.restart_utc_offset_minutes"] = 480 });
}
}
[Fact]
public async Task BadValues_AreRefused()
{
using HttpResponseMessage response = await _backend.Admin.PatchAsync("/api/admin/settings",
BackendFixture.Json(new JsonObject { ["fleet.restart_hour"] = 24, ["fleet.restart_minute"] = 60, ["fleet.restart_utc_offset_minutes"] = 900 }, "application/merge-patch+json"));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task DefaultLineTemplates_IncludeTheConnectTimings()
{
using HttpResponseMessage response = await _backend.Admin.GetAsync("/api/admin/tasks");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
JsonArray items = JsonNode.Parse(await response.Content.ReadAsStringAsync())!["items"]!.AsArray();
string[] kinds = [.. items.Select(i => i!["kind"]!.GetValue<string>())];
Assert.Contains("connect", kinds);
Assert.Contains("udp_echo", kinds);
Assert.Contains("tcp_echo", kinds);
}
}
@@ -51,6 +51,9 @@ public sealed class AgentSimulator : IAsyncDisposable
/// <summary>面板发来的发布清单(<c>moshiro.update.offer</c>)和更新请求(<c>moshiro.update.apply</c>)。</summary>
public ConcurrentQueue<UpdateOfferParams> UpdateOffers { get; } = new();
/// <summary>面板下发的定时重启设置(<c>moshiro.schedule</c>),按到达顺序。</summary>
public ConcurrentQueue<ScheduleParams> Schedules { get; } = new();
private int _updateApplies;
public int UpdateApplies => Volatile.Read(ref _updateApplies);
@@ -406,6 +409,9 @@ public sealed class AgentSimulator : IAsyncDisposable
case MoShiroMethods.UpdateOffer:
UpdateOffers.Enqueue(message.Params!.Value.Deserialize(MoShiroJsonContext.Default.UpdateOfferParams)!);
break;
case MoShiroMethods.Schedule:
Schedules.Enqueue(message.Params!.Value.Deserialize(MoShiroJsonContext.Default.ScheduleParams)!);
break;
case MoShiroMethods.UpdateApply:
Interlocked.Increment(ref _updateApplies);
break;