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

@@ -15,6 +15,31 @@ public static class FleetMethods
public const string Revoked = "fleet.revoked";
}
/// <summary>
/// 框架自己的方法声明,两个平面各一份。认证和握手阶段由连接代码直接处理的(<c>Inline</c>)不经过路由器。
/// 同一个方法名在两个平面里可以有不同的发起方:<c>fleet.state.sync</c> 在节点面是服务端发给节点,在对等面是 Agent 发给 Probe。
/// </summary>
public static class FleetMethodTable
{
public static readonly MethodTable Table = new(
[
// 节点面
MethodSpec.Notification(FleetMethods.Challenge, RpcSide.Server).AsInline(),
MethodSpec.Request(FleetMethods.Auth, RpcSide.Node).AsInline(),
MethodSpec.Request(FleetMethods.Hello, RpcSide.Node).AsInline(),
MethodSpec.Notification(FleetMethods.Heartbeat, RpcSide.Node).AsInline(),
MethodSpec.Request(FleetMethods.StateSync, RpcSide.Server),
MethodSpec.Notification(FleetMethods.OutboxAck, RpcSide.Server),
MethodSpec.Notification(FleetMethods.Revoked, RpcSide.Server),
// 对等面
MethodSpec.Notification(PeerMethods.Challenge, RpcSide.Listener).AsInline(),
MethodSpec.Request(PeerMethods.Auth, RpcSide.Dialer).AsInline(),
MethodSpec.Request(FleetMethods.StateSync, RpcSide.Listener),
MethodSpec.Notification(FleetMethods.OutboxAck, RpcSide.Listener),
]);
}
/// <summary>二进制通道:0–15 保留给框架,产品从 16 开始。</summary>
public static class FleetChannels
{
+120
View File
@@ -0,0 +1,120 @@
namespace Fleet.Rpc;
/// <summary>连接的哪一侧。节点面:服务端和节点;对等面:拨号方(Probe)和监听方(Agent)。</summary>
public enum RpcSide
{
Server,
Node,
Dialer,
Listener,
}
public enum RpcPlane
{
Node,
Peer,
}
public enum MethodKind
{
Request,
Notification,
}
public static class RpcSides
{
public static RpcPlane PlaneOf(RpcSide side) => side is RpcSide.Server or RpcSide.Node ? RpcPlane.Node : RpcPlane.Peer;
public static RpcSide Opposite(RpcSide side) => side switch
{
RpcSide.Server => RpcSide.Node,
RpcSide.Node => RpcSide.Server,
RpcSide.Dialer => RpcSide.Listener,
_ => RpcSide.Dialer,
};
}
/// <summary>
/// 一个方法的声明:<b>只有 <see cref="From"/> 能发起它</b>(不存在双向方法),类型是请求还是通知,哪些节点角色可以参与。
/// <see cref="Inline"/> 表示它在认证或握手阶段由连接代码直接处理,不经过路由器。
/// </summary>
public sealed record MethodSpec(string Name, RpcSide From, MethodKind Kind, IReadOnlyList<string>? Roles = null, bool Inline = false)
{
public RpcPlane Plane => RpcSides.PlaneOf(From);
/// <summary>接收方。</summary>
public RpcSide To => RpcSides.Opposite(From);
public bool AllowsRole(string? role) => Roles is null || (role is not null && Roles.Contains(role));
public static MethodSpec Request(string name, RpcSide from, params string[] roles) => new(name, from, MethodKind.Request, roles.Length == 0 ? null : roles);
public static MethodSpec Notification(string name, RpcSide from, params string[] roles) => new(name, from, MethodKind.Notification, roles.Length == 0 ? null : roles);
public MethodSpec AsInline() => this with { Inline = true };
}
/// <summary>二进制通道的声明:谁能发、哪些角色可以发。通道 0–15 保留给框架,产品从 16 开始。</summary>
public sealed record ChannelSpec(byte Channel, RpcSide From, IReadOnlyList<string>? Roles = null)
{
public RpcPlane Plane => RpcSides.PlaneOf(From);
public bool AllowsRole(string? role) => Roles is null || (role is not null && Roles.Contains(role));
}
/// <summary>
/// 协议方法和二进制通道的声明表,单一真源。路由器用它在注册时拒绝方向、类型、角色不符的处理函数,
/// 在收发时拒绝没有声明或不该由对方发起的方法。同一个方法名在不同平面里可以有不同的发起方,所以按(平面,名字)查找。
/// </summary>
public sealed class MethodTable
{
private readonly Dictionary<(RpcPlane, string), MethodSpec> _methods = [];
private readonly Dictionary<(RpcPlane, byte), ChannelSpec> _channels = [];
public MethodTable(IEnumerable<MethodSpec> methods, IEnumerable<ChannelSpec>? channels = null)
{
foreach (MethodSpec spec in methods)
{
if (!_methods.TryAdd((spec.Plane, spec.Name), spec))
{
throw new ArgumentException($"method {spec.Name} is declared twice in the {spec.Plane} plane");
}
}
foreach (ChannelSpec spec in channels ?? [])
{
if (!_channels.TryAdd((spec.Plane, spec.Channel), spec))
{
throw new ArgumentException($"channel {spec.Channel} is declared twice in the {spec.Plane} plane");
}
}
}
public IEnumerable<MethodSpec> Methods => _methods.Values;
public IEnumerable<ChannelSpec> Channels => _channels.Values;
public MethodSpec? Find(RpcPlane plane, string method) => _methods.GetValueOrDefault((plane, method));
public ChannelSpec? FindChannel(RpcPlane plane, byte channel) => _channels.GetValueOrDefault((plane, channel));
/// <summary>合并多张表(框架的加产品的)。</summary>
public static MethodTable Combine(params MethodTable[] tables) =>
new(tables.SelectMany(t => t.Methods), tables.SelectMany(t => t.Channels));
/// <summary>
/// 对账:<paramref name="receiver"/> 这一侧、<paramref name="role"/> 这个角色应该处理、但没有注册处理函数的方法
/// (认证阶段直接处理的 <see cref="MethodSpec.Inline"/> 方法除外)。<paramref name="role"/> 为空表示不限角色(服务端要处理所有角色发来的)。
/// </summary>
public IReadOnlyList<string> MissingHandlers(RpcSide receiver, string? role, IEnumerable<string> registered)
{
HashSet<string> have = [.. registered];
return
[
.. Methods
.Where(m => m.To == receiver && !m.Inline && (role is null || m.AllowsRole(role)) && !have.Contains(m.Name))
.Select(m => m.Name)
.Order(StringComparer.Ordinal),
];
}
}
+128 -8
View File
@@ -4,7 +4,17 @@ using Fleet.Core;
namespace Fleet.Rpc;
/// <summary>方法路由,与传输方式无关。<typeparamref name="TContext"/> 由传输层提供(例如调用方节点)。</summary>
/// <summary>对端违反了协议:方法没有声明、方向不对、请求和通知用反了、或者角色不允许。</summary>
public readonly record struct RpcViolation(string Method, string Reason);
/// <summary>一次分发的结果:要回的响应(通知为空),以及对端有没有违规。</summary>
public readonly record struct RpcDispatch(RpcMessage? Response, RpcViolation? Violation);
/// <summary>
/// 方法路由,与传输方式无关,也是协议方向的<b>唯一卡口</b>:节点和服务端、Probe 和 Agent 的入站分发都经过这里。
/// <typeparamref name="TContext"/> 由传输层提供(例如调用方节点)。路由器知道自己在连接的哪一侧(<see cref="Side"/>)和声明表,
/// 注册处理函数时核对方向、类型和角色,分发时拒绝没有声明或不该由对方发起的方法,发送时核对自己有没有资格发起。
/// </summary>
public sealed class RpcRouter<TContext>
{
private delegate ValueTask<Result<JsonElement?>> Route(TContext context, JsonElement? parameters, CancellationToken ct);
@@ -13,12 +23,28 @@ public sealed class RpcRouter<TContext>
private readonly Dictionary<string, Route> _routes = new(StringComparer.Ordinal);
private readonly Action<string, Exception>? _onUnhandled;
private readonly string? _localRole;
private readonly Func<TContext, string?>? _callerRole;
public RpcRouter(Action<string, Exception>? onUnhandled = null)
/// <param name="side">本路由器在连接的哪一侧。</param>
/// <param name="table">方法声明表。</param>
/// <param name="localRole">本节点的角色(节点侧和对等面用):声明限定了角色的方法,角色不符就不能注册。</param>
/// <param name="callerRole">从调用上下文取出对端的角色(服务端用):声明限定了角色的方法,角色不符就拒绝。</param>
public RpcRouter(RpcSide side, MethodTable table, string? localRole = null, Func<TContext, string?>? callerRole = null, Action<string, Exception>? onUnhandled = null)
{
Side = side;
Table = table;
_localRole = localRole;
_callerRole = callerRole;
_onUnhandled = onUnhandled;
}
public RpcSide Side { get; }
public MethodTable Table { get; }
private RpcPlane Plane => RpcSides.PlaneOf(Side);
public bool Has(string method) => _routes.ContainsKey(method);
public IReadOnlyCollection<string> Methods => _routes.Keys;
@@ -29,6 +55,7 @@ public sealed class RpcRouter<TContext>
JsonTypeInfo<TResult> resultInfo,
Func<TContext, TParams, CancellationToken, ValueTask<Result<TResult>>> handler)
{
Declare(method, MethodKind.Request);
_routes[method] = async (context, raw, ct) =>
{
if (!TryDeserialize(raw, paramsInfo, out TParams? p))
@@ -49,6 +76,7 @@ public sealed class RpcRouter<TContext>
JsonTypeInfo<TParams> paramsInfo,
Func<TContext, TParams, CancellationToken, ValueTask> handler)
{
Declare(method, MethodKind.Notification);
_routes[method] = async (context, raw, ct) =>
{
if (!TryDeserialize(raw, paramsInfo, out TParams? p))
@@ -66,12 +94,26 @@ public sealed class RpcRouter<TContext>
/// 处理一条请求或通知。请求返回响应;通知返回 null(失败时只丢弃)。
/// 处理函数里的异常属于程序缺陷:在这个边界捕获,回复 internal 错误,并交给 <c>onUnhandled</c> 记录。
/// </summary>
public async ValueTask<RpcMessage?> DispatchAsync(TContext context, RpcMessage message, CancellationToken ct)
public async ValueTask<RpcMessage?> DispatchAsync(TContext context, RpcMessage message, CancellationToken ct) =>
(await DispatchCheckedAsync(context, message, ct)).Response;
/// <summary>
/// 先核对声明(方向、类型、角色),再分发。违规的消息不会到达处理函数,返回的结果里带 <see cref="RpcViolation"/>,
/// 由会话决定怎么办(计数、超过上限就断开连接)。声明过、方向对、但这一侧没有处理函数的方法不算违规,只是不支持。
/// </summary>
public async ValueTask<RpcDispatch> DispatchCheckedAsync(TContext context, RpcMessage message, CancellationToken ct)
{
string method = message.Method ?? "";
if (Check(context, message) is { } violation)
{
return new RpcDispatch(
message.Id is null ? null : RpcMessage.Failure(message.Id, new FleetError(FleetErrors.ProtocolViolation, violation.Reason)),
violation);
}
if (!_routes.TryGetValue(method, out Route? route))
{
return message.Id is null ? null : RpcMessage.Failure(message.Id, new FleetError(FleetErrors.Unsupported, $"unknown method {method}"));
return new RpcDispatch(message.Id is null ? null : RpcMessage.Failure(message.Id, new FleetError(FleetErrors.Unsupported, $"unknown method {method}")), null);
}
Result<JsonElement?> result;
@@ -91,12 +133,90 @@ public sealed class RpcRouter<TContext>
if (message.Id is null)
{
return null;
return new RpcDispatch(null, null);
}
return result.TryGet(out JsonElement? value, out FleetError? error)
? new RpcMessage { Id = message.Id, Result = value ?? s_emptyObject }
: RpcMessage.Failure(message.Id, error);
return new RpcDispatch(
result.TryGet(out JsonElement? value, out FleetError? error)
? new RpcMessage { Id = message.Id, Result = value ?? s_emptyObject }
: RpcMessage.Failure(message.Id, error),
null);
}
/// <summary>入站检查:这条消息是不是对方有资格发起的、类型对不对、对方的角色允不允许。</summary>
public RpcViolation? Check(TContext context, RpcMessage message)
{
string method = message.Method ?? "";
MethodSpec? spec = Table.Find(Plane, method);
if (spec is null)
{
return new RpcViolation(method, $"method {method} is not declared");
}
if (spec.To != Side)
{
return new RpcViolation(method, $"{method} cannot be initiated by the {RpcSides.Opposite(Side)} side");
}
MethodKind kind = message.Id is null ? MethodKind.Notification : MethodKind.Request;
if (spec.Kind != kind)
{
return new RpcViolation(method, $"{method} is a {spec.Kind.ToString().ToLowerInvariant()}");
}
if (_callerRole is not null && !spec.AllowsRole(_callerRole(context)))
{
return new RpcViolation(method, $"{method} is not allowed for this node role");
}
return null;
}
/// <summary>
/// 发送前检查:本侧有没有资格发起这个方法、类型对不对、对端的角色允不允许(服务端发给节点时传入节点的角色)。
/// 违反是程序缺陷,抛 <see cref="InvalidOperationException"/>。
/// </summary>
public void CheckSend(string method, MethodKind kind, string? peerRole = null)
{
MethodSpec? spec = Table.Find(Plane, method);
if (spec is null)
{
throw new InvalidOperationException($"method {method} is not declared");
}
if (spec.From != Side)
{
throw new InvalidOperationException($"{method} cannot be initiated by the {Side} side");
}
if (spec.Kind != kind)
{
throw new InvalidOperationException($"{method} is a {spec.Kind.ToString().ToLowerInvariant()}");
}
if (peerRole is not null && !spec.AllowsRole(peerRole))
{
throw new InvalidOperationException($"{method} cannot be sent to a {peerRole}");
}
}
private void Declare(string method, MethodKind kind)
{
MethodSpec spec = Table.Find(Plane, method) ?? throw new InvalidOperationException($"method {method} is not declared in the method table");
if (spec.To != Side)
{
throw new InvalidOperationException($"{method} is initiated by the {spec.From} side and cannot be handled on the {Side} side");
}
if (spec.Kind != kind)
{
throw new InvalidOperationException($"{method} is declared as a {spec.Kind.ToString().ToLowerInvariant()}");
}
if (_localRole is not null && !spec.AllowsRole(_localRole))
{
throw new InvalidOperationException($"{method} is not for the {_localRole} role");
}
}
private static bool TryDeserialize<T>(JsonElement? raw, JsonTypeInfo<T> info, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out T? value)
+27 -6
View File
@@ -34,6 +34,9 @@ public sealed class RpcSessionOptions
/// <summary>待处理的入站请求上限;超过时接收循环等待(背压)。</summary>
public int InboundQueue { get; set; } = 256;
/// <summary>对端违反协议(方法没有声明、方向、类型或角色不对)的次数上限;超过就断开会话。</summary>
public int MaxViolations { get; set; } = 20;
}
/// <summary>
@@ -53,6 +56,8 @@ public sealed class RpcSession<TContext> : IAsyncDisposable
private readonly CancellationTokenSource _stop = new();
private long _nextId;
private long _lastReceive;
private int _violations;
private bool _violated;
public RpcSession(IRpcTransport transport, RpcRouter<TContext> router, TContext context, TimeProvider time, RpcSessionOptions? options = null)
{
@@ -81,7 +86,7 @@ public sealed class RpcSession<TContext> : IAsyncDisposable
while (!linked.IsCancellationRequested)
{
RpcFrame? frame = await _transport.ReceiveAsync(linked.Token);
if (frame is null)
if (frame is null || Volatile.Read(ref _violated))
{
return;
}
@@ -184,8 +189,16 @@ public sealed class RpcSession<TContext> : IAsyncDisposable
public ValueTask NotifyAsync<TParams>(string method, TParams parameters, JsonTypeInfo<TParams> paramsInfo, CancellationToken ct) =>
SendAsync(RpcMessage.Notification(method, parameters, paramsInfo), ct);
public ValueTask SendAsync(RpcMessage message, CancellationToken ct) =>
_transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(message, FleetRpcContext.Default.RpcMessage)), ct);
public ValueTask SendAsync(RpcMessage message, CancellationToken ct)
{
if (message.Method is not null)
{
// 请求和通知只能发本侧有资格发起的方法(响应没有方法名,不检查)
_router.CheckSend(message.Method, message.Id is null ? MethodKind.Notification : MethodKind.Request);
}
return _transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(message, FleetRpcContext.Default.RpcMessage)), ct);
}
public ValueTask SendBinaryAsync(ReadOnlyMemory<byte> frame, CancellationToken ct) =>
_transport.SendAsync(new RpcFrame(FrameKind.Binary, frame), ct);
@@ -205,10 +218,18 @@ public sealed class RpcSession<TContext> : IAsyncDisposable
{
await foreach (RpcMessage message in _inbound.Reader.ReadAllAsync(ct))
{
RpcMessage? response = await _router.DispatchAsync(_context, message, ct);
if (response is not null)
RpcDispatch outcome = await _router.DispatchCheckedAsync(_context, message, ct);
if (outcome.Response is not null)
{
await SendAsync(response, ct);
await SendAsync(outcome.Response, ct);
}
if (outcome.Violation is not null && ++_violations > _options.MaxViolations)
{
// 只关闭传输,不取消接收:接收循环在下一次读到帧或对端确认关闭时正常结束
Volatile.Write(ref _violated, true);
await _transport.CloseAsync("protocol violations", CancellationToken.None);
return;
}
}
}