feat!: pre-handshake knock on the agent TCP ports; probes get the agent certificate public key [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful

This commit is contained in:
justme committed 2026-10-07 18:56:40 +08:00
1 parent f03988d68b
commit b99b95c738
31 files changed
+539 -61

No files matched your search

@@ -1,4 +1,7 @@
using System.Net;
using Microsoft.Extensions.DependencyInjection;
using MoShiro.Backend.Application;
using MoShiro.Protocol;
using System.Text.Json.Nodes;
using MoShiro.TestSupport;
@@ -87,4 +90,14 @@ public sealed class AddressTests
await agent.HelloAsync();
await BackendFixture.EventuallyAsync(async () => (await AgentAsync(id))["public_host"]!.GetValue<string>() == "1.0.0.1");
}
[Fact]
public async Task ProbeAgentList_CarriesTheCertificatePublicKeyTheAgentReported_ForPinningAndKnocking()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
AgentService service = _backend.Services.GetRequiredService<AgentService>();
ProbeAgentList list = await service.ProbeAgentsAsync(_backend.DefaultProbeId, default);
AgentEndpoint endpoint = Assert.Single(list.Agents, a => a.AgentId == agent.NodeId);
Assert.Equal(Convert.ToBase64String(agent.TlsPublicKey), endpoint.PublicKey);
}
}
@@ -95,6 +95,19 @@ public sealed class AgentEndToEndTests
}
}
/// <summary>读到 0 字节(对端关闭)或者连接被重置,都算「没收到任何东西」;收到数据返回字节数。</summary>
internal static async Task<int> ReadOrResetAsync(Stream stream, byte[] buffer)
{
try
{
return await stream.ReadAsync(buffer).AsTask().WaitAsync(TimeSpan.FromSeconds(10));
}
catch (IOException)
{
return 0;
}
}
[Fact]
public async Task TcpDataPlane_ClosesConnectionsWithoutAValidFirstFrame()
{
@@ -106,7 +119,7 @@ public sealed class AgentEndToEndTests
await client.ConnectAsync(System.Net.IPAddress.Loopback, agent.App.TcpPorts[0]);
await client.GetStream().WriteAsync(new byte[TcpWire.AuthBytes]); // 全零:magic 不对
byte[] buffer = new byte[16];
Assert.Equal(0, await client.GetStream().ReadAsync(buffer).AsTask().WaitAsync(TimeSpan.FromSeconds(10)));
Assert.Equal(0, await ReadOrResetAsync(client.GetStream(), buffer)); // 连接被关闭:没有任何字节(Windows 上可能是 RST)
await BackendFixture.EventuallyAsync(() => Task.FromResult(agent.App.Counters.DroppedInvalid >= 1));
}
}
@@ -146,7 +159,7 @@ public sealed class AgentEndToEndTests
// The probe process restarts: a new session with its own streams; sequence numbers start from 0 again
await using ProbeSimulator second = first.Restarted();
await second.ConnectAsync("127.0.0.1", agent.App.ControlPort, agent.TlsSpki, agent.App.UdpPorts[0], agent.NodeId);
await second.ConnectAsync("127.0.0.1", agent.App.ControlPort, agent.TlsPublicKey, agent.App.UdpPorts[0], agent.NodeId);
LineTaskSet set = await second.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo" && t.Port == agent.App.UdpPorts[0]));
LineTask udp2 = set.Tasks.Single(t => t.Kind == "udp_echo");
Assert.NotEqual(udp.StreamId, udp2.StreamId);
@@ -208,11 +221,11 @@ public sealed class AgentEndToEndTests
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
await agent.CreateProbeAsync();
await using var stranger = new ProbeSimulator("prb_stranger", Fleet.Identity.NodeIdentity.Generate());
await Assert.ThrowsAnyAsync<Exception>(() => stranger.ConnectAsync("127.0.0.1", agent.App.ControlPort, agent.TlsSpki, agent.App.UdpPorts[0], agent.NodeId));
await Assert.ThrowsAnyAsync<Exception>(() => stranger.ConnectAsync("127.0.0.1", agent.App.ControlPort, agent.TlsPublicKey, agent.App.UdpPorts[0], agent.NodeId));
// 证书指纹不对(中间人)也不会连上
// 证书公钥不对(中间人)也不会连上
await using ProbeSimulator probe = await agent.CreateProbeAsync();
await Assert.ThrowsAnyAsync<Exception>(() => probe.ConnectAsync("127.0.0.1", agent.App.ControlPort, new byte[32], agent.App.UdpPorts[0], agent.NodeId));
await Assert.ThrowsAnyAsync<Exception>(() => probe.ConnectAsync("127.0.0.1", agent.App.ControlPort, Fleet.Identity.NodeIdentity.Generate().PublicKeySpki, agent.App.UdpPorts[0], agent.NodeId));
}
[Fact]
@@ -39,7 +39,7 @@ public sealed class AgentHarness : IAsyncDisposable
public string DataDirectory => _directory;
public byte[] TlsSpki => Convert.FromBase64String(_identity.File.TlsSpki);
public byte[] TlsPublicKey => _identity.Certificate.PublicKey.ExportSubjectPublicKeyInfo();
public static async Task<AgentHarness> StartAsync(BackendFixture backend, Action<AgentConfig>? configure = null, string? name = null)
{
@@ -85,7 +85,7 @@ public sealed class AgentHarness : IAsyncDisposable
public async Task<ProbeSimulator> ConnectProbeAsync()
{
ProbeSimulator probe = await CreateProbeAsync();
await probe.ConnectAsync("127.0.0.1", App.ControlPort, TlsSpki, App.UdpPorts[0], NodeId);
await probe.ConnectAsync("127.0.0.1", App.ControlPort, TlsPublicKey, App.UdpPorts[0], NodeId);
return probe;
}
@@ -104,7 +104,8 @@ public sealed class ArchitectureTests
Assert.Equal(["Fleet.Hosting", "Fleet.Link", "MoShiro.Measurement"], probe.ProjectReferences.Order().ToArray());
// 两个节点的共享库只依赖协议
Assert.Equal(["MoShiro.Protocol"], Get("MoShiro.Measurement").ProjectReferences.ToArray());
// 测量库在 TCP 数据面上做预握手敲门,所以直接引用 Fleet.Identity(节点侧库)
Assert.Equal(["Fleet.Identity", "MoShiro.Protocol"], Get("MoShiro.Measurement").ProjectReferences.Order().ToArray());
// 节点一侧的库没有运行时依赖包,并使用节点禁用列表(不能用 HttpClient、Process、写文件;唯一的写文件豁免在 ConfigStore)
foreach (string name in new[] { "Fleet.Link", "Fleet.Hosting", "MoShiro.Measurement", "MoShiro.Agent", "MoShiro.Probe" })
@@ -18,6 +18,7 @@ namespace MoShiro.TestSupport;
public sealed class AgentSimulator : IAsyncDisposable
{
private readonly NodeIdentity _identity;
private readonly NodeIdentity _tlsKey = NodeIdentity.Generate();
private readonly ClientWebSocket _socket = new();
private readonly ConcurrentDictionary<Guid, TaskCompletionSource<bool>> _acks = new();
private readonly ConcurrentDictionary<long, TaskCompletionSource<RpcMessage>> _responses = new();
@@ -71,6 +72,9 @@ public sealed class AgentSimulator : IAsyncDisposable
public byte[] PublicKey => _identity.PublicKeySpki;
/// <summary>hello 里上报的 TLS 证书公钥(Probe 用它固定证书、派生敲门密钥)。</summary>
public byte[] TlsPublicKey => _tlsKey.PublicKeySpki;
/// <summary>用这个节点的私钥为认证随机数签名(可以冒充别的节点 ID:用来测试服务端的校验)。</summary>
public byte[] SignAuth(NodeId claimedNode, string nonce) => _identity.Sign(SignaturePurpose.Auth, claimedNode.Value, nonce);
@@ -147,7 +151,7 @@ public sealed class AgentSimulator : IAsyncDisposable
var meta = JsonSerializer.SerializeToElement(
new AgentHelloMeta
{
TlsSpki = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)),
TlsPublicKey = Convert.ToBase64String(_tlsKey.PublicKeySpki),
Addresses = Addresses is null ? null : [.. Addresses],
ProbeTcpPort = 7443,
ProbeUdpPorts = [7443],
@@ -20,6 +20,7 @@ namespace MoShiro.TestSupport;
public sealed class ProbeSimulator : IAsyncDisposable
{
private readonly NodeIdentity _identity;
private byte[]? _agentKey;
private readonly RpcRouter<ProbeSimulator> _router;
private readonly ConcurrentDictionary<Guid, TaskCompletionSource> _acks = new();
private readonly ConcurrentDictionary<uint, EchoCollector> _echoes = new();
@@ -65,11 +66,12 @@ public sealed class ProbeSimulator : IAsyncDisposable
public long NowUs() => (TimeProvider.System.GetTimestamp() - _origin) * 1_000_000 / TimeProvider.System.TimestampFrequency;
public async Task ConnectAsync(string host, int controlPort, byte[] agentSpkiSha256, int udpPort, NodeId agentId, TimeProvider? time = null, CancellationToken ct = default)
public async Task ConnectAsync(string host, int controlPort, byte[] agentPublicKey, int udpPort, NodeId agentId, TimeProvider? time = null, CancellationToken ct = default)
{
_agentId = agentId;
(PeerSessionInfo info, TlsFrameTransport transport) = await PeerConnector.ConnectAsync(host, controlPort, agentSpkiSha256, _identity, ProbeId, "0.0.0-sim", 8 << 20, time ?? TimeProvider.System, ct);
(PeerSessionInfo info, TlsFrameTransport transport) = await PeerConnector.ConnectAsync(host, controlPort, agentPublicKey, _identity, ProbeId, "0.0.0-sim", 8 << 20, time ?? TimeProvider.System, ct);
_info = info;
_agentKey = agentPublicKey;
_udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0));
_agentUdp = new IPEndPoint(IPAddress.Parse(host), udpPort);
_rpc = new RpcSession<ProbeSimulator>(transport, _router, this, time ?? TimeProvider.System);
@@ -152,6 +154,7 @@ public sealed class ProbeSimulator : IAsyncDisposable
using var client = new System.Net.Sockets.TcpClient();
await client.ConnectAsync(IPAddress.Loopback, tcpPort, ct);
Stream stream = client.GetStream();
await stream.WriteAsync(Knock.Build(Knock.DeriveKey(_identity, _agentKey!), TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds()), ct); // 预握手敲门
if (tls)
{
#pragma warning disable CA5359 // 测试:Agent 的证书是自签名的,这里只验证数据面