refactor!: rename the project from Monitor to MoShiro [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful

Namespaces, assemblies, solution, binaries, images, systemd units, config paths, environment variables, protocol methods and the Linux user are all renamed. Breaking: existing databases, node identities and installed units must be recreated.
This commit is contained in:
justme committed 2026-10-07 02:03:25 +08:00
1 parent 27ac7fb787
commit 2e146d4da7
309 files changed
+1565 -1572

No files matched your search

@@ -0,0 +1,337 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Authentication;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using MoShiro.Protocol;
namespace MoShiro.Measurement;
/// <summary>拨号方的一条数据面 TCP 连接(已经完成 TLS 和首帧认证)。</summary>
public sealed class TcpLink : IDisposable
{
private readonly TcpClient _client;
private TcpLink(TcpClient client, Stream stream, long tcpUs, long? tlsUs)
{
_client = client;
Socket = client.Client;
Stream = stream;
TcpUs = tcpUs;
TlsUs = tlsUs;
}
public Stream Stream { get; }
/// <summary>底层套接字(内核采样用)。</summary>
public Socket Socket { get; }
public long TcpUs { get; }
public long? TlsUs { get; }
/// <summary>
/// 连接 → (TLS)→ 认证首帧。<paramref name="sendAuth"/> 为 false 时只测量建立连接的耗时(connect)。
/// 对端证书按 <see cref="BlockEnv.PeerSpkiSha256"/> 固定;没有指纹(目标任务,目标是任意地址)就只测握手耗时、不校验证书。
/// 失败时抛出 <see cref="SocketException"/>、<see cref="AuthenticationException"/>、<see cref="IOException"/> 或超时的 <see cref="OperationCanceledException"/>。
/// </summary>
public static async Task<TcpLink> OpenAsync(BlockEnv env, int port, bool tls, uint streamId, bool sendAuth, CancellationToken ct)
{
var client = new TcpClient { NoDelay = true };
try
{
long start = env.Clock.NowUs();
await client.ConnectAsync(env.PeerAddress!, port, ct);
long tcpUs = env.Clock.NowUs() - start;
Stream stream = client.GetStream();
long? tlsUs = null;
if (tls)
{
long tlsStart = env.Clock.NowUs();
byte[]? pin = env.PeerSpkiSha256;
#pragma warning disable CA5359 // 没有指纹时只测量握手耗时(目标任务的目标是任意地址)
var ssl = new SslStream(stream, false, (_, certificate, _, _) => pin is null || (certificate is not null && Pinned(certificate, pin)));
#pragma warning restore CA5359
await ssl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions { TargetHost = env.PeerAddress!.ToString() }, ct);
stream = ssl;
tlsUs = env.Clock.NowUs() - tlsStart;
}
if (sendAuth)
{
DataSession session = env.Session() ?? throw new InvalidOperationException("no data session");
byte[] auth = new byte[TcpWire.AuthBytes];
Span<byte> nonce = stackalloc byte[TcpWire.NonceBytes];
RandomNumberGenerator.Fill(nonce);
TcpWire.WriteAuth(auth, session.SessionId, streamId, nonce, session.Key);
await stream.WriteAsync(auth, ct);
}
return new TcpLink(client, stream, tcpUs, tlsUs);
}
catch
{
client.Dispose();
throw;
}
}
public void Dispose()
{
Stream.Dispose();
_client.Dispose();
}
private static bool Pinned(X509Certificate certificate, byte[] expected)
{
X509Certificate2 full = certificate as X509Certificate2 ?? X509CertificateLoader.LoadCertificate(certificate.GetRawCertData());
return CryptographicOperations.FixedTimeEquals(SHA256.HashData(full.PublicKey.ExportSubjectPublicKeyInfo()), expected);
}
}
/// <summary>监听方收到的一条已经认证的数据面 TCP 连接。</summary>
public sealed class TcpConnection
{
public TcpConnection(Stream stream, Socket socket, DataSession session, uint streamId)
{
Stream = stream;
Socket = socket;
Session = session;
StreamId = streamId;
}
public Stream Stream { get; }
/// <summary>底层套接字(内核采样用)。</summary>
public Socket Socket { get; }
public DataSession Session { get; }
public uint StreamId { get; }
}
public delegate Task TcpResponder(TcpConnection connection, CancellationToken ct);
/// <summary>
/// 监听方的 TCP 数据面:每个端口一个监听器。连接的第一个字节是 0x16 就先做 TLS(节点自己的证书),否则直接是明文。
/// 随后是认证首帧(<c>"MN" | ver | session_id | stream_id | nonce | mac</c>,随机数只能用一次),通过之后交给按 <c>stream_id</c> 登记的处理函数。
/// 首帧在限定时间内没有到达或者校验失败,直接关闭连接并计数。
/// </summary>
public sealed class TcpAcceptor
{
private static readonly TimeSpan s_authTimeout = TimeSpan.FromSeconds(5);
private readonly IPAddress _bind;
private readonly IReadOnlyList<int> _ports;
private readonly X509Certificate2 _certificate;
private readonly IDataSessions _sessions;
private readonly DataPlaneCounters _counters;
private readonly ConcurrentDictionary<uint, TcpResponder> _handlers = new();
private readonly List<TcpListener> _listeners = [];
public TcpAcceptor(IPAddress bind, IReadOnlyList<int> ports, X509Certificate2 certificate, IDataSessions sessions, DataPlaneCounters counters)
{
_bind = bind;
_ports = ports;
_certificate = certificate;
_sessions = sessions;
_counters = counters;
}
public IReadOnlyList<int> BoundPorts { get; private set; } = [];
public void Register(uint streamId, TcpResponder handler) => _handlers[streamId] = handler;
public void Unregister(uint streamId) => _handlers.TryRemove(streamId, out _);
/// <summary>开始监听(端口被占用等错误在这里抛出)。</summary>
public void Bind()
{
var bound = new List<int>();
foreach (int port in _ports)
{
var listener = new TcpListener(_bind, port);
listener.Start(128);
_listeners.Add(listener);
bound.Add(((IPEndPoint)listener.LocalEndpoint).Port);
}
BoundPorts = bound;
}
public async Task RunAsync(CancellationToken ct)
{
try
{
await Task.WhenAll(_listeners.Select(l => AcceptLoopAsync(l, ct)));
}
finally
{
foreach (TcpListener listener in _listeners)
{
listener.Stop();
}
}
}
private async Task AcceptLoopAsync(TcpListener listener, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
TcpClient client;
try
{
client = await listener.AcceptTcpClientAsync(ct);
}
catch (OperationCanceledException)
{
return;
}
catch (SocketException)
{
continue;
}
_ = Task.Run(() => HandleAsync(client, ct), CancellationToken.None);
}
}
private async Task HandleAsync(TcpClient client, CancellationToken ct)
{
using (client)
{
client.NoDelay = true;
try
{
Stream stream = client.GetStream();
using var authCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
authCts.CancelAfter(s_authTimeout);
byte[] first = new byte[1];
if (await stream.ReadAsync(first, authCts.Token) != 1)
{
return;
}
stream = new PrefixedStream(stream, first[0]);
if (first[0] == 0x16)
{
var ssl = new SslStream(stream, leaveInnerStreamOpen: false);
await ssl.AuthenticateAsServerAsync(new SslServerAuthenticationOptions { ServerCertificate = _certificate }, authCts.Token);
stream = ssl;
}
byte[] auth = new byte[TcpWire.AuthBytes];
await stream.ReadExactlyAsync(auth, authCts.Token);
if (!TcpWire.TryParseAuth(auth, out long sessionId, out uint streamId, out ReadOnlySpan<byte> nonce)
|| _sessions.Find(sessionId) is not { } session
|| !_handlers.TryGetValue(streamId, out TcpResponder? handler)
|| !TcpWire.VerifyAuth(auth, session.Key)
|| !session.UseNonce(nonce))
{
_counters.DropInvalid();
return;
}
await handler(new TcpConnection(stream, client.Client, session, streamId), ct);
}
catch (Exception ex) when (ex is IOException or SocketException or OperationCanceledException or ObjectDisposedException or AuthenticationException or InvalidDataException or EndOfStreamException)
{
// 连接被对端关闭、超时或者格式错误:直接结束这个连接
}
}
}
/// <summary>把已经读出的第一个字节放回流的开头(用于探测是 TLS 还是明文)。</summary>
private sealed class PrefixedStream : Stream
{
private readonly Stream _inner;
private byte? _prefix;
public PrefixedStream(Stream inner, byte prefix)
{
_inner = inner;
_prefix = prefix;
}
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => true;
public override long Length => throw new NotSupportedException();
public override long Position
{
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}
public override void Flush() => _inner.Flush();
public override Task FlushAsync(CancellationToken cancellationToken) => _inner.FlushAsync(cancellationToken);
public override int Read(byte[] buffer, int offset, int count) => Read(buffer.AsSpan(offset, count));
public override int Read(Span<byte> buffer)
{
if (buffer.IsEmpty)
{
return 0;
}
if (_prefix is { } prefix)
{
buffer[0] = prefix;
_prefix = null;
return 1;
}
return _inner.Read(buffer);
}
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
if (buffer.IsEmpty)
{
return ValueTask.FromResult(0);
}
if (_prefix is { } prefix)
{
buffer.Span[0] = prefix;
_prefix = null;
return ValueTask.FromResult(1);
}
return _inner.ReadAsync(buffer, cancellationToken);
}
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask();
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => _inner.Write(buffer, offset, count);
public override void Write(ReadOnlySpan<byte> buffer) => _inner.Write(buffer);
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default) => _inner.WriteAsync(buffer, cancellationToken);
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => _inner.WriteAsync(buffer, offset, count, cancellationToken);
protected override void Dispose(bool disposing)
{
if (disposing)
{
_inner.Dispose();
}
base.Dispose(disposing);
}
}
}
@@ -0,0 +1,118 @@
using System.Buffers.Binary;
using System.Net.Sockets;
using MoShiro.Protocol;
namespace MoShiro.Measurement;
/// <summary>内核对一条 TCP 连接的采样(Linux <c>TCP_INFO</c>)。</summary>
public readonly record struct TcpInfoSample(long SrttUs, long RttVarUs, long Cwnd, long TotalRetrans, long SegsOut, long BytesAcked);
/// <summary>
/// 读 Linux 的 <c>TCP_INFO</c>:通过 <see cref="Socket.GetRawSocketOption(int, int, Span{byte})"/>,不需要 P/Invoke(NativeAOT、glibc 和 musl 都一样)。
/// 缓冲区先清零,并且只读内核实际返回的长度之内的字段:老内核返回的结构更短,缺的字段是 0,而不是垃圾值。
/// 其他平台返回 false(没有这一类采样,指标在对应窗口里就是没有数据)。
/// </summary>
public static class TcpInfo
{
private const int SolTcp = 6;
private const int TcpInfoOption = 11;
// struct tcp_info 的偏移(include/uapi/linux/tcp.h):8 字节的状态位之后是 u32 字段;total_retrans 之后是对齐到 8 字节的 u64 字段
private const int RttOffset = 68;
private const int RttVarOffset = 72;
private const int SndCwndOffset = 80;
private const int TotalRetransOffset = 100;
private const int BytesAckedOffset = 120;
private const int SegsOutOffset = 136;
/// <summary>读到 <c>segs_out</c> 为止(内核 ≥ 4.2)。</summary>
public const int Length = SegsOutOffset + 4;
public static bool Supported => OperatingSystem.IsLinux();
public static bool TrySample(Socket socket, out TcpInfoSample sample)
{
sample = default;
if (!Supported)
{
return false;
}
try
{
Span<byte> buffer = stackalloc byte[Length];
buffer.Clear();
int returned = socket.GetRawSocketOption(SolTcp, TcpInfoOption, buffer);
if (returned < RttVarOffset + 4)
{
return false;
}
ReadOnlySpan<byte> info = buffer[..Math.Min(returned, Length)];
sample = new TcpInfoSample(
U32(info, RttOffset),
U32(info, RttVarOffset),
U32(info, SndCwndOffset),
U32(info, TotalRetransOffset),
U32(info, SegsOutOffset),
U64(info, BytesAckedOffset));
return true;
}
catch (Exception ex) when (ex is SocketException or ObjectDisposedException or PlatformNotSupportedException)
{
return false;
}
}
private static long U32(ReadOnlySpan<byte> info, int offset) => offset + 4 <= info.Length ? BinaryPrimitives.ReadUInt32LittleEndian(info[offset..]) : 0;
private static long U64(ReadOnlySpan<byte> info, int offset) => offset + 8 <= info.Length ? (long)BinaryPrimitives.ReadUInt64LittleEndian(info[offset..]) : 0;
}
/// <summary>
/// 一条连接上周期性的内核采样(<c>tcp_info</c> 流):每个采样一行 (ts, side, srtt, rttvar, cwnd, retrans_total, segs_out, bytes_acked)。
/// 两端各采样自己的套接字:Probe 的重传反映上行方向,Agent 的重传反映下行方向(代理场景里大流量走这个方向)。
/// 累计计数器在 Backend 按 side 分别求差。
/// </summary>
public sealed class TcpInfoRecorder
{
private readonly MonoClock _clock;
private readonly long _side;
public TcpInfoRecorder(StreamSpec spec, Side side, MonoClock clock)
{
Rows = new RowStore(spec, side, 4096);
_clock = clock;
_side = side == Side.Probe ? 0 : 1;
}
public RowStore Rows { get; }
/// <summary>每隔 <paramref name="interval"/> 采样一次,直到取消或套接字不可用(连接断开)。</summary>
public async Task RunAsync(Socket socket, TimeSpan interval, CancellationToken ct)
{
if (!TcpInfo.Supported)
{
return;
}
try
{
using var timer = new PeriodicTimer(interval, _clock.Time);
while (await timer.WaitForNextTickAsync(ct))
{
if (!TcpInfo.TrySample(socket, out TcpInfoSample s))
{
return;
}
long key = Rows.Append();
Rows.SetRow(key, [_clock.NowUs(), _side, s.SrttUs, s.RttVarUs, s.Cwnd, s.TotalRetrans, s.SegsOut, s.BytesAcked]);
}
}
catch (OperationCanceledException)
{
// 连接结束
}
}
}
@@ -0,0 +1,216 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
using Fleet.Core;
using MoShiro.Protocol;
namespace MoShiro.Measurement;
/// <summary>一个通过校验的 UDP 包,交给按 <c>stream_id</c> 登记的处理函数。处理函数在接收循环里同步执行,不能阻塞。</summary>
public readonly ref struct UdpDelivery
{
private readonly Span<byte> _packet;
private readonly Socket _socket;
private readonly SocketAddress _source;
private readonly DataPlaneCounters _counters;
internal UdpDelivery(UdpHeader header, Span<byte> packet, long receiveUs, DataSession session, Socket socket, SocketAddress source, DataPlaneCounters counters)
{
Header = header;
_packet = packet;
ReceiveUs = receiveUs;
Session = session;
_socket = socket;
_source = source;
_counters = counters;
}
public UdpHeader Header { get; }
public ReadOnlySpan<byte> Packet => _packet;
/// <summary>包到达的时刻(本节点的单调时钟,微秒):从接收返回的那一刻取,不含校验的耗时。</summary>
public long ReceiveUs { get; }
public DataSession Session { get; }
/// <summary>
/// 原地回复:同样的长度,只把 type 改成 <paramref name="type"/> 并重新计算 MAC,发回请求的实际源地址。
/// 回复与请求等长,所以监听方不能被当成放大器。发送失败(源地址已经不可达)不抛出。
/// </summary>
public void ReplyAs(UdpPacketType type)
{
_packet[3] = (byte)type;
UdpPacket.Seal(_packet, Session.Key);
try
{
_socket.SendTo(_packet, SocketFlags.None, _source);
_counters.PacketOut();
}
catch (SocketException)
{
// 回复丢失由对端的缺失体现
}
}
}
public delegate void UdpHandler(in UdpDelivery delivery);
/// <summary>
/// UDP 数据面:一个或多个套接字,按 <c>stream_id</c> 把包分发给积木。校验顺序:长度、magic、version、type →
/// 流和会话都存在 → MAC → (监听方)<see cref="IAdmission"/>(重放窗口、限速)。任何一步失败都静默丢弃并计数,不回复。
/// 接收循环只做校验和分发,不分配对象。两端共用这一份代码:Agent 监听多个端口并带准入策略,Probe 每个 Agent 一个临时端口。
/// </summary>
public sealed class UdpMux : IDisposable
{
private const int WindowsUdpConnReset = -1744830452; // SIO_UDP_CONNRESET
private readonly IDataSessions _sessions;
private readonly IAdmission? _admission;
private readonly MonoClock _clock;
private readonly DataPlaneCounters _counters;
private readonly INodeLog _log;
private readonly IPAddress _bind;
private readonly IReadOnlyList<int> _ports;
private readonly List<Socket> _sockets = [];
private readonly ConcurrentDictionary<uint, UdpHandler> _handlers = new();
/// <param name="ports">要监听的端口(0 表示由系统分配);空表示一个临时端口。</param>
public UdpMux(IDataSessions sessions, IAdmission? admission, MonoClock clock, DataPlaneCounters counters, INodeLog log, IPAddress bind, IReadOnlyList<int> ports)
{
_sessions = sessions;
_admission = admission;
_clock = clock;
_counters = counters;
_log = log;
_bind = bind;
_ports = ports.Count == 0 ? [0] : ports;
}
public IReadOnlyList<int> BoundPorts { get; private set; } = [];
public DataPlaneCounters Counters => _counters;
/// <summary>绑定套接字(端口被占用等错误在这里抛出)。</summary>
public void Bind()
{
var bound = new List<int>();
foreach (int port in _ports)
{
var socket = new Socket(_bind.AddressFamily, SocketType.Dgram, ProtocolType.Udp) { ReceiveBufferSize = 4 << 20, SendBufferSize = 4 << 20 };
if (_bind.AddressFamily == AddressFamily.InterNetworkV6)
{
socket.DualMode = true;
}
if (OperatingSystem.IsWindows())
{
// Windows 上对一个已关闭的端口发送之后,下一次接收会抛 ConnectionReset:关掉这个行为
socket.IOControl(WindowsUdpConnReset, [0], null);
}
socket.Bind(new IPEndPoint(_bind, port));
bound.Add(((IPEndPoint)socket.LocalEndPoint!).Port);
_sockets.Add(socket);
}
BoundPorts = bound;
}
public void Register(uint streamId, UdpHandler handler) => _handlers[streamId] = handler;
public void Unregister(uint streamId) => _handlers.TryRemove(streamId, out _);
/// <summary>从第一个套接字发出一个已经封好的包。发送失败(没有路由、缓冲区满)不抛出:丢包本身就是测量结果。</summary>
public void Send(EndPoint to, ReadOnlySpan<byte> packet)
{
try
{
_sockets[0].SendTo(packet, SocketFlags.None, to);
_counters.PacketOut();
}
catch (SocketException)
{
// 记为丢失
}
}
public async Task RunAsync(CancellationToken ct)
{
try
{
await Task.WhenAll(_sockets.Select(s => ReceiveLoopAsync(s, ct)));
}
catch (ObjectDisposedException)
{
// 套接字已释放:停止
}
}
public void Dispose()
{
foreach (Socket socket in _sockets)
{
socket.Dispose();
}
}
private async Task ReceiveLoopAsync(Socket socket, CancellationToken ct)
{
byte[] buffer = new byte[UdpPacket.MaxBytes + 64];
var source = new SocketAddress(socket.AddressFamily);
while (!ct.IsCancellationRequested)
{
int length;
try
{
length = await socket.ReceiveFromAsync(buffer.AsMemory(), SocketFlags.None, source, ct);
}
catch (OperationCanceledException)
{
return;
}
catch (ObjectDisposedException)
{
return;
}
catch (SocketException ex)
{
// 个别错误(例如对端的 ICMP 不可达)不能让接收循环退出
_log.Warn("udp receive failed", ("err", ex.SocketErrorCode));
continue;
}
Dispatch(socket, buffer, length, source, _clock.NowUs());
}
}
private void Dispatch(Socket socket, byte[] buffer, int length, SocketAddress source, long receiveUs)
{
_counters.PacketIn();
Span<byte> packet = buffer.AsSpan(0, length);
if (!UdpPacket.TryParseHeader(packet, out UdpHeader header)
|| !_handlers.TryGetValue(header.StreamId, out UdpHandler? handler)
|| _sessions.Find(header.SessionId) is not { } session
|| !UdpPacket.VerifyMac(packet, session.Key))
{
_counters.DropInvalid();
return;
}
if (_admission is not null)
{
switch (_admission.AdmitPacket(session, header, source, receiveUs))
{
case Admission.Invalid:
_counters.DropInvalid();
return;
case Admission.RateLimited:
_counters.DropRateLimited();
return;
}
}
handler(new UdpDelivery(header, packet, receiveUs, session, socket, source, _counters));
}
}