From b99b95c7388ffef785d1633303c71b25e15dbceb Mon Sep 17 00:00:00 2001 From: JustMe Date: Wed, 7 Oct 2026 18:56:40 +0800 Subject: [PATCH] feat!: pre-handshake knock on the agent TCP ports; probes get the agent certificate public key [CI] --- backend/agent/src/MoShiro.Agent/AgentApp.cs | 12 +- .../src/MoShiro.Agent/Tasks/TaskManager.cs | 3 + backend/fleet/src/Fleet.Identity/Knock.cs | 80 +++++++++++++ backend/fleet/src/Fleet.Identity/KnockGate.cs | 113 ++++++++++++++++++ .../fleet/src/Fleet.Identity/NodeIdentity.cs | 10 ++ backend/fleet/src/Fleet.Link/Peer.cs | 19 ++- backend/fleet/tests/Fleet.Tests/KnockTests.cs | 112 +++++++++++++++++ backend/fleet/tests/Fleet.Tests/LinkTests.cs | 79 ++++++++++-- .../src/MoShiro.Probe/Link/AgentConnection.cs | 6 +- .../src/MoShiro.Probe/Link/AgentDirectory.cs | 6 +- .../probe/src/MoShiro.Probe/ProbeConfig.cs | 6 +- .../tests/MoShiro.Probe.Tests/ProbeTests.cs | 2 +- .../MoShiro.Measurement/Core/Contracts.cs | 4 + .../MoShiro.Measurement.csproj | 1 + backend/shared/MoShiro.Measurement/Net/Tcp.cs | 20 +++- .../shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs | 11 +- .../Agents/AgentService.cs | 5 +- .../Agents/MoShiroFleetHooks.cs | 15 ++- .../Models/AgentModels.cs | 6 +- .../MoShiro.Backend.Storage.Sqlite/Schema.cs | 11 +- .../SqliteAgentStore.cs | 9 +- .../MoShiro.Backend.Tests/AddressTests.cs | 13 ++ .../AgentEndToEndTests.cs | 23 +++- .../MoShiro.Backend.Tests/AgentHarness.cs | 4 +- .../ArchitectureTests.cs | 3 +- .../MoShiro.TestSupport/AgentSimulator.cs | 6 +- .../MoShiro.TestSupport/ProbeSimulator.cs | 7 +- docs/enrollment.md | 2 +- docs/protocol.md | 4 +- docs/security.md | 5 +- docs/storage.md | 3 +- 31 files changed, 539 insertions(+), 61 deletions(-) create mode 100644 backend/fleet/src/Fleet.Identity/Knock.cs create mode 100644 backend/fleet/src/Fleet.Identity/KnockGate.cs create mode 100644 backend/fleet/tests/Fleet.Tests/KnockTests.cs diff --git a/backend/agent/src/MoShiro.Agent/AgentApp.cs b/backend/agent/src/MoShiro.Agent/AgentApp.cs index cec7db4..8aba5c9 100644 --- a/backend/agent/src/MoShiro.Agent/AgentApp.cs +++ b/backend/agent/src/MoShiro.Agent/AgentApp.cs @@ -1,3 +1,4 @@ +using Fleet.Identity; using System.Net; using System.Text.Json; using Fleet.Core; @@ -16,6 +17,7 @@ public sealed class AgentApp : IDisposable private readonly AgentConfig _config; private readonly bool _ipv6Supported; + private readonly KnockGate _knock; private readonly RestartController _restart; private readonly RestartController? _ownedRestart; private readonly LoadedNodeIdentity _identity; @@ -60,7 +62,9 @@ public sealed class AgentApp : IDisposable _ipv6Supported = bind.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6; _admission = new AgentAdmission(_streams); _udp = new UdpMux(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.UdpPorts); - _tcp = new TcpAcceptor(bind, config.Listen.TcpPorts, identity.Certificate, _sessions, _counters.Data); + // 预握手敲门:控制端口和数据端口共用一个关卡(限速和去重一起算);名册就是期望状态里的 peers + _knock = new KnockGate(identity.PeerIdentity, RosterKeys, time); + _tcp = new TcpAcceptor(bind, config.Listen.TcpPorts, identity.Certificate, _sessions, _counters.Data, _knock); // 监听方积木的环境:和 Probe 的拨号方共用同一套积木,区别只在这里装配的部分(准入策略、TCP 监听) var env = new BlockEnv @@ -92,6 +96,7 @@ public sealed class AgentApp : IDisposable Listen = new IPEndPoint(bind, config.Listen.ControlPort), Certificate = identity.Certificate, LookupPeerKey = _tasks.LookupPeerKey, + Knock = _knock, }, _hub.OnSessionAsync, log, @@ -284,6 +289,9 @@ public sealed class AgentApp : IDisposable return link; } + /// 敲门关卡的名册:期望状态里的 peers(任务管理器在构造之后才有)。 + private IReadOnlyList RosterKeys() => _tasks.PeerKeys(); + private JsonElement? HelloMeta() { var meta = new AgentHelloMeta @@ -292,7 +300,7 @@ public sealed class AgentApp : IDisposable Ipv6Supported = _ipv6Supported, Addresses = [.. LocalAddresses.Sample()], Platform = MoShiro.Measurement.PlatformDetector.Detect(), - TlsSpki = _identity.File.TlsSpki, + TlsPublicKey = Convert.ToBase64String(_identity.Certificate.PublicKey.ExportSubjectPublicKeyInfo()), ProbeTcpPort = ControlPort, ProbeUdpPorts = [.. UdpPorts], LocalLimits = new LocalLimits diff --git a/backend/agent/src/MoShiro.Agent/Tasks/TaskManager.cs b/backend/agent/src/MoShiro.Agent/Tasks/TaskManager.cs index a8254a0..e61f699 100644 --- a/backend/agent/src/MoShiro.Agent/Tasks/TaskManager.cs +++ b/backend/agent/src/MoShiro.Agent/Tasks/TaskManager.cs @@ -52,6 +52,9 @@ public sealed class TaskManager /// 点对点握手时查找 Probe 的公钥(SPKI)。 public byte[]? LookupPeerKey(string peerId) => _peers.TryGetValue(peerId, out byte[]? key) ? key : null; + /// 名册里全部 Probe 的公钥:敲门关卡用它们派生配对密钥。 + public IReadOnlyList PeerKeys() => [.. _peers.Values]; + /// 一个新的 Probe 会话开始:为它登记全部线路任务的流并启动监听方积木,返回要下发给它的线路任务集。 public LineTaskSet OpenSession(ProbeSession probe) { diff --git a/backend/fleet/src/Fleet.Identity/Knock.cs b/backend/fleet/src/Fleet.Identity/Knock.cs new file mode 100644 index 0000000..8dd41fa --- /dev/null +++ b/backend/fleet/src/Fleet.Identity/Knock.cs @@ -0,0 +1,80 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; +using System.Text; + +namespace Fleet.Identity; + +/// +/// 预握手敲门:TCP 连接建立之后、TLS 握手之前,拨号方先发 32 字节 nonce(16) | tag(16), +/// tag = HMAC-SHA256(K_pair, 时间窗 | nonce) 截断到 16 字节。监听方核对不过就什么都不写、直接断开, +/// 所以扫描器拿不到证书、TLS 参数和 JARM 指纹。K_pair 只有名册里的成员算得出:双方身份密钥做静态 ECDH,再经 HKDF 加用途标签派生。 +/// TCP 的 SYN-ACK 由内核发出,端口在扫描器眼里仍是「开着但不说话」,不是「没监听」。 +/// +public static class Knock +{ + public const int PacketBytes = 32; + public const int NonceBytes = 16; + public const int TagBytes = 16; + + /// 时间窗长度;接受前后各一个窗,容忍时钟偏差。 + public const int WindowSeconds = 30; + + private const string Label = "moshiro-knock-v1"; + + /// 两把身份密钥的配对密钥。info 里的两个公钥按字节序排列,所以两边算出的是同一个值。 + public static byte[] DeriveKey(NodeIdentity own, ReadOnlySpan peerSpki) + { + byte[] secret = own.AgreeWith(peerSpki); + try + { + ReadOnlySpan mine = own.PublicKeySpki; + bool mineFirst = mine.SequenceCompareTo(peerSpki) <= 0; + byte[] info = [.. Encoding.UTF8.GetBytes(Label), .. (mineFirst ? mine : peerSpki), .. (mineFirst ? peerSpki : mine)]; + return HKDF.DeriveKey(HashAlgorithmName.SHA256, secret, 32, null, info); + } + finally + { + CryptographicOperations.ZeroMemory(secret); + } + } + + public static byte[] Build(ReadOnlySpan key, long nowMs) + { + byte[] packet = new byte[PacketBytes]; + RandomNumberGenerator.Fill(packet.AsSpan(0, NonceBytes)); + Tag(key, WindowOf(nowMs), packet.AsSpan(0, NonceBytes), packet.AsSpan(NonceBytes)); + return packet; + } + + /// tag 在前一个、当前、后一个时间窗里有一个对得上就通过。不管重放(重放由 按 nonce 去重)。 + public static bool Check(ReadOnlySpan key, ReadOnlySpan packet, long nowMs) + { + if (packet.Length != PacketBytes) + { + return false; + } + + long window = WindowOf(nowMs); + Span expected = stackalloc byte[TagBytes]; + bool ok = false; + for (long w = window - 1; w <= window + 1; w++) + { + Tag(key, w, packet[..NonceBytes], expected); + ok |= CryptographicOperations.FixedTimeEquals(expected, packet[NonceBytes..]); + } + + return ok; + } + + private static long WindowOf(long nowMs) => nowMs / 1000 / WindowSeconds; + + private static void Tag(ReadOnlySpan key, long window, ReadOnlySpan nonce, Span destination) + { + Span data = stackalloc byte[8 + NonceBytes]; + BinaryPrimitives.WriteInt64BigEndian(data, window); + nonce.CopyTo(data[8..]); + Span full = stackalloc byte[32]; + HMACSHA256.HashData(key, data, full); + full[..TagBytes].CopyTo(destination); + } +} diff --git a/backend/fleet/src/Fleet.Identity/KnockGate.cs b/backend/fleet/src/Fleet.Identity/KnockGate.cs new file mode 100644 index 0000000..01a1135 --- /dev/null +++ b/backend/fleet/src/Fleet.Identity/KnockGate.cs @@ -0,0 +1,113 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Security.Cryptography; + +namespace Fleet.Identity; + +/// +/// 监听方的敲门关卡:新连接的前 32 字节必须是名册里某个成员的敲门包,否则不写任何字节、直接断开。 +/// 每个来源地址限速(陌生人用随机数据也要消耗 HMAC 计算),nonce 在时间窗内去重(防重放)。 +/// 名册由调用方提供(对端公钥列表);配对密钥按公钥缓存,名册变了自然只算新的。 +/// +public sealed class KnockGate +{ + private static readonly TimeSpan s_readTimeout = TimeSpan.FromSeconds(3); + + private readonly NodeIdentity _own; + private readonly Func> _peers; + private readonly TimeProvider _time; + private readonly int _attemptsPerPeriod; + private readonly long _periodMs; + private readonly ConcurrentDictionary _keys = new(); + private readonly object _gate = new(); + private readonly Dictionary _seenNonces = []; + private readonly Dictionary _sources = []; + private long _lastSweepMs; + + public KnockGate(NodeIdentity own, Func> peers, TimeProvider time, int attemptsPerPeriod = 300, TimeSpan? period = null) + { + _own = own; + _peers = peers; + _time = time; + _attemptsPerPeriod = attemptsPerPeriod; + _periodMs = (long)(period ?? TimeSpan.FromSeconds(10)).TotalMilliseconds; + } + + /// 读 32 字节并核对。通过返回 true,流从敲门包之后继续(TLS 或明文首帧);不通过返回 false,调用方直接关闭连接,不要写任何东西。 + public async ValueTask AdmitAsync(Stream stream, IPAddress? source, CancellationToken ct) + { + long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); + if (!TryCountSource(source, now)) + { + return false; + } + + byte[] packet = new byte[Knock.PacketBytes]; + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(s_readTimeout); + try + { + await stream.ReadExactlyAsync(packet, timeout.Token); + } + catch (Exception ex) when (ex is EndOfStreamException or OperationCanceledException or IOException or ObjectDisposedException) + { + return false; + } + + return Accept(packet, now); + } + + /// 核对一个敲门包(不读流):名册里有成员的密钥对得上,并且 nonce 没用过。 + public bool Accept(ReadOnlySpan packet, long nowMs) + { + bool matched = false; + foreach (byte[] peer in _peers()) + { + byte[] key = _keys.GetOrAdd(Convert.ToBase64String(peer), _ => Knock.DeriveKey(_own, peer)); + matched |= Knock.Check(key, packet, nowMs); + } + + if (!matched) + { + return false; + } + + string nonce = Convert.ToHexString(packet[..Knock.NonceBytes]); + lock (_gate) + { + Sweep(nowMs); + return _seenNonces.TryAdd(nonce, nowMs + (Knock.WindowSeconds * 3 * 1000L)); + } + } + + private bool TryCountSource(IPAddress? source, long nowMs) + { + string key = source is null ? "" : source.IsIPv4MappedToIPv6 ? source.MapToIPv4().ToString() : source.ToString(); + lock (_gate) + { + Sweep(nowMs); + (long start, int count) = _sources.TryGetValue(key, out var entry) && nowMs - entry.WindowStartMs < _periodMs ? entry : (nowMs, 0); + _sources[key] = (start, count + 1); + return count < _attemptsPerPeriod; + } + } + + private void Sweep(long nowMs) + { + if (nowMs - _lastSweepMs < 5000 && _seenNonces.Count < 4096 && _sources.Count < 4096) + { + return; + } + + _lastSweepMs = nowMs; + foreach (string nonce in _seenNonces.Where(p => p.Value <= nowMs).Select(p => p.Key).ToList()) + { + _seenNonces.Remove(nonce); + } + + foreach (string source in _sources.Where(p => nowMs - p.Value.WindowStartMs >= _periodMs).Select(p => p.Key).ToList()) + { + _sources.Remove(source); + } + } +} diff --git a/backend/fleet/src/Fleet.Identity/NodeIdentity.cs b/backend/fleet/src/Fleet.Identity/NodeIdentity.cs index 5e12341..04d8095 100644 --- a/backend/fleet/src/Fleet.Identity/NodeIdentity.cs +++ b/backend/fleet/src/Fleet.Identity/NodeIdentity.cs @@ -41,6 +41,16 @@ public sealed class NodeIdentity : IDisposable public byte[] ExportPkcs8() => _key.ExportPkcs8PrivateKey(); + /// 与对端公钥(SPKI)做静态 ECDH,返回原始共享秘密。只用于派生对称密钥(敲门),必须再经 KDF,不能直接当密钥用。 + public byte[] AgreeWith(ReadOnlySpan peerSpki) + { + using ECDiffieHellman own = ECDiffieHellman.Create(); + own.ImportPkcs8PrivateKey(_key.ExportPkcs8PrivateKey(), out _); + using ECDiffieHellman peer = ECDiffieHellman.Create(); + peer.ImportSubjectPublicKeyInfo(peerSpki, out _); + return own.DeriveRawSecretAgreement(peer.PublicKey); + } + public byte[] Sign(string purpose, params string[] fields) => _key.SignData(Encoding.UTF8.GetBytes(SignatureString.Build(purpose, fields)), HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation); diff --git a/backend/fleet/src/Fleet.Link/Peer.cs b/backend/fleet/src/Fleet.Link/Peer.cs index 691b544..ba94a2b 100644 --- a/backend/fleet/src/Fleet.Link/Peer.cs +++ b/backend/fleet/src/Fleet.Link/Peer.cs @@ -22,6 +22,9 @@ public sealed class PeerListenerOptions /// 按对端 ID 查找允许连接的公钥(SPKI)。不在列表里的对端一律拒绝(期望状态里的 peers)。 public required Func LookupPeerKey { get; init; } + /// 预握手敲门:每个连接先过这一关(名册里的 Probe 才算得出敲门包),不通过就直接断开,什么都不写。 + public required KnockGate Knock { get; init; } + public int MaxFrameBytes { get; init; } = 8 << 20; public int MaxSessions { get; init; } = 8; @@ -98,7 +101,13 @@ public sealed class PeerListener PeerSockets.EnableKeepAlive(client.Client); using var timeout = new CancellationTokenSource(_options.HandshakeTimeout, _time); using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token); - var tls = new SslStream(client.GetStream(), leaveInnerStreamOpen: false); + Stream stream = client.GetStream(); + if (!await _options.Knock.AdmitAsync(stream, (client.Client.RemoteEndPoint as IPEndPoint)?.Address, linked.Token)) + { + return; + } + + var tls = new SslStream(stream, leaveInnerStreamOpen: false); await tls.AuthenticateAsServerAsync( new SslServerAuthenticationOptions { ServerCertificate = _options.Certificate, ClientCertificateRequired = false, EnabledSslProtocols = System.Security.Authentication.SslProtocols.Tls12 | System.Security.Authentication.SslProtocols.Tls13 }, linked.Token); @@ -222,8 +231,10 @@ internal static class PeerSockets public static class PeerConnector { public static async Task<(PeerSessionInfo Info, TlsFrameTransport Transport)> ConnectAsync( - string host, int port, byte[] expectedSpkiSha256, NodeIdentity identity, string peerId, string? version, int maxFrameBytes, TimeProvider time, CancellationToken ct) + string host, int port, byte[] agentPublicKey, NodeIdentity identity, string peerId, string? version, int maxFrameBytes, TimeProvider time, CancellationToken ct) { + // 证书按公钥的指纹固定;敲门密钥由双方身份密钥派生 + byte[] expectedSpkiSha256 = NodeIdentity.Fingerprint(agentPublicKey); TcpClient client = IPAddress.TryParse(host, out IPAddress? address) ? new TcpClient(address.AddressFamily) : new TcpClient(); client.NoDelay = true; PeerSockets.EnableKeepAlive(client.Client); @@ -232,8 +243,10 @@ public static class PeerConnector using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10), time); using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token); await client.ConnectAsync(host, port, linked.Token); + Stream stream = client.GetStream(); + await stream.WriteAsync(Knock.Build(Knock.DeriveKey(identity, agentPublicKey), time.GetUtcNow().ToUnixTimeMilliseconds()), linked.Token); var tls = new SslStream( - client.GetStream(), leaveInnerStreamOpen: false, + stream, leaveInnerStreamOpen: false, (_, certificate, _, _) => certificate is not null && SpkiMatches(certificate, expectedSpkiSha256)); await tls.AuthenticateAsClientAsync(new SslClientAuthenticationOptions { TargetHost = host }, linked.Token); var transport = new TlsFrameTransport(tls, maxFrameBytes); diff --git a/backend/fleet/tests/Fleet.Tests/KnockTests.cs b/backend/fleet/tests/Fleet.Tests/KnockTests.cs new file mode 100644 index 0000000..ef56807 --- /dev/null +++ b/backend/fleet/tests/Fleet.Tests/KnockTests.cs @@ -0,0 +1,112 @@ +using System.Net; +using Fleet.Identity; + +namespace Fleet.Tests; + +public sealed class KnockTests +{ + private sealed class Clock : TimeProvider + { + public DateTimeOffset Now { get; set; } = new(2026, 10, 7, 12, 0, 0, TimeSpan.Zero); + + public override DateTimeOffset GetUtcNow() => Now; + } + + [Fact] + public void PairKey_IsTheSameOnBothSides_AndDiffersPerPair() + { + using NodeIdentity probe = NodeIdentity.Generate(); + using NodeIdentity agent = NodeIdentity.Generate(); + using NodeIdentity other = NodeIdentity.Generate(); + byte[] fromProbe = Knock.DeriveKey(probe, agent.PublicKeySpki); + byte[] fromAgent = Knock.DeriveKey(agent, probe.PublicKeySpki); + Assert.Equal(fromProbe, fromAgent); + Assert.Equal(32, fromProbe.Length); + Assert.NotEqual(fromProbe, Knock.DeriveKey(other, agent.PublicKeySpki)); + } + + [Fact] + public void Packet_IsAcceptedWithinTheWindowAndOneWindowOfSkew_ButNotBeyond() + { + byte[] key = new byte[32]; + long now = new DateTimeOffset(2026, 10, 7, 12, 0, 15, TimeSpan.Zero).ToUnixTimeMilliseconds(); + byte[] packet = Knock.Build(key, now); + Assert.Equal(Knock.PacketBytes, packet.Length); + Assert.True(Knock.Check(key, packet, now)); + Assert.True(Knock.Check(key, packet, now + (Knock.WindowSeconds * 1000))); + Assert.True(Knock.Check(key, packet, now - (Knock.WindowSeconds * 1000))); + Assert.False(Knock.Check(key, packet, now + (3 * Knock.WindowSeconds * 1000))); + Assert.False(Knock.Check(key, packet, now - (3 * Knock.WindowSeconds * 1000))); + } + + [Fact] + public void Packet_WithAWrongKeyATamperedByteOrTheWrongLength_IsRejected() + { + byte[] key = new byte[32]; + long now = 1_700_000_000_000; + byte[] packet = Knock.Build(key, now); + Assert.False(Knock.Check(new byte[32] { 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, packet, now)); + foreach (int index in new[] { 0, 15, 16, 31 }) + { + byte[] tampered = (byte[])packet.Clone(); + tampered[index] ^= 0x01; + Assert.False(Knock.Check(key, tampered, now), $"byte {index}"); + } + + Assert.False(Knock.Check(key, packet.AsSpan(0, 31), now)); + Assert.False(Knock.Check(key, [], now)); + } + + [Fact] + public void Gate_AdmitsRosterMembersOnce_AndRejectsReplaysStrangersAndExpiredPackets() + { + using NodeIdentity agent = NodeIdentity.Generate(); + using NodeIdentity probe = NodeIdentity.Generate(); + using NodeIdentity stranger = NodeIdentity.Generate(); + var roster = new List { probe.PublicKeySpki }; + var gate = new KnockGate(agent, () => roster, new Clock()); + long now = 1_700_000_000_000; + + byte[] packet = Knock.Build(Knock.DeriveKey(probe, agent.PublicKeySpki), now); + Assert.True(gate.Accept(packet, now)); + Assert.False(gate.Accept(packet, now), "the same nonce cannot be used twice"); + Assert.False(gate.Accept(Knock.Build(Knock.DeriveKey(stranger, agent.PublicKeySpki), now), now), "not on the roster"); + Assert.False(gate.Accept(Knock.Build(Knock.DeriveKey(probe, agent.PublicKeySpki), now - 10 * 60_000), now), "expired"); + + // 名册变了:新成员马上能进,被移除的马上进不来 + using NodeIdentity second = NodeIdentity.Generate(); + roster.Add(second.PublicKeySpki); + Assert.True(gate.Accept(Knock.Build(Knock.DeriveKey(second, agent.PublicKeySpki), now), now)); + roster.RemoveAt(0); + Assert.False(gate.Accept(Knock.Build(Knock.DeriveKey(probe, agent.PublicKeySpki), now), now)); + } + + [Fact] + public async Task Gate_RateLimitsEachSource_AndReadsAtMost32Bytes() + { + using NodeIdentity agent = NodeIdentity.Generate(); + using NodeIdentity probe = NodeIdentity.Generate(); + var clock = new Clock(); + var gate = new KnockGate(agent, () => [probe.PublicKeySpki], clock, attemptsPerPeriod: 3); + IPAddress noisy = IPAddress.Parse("203.0.113.9"); + IPAddress quiet = IPAddress.Parse("203.0.113.10"); + byte[] key = Knock.DeriveKey(probe, agent.PublicKeySpki); + + for (int i = 0; i < 3; i++) + { + Assert.False(await gate.AdmitAsync(new MemoryStream(new byte[Knock.PacketBytes]), noisy, default)); + } + + // 超过限额:连有效的敲门包也不读、不算 + byte[] valid = Knock.Build(key, clock.Now.ToUnixTimeMilliseconds()); + Assert.False(await gate.AdmitAsync(new MemoryStream(valid), noisy, default)); + Assert.True(await gate.AdmitAsync(new MemoryStream(valid), quiet, default)); + + // 过了统计周期,恢复 + clock.Now += TimeSpan.FromSeconds(11); + Assert.True(await gate.AdmitAsync(new MemoryStream(Knock.Build(key, clock.Now.ToUnixTimeMilliseconds())), noisy, default)); + + // 流在 32 字节之前就结束:拒绝,不抛异常 + Assert.False(await gate.AdmitAsync(new MemoryStream(new byte[10]), quiet, default)); + } +} diff --git a/backend/fleet/tests/Fleet.Tests/LinkTests.cs b/backend/fleet/tests/Fleet.Tests/LinkTests.cs index ecbbd43..284ea3b 100644 --- a/backend/fleet/tests/Fleet.Tests/LinkTests.cs +++ b/backend/fleet/tests/Fleet.Tests/LinkTests.cs @@ -280,12 +280,20 @@ public sealed class LinkTests private sealed class PeerFixture : IAsyncDisposable { private readonly CancellationTokenSource _stop = new(); + private readonly NodeIdentity _identity; public PeerFixture(Func lookup, Func? onSession = null) { Certificate = NodeCertificate.LoadPfx(NodeCertificate.CreateSelfSignedPfx("agent", TimeSpan.FromDays(30), TimeProvider.System)); + _identity = NodeIdentity.FromPkcs8(Certificate.GetECDsaPrivateKey()!.ExportPkcs8PrivateKey()); Listener = new PeerListener( - new PeerListenerOptions { Listen = new IPEndPoint(IPAddress.Loopback, 0), Certificate = Certificate, LookupPeerKey = lookup }, + new PeerListenerOptions + { + Listen = new IPEndPoint(IPAddress.Loopback, 0), + Certificate = Certificate, + LookupPeerKey = lookup, + Knock = new KnockGate(_identity, () => KnockPeers, TimeProvider.System), + }, onSession ?? ((_, transport, ct) => KeepOpenAsync(transport, ct)), NullNodeLog.Instance, TimeProvider.System); Task = Listener.RunAsync(_stop.Token); @@ -299,10 +307,17 @@ public sealed class LinkTests public byte[] Spki => NodeCertificate.SpkiFingerprint(Certificate); + /// Agent 的 TLS 证书公钥:Probe 用它固定证书、派生敲门密钥。 + public byte[] PublicKey => Certificate.PublicKey.ExportSubjectPublicKeyInfo(); + + /// 名册:能敲开门的 Probe 公钥。 + public List KnockPeers { get; } = []; + public async ValueTask DisposeAsync() { await _stop.CancelAsync(); await Task; + _identity.Dispose(); Certificate.Dispose(); _stop.Dispose(); } @@ -325,9 +340,10 @@ public sealed class LinkTests seen.TrySetResult(info); return Task.Delay(Timeout.Infinite, ct).ContinueWith(_ => { }, CancellationToken.None); }); + agent.KnockPeers.Add(probe.PublicKeySpki); await agent.Listener.Started.Task; - (PeerSessionInfo client, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default); + (PeerSessionInfo client, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default); await using (transport) { PeerSessionInfo server = await seen.Task.WaitAsync(TimeSpan.FromSeconds(5)); @@ -343,17 +359,18 @@ public sealed class LinkTests using NodeIdentity probe = NodeIdentity.Generate(); using NodeIdentity impostor = NodeIdentity.Generate(); await using var agent = new PeerFixture(id => id == "prb_1" ? probe.PublicKeySpki : null); + agent.KnockPeers.AddRange([probe.PublicKeySpki, impostor.PublicKeySpki]); // 敲门通过之后,名册外的 ID 和伪造的签名仍然被拒绝 await agent.Listener.Started.Task; InvalidOperationException unknown = await Assert.ThrowsAsync(async () => - await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_unknown", null, 1 << 20, TimeProvider.System, default)); + await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_unknown", null, 1 << 20, TimeProvider.System, default)); InvalidOperationException forged = await Assert.ThrowsAsync(async () => - await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, impostor, "prb_1", null, 1 << 20, TimeProvider.System, default)); + await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, impostor, "prb_1", null, 1 << 20, TimeProvider.System, default)); Assert.Equal(unknown.Message, forged.Message); Assert.Contains("fleet_unauthorized", forged.Message); // 正主仍然可以连接 - (_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default); + (_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default); await ok.DisposeAsync(); } @@ -362,11 +379,13 @@ public sealed class LinkTests { using NodeIdentity probe = NodeIdentity.Generate(); await using var agent = new PeerFixture(_ => probe.PublicKeySpki); + agent.KnockPeers.Add(probe.PublicKeySpki); await agent.Listener.Started.Task; - byte[] wrong = SHA256.HashData("another certificate"u8); - await Assert.ThrowsAnyAsync(async () => - await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, wrong, probe, "prb_1", null, 1 << 20, TimeProvider.System, default)); + // 公钥不对:敲门密钥对不上,Agent 直接断开(拿到的不是证书,连不是它的证书都看不到) + using NodeIdentity another = NodeIdentity.Generate(); + await Assert.ThrowsAnyAsync(async () => + await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, another.PublicKeySpki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default)); } [Fact] @@ -382,9 +401,10 @@ public sealed class LinkTests await using var session = new RpcSession(transport, router, info.PeerId, TimeProvider.System); await session.RunAsync(ct); }); + agent.KnockPeers.Add(probe.PublicKeySpki); 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); + (_, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default); await using var client = new RpcSession(transport, new RpcRouter(RpcSide.Dialer, table), "probe", TimeProvider.System); Task run = client.RunAsync(default); Result result = await client.RequestAsync("agent.echo", new Number { Value = 41 }, LinkJson.Default.Number, LinkJson.Default.Number, default); @@ -398,6 +418,7 @@ public sealed class LinkTests { using NodeIdentity probe = NodeIdentity.Generate(); await using var agent = new PeerFixture(_ => probe.PublicKeySpki); + agent.KnockPeers.Add(probe.PublicKeySpki); await agent.Listener.Started.Task; // 不说 TLS 的连接:握手失败,监听继续 @@ -407,7 +428,45 @@ public sealed class LinkTests await garbage.GetStream().WriteAsync("GET / HTTP/1.1\r\n\r\n"u8.ToArray()); } - (_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default); + (_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default); + await ok.DisposeAsync(); + } + + [Fact] + public async Task PeerListener_WritesNothingToStrangers_NotEvenATlsServerHello() + { + using NodeIdentity probe = NodeIdentity.Generate(); + await using var agent = new PeerFixture(_ => probe.PublicKeySpki); + agent.KnockPeers.Add(probe.PublicKeySpki); + await agent.Listener.Started.Task; + + // 一个像样的 TLS ClientHello 开头、随机数据、什么都不发:都拿不到任何字节(连接被直接关闭) + foreach (byte[] payload in new[] { new byte[] { 0x16, 0x03, 0x01, 0x00, 0x05, 0x01, 0x00, 0x00, 0x01, 0x00 }.Concat(RandomNumberGenerator.GetBytes(64)).ToArray(), RandomNumberGenerator.GetBytes(32), [] }) + { + using var scanner = new TcpClient(); + await scanner.ConnectAsync(IPAddress.Loopback, agent.Listener.Port); + NetworkStream stream = scanner.GetStream(); + if (payload.Length > 0) + { + await stream.WriteAsync(payload); + } + + byte[] buffer = new byte[64]; + int read; + try + { + read = await stream.ReadAsync(buffer).AsTask().WaitAsync(TimeSpan.FromSeconds(10)); + } + catch (IOException) + { + read = 0; // 连接被重置也是「什么都没收到」 + } + + Assert.Equal(0, read); + } + + // 名册里的 Probe 仍然进得来 + (_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default); await ok.DisposeAsync(); } diff --git a/backend/probe/src/MoShiro.Probe/Link/AgentConnection.cs b/backend/probe/src/MoShiro.Probe/Link/AgentConnection.cs index 90f8ff4..dcc3c74 100644 --- a/backend/probe/src/MoShiro.Probe/Link/AgentConnection.cs +++ b/backend/probe/src/MoShiro.Probe/Link/AgentConnection.cs @@ -89,8 +89,9 @@ public sealed class AgentConnection throw new SocketException((int)SocketError.HostNotFound); } + byte[] agentKey = Convert.FromBase64String(Endpoint.PublicKey); (PeerSessionInfo info, TlsFrameTransport transport) = await PeerConnector.ConnectAsync( - address.ToString(), Endpoint.TcpPort, Convert.FromBase64String(Endpoint.Spki), _env.Identity, _env.ProbeId, ProbeApp.Version, 8 << 20, _env.Time, ct); + address.ToString(), Endpoint.TcpPort, agentKey, _env.Identity, _env.ProbeId, ProbeApp.Version, 8 << 20, _env.Time, ct); var remote = (IPEndPoint)info.Remote!; var data = new DataSession(info.Credentials.SessionIdValue, info.Credentials.Key); using var udp = new UdpMux(new SingleSession(data), null, _env.Clock, new DataPlaneCounters(), _env.Log, remote.AddressFamily == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, []); @@ -105,7 +106,8 @@ public sealed class AgentConnection Session = () => data, Udp = udp, PeerAddress = remote.Address, - PeerSpkiSha256 = Convert.FromBase64String(Endpoint.Spki), + PeerSpkiSha256 = NodeIdentity.Fingerprint(agentKey), + KnockKey = Knock.DeriveKey(_env.Identity, agentKey), Fragments = shipper, MaxPacketsPerSecond = _env.Limits.MaxTotalPps, MaxBulkMbps = _env.Limits.MaxTestMbps, diff --git a/backend/probe/src/MoShiro.Probe/Link/AgentDirectory.cs b/backend/probe/src/MoShiro.Probe/Link/AgentDirectory.cs index e41904f..69beae9 100644 --- a/backend/probe/src/MoShiro.Probe/Link/AgentDirectory.cs +++ b/backend/probe/src/MoShiro.Probe/Link/AgentDirectory.cs @@ -72,12 +72,12 @@ public sealed class AgentDirectory { if (!string.IsNullOrWhiteSpace(agent.Host)) { - next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Host, TcpPort = agent.TcpPort, Spki = agent.Spki }); + next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Host, TcpPort = agent.TcpPort, PublicKey = agent.PublicKey }); } if (!string.IsNullOrWhiteSpace(agent.Ipv6Host)) { - next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Ipv6Host, TcpPort = agent.TcpPort, Spki = agent.Spki, IpVersion = IpVersions.V6 }); + next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Ipv6Host, TcpPort = agent.TcpPort, PublicKey = agent.PublicKey, IpVersion = IpVersions.V6 }); } } lock (_gate) @@ -108,5 +108,5 @@ public sealed class AgentDirectory a.Count == b.Count && a.Zip(b).All(p => Equal(p.First, p.Second)); public static bool Equal(KnownAgent a, KnownAgent b) => - a.AgentId == b.AgentId && a.IpVersion == b.IpVersion && a.Host == b.Host && a.TcpPort == b.TcpPort && a.Spki == b.Spki; + a.AgentId == b.AgentId && a.IpVersion == b.IpVersion && a.Host == b.Host && a.TcpPort == b.TcpPort && a.PublicKey == b.PublicKey; } diff --git a/backend/probe/src/MoShiro.Probe/ProbeConfig.cs b/backend/probe/src/MoShiro.Probe/ProbeConfig.cs index 53c697c..4f4d6a2 100644 --- a/backend/probe/src/MoShiro.Probe/ProbeConfig.cs +++ b/backend/probe/src/MoShiro.Probe/ProbeConfig.cs @@ -48,9 +48,9 @@ public sealed class KnownAgent [JsonPropertyName("tcp_port")] public int TcpPort { get; set; } - /// Agent 的 TLS 证书指纹(SHA-256,base64)。 - [JsonPropertyName("spki")] - public string Spki { get; set; } = ""; + /// Agent 的 TLS 证书公钥(SPKI,base64):证书按它的 SHA-256 指纹固定,敲门密钥由它派生。 + [JsonPropertyName("public_key")] + public string PublicKey { get; set; } = ""; } public sealed class ProbeLimits diff --git a/backend/probe/tests/MoShiro.Probe.Tests/ProbeTests.cs b/backend/probe/tests/MoShiro.Probe.Tests/ProbeTests.cs index 3b9825f..8eadedb 100644 --- a/backend/probe/tests/MoShiro.Probe.Tests/ProbeTests.cs +++ b/backend/probe/tests/MoShiro.Probe.Tests/ProbeTests.cs @@ -58,7 +58,7 @@ public sealed class ProbeTests private static ProbeAgentList List(long version, params string[] ids) => new() { Version = version, - Agents = [.. ids.Select(id => new AgentEndpoint { AgentId = new NodeId(id), Host = id + ".example", TcpPort = 7443, Spki = "c3BraQ==" })], + Agents = [.. ids.Select(id => new AgentEndpoint { AgentId = new NodeId(id), Host = id + ".example", TcpPort = 7443, PublicKey = "c3BraQ==" })], }; [Fact] diff --git a/backend/shared/MoShiro.Measurement/Core/Contracts.cs b/backend/shared/MoShiro.Measurement/Core/Contracts.cs index 1dea055..d9fe153 100644 --- a/backend/shared/MoShiro.Measurement/Core/Contracts.cs +++ b/backend/shared/MoShiro.Measurement/Core/Contracts.cs @@ -217,6 +217,9 @@ public sealed class BlockEnv /// 拨号方:对端 TLS 证书的 SPKI 指纹;null 表示不校验证书(目标任务,目标是任意地址)。 public byte[]? PeerSpkiSha256 { get; init; } + /// 拨号方:和对端(Agent)的配对敲门密钥;每条 TCP 连接建立后先发敲门包。null 表示对端不要求敲门(目标任务的任意地址)。 + public byte[]? KnockKey { get; init; } + /// 拨号方:列块出口。 public IFragmentSink? Fragments { get; init; } @@ -241,6 +244,7 @@ public sealed class BlockEnv TcpListen = TcpListen, PeerAddress = PeerAddress, PeerSpkiSha256 = PeerSpkiSha256, + KnockKey = KnockKey, Fragments = Fragments, MaxBulkMbps = MaxBulkMbps, MaxPacketsPerSecond = MaxPacketsPerSecond, diff --git a/backend/shared/MoShiro.Measurement/MoShiro.Measurement.csproj b/backend/shared/MoShiro.Measurement/MoShiro.Measurement.csproj index baf3b8d..6f2c5f1 100644 --- a/backend/shared/MoShiro.Measurement/MoShiro.Measurement.csproj +++ b/backend/shared/MoShiro.Measurement/MoShiro.Measurement.csproj @@ -6,5 +6,6 @@ + diff --git a/backend/shared/MoShiro.Measurement/Net/Tcp.cs b/backend/shared/MoShiro.Measurement/Net/Tcp.cs index 98e798b..3141426 100644 --- a/backend/shared/MoShiro.Measurement/Net/Tcp.cs +++ b/backend/shared/MoShiro.Measurement/Net/Tcp.cs @@ -5,6 +5,7 @@ using System.Net.Sockets; using System.Security.Authentication; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; +using Fleet.Identity; using MoShiro.Protocol; namespace MoShiro.Measurement; @@ -46,6 +47,12 @@ public sealed class TcpLink : IDisposable await client.ConnectAsync(env.PeerAddress!, port, ct); long tcpUs = env.Clock.NowUs() - start; Stream stream = client.GetStream(); + if (env.KnockKey is { } knockKey) + { + // 预握手敲门:监听方不认就不会回任何字节 + await stream.WriteAsync(Knock.Build(knockKey, env.Time.GetUtcNow().ToUnixTimeMilliseconds()), ct); + } + long? tlsUs = null; if (tls) { @@ -115,7 +122,7 @@ public sealed class TcpConnection public delegate Task TcpResponder(TcpConnection connection, CancellationToken ct); /// -/// 监听方的 TCP 数据面:每个端口一个监听器。连接的第一个字节是 0x16 就先做 TLS(节点自己的证书),否则直接是明文。 +/// 监听方的 TCP 数据面:每个端口一个监听器。连接先过预握手敲门,然后第一个字节是 0x16 就先做 TLS(节点自己的证书),否则直接是明文。 /// 随后是认证首帧("MN" | ver | session_id | stream_id | nonce | mac,随机数只能用一次),通过之后交给按 stream_id 登记的处理函数。 /// 首帧在限定时间内没有到达或者校验失败,直接关闭连接并计数。 /// @@ -128,11 +135,13 @@ public sealed class TcpAcceptor private readonly X509Certificate2 _certificate; private readonly IDataSessions _sessions; private readonly DataPlaneCounters _counters; + private readonly KnockGate _knock; private readonly ConcurrentDictionary _handlers = new(); private readonly List _listeners = []; - public TcpAcceptor(IPAddress bind, IReadOnlyList ports, X509Certificate2 certificate, IDataSessions sessions, DataPlaneCounters counters) + public TcpAcceptor(IPAddress bind, IReadOnlyList ports, X509Certificate2 certificate, IDataSessions sessions, DataPlaneCounters counters, KnockGate knock) { + _knock = knock; _bind = bind; _ports = ports; _certificate = certificate; @@ -214,6 +223,13 @@ public sealed class TcpAcceptor using var authCts = CancellationTokenSource.CreateLinkedTokenSource(ct); authCts.CancelAfter(s_authTimeout); + // 预握手敲门:名册里的 Probe 才算得出敲门包;不通过就不写任何字节、直接断开 + if (!await _knock.AdmitAsync(stream, (client.Client.RemoteEndPoint as IPEndPoint)?.Address, authCts.Token)) + { + _counters.DropInvalid(); + return; + } + byte[] first = new byte[1]; if (await stream.ReadAsync(first, authCts.Token) != 1) { diff --git a/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs b/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs index 9442c7c..9703560 100644 --- a/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs +++ b/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs @@ -149,9 +149,9 @@ public sealed class AgentEndpoint [JsonPropertyName("udp_ports")] public List UdpPorts { get; set; } = []; - /// Agent 面向 Probe 的 TLS 证书指纹(SHA-256,base64)。 - [JsonPropertyName("spki")] - public string Spki { get; set; } = ""; + /// Agent 面向 Probe 的 TLS 证书公钥(SPKI,base64):证书按它的 SHA-256 指纹固定,敲门密钥由它和 Probe 的身份密钥派生。 + [JsonPropertyName("public_key")] + public string PublicKey { get; set; } = ""; } /// 期望状态文档(Fleet state type = probe_agents):Backend 直接下发给每个 Probe 的 Agent 列表。 @@ -323,8 +323,9 @@ public sealed class AgentHelloMeta [JsonPropertyName("platform")] public PlatformInfo? Platform { get; set; } - [JsonPropertyName("tls_spki")] - public string? TlsSpki { get; set; } + /// Agent 面向 Probe 的 TLS 证书公钥(SPKI,base64);面板据此得到证书指纹,并下发给 Probe 用来固定证书和派生敲门密钥。 + [JsonPropertyName("tls_pubkey")] + public string? TlsPublicKey { get; set; } /// 本机的公网地址(IPv4 公网地址、IPv6 全球单播地址):面板据此自动填 public_host 和 ipv6_host,管理员手填的优先。 [JsonPropertyName("addresses")] diff --git a/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs b/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs index f46503a..2f1e150 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs @@ -157,7 +157,8 @@ public sealed partial class AgentService IReadOnlyList agents = await _agents.ListAsync(ct); long version = long.TryParse(await _settings.GetAsync(TaskPlanner.ProbeAgentsVersionKey, ct), out long v) ? v : 0; var result = new ProbeAgentList { Version = version }; - foreach (AgentRecord agent in agents.Where(a => a.Status == NodeStatus.Active && a.PublicHost.Length > 0 && links.IsLinked(probeId, a.Id.Value))) + // 没有上报证书公钥的 Agent(旧版本)还不能被 Probe 连接:Probe 需要它来固定证书和派生敲门密钥 + foreach (AgentRecord agent in agents.Where(a => a.Status == NodeStatus.Active && a.PublicHost.Length > 0 && a.TlsPublicKey is { Length: > 0 } && links.IsLinked(probeId, a.Id.Value))) { result.Agents.Add(new AgentEndpoint { @@ -167,7 +168,7 @@ public sealed partial class AgentService Ipv6Host = agent.Ipv6Supported && agent.Ipv6Host.Length > 0 ? agent.Ipv6Host : null, TcpPort = agent.ProbeTcpPort, UdpPorts = [.. agent.ProbeUdpPorts], - Spki = Convert.ToBase64String(agent.TlsSpki), + PublicKey = Convert.ToBase64String(agent.TlsPublicKey!), }); } diff --git a/backend/src/MoShiro.Backend.Application/Agents/MoShiroFleetHooks.cs b/backend/src/MoShiro.Backend.Application/Agents/MoShiroFleetHooks.cs index 5e356ac..fa507d3 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/MoShiroFleetHooks.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/MoShiroFleetHooks.cs @@ -209,12 +209,19 @@ public sealed class MoShiroNodeEventSink : INodeEventSink } } + // Agent 上报 TLS 证书的公钥:指纹由面板自己算,不信节点报的指纹 + byte[]? tlsKey = null; byte[]? spki = null; - if (meta.TlsSpki is not null) + if (meta.TlsPublicKey is not null) { try { - spki = Convert.FromBase64String(meta.TlsSpki); + byte[] candidate = Convert.FromBase64String(meta.TlsPublicKey); + if (Fleet.Identity.NodeIdentity.IsValidPublicKey(candidate)) + { + tlsKey = candidate; + spki = Fleet.Identity.NodeIdentity.Fingerprint(candidate); + } } catch (FormatException) { @@ -225,7 +232,7 @@ public sealed class MoShiroNodeEventSink : INodeEventSink AgentRecord? before = await _agents.GetAsync(id, ct); string? limitsJson = meta.LocalLimits is null ? null : JsonSerializer.Serialize(meta.LocalLimits, LocalLimitsJson.Default.LocalLimits); string? ipv6Json = meta.Ipv6 is null ? null : JsonSerializer.Serialize(meta.Ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic); - await _agents.ApplyHelloAsync(id, new AgentHello(kinds, hello.NodeVersion, limitsJson, spki is { Length: 32 } ? spki : null, meta.ProbeTcpPort, meta.ProbeUdpPorts, meta.Ipv6Supported, ipv6Json, meta.Addresses), ct); + await _agents.ApplyHelloAsync(id, new AgentHello(kinds, hello.NodeVersion, limitsJson, spki is { Length: 32 } ? spki : null, meta.ProbeTcpPort, meta.ProbeUdpPorts, meta.Ipv6Supported, ipv6Json, meta.Addresses, tlsKey), ct); // 自动识别对外地址:Agent 上报的公网地址;手填的不动 if (meta.Addresses is not null && await _agents.GetAsync(id, ct) is { } reported) @@ -240,7 +247,7 @@ public sealed class MoShiroNodeEventSink : INodeEventSink await _releases.OfferToNodeAsync(id, ct); await _schedule.SendToNodeAsync(id, ct); if (before is not null && after is not null - && (!before.TlsSpki.AsSpan().SequenceEqual(after.TlsSpki) || before.ProbeTcpPort != after.ProbeTcpPort || !before.ProbeUdpPorts.SequenceEqual(after.ProbeUdpPorts) || before.Ipv6Supported != after.Ipv6Supported || before.PublicHost != after.PublicHost || before.Ipv6Host != after.Ipv6Host) + && (!before.TlsSpki.AsSpan().SequenceEqual(after.TlsSpki) || !(before.TlsPublicKey ?? []).AsSpan().SequenceEqual(after.TlsPublicKey ?? []) || before.ProbeTcpPort != after.ProbeTcpPort || !before.ProbeUdpPorts.SequenceEqual(after.ProbeUdpPorts) || before.Ipv6Supported != after.Ipv6Supported || before.PublicHost != after.PublicHost || before.Ipv6Host != after.Ipv6Host) && after.Status == NodeStatus.Active) { await _service.AfterAgentSetChangedAsync(ct); diff --git a/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs b/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs index 90a0a0f..25a38af 100644 --- a/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs +++ b/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs @@ -28,7 +28,8 @@ public sealed record AgentRecord( string? Ipv6DiagnosticsJson = null, bool PublicHostManual = false, bool Ipv6HostManual = false, - IReadOnlyList? ReportedAddresses = null); + IReadOnlyList? ReportedAddresses = null, + byte[]? TlsPublicKey = null); /// 管理员可以修改的字段(merge-patch 语义:为 null 的字段不改)。 public sealed class AgentPatch @@ -59,7 +60,8 @@ public sealed record AgentHello( IReadOnlyList? ProbeUdpPorts, bool Ipv6Supported = false, string? Ipv6DiagnosticsJson = null, - IReadOnlyList? Addresses = null); + IReadOnlyList? Addresses = null, + byte[]? TlsPublicKey = null); /// 注册之后 MoShiro 的补充:公开地址和端口。 public sealed record AgentEnrollment(string PublicHost, int ProbeTcpPort, IReadOnlyList ProbeUdpPorts, IReadOnlyList Kinds); diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs index 2a735d8..eb95d9d 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs @@ -5,7 +5,7 @@ namespace MoShiro.Backend.Storage.Sqlite; /// 表结构。版本记录在 PRAGMA user_version;以后的迁移是按版本号追加的内置 SQL。 internal static class Schema { - public const int CurrentVersion = 9; + public const int CurrentVersion = 10; public static void Migrate(SqliteConnection connection) { @@ -131,6 +131,15 @@ internal static class Schema connection.Exec("PRAGMA user_version = 9"); transaction.Commit(); } + + if (version < 10) + { + // 预握手敲门:Probe 需要 Agent 的证书公钥(不只是指纹)来派生配对密钥,由 Agent 在 hello 里上报 + using SqliteTransaction transaction = connection.BeginTransaction(); + connection.Exec("ALTER TABLE agents ADD COLUMN tls_pubkey BLOB"); + connection.Exec("PRAGMA user_version = 10"); + transaction.Commit(); + } } /// diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs index 99b3013..2b4e11f 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs @@ -14,7 +14,7 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore { private const string AgentColumns = "id, name, status, pubkey, tls_spki, public_host, probe_tcp_port, probe_udp_ports, labels, public_visible, kinds, agent_version, local_limits, " + - "task_version, applied_task_version, rejected, created_at, last_seen_at, row_version, ipv6_host, ipv6_supported, ipv6_diagnostics, public_host_manual, ipv6_host_manual, reported_addresses"; + "task_version, applied_task_version, rejected, created_at, last_seen_at, row_version, ipv6_host, ipv6_supported, ipv6_diagnostics, public_host_manual, ipv6_host_manual, reported_addresses, tls_pubkey"; private readonly SqliteDatabase _db; @@ -123,11 +123,11 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore _db.ExecuteAsync(c => { c.Exec( - "UPDATE agents SET kinds = @kinds, agent_version = @version, local_limits = COALESCE(@limits, local_limits), tls_spki = COALESCE(@spki, tls_spki), " + + "UPDATE agents SET kinds = @kinds, agent_version = @version, local_limits = COALESCE(@limits, local_limits), tls_spki = COALESCE(@spki, tls_spki), tls_pubkey = COALESCE(@pub, tls_pubkey), " + "probe_tcp_port = COALESCE(@tcp, probe_tcp_port), probe_udp_ports = COALESCE(@udp, probe_udp_ports), ipv6_supported = @v6, ipv6_diagnostics = @diag, " + "reported_addresses = COALESCE(@addr, reported_addresses), row_version = row_version + 1 WHERE id = @id", ("@id", id.Value), ("@kinds", StorageJson.ToJson(hello.Kinds)), ("@version", hello.AgentVersion), ("@limits", hello.LocalLimitsJson), - ("@spki", hello.TlsSpki), ("@tcp", hello.ProbeTcpPort), ("@udp", hello.ProbeUdpPorts is null ? null : StorageJson.ToJson(hello.ProbeUdpPorts)), ("@v6", hello.Ipv6Supported ? 1 : 0), ("@diag", hello.Ipv6DiagnosticsJson), + ("@spki", hello.TlsSpki), ("@pub", hello.TlsPublicKey), ("@tcp", hello.ProbeTcpPort), ("@udp", hello.ProbeUdpPorts is null ? null : StorageJson.ToJson(hello.ProbeUdpPorts)), ("@v6", hello.Ipv6Supported ? 1 : 0), ("@diag", hello.Ipv6DiagnosticsJson), ("@addr", hello.Addresses is null ? null : StorageJson.ToJson(hello.Addresses))); }, ct); @@ -266,7 +266,8 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore r.NullableString(21), r.GetInt64(22) != 0, r.GetInt64(23) != 0, - StorageJson.Strings(r.GetString(24))); + StorageJson.Strings(r.GetString(24)), + r.NullableBytes(25)); private static NodeRecord ToNode(AgentRecord a, string? metaJson) => new(a.Id, a.Name, a.Status, a.PublicKey, a.TlsSpki, a.Labels, metaJson, a.AgentVersion, a.CreatedAtMs, a.LastSeenAtMs); diff --git a/backend/tests/MoShiro.Backend.Tests/AddressTests.cs b/backend/tests/MoShiro.Backend.Tests/AddressTests.cs index 2c1705f..b08b4e8 100644 --- a/backend/tests/MoShiro.Backend.Tests/AddressTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/AddressTests.cs @@ -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() == "1.0.0.1"); } + + [Fact] + public async Task ProbeAgentList_CarriesTheCertificatePublicKeyTheAgentReported_ForPinningAndKnocking() + { + await using AgentSimulator agent = await _backend.ConnectAgentAsync(); + AgentService service = _backend.Services.GetRequiredService(); + 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); + } } diff --git a/backend/tests/MoShiro.Backend.Tests/AgentEndToEndTests.cs b/backend/tests/MoShiro.Backend.Tests/AgentEndToEndTests.cs index 1cfbc88..de2f7f5 100644 --- a/backend/tests/MoShiro.Backend.Tests/AgentEndToEndTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/AgentEndToEndTests.cs @@ -95,6 +95,19 @@ public sealed class AgentEndToEndTests } } + /// 读到 0 字节(对端关闭)或者连接被重置,都算「没收到任何东西」;收到数据返回字节数。 + internal static async Task 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(() => stranger.ConnectAsync("127.0.0.1", agent.App.ControlPort, agent.TlsSpki, agent.App.UdpPorts[0], agent.NodeId)); + await Assert.ThrowsAnyAsync(() => 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(() => probe.ConnectAsync("127.0.0.1", agent.App.ControlPort, new byte[32], agent.App.UdpPorts[0], agent.NodeId)); + await Assert.ThrowsAnyAsync(() => probe.ConnectAsync("127.0.0.1", agent.App.ControlPort, Fleet.Identity.NodeIdentity.Generate().PublicKeySpki, agent.App.UdpPorts[0], agent.NodeId)); } [Fact] diff --git a/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs b/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs index 56b3b18..cd63e15 100644 --- a/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs +++ b/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs @@ -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 StartAsync(BackendFixture backend, Action? configure = null, string? name = null) { @@ -85,7 +85,7 @@ public sealed class AgentHarness : IAsyncDisposable public async Task 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; } diff --git a/backend/tests/MoShiro.Backend.Tests/ArchitectureTests.cs b/backend/tests/MoShiro.Backend.Tests/ArchitectureTests.cs index 8eb2b01..cdebec7 100644 --- a/backend/tests/MoShiro.Backend.Tests/ArchitectureTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/ArchitectureTests.cs @@ -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" }) diff --git a/backend/tests/MoShiro.TestSupport/AgentSimulator.cs b/backend/tests/MoShiro.TestSupport/AgentSimulator.cs index 1e27dc1..603fc25 100644 --- a/backend/tests/MoShiro.TestSupport/AgentSimulator.cs +++ b/backend/tests/MoShiro.TestSupport/AgentSimulator.cs @@ -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> _acks = new(); private readonly ConcurrentDictionary> _responses = new(); @@ -71,6 +72,9 @@ public sealed class AgentSimulator : IAsyncDisposable public byte[] PublicKey => _identity.PublicKeySpki; + /// hello 里上报的 TLS 证书公钥(Probe 用它固定证书、派生敲门密钥)。 + public byte[] TlsPublicKey => _tlsKey.PublicKeySpki; + /// 用这个节点的私钥为认证随机数签名(可以冒充别的节点 ID:用来测试服务端的校验)。 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], diff --git a/backend/tests/MoShiro.TestSupport/ProbeSimulator.cs b/backend/tests/MoShiro.TestSupport/ProbeSimulator.cs index 5d0aa39..c02797a 100644 --- a/backend/tests/MoShiro.TestSupport/ProbeSimulator.cs +++ b/backend/tests/MoShiro.TestSupport/ProbeSimulator.cs @@ -20,6 +20,7 @@ namespace MoShiro.TestSupport; public sealed class ProbeSimulator : IAsyncDisposable { private readonly NodeIdentity _identity; + private byte[]? _agentKey; private readonly RpcRouter _router; private readonly ConcurrentDictionary _acks = new(); private readonly ConcurrentDictionary _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(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 的证书是自签名的,这里只验证数据面 diff --git a/docs/enrollment.md b/docs/enrollment.md index cbd45f8..2786ca2 100644 --- a/docs/enrollment.md +++ b/docs/enrollment.md @@ -50,7 +50,7 @@ probe.json:{ fleet: { server, identity_dir }, restart_at?, limits? } ← lim Probe 启动 ├─ 连接 server:Fleet 长连接(用 probe_id 和私钥认证),fleet.hello 带上系统、架构和构建信息 - ├─ 面板推送期望状态 probe_agents:{ version, agents: [ {agent_id, host, tcp_port, udp_ports, spki}... ] } + ├─ 面板推送期望状态 probe_agents:{ version, agents: [ {agent_id, host, tcp_port, udp_ports, public_key}... ] } ├─ 调和:新增的 Agent → 建立点对点会话(peer 用途签名);被移除或禁用的 → 断开 └─ 之后列表的任何变化(Agent 注册、禁用、地址或证书变化),面板都重新推送同一种期望状态; Probe 用 Fleet.Hosting.ConfigStore 把最新列表写回 agents.json(这是配置,不是测量数据;面板暂时连不上时,Probe 靠它继续测量) diff --git a/docs/protocol.md b/docs/protocol.md index 7f5dd48..795b9ab 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -12,13 +12,13 @@ MoShiro 的两条控制链路都建立在 Fleet 之上: | MoShiro 需要的能力 | 使用的 Fleet 机制 | MoShiro 提供的内容 | | --- | --- | --- | | 下发任务 | 期望状态 `fleet.state.sync` | 文档类型 `TaskSet`,包括 `tasks[]`、`peers`(允许连这个 Agent 的 Probe 公钥,已按线路开关过滤) | -| 下发 Agent 列表给 Probe | 期望状态 `fleet.state.sync`(Probe 直连面板) | 文档类型 `probe_agents`:`version`、`agents[]`(`agent_id`、`host`、`tcp_port`、`udp_ports`、`spki`),已按线路开关过滤;Agent 集合或线路开关变化时重新推送 | +| 下发 Agent 列表给 Probe | 期望状态 `fleet.state.sync`(Probe 直连面板) | 文档类型 `probe_agents`:`version`、`agents[]`(`agent_id`、`host`、`tcp_port`、`udp_ports`、`public_key`),`public_key` 是 Agent 面向 Probe 的 TLS 证书公钥(SPKI,base64):Probe 用它固定证书并派生敲门密钥;没有上报公钥的 Agent 不会下发。已按线路开关过滤;Agent 集合或线路开关变化时重新推送 | | 定时重启 | 通知 `moshiro.schedule`(B→A、B→P) | `{restart_at}`,形如 `04:30+08:00`,空表示关闭;连上时补发、管理端改了设置就群发。节点本地写了 `restart_at` 或 `MOSHIRO_RESTART_AT` 时以本地为准;节点只是到点自己有序退出,由服务管理器拉起 | | 更新 | 通知 `moshiro.update.offer` / `moshiro.update.apply`(B→A、B→P),心跳附加字段 `update` | `offer` 带 CI 签名的发布清单原文和签名,节点只写进数据目录;`apply` 请求更新;`update` 是宿主机更新器的状态(`state`、`current`、`available`、`error`、`at`),见 [deployment.md](deployment.md) | | 上报原始块 | 发件箱,二进制通道 **16** | `RawChunk`(自描述,见 [measurement.md](measurement.md) 第 4 节),`msg_id = chunk_id` | | Probe 交付列块 | 点对点会话的发件箱,二进制通道 **17** | 列块:`u32 stream_id \| u32 first_seq \| u32 count \| 列数据(Brotli)` | | 节点自身状态 | `fleet.heartbeat` 的附加字段 | Agent:`sessions`、`probes[]`、`pps_in/out`、`dropped_invalid`、`dropped_rate_limited`、`queue_bytes`、`update`;Probe:只有 `update` | -| 版本与能力 | `fleet.hello` | capabilities 包括 `moshiro.kinds:<列表>`、`moshiro.blocks:<列表>`;Agent 附加 `platform`(系统、架构、版本、tag、提交、构建时间)、`tls_spki`、`probe_ports`、`local_limits` 摘要;Probe 附加 `platform` | +| 版本与能力 | `fleet.hello` | capabilities 包括 `moshiro.kinds:<列表>`、`moshiro.blocks:<列表>`;Agent 附加 `platform`(系统、架构、版本、tag、提交、构建时间)、`tls_pubkey`(面向 Probe 的证书公钥,面板自己算指纹)、`addresses`(本机公网地址)、`probe_ports`、`local_limits` 摘要;Probe 附加 `platform` | ## 2. `moshiro.*` 方法 diff --git a/docs/security.md b/docs/security.md index 9e58c73..6c9795a 100644 --- a/docs/security.md +++ b/docs/security.md @@ -47,7 +47,7 @@ Agent、Backend、Probe 之间互相不信任。这份文档只回答三个问 | G1 | 节点不验证 Backend 的身份,只靠 TLS 加公共 CA | `Fleet.Link` 里没有证书固定,也没有应用层验证 | 链路上任一环节被攻破,就能给节点下发任务集、Agent 名单、对端公钥、重启计划(更新清单有签名,不受影响) | | G2 | Probe 的私钥由 Backend 生成 | `ProbeService` 里的 `NodeIdentity.Generate`(创建和轮换) | Backend 接触过 Probe 的私钥,可以冒充它;Agent 的密钥是自己生成的,没有这个问题 | | G3 | Probe 会拨 Backend 给的任意 `host:port` | `AgentDirectory`、`AgentConnection` 不做任何地址检查;本地 `limits` 只有速率和 pps | 被攻破的 Backend 能让客户内网里的 Probe 去探测内网。固定 SPKI 只能防止建立会话,防不了探测本身 | -| G4 | Agent 的 TCP 端口对任何人完成 TLS 握手并出示证书 | `TcpAcceptor`、`PeerListener` 在 `AcceptTcpClient` 之后直接 `AuthenticateAsServer` | 自签证书、TLS 参数、JARM 都能被扫描器指纹识别,一眼看出这是个服务 | +| G4(已修,阶段二) | Agent 的 TCP 端口对任何人完成 TLS 握手并出示证书 | `TcpAcceptor`、`PeerListener` 在 `AcceptTcpClient` 之后直接 `AuthenticateAsServer` | 自签证书、TLS 参数、JARM 都能被扫描器指纹识别,一眼看出这是个服务 | | G5 | Backend 入库只限制了单块的大小 | `MaxDecodedChunkBytes` 等;没有找到按节点的速率或配额 | 恶意节点可以灌满磁盘 | | G6 | 对端公钥、Agent 地址全由 Backend 决定 | `TaskSet.Peers`、`ProbeAgentList` | 被攻破的 Backend 能把恶意 Probe 加进 Agent 的白名单,或把 Probe 指向恶意 Agent | | G7(已解决) | 方法的方向和角色没有统一声明:原来靠「谁注册了处理器」隐式决定 | `NodeConnection.HandleMessageAsync` 只按方法名分发到路由器;文档里的 `B→A`、`A→B` 只存在于 [protocol.md](protocol.md),没有对账 | 新增一个方法时,方向写反、角色写错(例如 Probe 调了只给 Agent 的方法)不会被任何东西发现 | @@ -226,6 +226,7 @@ Agent 的 `max_total_mbps` 默认是 1200,对 1 核 1 GB 的小机器没有意 - `K_pair = HKDF(ECDH(Probe 身份私钥, Agent 身份公钥), "moshiro-knock-v1")`:只有名册里的成员算得出;ECDH 和签名共用同一把 P-256 密钥时,用不同的 HKDF 标签做域隔离。 - 时间窗 30 秒,接受前后各一个窗(容忍时钟偏差);`nonce` 在窗内去重,防重放;每个来源地址限速,避免陌生人用随机数据消耗 HMAC 计算。 - 这是破坏性变更:旧 Probe 不发敲门包,会被拒绝。 +- **已实现**:`Fleet.Identity.Knock`(构造与核对)和 `KnockGate`(读 32 字节、名册、nonce 去重、按来源限速,默认每来源每 10 秒 300 次);控制端口(`PeerListener`)和数据端口(`TcpAcceptor`)共用同一个关卡,Probe 一侧 `PeerConnector` 和 `TcpLink` 在 TCP 连接建立后、TLS 之前发敲门包。名册是期望状态里的 `peers`(Probe 的对等面公钥)。Probe 需要 Agent 的证书公钥来派生配对密钥,所以 `probe_agents` 下发的是 `public_key`(Agent 在 hello 里上报,面板自己算指纹)。陌生连接得到的是被关闭的连接(Windows 上可能表现为重置),没有任何字节。 **局限,必须说清楚**:TCP 的 SYN-ACK 由内核发出,用户态进程拦不住,所以端口在扫描器眼里仍是「开着,但不说话」,**不是「没监听」**。要做到真正的没监听,有两条路: @@ -264,7 +265,7 @@ Agent 和 Probe 的核心永远是纯采集器。**所有敏感能力**(exec ```text 零 协议方向声明表 + 收发校验 + 对账测试 G7,第 8 节;同时把 NodeConnection 按职责拆开(第 13.2 节) 已完成,不破坏 一 Probe 自己生成密钥 + 加入令牌注册 + 密钥按用途拆开 G2、G11 已完成,破坏性:旧 probe.json 失效 - 二 预握手敲门(等级一) G4;先基于现有的对端公钥列表,名册出来后换成名册 破坏性:旧 Probe 被拒绝 + 二 预握手敲门(等级一) G4;先基于现有的对端公钥列表,名册出来后换成名册 已完成,破坏性:旧 Probe 被拒绝 三 操作员凭证 + 名册 + 节点验签与持久化 G1 的大部分、G3、G6;webauthn 和 ecdsa 先做,oidc 随后 破坏性:节点要固定操作员公钥并重新注册 四 测量数据的可信 + 入库配额和输入校验 + CSP G5、G8、G9、G10、T5(第 9.1、9.2 节) 破坏性:块格式加签名 五 Backend 应用层身份固定 + 下行消息签名 G1 的剩余部分(链路劫持) 破坏性 diff --git a/docs/storage.md b/docs/storage.md index c709eba..959b946 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -37,7 +37,8 @@ CREATE TABLE agents ( name TEXT NOT NULL UNIQUE, status TEXT NOT NULL, -- pending | active | disabled pubkey BLOB NOT NULL, -- ECDSA P-256 身份公钥 - tls_spki BLOB NOT NULL, -- 面向 Probe 的证书指纹 + tls_spki BLOB NOT NULL, -- 面向 Probe 的证书指纹(由 tls_pubkey 算出) + tls_pubkey BLOB, -- 面向 Probe 的证书公钥,Agent 在 hello 里上报;Probe 用它固定证书、派生敲门密钥 public_host TEXT NOT NULL, -- 自动识别(Agent 上报的公网地址),手填后标记 public_host_manual,不再被覆盖 probe_tcp_port INTEGER NOT NULL, probe_udp_ports TEXT NOT NULL, labels TEXT NOT NULL DEFAULT '[]',