feat: declare every protocol method's direction and enforce it in the router; add the zero-trust design [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
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.
This commit is contained in:
1 parent
ee22309e65
commit
1ff847e45a
36 files changed
+1799
-639
No files matched your search
@@ -168,15 +168,45 @@ public sealed class OperationsAndSecurityTests
|
||||
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", "moshiro.exec", "moshiro.write_file", "fleet.update" })
|
||||
{
|
||||
RpcMessage reply = await agent.RequestRawAsync(method);
|
||||
Assert.Equal("fleet_unsupported", reply.Error!.Data!.Code);
|
||||
Assert.Equal("fleet_protocol_violation", reply.Error!.Data!.Code);
|
||||
}
|
||||
|
||||
// 连接仍然可用:路由器照常回复
|
||||
Assert.Equal("fleet_unsupported", (await agent.RequestRawAsync("fleet.exec")).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]
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
using System.Reflection;
|
||||
using Fleet.Rpc;
|
||||
using Fleet.Server;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MoShiro.Probe;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 协议方向声明(<see cref="MoShiroMethodTable"/>)和实际代码的对账:每个方法常量都声明了,每一侧该处理的方法都注册了处理函数,
|
||||
/// 二进制通道都有对应的收件箱,文档里写了每一个方法。新增方法绕不过声明,声明了没人处理也会被发现。
|
||||
/// 注册处理函数时路由器本身还会核对方向、类型和角色,不符就在启动时失败。
|
||||
/// </summary>
|
||||
[Collection("probe-e2e")]
|
||||
public sealed class ProtocolDeclarationTests
|
||||
{
|
||||
private static readonly MethodTable s_table = MoShiroMethodTable.Table;
|
||||
|
||||
private readonly BackendFixture _backend;
|
||||
|
||||
public ProtocolDeclarationTests(BackendFixture backend)
|
||||
{
|
||||
_backend = backend;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ConstantsOf(Type type) =>
|
||||
type.GetFields(BindingFlags.Public | BindingFlags.Static).Where(f => f.IsLiteral && f.FieldType == typeof(string)).Select(f => (string)f.GetRawConstantValue()!);
|
||||
|
||||
[Fact]
|
||||
public void EveryMethodConstant_IsDeclared()
|
||||
{
|
||||
HashSet<string> declared = [.. s_table.Methods.Select(m => m.Name)];
|
||||
string[] undeclared = [.. ConstantsOf(typeof(FleetMethods)).Concat(ConstantsOf(typeof(PeerMethods))).Concat(ConstantsOf(typeof(MoShiroMethods))).Where(n => !declared.Contains(n))];
|
||||
Assert.Empty(undeclared);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryDeclaredMethod_IsDocumented()
|
||||
{
|
||||
string root = FindRoot();
|
||||
string docs = File.ReadAllText(Path.Combine(root, "docs", "protocol.md")) + File.ReadAllText(Path.Combine(root, "backend", "fleet", "README.md"));
|
||||
string[] missing = [.. s_table.Methods.Select(m => m.Name).Distinct().Where(n => !docs.Contains($"`{n}`", StringComparison.Ordinal))];
|
||||
Assert.Empty(missing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Backend_HandlesEveryMethodNodesMayInitiate_AndHasAnInboxForEveryDeclaredChannel()
|
||||
{
|
||||
FleetRouter router = _backend.Services.GetRequiredService<FleetRouter>();
|
||||
Assert.Empty(router.Table.MissingHandlers(RpcSide.Server, null, router.Methods));
|
||||
|
||||
byte[] inboxes = [.. _backend.Services.GetServices<IInbox>().Select(i => i.Channel).Order()];
|
||||
byte[] declared = [.. s_table.Channels.Where(c => c.Plane == RpcPlane.Node).Select(c => c.Channel).Order()];
|
||||
Assert.Equal(declared, inboxes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Agent_HandlesEveryMethodItShould_OnBothPlanes()
|
||||
{
|
||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
||||
Assert.Empty(s_table.MissingHandlers(RpcSide.Node, NodeRoles.Agent, agent.App.Link.Router.Methods));
|
||||
// 对等面:Agent 是监听方,要处理 Probe 发来的方法
|
||||
Assert.Empty(s_table.MissingHandlers(RpcSide.Listener, null, agent.App.PeerRouter.Methods));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_HandlesEveryMethodItShould_OnBothPlanes()
|
||||
{
|
||||
await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend);
|
||||
Assert.Empty(s_table.MissingHandlers(RpcSide.Node, NodeRoles.Probe, probe.App.Link.Router.Methods));
|
||||
// 对等面:Probe 是拨号方,要处理 Agent 发来的方法
|
||||
Assert.Empty(s_table.MissingHandlers(RpcSide.Dialer, null, AgentConnection.Router.Methods));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RolesOfNodeIds_FollowThePrefix()
|
||||
{
|
||||
Assert.Equal(NodeRoles.Agent, NodeRoles.Of(AgentIds.Prefix + "_01ABC"));
|
||||
Assert.Equal(NodeRoles.Probe, NodeRoles.Of(ProbeId.Prefix + "_01ABC"));
|
||||
Assert.Null(NodeRoles.Of("usr_01ABC"));
|
||||
}
|
||||
|
||||
private static string FindRoot()
|
||||
{
|
||||
string? dir = AppContext.BaseDirectory;
|
||||
while (dir is not null && !File.Exists(Path.Combine(dir, "docs", "protocol.md")))
|
||||
{
|
||||
dir = Path.GetDirectoryName(dir);
|
||||
}
|
||||
|
||||
return dir ?? throw new InvalidOperationException("repository root not found");
|
||||
}
|
||||
}
|
||||
@@ -313,7 +313,7 @@ public sealed class ProbeSimulator : IAsyncDisposable
|
||||
}
|
||||
|
||||
private RpcRouter<ProbeSimulator> BuildRouter() =>
|
||||
new RpcRouter<ProbeSimulator>()
|
||||
new RpcRouter<ProbeSimulator>(RpcSide.Dialer, MoShiroMethodTable.Table)
|
||||
.On(
|
||||
FleetMethods.StateSync,
|
||||
FleetRpcContext.Default.StateSyncParams,
|
||||
|
||||
Reference in new issue
Block a user