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

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:
justme committed 2026-10-07 16:17:02 +08:00
1 parent ee22309e65
commit 1ff847e45a
36 files changed
+1799 -639

No files matched your search

@@ -67,7 +67,7 @@ public sealed class IdentityAndRpcTests
[Fact]
public async Task Router_DispatchesTypedRequests_AndReportsStructuredErrors()
{
var router = new RpcRouter<string>()
var router = new RpcRouter<string>(RpcSide.Listener, TestTables.Of(TestTables.Req("test.add"), TestTables.Req("test.boom"), TestTables.Req("test.nope")))
.On("test.add", EchoContext.Default.AddParams, EchoContext.Default.AddResult,
(caller, p, _) => ValueTask.FromResult(p.A < 0 ? Result<AddResult>.Fail(FleetErrors.Forbidden, "negative") : Result<AddResult>.Ok(new AddResult { Sum = p.A + p.B, Caller = caller })))
.On<AddParams, AddResult>("test.boom", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new InvalidOperationException("secret detail"));
@@ -92,7 +92,7 @@ public sealed class IdentityAndRpcTests
public async Task Router_HandlerExceptions_AreContained_AndNeverLeakDetails()
{
string? logged = null;
var router = new RpcRouter<string>((method, ex) => logged = method + ":" + ex.Message)
var router = new RpcRouter<string>(RpcSide.Listener, TestTables.Of(TestTables.Req("test.boom")), onUnhandled: (method, ex) => logged = method + ":" + ex.Message)
.On<AddParams, AddResult>("test.boom", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new InvalidOperationException("secret detail"));
RpcMessage reply = (await router.DispatchAsync("a", Request(1, "test.boom", """{"a":1,"b":1}"""), CancellationToken.None))!;
@@ -105,7 +105,7 @@ public sealed class IdentityAndRpcTests
public async Task Router_Notifications_ProduceNoResponse_EvenOnFailure()
{
int calls = 0;
var router = new RpcRouter<string>().OnNotify("test.note", EchoContext.Default.AddParams, (_, _, _) =>
var router = new RpcRouter<string>(RpcSide.Listener, TestTables.Of(TestTables.Note("test.note"), TestTables.Note("test.unknown"))).OnNotify("test.note", EchoContext.Default.AddParams, (_, _, _) =>
{
calls++;
return ValueTask.CompletedTask;
+10 -8
View File
@@ -227,8 +227,9 @@ public sealed class LinkTests
{
(TlsFrameTransport left, TlsFrameTransport right) = await PairAsync();
RpcSession<string>? rightSession = null;
var leftRouter = new RpcRouter<string>().On("left.double", LinkJson.Default.Number, LinkJson.Default.Number, (_, p, _) => ValueTask.FromResult(Result<Number>.Ok(new Number { Value = p.Value * 2 })));
var rightRouter = new RpcRouter<string>().On(
MethodTable table = TestTables.Of(TestTables.Req("left.double", RpcSide.Listener), TestTables.Req("right.relay"), TestTables.Req("right.unhandled"));
var leftRouter = new RpcRouter<string>(RpcSide.Dialer, table).On("left.double", LinkJson.Default.Number, LinkJson.Default.Number, (_, p, _) => ValueTask.FromResult(Result<Number>.Ok(new Number { Value = p.Value * 2 })));
var rightRouter = new RpcRouter<string>(RpcSide.Listener, table).On(
"right.relay", LinkJson.Default.Number, LinkJson.Default.Number,
async (_, p, ct) =>
{
@@ -248,7 +249,7 @@ public sealed class LinkTests
Result<Number> result = await leftSession.RequestAsync("right.relay", new Number { Value = 21 }, LinkJson.Default.Number, LinkJson.Default.Number, default);
Assert.Equal(43, result.Value.Value);
Result<JsonElement> unknown = await leftSession.RequestAsync("nope", null, default);
Result<JsonElement> unknown = await leftSession.RequestAsync("right.unhandled", null, default);
Assert.Equal("fleet_unsupported", unknown.Error.Code.Value);
await leftSession.CloseAsync("bye", default);
@@ -262,13 +263,13 @@ public sealed class LinkTests
{
(TlsFrameTransport left, TlsFrameTransport right) = await PairAsync();
await using var silent = right; // 对端不读也不回
await using var session = new RpcSession<string>(left, new RpcRouter<string>(), "x", TimeProvider.System, new RpcSessionOptions { RequestTimeout = TimeSpan.FromMilliseconds(200) });
await using var session = new RpcSession<string>(left, new RpcRouter<string>(RpcSide.Dialer, TestTables.Of(TestTables.Req("test.any"))), "x", TimeProvider.System, new RpcSessionOptions { RequestTimeout = TimeSpan.FromMilliseconds(200) });
Task run = session.RunAsync(default);
Result<JsonElement> timedOut = await session.RequestAsync("any", null, default);
Result<JsonElement> timedOut = await session.RequestAsync("test.any", null, default);
Assert.Equal(FleetErrors.Timeout, timedOut.Error.Code);
Task<Result<JsonElement>> pending = session.RequestAsync("any", null, default, TimeSpan.FromSeconds(30)).AsTask();
Task<Result<JsonElement>> pending = session.RequestAsync("test.any", null, default, TimeSpan.FromSeconds(30)).AsTask();
await silent.CloseAsync("gone", default);
Assert.Equal(FleetErrors.Timeout, (await pending.WaitAsync(TimeSpan.FromSeconds(5))).Error.Code);
await run.WaitAsync(TimeSpan.FromSeconds(5));
@@ -372,7 +373,8 @@ public sealed class LinkTests
public async Task PeerSession_CarriesRpcBothWays_AfterTheHandshake()
{
using NodeIdentity probe = NodeIdentity.Generate();
var router = new RpcRouter<string>().On("agent.echo", LinkJson.Default.Number, LinkJson.Default.Number, (_, p, _) => ValueTask.FromResult(Result<Number>.Ok(new Number { Value = p.Value + 1 })));
MethodTable table = TestTables.Of(TestTables.Req("agent.echo"));
var router = new RpcRouter<string>(RpcSide.Listener, table).On("agent.echo", LinkJson.Default.Number, LinkJson.Default.Number, (_, p, _) => ValueTask.FromResult(Result<Number>.Ok(new Number { Value = p.Value + 1 })));
await using var agent = new PeerFixture(
_ => probe.PublicKeySpki,
async (info, transport, ct) =>
@@ -383,7 +385,7 @@ public sealed class LinkTests
await agent.Listener.Started.Task;
(_, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
await using var client = new RpcSession<string>(transport, new RpcRouter<string>(), "probe", TimeProvider.System);
await using var client = new RpcSession<string>(transport, new RpcRouter<string>(RpcSide.Dialer, table), "probe", TimeProvider.System);
Task run = client.RunAsync(default);
Result<Number> result = await client.RequestAsync("agent.echo", new Number { Value = 41 }, LinkJson.Default.Number, LinkJson.Default.Number, default);
Assert.Equal(42, result.Value.Value);
@@ -0,0 +1,200 @@
using System.Text.Json;
using System.Threading.Channels;
using Fleet.Core;
using Fleet.Rpc;
namespace Fleet.Tests;
/// <summary>协议方向声明:路由器是唯一卡口,注册、分发、发送三处都核对声明表。</summary>
public sealed class MethodDirectionTests
{
private static readonly MethodTable s_table = TestTables.Of(
MethodSpec.Request("t.ping", RpcSide.Node),
MethodSpec.Notification("t.note", RpcSide.Node, "agent"),
MethodSpec.Request("t.push", RpcSide.Server),
MethodSpec.Request("t.inline", RpcSide.Node).AsInline(),
// 同一个方法名在对等面里另有发起方:按(平面,名字)区分
MethodSpec.Request("t.ping", RpcSide.Listener));
private static readonly JsonElement s_empty = JsonDocument.Parse("{}").RootElement.Clone();
private static RpcMessage Request(string method) => new() { Id = 1, Method = method, Params = s_empty };
private static RpcMessage Notification(string method) => new() { Method = method, Params = s_empty };
private static RpcRouter<string> Server(string? localRole = null) => new(RpcSide.Server, s_table, localRole, callerRole: role => role);
[Fact]
public void Registration_RefusesUndeclaredMethods_WrongDirection_WrongKind_AndWrongRole()
{
RpcRouter<string> server = Server();
Assert.Throws<InvalidOperationException>(() => server.On("t.unknown", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new NotSupportedException()));
// t.push 是服务端发给节点的:服务端这一侧不能处理它
Assert.Throws<InvalidOperationException>(() => server.On("t.push", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new NotSupportedException()));
// t.ping 声明成请求:不能按通知注册
Assert.Throws<InvalidOperationException>(() => server.OnNotify("t.ping", EchoContext.Default.AddParams, (_, _, _) => ValueTask.CompletedTask));
// 节点这一侧:t.note 只给 agent 角色,probe 注册不了;t.push 是它该处理的
var probe = new RpcRouter<string>(RpcSide.Node, s_table, "probe");
probe.On("t.push", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new NotSupportedException());
Assert.Throws<InvalidOperationException>(() => new RpcRouter<string>(RpcSide.Server, s_table, "probe").OnNotify("t.note", EchoContext.Default.AddParams, (_, _, _) => ValueTask.CompletedTask));
server.On("t.ping", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => ValueTask.FromResult(Result<AddResult>.Ok(new AddResult())));
}
[Fact]
public async Task Dispatch_RefusesWhatThePeerMayNotInitiate_AndReportsAViolation()
{
int calls = 0;
RpcRouter<string> server = Server()
.On("t.ping", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) =>
{
calls++;
return ValueTask.FromResult(Result<AddResult>.Ok(new AddResult()));
})
.OnNotify("t.note", EchoContext.Default.AddParams, (_, _, _) =>
{
calls++;
return ValueTask.CompletedTask;
});
// 正常
RpcDispatch ok = await server.DispatchCheckedAsync("agent", Request("t.ping"), default);
Assert.Null(ok.Violation);
Assert.NotNull(ok.Response!.Result);
// 没有声明的方法
RpcDispatch undeclared = await server.DispatchCheckedAsync("agent", Request("t.nope"), default);
Assert.NotNull(undeclared.Violation);
Assert.Equal("fleet_protocol_violation", undeclared.Response!.Error!.Data!.Code);
// 节点冒充服务端:t.push 只能由服务端发起
RpcDispatch backwards = await server.DispatchCheckedAsync("agent", Request("t.push"), default);
Assert.NotNull(backwards.Violation);
Assert.Equal("fleet_protocol_violation", backwards.Response!.Error!.Data!.Code);
// 请求和通知用反了
Assert.NotNull((await server.DispatchCheckedAsync("agent", Notification("t.ping"), default)).Violation);
RpcDispatch asRequest = await server.DispatchCheckedAsync("agent", Request("t.note"), default);
Assert.NotNull(asRequest.Violation);
Assert.NotNull(asRequest.Response);
// 违规的消息到不了处理函数;通知违规不回响应
Assert.Equal(1, calls);
RpcDispatch silentViolation = await server.DispatchCheckedAsync("agent", Notification("t.nope"), default);
Assert.NotNull(silentViolation.Violation);
Assert.Null(silentViolation.Response);
}
[Fact]
public async Task Dispatch_ADeclaredMethodWithoutAHandler_IsUnsupported_NotAViolation()
{
RpcDispatch outcome = await Server().DispatchCheckedAsync("agent", Request("t.ping"), default);
Assert.Null(outcome.Violation);
Assert.Equal("fleet_unsupported", outcome.Response!.Error!.Data!.Code);
}
[Fact]
public async Task Dispatch_ChecksTheCallersRole()
{
RpcRouter<string> server = Server().OnNotify("t.note", EchoContext.Default.AddParams, (_, _, _) => ValueTask.CompletedTask);
Assert.Null((await server.DispatchCheckedAsync("agent", Notification("t.note"), default)).Violation);
Assert.NotNull((await server.DispatchCheckedAsync("probe", Notification("t.note"), default)).Violation);
Assert.NotNull((await server.DispatchCheckedAsync(null!, Notification("t.note"), default)).Violation);
}
[Fact]
public void CheckSend_RefusesWhatThisSideMayNotInitiate()
{
RpcRouter<string> server = Server();
server.CheckSend("t.push", MethodKind.Request);
Assert.Throws<InvalidOperationException>(() => server.CheckSend("t.ping", MethodKind.Request)); // 那是节点发起的
Assert.Throws<InvalidOperationException>(() => server.CheckSend("t.push", MethodKind.Notification)); // 类型不对
Assert.Throws<InvalidOperationException>(() => server.CheckSend("t.unknown", MethodKind.Request));
// 对端的角色也要允许
var node = new RpcRouter<string>(RpcSide.Node, s_table, "agent");
node.CheckSend("t.note", MethodKind.Notification, "agent");
Assert.Throws<InvalidOperationException>(() => node.CheckSend("t.note", MethodKind.Notification, "probe"));
}
[Fact]
public void MethodTable_RefusesDuplicates_AndReportsMissingHandlers()
{
Assert.Throws<ArgumentException>(() => TestTables.Of(TestTables.Req("a"), TestTables.Note("a")));
Assert.Throws<ArgumentException>(() => new MethodTable([], [new ChannelSpec(16, RpcSide.Node), new ChannelSpec(16, RpcSide.Server)]));
// 服务端要处理 t.ping 和 t.note;t.inline 在握手阶段直接处理,不算缺
Assert.Equal(["t.note", "t.ping"], s_table.MissingHandlers(RpcSide.Server, null, []));
Assert.Equal(["t.ping"], s_table.MissingHandlers(RpcSide.Server, "probe", []));
Assert.Equal(["t.note"], s_table.MissingHandlers(RpcSide.Server, "agent", ["t.ping"]));
Assert.Empty(s_table.MissingHandlers(RpcSide.Server, "agent", ["t.ping", "t.note"]));
// 同名方法在对等面的接收方是拨号方?不:t.ping 在对等面由监听方发起,拨号方处理
Assert.Equal(["t.ping"], s_table.MissingHandlers(RpcSide.Dialer, null, []));
}
[Fact]
public async Task Session_ClosesTheConnection_AfterTooManyViolations()
{
(ChannelTransport a, ChannelTransport b) = ChannelTransport.Pair();
RpcRouter<string> server = Server().On("t.ping", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => ValueTask.FromResult(Result<AddResult>.Ok(new AddResult())));
await using var session = new RpcSession<string>(b, server, "agent", TimeProvider.System, new RpcSessionOptions { MaxViolations = 3 });
Task run = session.RunAsync(default);
for (int i = 1; i <= 6; i++)
{
await a.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(new RpcMessage { Id = i, Method = "t.nope", Params = s_empty }, FleetRpcContext.Default.RpcMessage)), default);
}
// 违规的请求先收到失败响应,超过上限之后连接被关闭
int failures = 0;
while (await a.ReceiveAsync(default).AsTask().WaitAsync(TimeSpan.FromSeconds(5)) is { } frame)
{
if (JsonSerializer.Deserialize(frame.Data.Span, FleetRpcContext.Default.RpcMessage)?.Error?.Data?.Code == "fleet_protocol_violation")
{
failures++;
}
}
Assert.InRange(failures, 3, 6);
await a.CloseAsync("done", default); // 对端确认关闭之后,会话的接收循环正常结束
await run.WaitAsync(TimeSpan.FromSeconds(5));
}
/// <summary>进程内的一对传输:一端发的帧从另一端收到;关闭时对端收到 null。</summary>
private sealed class ChannelTransport : IRpcTransport
{
private readonly Channel<RpcFrame?> _in;
private readonly Channel<RpcFrame?> _out;
private ChannelTransport(Channel<RpcFrame?> input, Channel<RpcFrame?> output)
{
_in = input;
_out = output;
}
public static (ChannelTransport A, ChannelTransport B) Pair()
{
Channel<RpcFrame?> ab = Channel.CreateUnbounded<RpcFrame?>();
Channel<RpcFrame?> ba = Channel.CreateUnbounded<RpcFrame?>();
return (new ChannelTransport(ba, ab), new ChannelTransport(ab, ba));
}
public ValueTask SendAsync(RpcFrame frame, CancellationToken ct)
{
_out.Writer.TryWrite(frame);
return ValueTask.CompletedTask;
}
public async ValueTask<RpcFrame?> ReceiveAsync(CancellationToken ct) =>
await _in.Reader.WaitToReadAsync(ct) && _in.Reader.TryRead(out RpcFrame? frame) ? frame : null;
public ValueTask CloseAsync(string reason, CancellationToken ct)
{
_out.Writer.TryWrite(null);
return ValueTask.CompletedTask;
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
@@ -131,7 +131,13 @@ public sealed class RestartScheduleTests
Assert.DoesNotContain(log.Lines, l => l.Contains("scheduled_restart", StringComparison.Ordinal));
// 清理:把进程停掉
Assert.True(controller.SetRemote("04:30+08:00"));
time.Advance();
// 新的计时器是异步注册的:反复推进,直到进程退出
for (int i = 0; i < 100 && !shell.IsCompleted; i++)
{
time.Advance();
await Task.Delay(50);
}
await shell.WaitAsync(TimeSpan.FromSeconds(10));
}
@@ -0,0 +1,13 @@
using Fleet.Rpc;
namespace Fleet.Tests;
/// <summary>测试里用的方法声明:除非另有说明,方法都由拨号方发给监听方。</summary>
internal static class TestTables
{
public static MethodTable Of(params MethodSpec[] specs) => new(specs);
public static MethodSpec Req(string name, RpcSide from = RpcSide.Dialer) => MethodSpec.Request(name, from);
public static MethodSpec Note(string name, RpcSide from = RpcSide.Dialer) => MethodSpec.Notification(name, from);
}