chore: initial project import

This commit is contained in:
justme committed 2026-10-04 21:46:39 +08:00
commit 3fc4e5ca53
347 files changed
+56172

No files matched your search

@@ -0,0 +1,128 @@
namespace Monitor.Protocol;
[Flags]
public enum Scope
{
Line = 1,
Target = 2,
}
public enum ColumnType
{
U8,
U16,
U32,
U64,
I32,
I64,
}
public enum ColumnEnc
{
Plain,
Delta,
}
/// <summary>时间列的时钟归属:Probe、Agent 各自的单调时钟(微秒),或所在一侧的本地时钟。</summary>
public enum ColumnRole
{
None,
TsProbe,
TsAgent,
TsLocal,
}
public sealed record ColumnSpec(string Name, ColumnType Type, ColumnEnc Enc = ColumnEnc.Plain, bool Nullable = false, ColumnRole Role = ColumnRole.None);
/// <summary>数据面上的一侧:Probe 永远是拨号方,Agent 永远是监听方。</summary>
public enum Side
{
Probe,
Agent,
}
/// <summary>
/// 一个记录流(一张表)。<see cref="Driver"/> 是行的主导方:行由它发出的包或事件决定,另一侧的列按 <see cref="Key"/>(例如 <c>seq</c>)补充,
/// 缺失就是缺失(回显缺 t2/t3 表示上行丢失,单向数据流缺 t_recv 表示丢失)。没有 <see cref="Key"/> 的流只有主导方一侧的列,原样透传。
/// 每一列属于哪一侧由它的时钟决定(<c>TsProbe</c> 属于 Probe,<c>TsAgent</c> 属于 Agent),其他列属于主导方。
/// <see cref="Union"/> 流是例外:两侧各自记录完整的行(行里有一列标明是哪一侧),合并时直接拼接,不连接。
/// 这一条声明同时决定了两端各记录什么、Agent 怎么合并,新增一种记录流不需要写合并代码。
/// </summary>
public sealed record StreamSpec(string Name, IReadOnlyList<ColumnSpec> Columns, Side Driver = Side.Probe, string? Key = null, bool Union = false)
{
public Side OwnerOf(ColumnSpec column) => column.Role switch
{
ColumnRole.TsProbe => Side.Probe,
ColumnRole.TsAgent => Side.Agent,
_ => Driver,
};
/// <summary>
/// 该侧记录的列(不含连接键:键两侧都有,由行的编号表示)。<see cref="Union"/> 流两侧各自记录完整的行(例如各自套接字的内核采样),
/// 所以每一侧拥有所有列。
/// </summary>
public IReadOnlyList<ColumnSpec> ColumnsOf(Side side) =>
[.. Columns.Where(c => c.Name != Key && (Union || OwnerOf(c) == side))];
}
/// <summary>积木组合(只描述,不含代码;节点按这个描述组装执行图)。</summary>
public sealed record BlockSet(string Transport, string Pacer, string Payload, string Behavior, IReadOnlyList<string> Samplers);
/// <summary>
/// 采集方法声明:积木组合 + 参数 + 记录的列 + 带宽预算。
/// 这一条声明同时被 Probe/Agent(组装执行)、Backend(校验与目录)、Analysis(按列名读数据)使用。
/// </summary>
public sealed record KindSpec(
string Id,
string Title,
string Description,
Scope Scopes,
BlockSet Blocks,
IReadOnlyList<ParamSpec> Params,
IReadOnlyList<StreamSpec> Streams,
Func<ParamValues, long> BudgetBitsPerSecond)
{
public StreamSpec? FindStream(string name)
{
foreach (StreamSpec stream in Streams)
{
if (stream.Name == name)
{
return stream;
}
}
return null;
}
}
public static class Col
{
public static ColumnSpec U8(string name, bool nullable = false) => new(name, ColumnType.U8, ColumnEnc.Plain, nullable);
public static ColumnSpec U16(string name, bool nullable = false) => new(name, ColumnType.U16, ColumnEnc.Plain, nullable);
public static ColumnSpec U32(string name, bool nullable = false) => new(name, ColumnType.U32, ColumnEnc.Plain, nullable);
public static ColumnSpec U64(string name, ColumnEnc enc = ColumnEnc.Plain) => new(name, ColumnType.U64, enc);
/// <summary>单调递增的序号:差分编码。</summary>
public static ColumnSpec Seq(string name) => new(name, ColumnType.U32, ColumnEnc.Delta);
public static ColumnSpec TsProbe(string name, bool nullable = false) => new(name, ColumnType.I64, ColumnEnc.Delta, nullable, ColumnRole.TsProbe);
public static ColumnSpec TsAgent(string name, bool nullable = false) => new(name, ColumnType.I64, ColumnEnc.Delta, nullable, ColumnRole.TsAgent);
public static ColumnSpec TsLocal(string name) => new(name, ColumnType.I64, ColumnEnc.Delta, false, ColumnRole.TsLocal);
}
/// <summary>带宽预算的估算函数(bit/s)。</summary>
public static class Bandwidth
{
private const int UdpOverheadBytes = 28;
private const int TcpOverheadBytes = 52;
public static long UdpEcho(long rateHz, long sizeBytes) => rateHz * ((sizeBytes + UdpOverheadBytes) * 8) * 2;
public static long TcpEcho(long rateHz, long sizeBytes) => rateHz * ((sizeBytes + TcpOverheadBytes) * 8) * 2;
}
@@ -0,0 +1,161 @@
namespace Monitor.Protocol;
/// <summary>
/// Kind 目录。新增一种采集方法 = 在这里加一个静态字段并放进 <see cref="All"/>;
/// 参数一律用 <see cref="P"/> 声明默认值和上下限。
/// </summary>
public static class Kinds
{
private static readonly string[] s_directions = ["fwd", "rev", "bidir"];
private static readonly StreamSpec s_echoMsg = new(
"msg",
[
Col.Seq("seq"), Col.U16("size"),
Col.TsProbe("t1"), Col.TsAgent("t2", nullable: true), Col.TsAgent("t3", nullable: true), Col.TsProbe("t4", nullable: true),
],
Side.Probe,
"seq");
private static readonly StreamSpec s_tcpInfo = new(
"tcp_info",
[
Col.TsLocal("ts"), Col.U8("side"), Col.U32("srtt_us"), Col.U32("rttvar_us"), Col.U32("cwnd"),
Col.U32("retrans_total"), Col.U32("segs_out"), Col.U64("bytes_acked"),
],
Union: true);
public static readonly KindSpec UdpEcho = new(
"udp_echo",
"UDP echo",
"Fixed-rate UDP echo with four timestamps per packet.",
Scope.Line,
new BlockSet("udp", "fixed", "fixed", "echo", []),
[
P.Int("rate_hz", 10, 1, 200, "Hz"),
P.Int("size", 64, 44, 1400, "B"),
P.Int("port", 7443, 1, 65535),
],
[s_echoMsg],
p => Bandwidth.UdpEcho(p.GetInt("rate_hz"), p.GetInt("size")));
public static readonly KindSpec UdpTrain = new(
"udp_train",
"UDP train",
"Fixed-rate or laddered one-way UDP stream (Agent to Probe) for loss and jitter under load.",
Scope.Line,
new BlockSet("udp", "ladder", "fixed", "sink", []),
[
P.Number("mbps", 10, 0.1, 1000, "Mbit/s"),
P.Int("size", 1250, 200, 1400, "B"),
P.Int("duration_s", 10, 1, 120, "s"),
P.Enum("direction", "rev", s_directions),
P.Int("port", 7443, 1, 65535),
],
[
new StreamSpec("pkt", [Col.Seq("seq"), Col.U16("size"), Col.TsAgent("t_send"), Col.TsProbe("t_recv", nullable: true)], Side.Agent, "seq"),
new StreamSpec("step", [Col.U16("index"), Col.U32("mbps"), Col.TsProbe("start_ts"), Col.TsProbe("end_ts")]),
],
p => (long)(p.GetNumber("mbps") * 1_000_000));
public static readonly KindSpec TcpEcho = new(
"tcp_echo",
"TCP echo",
"Small TCP messages echoed with four timestamps, plus periodic kernel tcp_info samples.",
Scope.Line,
new BlockSet("tcp", "fixed", "uniform", "echo", ["tcp_info"]),
[
P.Int("rate_hz", 20, 1, 200, "Hz"),
P.Int("min", 32, 16, 1400, "B"),
P.Int("max", 64, 16, 1400, "B"),
P.Int("tcp_info_ms", 100, 10, 1000, "ms"),
P.Int("port", 7444, 1, 65535),
P.Bool("tls", false),
],
[s_echoMsg, s_tcpInfo],
p => Bandwidth.TcpEcho(p.GetInt("rate_hz"), p.GetInt("max")));
public static readonly KindSpec TcpBulk = new(
"tcp_bulk",
"TCP bulk",
"Bulk TCP transfer on several streams with periodic byte counters and tcp_info.",
Scope.Line,
new BlockSet("tcp", "fixed", "fixed", "source", ["tcp_info"]),
[
P.Int("streams", 4, 1, 16),
P.Int("duration_s", 4, 1, 120, "s"),
P.Enum("direction", "rev", s_directions),
P.Int("sample_ms", 100, 20, 1000, "ms"),
P.Int("port", 7444, 1, 65535),
P.Bool("tls", true),
],
[
new StreamSpec("bytes", [Col.TsProbe("ts"), Col.U8("conn"), Col.U64("bytes")]),
s_tcpInfo,
],
_ => 0);
public static readonly KindSpec Connect = new(
"connect",
"Connect",
"Repeated TCP and TLS connection establishment with per-phase timings and outcome.",
Scope.Line | Scope.Target,
new BlockSet("tcp", "fixed", "fixed", "connect", []),
[
P.Int("per_minute", 6, 1, 60),
P.Bool("tls", true),
P.Int("timeout_ms", 5000, 500, 30000, "ms"),
P.Int("port", 7444, 1, 65535),
],
[
new StreamSpec(
"conn",
[Col.Seq("attempt"), Col.TsProbe("ts"), Col.U32("tcp_us", nullable: true), Col.U32("tls_us", nullable: true), Col.U8("outcome")]),
],
p => p.GetInt("per_minute") * 4000 / 60);
public static readonly KindSpec Hold = new(
"hold",
"Hold",
"A long-lived TLS connection with periodic heartbeats; records resets and lifetime.",
Scope.Line,
new BlockSet("tls", "fixed", "fixed", "hold", []),
[
P.Int("heartbeat_s", 30, 5, 300, "s"),
P.Int("port", 7444, 1, 65535),
],
[
new StreamSpec("hold", [Col.TsProbe("ts"), Col.U8("event"), Col.U32("lifetime_ms")]),
],
p => 2000 / Math.Max(1, p.GetInt("heartbeat_s")) * 8);
public static readonly KindSpec IcmpEcho = new(
"icmp_echo",
"ICMP echo",
"Probe or Agent side ICMP echo to a target address.",
Scope.Target,
new BlockSet("icmp", "fixed", "fixed", "echo", []),
[
P.Int("rate_hz", 1, 1, 20, "Hz"),
P.Int("size", 64, 44, 1400, "B"),
],
[
new StreamSpec("msg", [Col.Seq("seq"), Col.U16("size"), Col.TsLocal("t1"), new ColumnSpec("t4", ColumnType.I64, ColumnEnc.Delta, true, ColumnRole.TsLocal)]),
],
p => Bandwidth.UdpEcho(p.GetInt("rate_hz"), p.GetInt("size")));
public static readonly IReadOnlyList<KindSpec> All = [UdpEcho, UdpTrain, TcpEcho, TcpBulk, Connect, Hold, IcmpEcho];
public static KindSpec? Find(string id)
{
foreach (KindSpec kind in All)
{
if (kind.Id == id)
{
return kind;
}
}
return null;
}
}
@@ -0,0 +1,223 @@
using System.Globalization;
using System.Text.Json;
namespace Monitor.Protocol;
public enum ParamType
{
Int,
Number,
Bool,
Enum,
}
/// <summary>
/// 参数定义:类型、默认值、上下限。节点与 Backend 使用同一套校验,禁止写没有上下限的参数。
/// </summary>
public abstract record ParamSpec(string Name, string Description, string? Unit)
{
public abstract ParamType Type { get; }
/// <summary>用于目录展示的默认值。</summary>
public abstract JsonElement DefaultValue { get; }
/// <summary>校验并规范化一个参数值;失败时返回错误说明。</summary>
public abstract bool TryNormalize(JsonElement input, out JsonElement normalized, out string error);
protected static JsonElement Of(long value) => JsonSerializer.SerializeToElement(value, ParamJson.Default.Int64);
protected static JsonElement Of(double value) => JsonSerializer.SerializeToElement(value, ParamJson.Default.Double);
protected static JsonElement Of(bool value) => JsonSerializer.SerializeToElement(value, ParamJson.Default.Boolean);
protected static JsonElement Of(string value) => JsonSerializer.SerializeToElement(value, ParamJson.Default.String);
}
public sealed record IntParam(string Name, long Default, long Min, long Max, string? Unit = null, string Description = "")
: ParamSpec(Name, Description, Unit)
{
public override ParamType Type => ParamType.Int;
public override JsonElement DefaultValue => Of(Default);
public override bool TryNormalize(JsonElement input, out JsonElement normalized, out string error)
{
normalized = default;
error = "";
if (input.ValueKind != JsonValueKind.Number || !input.TryGetInt64(out long value))
{
error = "must be an integer";
return false;
}
if (value < Min || value > Max)
{
error = $"must be between {Min.ToString(CultureInfo.InvariantCulture)} and {Max.ToString(CultureInfo.InvariantCulture)}";
return false;
}
normalized = Of(value);
return true;
}
}
public sealed record NumberParam(string Name, double Default, double Min, double Max, string? Unit = null, string Description = "")
: ParamSpec(Name, Description, Unit)
{
public override ParamType Type => ParamType.Number;
public override JsonElement DefaultValue => Of(Default);
public override bool TryNormalize(JsonElement input, out JsonElement normalized, out string error)
{
normalized = default;
error = "";
if (input.ValueKind != JsonValueKind.Number || !input.TryGetDouble(out double value) || !double.IsFinite(value))
{
error = "must be a number";
return false;
}
if (value < Min || value > Max)
{
error = $"must be between {Min.ToString(CultureInfo.InvariantCulture)} and {Max.ToString(CultureInfo.InvariantCulture)}";
return false;
}
normalized = Of(value);
return true;
}
}
public sealed record BoolParam(string Name, bool Default, string Description = "")
: ParamSpec(Name, Description, null)
{
public override ParamType Type => ParamType.Bool;
public override JsonElement DefaultValue => Of(Default);
public override bool TryNormalize(JsonElement input, out JsonElement normalized, out string error)
{
normalized = default;
error = "";
if (input.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
{
error = "must be a boolean";
return false;
}
normalized = Of(input.GetBoolean());
return true;
}
}
public sealed record EnumParam(string Name, string Default, IReadOnlyList<string> Values, string Description = "")
: ParamSpec(Name, Description, null)
{
public override ParamType Type => ParamType.Enum;
public override JsonElement DefaultValue => Of(Default);
public override bool TryNormalize(JsonElement input, out JsonElement normalized, out string error)
{
normalized = default;
error = "";
string? value = input.ValueKind == JsonValueKind.String ? input.GetString() : null;
if (value is null || !Values.Contains(value))
{
error = "must be one of: " + string.Join(", ", Values);
return false;
}
normalized = Of(value);
return true;
}
}
/// <summary>参数声明的简写。</summary>
public static class P
{
public static ParamSpec Int(string name, long @default, long min, long max, string? unit = null, string description = "") =>
new IntParam(name, @default, min, max, unit, description);
public static ParamSpec Number(string name, double @default, double min, double max, string? unit = null, string description = "") =>
new NumberParam(name, @default, min, max, unit, description);
public static ParamSpec Bool(string name, bool @default, string description = "") => new BoolParam(name, @default, description);
public static ParamSpec Enum(string name, string @default, string[] values, string description = "") =>
new EnumParam(name, @default, values, description);
}
[System.Text.Json.Serialization.JsonSerializable(typeof(long))]
[System.Text.Json.Serialization.JsonSerializable(typeof(double))]
[System.Text.Json.Serialization.JsonSerializable(typeof(bool))]
[System.Text.Json.Serialization.JsonSerializable(typeof(string))]
internal sealed partial class ParamJson : System.Text.Json.Serialization.JsonSerializerContext;
/// <summary>已经校验并补全了默认值的参数。</summary>
public sealed class ParamValues
{
private readonly Dictionary<string, JsonElement> _values;
public ParamValues(Dictionary<string, JsonElement> values)
{
_values = values;
}
public IReadOnlyDictionary<string, JsonElement> Values => _values;
public long GetInt(string name) => _values[name].GetInt64();
public double GetNumber(string name) => _values[name].GetDouble();
public bool GetBool(string name) => _values[name].GetBoolean();
public string GetString(string name) => _values[name].GetString()!;
}
public sealed record ParamResolution(ParamValues? Values, IReadOnlyDictionary<string, string> Errors)
{
public bool IsValid => Errors.Count == 0;
}
public static class ParamResolver
{
/// <summary>未知参数视为错误;缺失的参数取默认值。</summary>
public static ParamResolution Resolve(IReadOnlyList<ParamSpec> specs, IReadOnlyDictionary<string, JsonElement>? input)
{
var errors = new Dictionary<string, string>();
var values = new Dictionary<string, JsonElement>(specs.Count);
foreach (ParamSpec spec in specs)
{
if (input is not null && input.TryGetValue(spec.Name, out JsonElement raw))
{
if (spec.TryNormalize(raw, out JsonElement normalized, out string error))
{
values[spec.Name] = normalized;
}
else
{
errors[spec.Name] = error;
}
}
else
{
values[spec.Name] = spec.DefaultValue;
}
}
if (input is not null)
{
foreach (string key in input.Keys)
{
if (!values.ContainsKey(key) && !errors.ContainsKey(key))
{
errors[key] = "is not a parameter of this kind";
}
}
}
return new ParamResolution(errors.Count == 0 ? new ParamValues(values) : null, errors);
}
}
@@ -0,0 +1,71 @@
using System.Text.Json.Serialization;
using Fleet.Core;
namespace Monitor.Protocol;
[JsonConverter(typeof(ProbeIdJsonConverter))]
public readonly record struct ProbeId(string Value)
{
public const string Prefix = "prb";
public override string ToString() => Value;
}
[JsonConverter(typeof(TaskIdJsonConverter))]
public readonly record struct TaskId(string Value)
{
public const string Prefix = "tsk";
public override string ToString() => Value;
}
[JsonConverter(typeof(TestIdJsonConverter))]
public readonly record struct TestId(string Value)
{
public const string Prefix = "tst";
public override string ToString() => Value;
}
/// <summary>原始块 ID(UUIDv7,同时是发件箱的 msg_id)。</summary>
[JsonConverter(typeof(ChunkIdJsonConverter))]
public readonly record struct ChunkId(Guid Value)
{
public static ChunkId New(TimeProvider time) => new(Guid.CreateVersion7(time.GetUtcNow()));
public override string ToString() => Value.ToString();
}
public static class AgentIds
{
/// <summary>Agent 就是 Fleet 节点,ID 前缀为 <c>agt</c>。</summary>
public const string Prefix = "agt";
}
public sealed class ProbeIdJsonConverter : StringIdJsonConverter<ProbeId>
{
protected override ProbeId Create(string value) => new(value);
protected override string GetValue(ProbeId id) => id.Value;
}
public sealed class TaskIdJsonConverter : StringIdJsonConverter<TaskId>
{
protected override TaskId Create(string value) => new(value);
protected override string GetValue(TaskId id) => id.Value;
}
public sealed class TestIdJsonConverter : StringIdJsonConverter<TestId>
{
protected override TestId Create(string value) => new(value);
protected override string GetValue(TestId id) => id.Value;
}
public sealed class ChunkIdJsonConverter : StringIdJsonConverter<ChunkId>
{
protected override ChunkId Create(string value) => new(Guid.Parse(value));
protected override string GetValue(ChunkId id) => id.Value.ToString();
}
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\fleet\src\Fleet.Core\Fleet.Core.csproj" />
<ProjectReference Include="..\..\fleet\src\Fleet.Rpc\Fleet.Rpc.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,45 @@
using Fleet.Core;
namespace Monitor.Protocol;
/// <summary>Monitor 的错误码(在 Fleet 错误码之上扩展)。REST、JSON-RPC、OpenAPI 共用同一份。</summary>
public static class MonitorErrors
{
public static readonly ErrorCode ValidationFailed = new("validation_failed");
public static readonly ErrorCode BudgetExceeded = new("budget_exceeded");
public static readonly ErrorCode Unauthorized = new("unauthorized");
public static readonly ErrorCode CsrfFailed = new("csrf_failed");
public static readonly ErrorCode Forbidden = new("forbidden");
public static readonly ErrorCode SitePrivate = new("site_private");
public static readonly ErrorCode NotFound = new("not_found");
public static readonly ErrorCode AgentNotFound = new("agent_not_found");
public static readonly ErrorCode ProbeNotFound = new("probe_not_found");
public static readonly ErrorCode ProbeRevoked = new("probe_revoked");
public static readonly ErrorCode ProbeRequired = new("probe_required");
public static readonly ErrorCode TaskNotFound = new("task_not_found");
public static readonly ErrorCode TestNotFound = new("test_not_found");
public static readonly ErrorCode ChunkNotFound = new("chunk_not_found");
public static readonly ErrorCode TokenNotFound = new("token_not_found");
public static readonly ErrorCode UnknownKind = new("unknown_kind");
public static readonly ErrorCode UnknownPlan = new("unknown_plan");
public static readonly ErrorCode NameConflict = new("name_conflict");
public static readonly ErrorCode ProbeOffline = new("probe_offline");
public static readonly ErrorCode TestBusy = new("test_busy");
public static readonly ErrorCode AgentNotActive = new("agent_not_active");
public static readonly ErrorCode AgentOffline = new("agent_offline");
public static readonly ErrorCode RawExpired = new("raw_expired");
public static readonly ErrorCode JoinTokenExpired = new("join_token_expired");
public static readonly ErrorCode PreconditionFailed = new("precondition_failed");
public static readonly ErrorCode PayloadTooLarge = new("payload_too_large");
public static readonly ErrorCode RangeTooLarge = new("range_too_large");
public static readonly ErrorCode InvalidStep = new("invalid_step");
public static readonly ErrorCode InvalidRange = new("invalid_range");
public static readonly ErrorCode UnknownMetric = new("unknown_metric");
public static readonly ErrorCode UnknownPreset = new("unknown_preset");
public static readonly ErrorCode TooManyMetrics = new("too_many_metrics");
public static readonly ErrorCode TooManyRequests = new("too_many_requests");
public static readonly ErrorCode TooManyStreams = new("too_many_streams");
public static readonly ErrorCode StorageUnavailable = new("storage_unavailable");
public static readonly ErrorCode RawCorrupt = new("raw_corrupt");
public static readonly ErrorCode Internal = new("internal_error");
}
@@ -0,0 +1,43 @@
namespace Monitor.Protocol;
public static class ProtocolVersion
{
public const int Container = 1;
public const int Wire = 1;
}
public static class ProtocolLimits
{
/// <summary>持续任务每 10 秒切一块。</summary>
public const int ChunkSeconds = 10;
/// <summary>单块记录数上限。</summary>
public const int MaxRecordsPerChunk = 50_000;
/// <summary>解压后的块上限(防解压炸弹)。</summary>
public const int MaxDecodedChunkBytes = 64 << 20;
public const int MaxStreamsPerChunk = 16;
public const int MaxColumnsPerStream = 64;
/// <summary>每条线路持续任务的总带宽上限(bit/s)。</summary>
public const long MaxLineBudgetBitsPerSecond = 100_000;
}
/// <summary>二进制通道号(0–15 属于 Fleet,Monitor 从 16 开始)。</summary>
public static class MonitorChannels
{
/// <summary>Agent → Backend:RawChunk(发件箱)。</summary>
public const byte RawChunk = 16;
/// <summary>Probe → Agent:列块(点对点会话的发件箱)。</summary>
public const byte ColumnBlock = 17;
}
public static class MonitorCapabilities
{
public const string Base = "monitor.v1";
public const string KindsPrefix = "monitor.kinds:";
public const string BlocksPrefix = "monitor.blocks:";
}
@@ -0,0 +1,35 @@
namespace Monitor.Protocol;
/// <summary>CRC-32C(Castagnoli),RawChunk 的完整性校验。</summary>
public static class Crc32c
{
private static readonly uint[] s_table = BuildTable();
public static uint Compute(ReadOnlySpan<byte> data)
{
uint crc = uint.MaxValue;
foreach (byte b in data)
{
crc = s_table[(crc ^ b) & 0xFF] ^ (crc >> 8);
}
return ~crc;
}
private static uint[] BuildTable()
{
var table = new uint[256];
for (uint i = 0; i < 256; i++)
{
uint c = i;
for (int k = 0; k < 8; k++)
{
c = (c & 1) != 0 ? (c >> 1) ^ 0x82F63B78u : c >> 1;
}
table[i] = c;
}
return table;
}
}
@@ -0,0 +1,210 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Fleet.Core;
namespace Monitor.Protocol;
/// <summary>Agent 单调时钟与 UTC 的锚点,以及 Probe 时钟相对 Agent 时钟的偏移估计。</summary>
public sealed class ChunkClock
{
[JsonPropertyName("agent_mono_us")]
public long AgentMonoUs { get; set; }
[JsonPropertyName("utc_ms")]
public long UtcMs { get; set; }
/// <summary>Agent 时钟 − Probe 时钟(微秒)。</summary>
[JsonPropertyName("probe_offset_us")]
public long ProbeOffsetUs { get; set; }
/// <summary>Probe 相对 Agent 的时钟漂移(ppb,十亿分之一)。</summary>
[JsonPropertyName("probe_drift_ppb")]
public long ProbeDriftPpb { get; set; }
}
public sealed class ColumnHeader
{
[JsonPropertyName("name")]
public string Name { get; set; } = "";
/// <summary>u8 | u16 | u32 | u64 | i32 | i64</summary>
[JsonPropertyName("type")]
public string Type { get; set; } = "";
/// <summary>plain | delta</summary>
[JsonPropertyName("enc")]
public string Enc { get; set; } = "plain";
[JsonPropertyName("nullable")]
public bool Nullable { get; set; }
/// <summary>ts_probe | ts_agent | ts_local,其他为空。</summary>
[JsonPropertyName("role")]
public string? Role { get; set; }
}
public sealed class StreamHeader
{
[JsonPropertyName("name")]
public string Name { get; set; } = "";
[JsonPropertyName("count")]
public int Count { get; set; }
[JsonPropertyName("columns")]
public List<ColumnHeader> Columns { get; set; } = [];
}
/// <summary>块头(JSON,自描述):列定义写在块里,新增列或新增 Kind 不需要升级 container_version。</summary>
public sealed class RawChunkHeader
{
[JsonPropertyName("chunk_id")]
public ChunkId ChunkId { get; set; }
[JsonPropertyName("container_version")]
public int ContainerVersion { get; set; } = ProtocolVersion.Container;
[JsonPropertyName("kind")]
public string Kind { get; set; } = "";
[JsonPropertyName("task_id")]
public TaskId? TaskId { get; set; }
[JsonPropertyName("test_id")]
public TestId? TestId { get; set; }
[JsonPropertyName("phase")]
public int? Phase { get; set; }
[JsonPropertyName("agent_id")]
public NodeId AgentId { get; set; }
[JsonPropertyName("probe_id")]
public ProbeId? ProbeId { get; set; }
[JsonPropertyName("target")]
public string? Target { get; set; }
/// <summary>参数的完整快照(包含默认值)。</summary>
[JsonPropertyName("params")]
public Dictionary<string, JsonElement> Params { get; set; } = [];
[JsonPropertyName("port")]
public int Port { get; set; }
[JsonPropertyName("direction")]
public string? Direction { get; set; }
[JsonPropertyName("start_utc_ms")]
public long StartUtcMs { get; set; }
[JsonPropertyName("duration_ms")]
public int DurationMs { get; set; }
/// <summary>Probe 一侧的列没有在宽限期内送达:只有 Agent 一侧的数据。</summary>
[JsonPropertyName("partial")]
public bool Partial { get; set; }
[JsonPropertyName("clock")]
public ChunkClock Clock { get; set; } = new();
[JsonPropertyName("streams")]
public List<StreamHeader> Streams { get; set; } = [];
/// <summary>
/// "line:&lt;probe_id&gt;" 或 "target:&lt;task_id&gt;":存储和分析用的序列标识。
/// 一条线路是一对(Probe,Agent):同一个 Agent 上每个 Probe 的数据各是一个序列,互不混合。
/// </summary>
[JsonIgnore]
public string Series => TaskId is { } id && Target is not null ? "target:" + id.Value : LinePrefix + (ProbeId?.Value ?? "");
public const string LinePrefix = "line:";
}
public sealed class RawColumn
{
public RawColumn(string name, ColumnType type, ColumnEnc enc, bool nullable, ColumnRole role, long[] values, bool[]? valid)
{
Name = name;
Type = type;
Enc = enc;
Nullable = nullable;
Role = role;
Values = values;
Valid = valid;
}
public string Name { get; }
public ColumnType Type { get; }
public ColumnEnc Enc { get; }
public bool Nullable { get; }
public ColumnRole Role { get; }
/// <summary>所有列统一用 long 存放(u64 按位保存)。缺失值对应的位置内容无意义,请先看 <see cref="IsValid"/>。</summary>
public long[] Values { get; }
public bool[]? Valid { get; }
public int Count => Values.Length;
public bool IsValid(int row) => Valid is null || Valid[row];
}
public sealed class RawStream
{
public RawStream(string name, int count, IReadOnlyList<RawColumn> columns)
{
Name = name;
Count = count;
Columns = columns;
}
public string Name { get; }
public int Count { get; }
public IReadOnlyList<RawColumn> Columns { get; }
public RawColumn? Find(string name)
{
foreach (RawColumn column in Columns)
{
if (column.Name == name)
{
return column;
}
}
return null;
}
}
public sealed class RawChunk
{
public RawChunk(RawChunkHeader header, IReadOnlyList<RawStream> streams)
{
Header = header;
Streams = streams;
}
public RawChunkHeader Header { get; }
public IReadOnlyList<RawStream> Streams { get; }
public RawStream? Find(string name)
{
foreach (RawStream stream in Streams)
{
if (stream.Name == name)
{
return stream;
}
}
return null;
}
}
@@ -0,0 +1,345 @@
using System.Buffers.Binary;
using System.IO.Compression;
using System.Text.Json;
using Fleet.Core;
namespace Monitor.Protocol;
/// <summary>
/// RawChunk 的编解码(Probe、Agent、Backend、离线工具共用同一份代码)。
/// 布局:<c>"MNRC" | u8 container_version | u32 crc32c(压缩数据) | Brotli( u32 header_len | header JSON | 各列数据 )</c>。
/// 列数据:可空列先写有效位图,再写有效值;plain 为无符号 varint(有符号类型用 zigzag),delta 为与上一个有效值之差的 zigzag varint。
/// </summary>
public static class RawChunkCodec
{
private const int PrefixBytes = 4 + 1 + 4;
private static ReadOnlySpan<byte> Magic => "MNRC"u8;
/// <summary>压缩之前的正文:<c>u32 header_len | header JSON | 各列数据</c>。布局变化时黄金字节测试会失败,提醒升级版本。</summary>
public static byte[] EncodeBody(RawChunk chunk)
{
RawChunkHeader header = chunk.Header;
header.ContainerVersion = ProtocolVersion.Container;
header.Streams = [];
foreach (RawStream stream in chunk.Streams)
{
var streamHeader = new StreamHeader { Name = stream.Name, Count = stream.Count };
foreach (RawColumn column in stream.Columns)
{
streamHeader.Columns.Add(new ColumnHeader
{
Name = column.Name,
Type = TypeName(column.Type),
Enc = column.Enc == ColumnEnc.Delta ? "delta" : "plain",
Nullable = column.Nullable,
Role = RoleName(column.Role),
});
}
header.Streams.Add(streamHeader);
}
byte[] headerJson = JsonSerializer.SerializeToUtf8Bytes(header, MonitorJsonContext.Default.RawChunkHeader);
using var body = new MemoryStream();
Span<byte> length = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(length, (uint)headerJson.Length);
body.Write(length);
body.Write(headerJson);
foreach (RawStream stream in chunk.Streams)
{
foreach (RawColumn column in stream.Columns)
{
WriteColumn(body, column);
}
}
return body.ToArray();
}
public static byte[] Encode(RawChunk chunk)
{
byte[] plain = EncodeBody(chunk);
using var compressed = new MemoryStream();
using (var brotli = new BrotliStream(compressed, CompressionLevel.Optimal, leaveOpen: true))
{
brotli.Write(plain);
}
byte[] payload = compressed.ToArray();
byte[] result = new byte[PrefixBytes + payload.Length];
Magic.CopyTo(result);
result[4] = ProtocolVersion.Container;
BinaryPrimitives.WriteUInt32LittleEndian(result.AsSpan(5), Crc32c.Compute(payload));
payload.CopyTo(result, PrefixBytes);
return result;
}
public static Result<RawChunk> Decode(ReadOnlySpan<byte> data)
{
if (data.Length < PrefixBytes || !data[..4].SequenceEqual(Magic))
{
return Corrupt("bad magic");
}
if (data[4] != ProtocolVersion.Container)
{
return Corrupt($"unsupported container version {data[4]}");
}
ReadOnlySpan<byte> payload = data[PrefixBytes..];
if (BinaryPrimitives.ReadUInt32LittleEndian(data[5..]) != Crc32c.Compute(payload))
{
return Corrupt("crc mismatch");
}
byte[]? plain = Inflate(payload);
if (plain is null)
{
return Corrupt("invalid or oversized compressed data");
}
if (plain.Length < 4)
{
return Corrupt("truncated header length");
}
uint headerLength = BinaryPrimitives.ReadUInt32LittleEndian(plain);
if (headerLength > plain.Length - 4)
{
return Corrupt("header length exceeds data");
}
RawChunkHeader? header;
try
{
header = JsonSerializer.Deserialize(plain.AsSpan(4, (int)headerLength), MonitorJsonContext.Default.RawChunkHeader);
}
catch (JsonException)
{
return Corrupt("header is not valid json");
}
if (header is null || header.Streams.Count > ProtocolLimits.MaxStreamsPerChunk)
{
return Corrupt("header is missing or has too many streams");
}
ReadOnlySpan<byte> columns = plain.AsSpan(4 + (int)headerLength);
var streams = new List<RawStream>(header.Streams.Count);
foreach (StreamHeader streamHeader in header.Streams)
{
if (streamHeader.Count is < 0 or > ProtocolLimits.MaxRecordsPerChunk || streamHeader.Columns.Count > ProtocolLimits.MaxColumnsPerStream)
{
return Corrupt($"stream {streamHeader.Name} has an invalid size");
}
var cols = new List<RawColumn>(streamHeader.Columns.Count);
foreach (ColumnHeader columnHeader in streamHeader.Columns)
{
if (!TryParseType(columnHeader.Type, out ColumnType type) || !TryParseEnc(columnHeader.Enc, out ColumnEnc enc))
{
return Corrupt($"column {columnHeader.Name} has an unknown type or encoding");
}
RawColumn? column = ReadColumn(ref columns, columnHeader, type, enc, streamHeader.Count);
if (column is null)
{
return Corrupt($"column {streamHeader.Name}.{columnHeader.Name} is truncated or out of range");
}
cols.Add(column);
}
streams.Add(new RawStream(streamHeader.Name, streamHeader.Count, cols));
}
return columns.Length == 0 ? Result<RawChunk>.Ok(new RawChunk(header, streams)) : Corrupt("trailing bytes");
}
private static Result<RawChunk> Corrupt(string message) =>
Result<RawChunk>.Fail(MonitorErrors.RawCorrupt, message);
private static byte[]? Inflate(ReadOnlySpan<byte> payload)
{
using var input = new MemoryStream(payload.ToArray());
using var brotli = new BrotliStream(input, CompressionMode.Decompress);
using var output = new MemoryStream();
byte[] buffer = new byte[16 * 1024];
try
{
int read;
while ((read = brotli.Read(buffer, 0, buffer.Length)) > 0)
{
output.Write(buffer, 0, read);
if (output.Length > ProtocolLimits.MaxDecodedChunkBytes)
{
return null;
}
}
}
catch (InvalidDataException)
{
return null;
}
return output.ToArray();
}
private static void WriteColumn(MemoryStream body, RawColumn column)
{
int count = column.Count;
if (column.Nullable)
{
byte[] bitmap = new byte[(count + 7) / 8];
for (int i = 0; i < count; i++)
{
if (column.IsValid(i))
{
bitmap[i >> 3] |= (byte)(1 << (i & 7));
}
}
body.Write(bitmap);
}
bool signed = column.Type is ColumnType.I32 or ColumnType.I64;
long previous = 0;
for (int i = 0; i < count; i++)
{
if (!column.IsValid(i))
{
continue;
}
long value = column.Values[i];
if (column.Enc == ColumnEnc.Delta)
{
VarInt.Write(body, VarInt.ZigZag(value - previous));
previous = value;
}
else
{
VarInt.Write(body, signed ? VarInt.ZigZag(value) : (ulong)value);
}
}
}
private static RawColumn? ReadColumn(ref ReadOnlySpan<byte> data, ColumnHeader header, ColumnType type, ColumnEnc enc, int count)
{
bool[]? valid = null;
if (header.Nullable)
{
int bitmapBytes = (count + 7) / 8;
if (data.Length < bitmapBytes)
{
return null;
}
valid = new bool[count];
for (int i = 0; i < count; i++)
{
valid[i] = (data[i >> 3] & (1 << (i & 7))) != 0;
}
data = data[bitmapBytes..];
}
bool signed = type is ColumnType.I32 or ColumnType.I64;
long[] values = new long[count];
long previous = 0;
for (int i = 0; i < count; i++)
{
if (valid is not null && !valid[i])
{
continue;
}
if (!VarInt.TryRead(data, out ulong raw, out int consumed))
{
return null;
}
data = data[consumed..];
long value;
if (enc == ColumnEnc.Delta)
{
value = previous + VarInt.UnZigZag(raw);
previous = value;
}
else
{
value = signed ? VarInt.UnZigZag(raw) : unchecked((long)raw);
}
if (!InRange(type, value))
{
return null;
}
values[i] = value;
}
return new RawColumn(header.Name, type, enc, header.Nullable, ParseRole(header.Role), values, valid);
}
private static bool InRange(ColumnType type, long value) => type switch
{
ColumnType.U8 => value is >= 0 and <= byte.MaxValue,
ColumnType.U16 => value is >= 0 and <= ushort.MaxValue,
ColumnType.U32 => value is >= 0 and <= uint.MaxValue,
ColumnType.I32 => value is >= int.MinValue and <= int.MaxValue,
_ => true,
};
public static string TypeName(ColumnType type) => type switch
{
ColumnType.U8 => "u8",
ColumnType.U16 => "u16",
ColumnType.U32 => "u32",
ColumnType.U64 => "u64",
ColumnType.I32 => "i32",
_ => "i64",
};
private static bool TryParseType(string text, out ColumnType type)
{
switch (text)
{
case "u8": type = ColumnType.U8; return true;
case "u16": type = ColumnType.U16; return true;
case "u32": type = ColumnType.U32; return true;
case "u64": type = ColumnType.U64; return true;
case "i32": type = ColumnType.I32; return true;
case "i64": type = ColumnType.I64; return true;
default: type = default; return false;
}
}
private static bool TryParseEnc(string text, out ColumnEnc enc)
{
switch (text)
{
case "plain": enc = ColumnEnc.Plain; return true;
case "delta": enc = ColumnEnc.Delta; return true;
default: enc = default; return false;
}
}
private static string? RoleName(ColumnRole role) => role switch
{
ColumnRole.TsProbe => "ts_probe",
ColumnRole.TsAgent => "ts_agent",
ColumnRole.TsLocal => "ts_local",
_ => null,
};
private static ColumnRole ParseRole(string? text) => text switch
{
"ts_probe" => ColumnRole.TsProbe,
"ts_agent" => ColumnRole.TsAgent,
"ts_local" => ColumnRole.TsLocal,
_ => ColumnRole.None,
};
}
@@ -0,0 +1,58 @@
namespace Monitor.Protocol;
/// <summary>按 <see cref="StreamSpec"/> 逐行构造一个 <see cref="RawStream"/>(用于测试、模拟器和离线工具,不用于节点热路径)。</summary>
public sealed class StreamBuilder
{
private readonly StreamSpec _spec;
private readonly List<long>[] _values;
private readonly List<bool>[] _valid;
public StreamBuilder(StreamSpec spec)
{
_spec = spec;
_values = new List<long>[spec.Columns.Count];
_valid = new List<bool>[spec.Columns.Count];
for (int i = 0; i < _values.Length; i++)
{
_values[i] = [];
_valid[i] = [];
}
}
public int Count { get; private set; }
/// <summary>添加一行;<c>null</c> 表示缺失值(只允许用于可空列)。</summary>
public StreamBuilder Add(params long?[] row)
{
if (row.Length != _values.Length)
{
throw new ArgumentException($"stream {_spec.Name} expects {_values.Length} values, got {row.Length}", nameof(row));
}
for (int i = 0; i < row.Length; i++)
{
if (row[i] is null && !_spec.Columns[i].Nullable)
{
throw new ArgumentException($"column {_spec.Columns[i].Name} is not nullable", nameof(row));
}
_values[i].Add(row[i] ?? 0);
_valid[i].Add(row[i] is not null);
}
Count++;
return this;
}
public RawStream Build()
{
var columns = new List<RawColumn>(_values.Length);
for (int i = 0; i < _values.Length; i++)
{
ColumnSpec spec = _spec.Columns[i];
columns.Add(new RawColumn(spec.Name, spec.Type, spec.Enc, spec.Nullable, spec.Role, [.. _values[i]], spec.Nullable ? [.. _valid[i]] : null));
}
return new RawStream(_spec.Name, Count, columns);
}
}
@@ -0,0 +1,440 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Fleet.Core;
namespace Monitor.Protocol;
/// <summary>monitor.* 方法名。新增方法 = 在这里定义名称和 params/result,再在组装处加一行 <c>.On(...)</c>。</summary>
public static class MonitorMethods
{
public const string TestRun = "monitor.test.run";
public const string TestCancel = "monitor.test.cancel";
public const string ProbeDisconnect = "monitor.probe.disconnect";
public const string TestStatus = "monitor.test.status";
public const string Event = "monitor.event";
public const string Host = "monitor.host";
public const string ProbeAgents = "monitor.probe.agents";
public const string ProbeAgentsChanged = "monitor.probe.agents_changed";
// Probe ↔ Agent(点对点会话)
public const string Clock = "monitor.clock";
public const string TestPhase = "monitor.test.phase";
public const string TestPhaseEnd = "monitor.test.phase_end";
public const string TestAbort = "monitor.test.abort";
}
public static class AgentEventKinds
{
public const string ProbeConnected = "probe_connected";
public const string ProbeDisconnected = "probe_disconnected";
public const string EndpointChanged = "endpoint_changed";
public const string SessionRejected = "session_rejected";
public const string ClockStep = "clock_step";
public const string Gap = "gap";
public const string CpuSaturated = "cpu_saturated";
public static readonly IReadOnlyList<string> All =
[ProbeConnected, ProbeDisconnected, EndpointChanged, SessionRejected, ClockStep, Gap, CpuSaturated];
}
public static class TestStatuses
{
public const string Queued = "queued";
public const string Running = "running";
public const string PhaseDone = "phase_done";
public const string Succeeded = "succeeded";
public const string Failed = "failed";
public const string Aborted = "aborted";
}
public sealed class TestPhase
{
[JsonPropertyName("kind")]
public string Kind { get; set; } = "";
[JsonPropertyName("params")]
public Dictionary<string, JsonElement> Params { get; set; } = [];
[JsonPropertyName("duration_s")]
public int DurationSeconds { get; set; }
}
public sealed class TestRunParams
{
[JsonPropertyName("test_id")]
public TestId TestId { get; set; }
/// <summary>在哪个 Probe 上运行:必须是当前连着这个 Agent 的 Probe。</summary>
[JsonPropertyName("probe_id")]
public ProbeId ProbeId { get; set; }
[JsonPropertyName("plan")]
public string Plan { get; set; } = "";
[JsonPropertyName("phases")]
public List<TestPhase> Phases { get; set; } = [];
}
public sealed class TestRunResult
{
[JsonPropertyName("accepted")]
public bool Accepted { get; set; }
}
public sealed class TestCancelParams
{
[JsonPropertyName("test_id")]
public TestId TestId { get; set; }
}
/// <summary>Backend → Agent:断开某个 Probe 的会话(Probe 会按退避重连,除非它已经被吊销)。</summary>
public sealed class ProbeDisconnectParams
{
[JsonPropertyName("probe_id")]
public ProbeId ProbeId { get; set; }
}
public sealed class TestStatusParams
{
[JsonPropertyName("test_id")]
public TestId TestId { get; set; }
[JsonPropertyName("status")]
public string Status { get; set; } = "";
[JsonPropertyName("phase")]
public int? Phase { get; set; }
[JsonPropertyName("error_code")]
public string? ErrorCode { get; set; }
}
public sealed class AgentEventParams
{
/// <summary><c>probe_connected</c> 时:这个 Probe 的 id、版本、地址和连接时间(Backend 不用等下一次心跳就能显示)。</summary>
[JsonPropertyName("probe")]
public ConnectedProbe? Probe { get; set; }
[JsonPropertyName("kind")]
public string Kind { get; set; } = "";
[JsonPropertyName("at_utc_ms")]
public long AtUtcMs { get; set; }
[JsonPropertyName("detail")]
public string? Detail { get; set; }
}
public sealed class AgentEndpoint
{
[JsonPropertyName("agent_id")]
public NodeId AgentId { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; } = "";
[JsonPropertyName("host")]
public string Host { get; set; } = "";
[JsonPropertyName("tcp_port")]
public int TcpPort { get; set; }
[JsonPropertyName("udp_ports")]
public List<int> UdpPorts { get; set; } = [];
/// <summary>Agent 面向 Probe 的 TLS 证书指纹(SHA-256,base64)。</summary>
[JsonPropertyName("spki")]
public string Spki { get; set; } = "";
}
public sealed class ProbeAgentsParams
{
/// <summary>Probe 目前持有的列表版本(用于调试;服务端总是返回当前列表)。</summary>
[JsonPropertyName("have_version")]
public long HaveVersion { get; set; }
}
public sealed class ProbeAgentsResult
{
[JsonPropertyName("version")]
public long Version { get; set; }
[JsonPropertyName("agents")]
public List<AgentEndpoint> Agents { get; set; } = [];
}
/// <summary>注册元数据(Monitor 定义,经 Fleet 的 <c>meta</c> 字段带上来)。</summary>
public sealed class AgentEnrollMeta
{
[JsonPropertyName("probe_tcp_port")]
public int ProbeTcpPort { get; set; } = 7443;
[JsonPropertyName("probe_udp_ports")]
public List<int> ProbeUdpPorts { get; set; } = [7443];
[JsonPropertyName("public_host")]
public string? PublicHost { get; set; }
[JsonPropertyName("kinds")]
public List<string> Kinds { get; set; } = [];
}
public sealed class LocalLimits
{
[JsonPropertyName("max_total_mbps")]
public double MaxTotalMbps { get; set; }
[JsonPropertyName("max_total_pps")]
public long MaxTotalPps { get; set; }
[JsonPropertyName("allowed_target_cidrs")]
public List<string> AllowedTargetCidrs { get; set; } = [];
[JsonPropertyName("max_concurrent_tests")]
public int MaxConcurrentTests { get; set; } = 1;
}
/// <summary><c>fleet.hello</c> 的附加字段。</summary>
public sealed class AgentHelloMeta
{
[JsonPropertyName("tls_spki")]
public string? TlsSpki { get; set; }
[JsonPropertyName("probe_tcp_port")]
public int? ProbeTcpPort { get; set; }
[JsonPropertyName("probe_udp_ports")]
public List<int>? ProbeUdpPorts { get; set; }
[JsonPropertyName("local_limits")]
public LocalLimits? LocalLimits { get; set; }
}
/// <summary>
/// 主机资源占用的只读快照(Agent → Backend,每隔几秒一次)。只有汇总的数字:<b>没有 CPU 型号、主机名、网卡名,也没有任何 IP 地址</b>。
/// 读不到的项是 null。
/// </summary>
public sealed class HostSnapshot
{
[JsonPropertyName("cpu_percent")]
public double? CpuPercent { get; set; }
[JsonPropertyName("mem_used")]
public long? MemUsed { get; set; }
[JsonPropertyName("mem_total")]
public long? MemTotal { get; set; }
[JsonPropertyName("disk_used")]
public long? DiskUsed { get; set; }
[JsonPropertyName("disk_total")]
public long? DiskTotal { get; set; }
/// <summary>所有物理网卡的累计收发字节(自开机以来)。</summary>
[JsonPropertyName("net_rx_total")]
public long? NetRxTotal { get; set; }
[JsonPropertyName("net_tx_total")]
public long? NetTxTotal { get; set; }
[JsonPropertyName("net_rx_bps")]
public long? NetRxBps { get; set; }
[JsonPropertyName("net_tx_bps")]
public long? NetTxBps { get; set; }
[JsonPropertyName("uptime_s")]
public long? UptimeSeconds { get; set; }
[JsonPropertyName("at_utc_ms")]
public long AtUtcMs { get; set; }
}
/// <summary>Agent 眼中的一个已连接 Probe。</summary>
public sealed class ConnectedProbe
{
[JsonPropertyName("id")]
public ProbeId Id { get; set; }
[JsonPropertyName("version")]
public string? Version { get; set; }
/// <summary>Probe 的远端地址(经过 NAT 之后的公网 IP:端口)。</summary>
[JsonPropertyName("remote")]
public string? Remote { get; set; }
[JsonPropertyName("since_utc_ms")]
public long SinceUtcMs { get; set; }
}
/// <summary><c>fleet.heartbeat</c> 的附加字段:只有进程内的计数,没有主机信息。</summary>
public sealed class AgentHeartbeatExtra
{
/// <summary>当前连着的 Probe 会话数(等于 <see cref="Probes"/> 的个数)。</summary>
[JsonPropertyName("sessions")]
public int Sessions { get; set; }
/// <summary>当前连着的 Probe:每次心跳都是完整的快照,Backend 靠它纠正错过的 probe_connected / probe_disconnected 事件。</summary>
[JsonPropertyName("probes")]
public List<ConnectedProbe> Probes { get; set; } = [];
[JsonPropertyName("pps_in")]
public long PpsIn { get; set; }
[JsonPropertyName("pps_out")]
public long PpsOut { get; set; }
[JsonPropertyName("dropped_invalid")]
public long DroppedInvalid { get; set; }
[JsonPropertyName("dropped_rate_limited")]
public long DroppedRateLimited { get; set; }
[JsonPropertyName("queue_bytes")]
public long QueueBytes { get; set; }
}
/// <summary>Agent 下发给 Probe 的一条线路任务(期望状态类型 <c>line_task_set</c> 里的一项)。</summary>
public sealed class LineTask
{
[JsonPropertyName("id")]
public TaskId Id { get; set; }
[JsonPropertyName("kind")]
public string Kind { get; set; } = "";
[JsonPropertyName("params")]
public Dictionary<string, JsonElement> Params { get; set; } = [];
/// <summary>数据面用它区分任务;Agent 在本进程生命周期内分配。</summary>
[JsonPropertyName("stream_id")]
public uint StreamId { get; set; }
[JsonPropertyName("port")]
public int Port { get; set; }
}
/// <summary>这条线路的期望状态(Agent → Probe):Probe 按它调和自己一侧的角色。</summary>
public sealed class LineTaskSet
{
public const string StateType = "line_task_set";
[JsonPropertyName("version")]
public long Version { get; set; }
[JsonPropertyName("tasks")]
public List<LineTask> Tasks { get; set; } = [];
/// <summary>Agent 的数据面端口(Probe 把包发到这里)。</summary>
[JsonPropertyName("udp_ports")]
public List<int> UdpPorts { get; set; } = [];
[JsonPropertyName("tcp_ports")]
public List<int> TcpPorts { get; set; } = [];
}
/// <summary><c>monitor.clock</c>:Probe 用它估计与 Agent 单调时钟的偏移(对称往返假设)。</summary>
public sealed class ClockParams
{
[JsonPropertyName("probe_mono_us")]
public long ProbeMonoUs { get; set; }
}
public sealed class ClockResult
{
[JsonPropertyName("agent_mono_us")]
public long AgentMonoUs { get; set; }
}
/// <summary>按需测试的一个阶段(Agent → Probe):Agent 编排时间,Probe 准备好自己一侧的角色之后应答。</summary>
public sealed class TestPhaseStartParams
{
[JsonPropertyName("test_id")]
public TestId TestId { get; set; }
[JsonPropertyName("phase")]
public int Phase { get; set; }
[JsonPropertyName("kind")]
public string Kind { get; set; } = "";
[JsonPropertyName("params")]
public Dictionary<string, JsonElement> Params { get; set; } = [];
[JsonPropertyName("stream_id")]
public uint StreamId { get; set; }
[JsonPropertyName("duration_s")]
public int DurationSeconds { get; set; }
[JsonPropertyName("port")]
public int Port { get; set; }
}
public sealed class TestPhaseStartResult
{
[JsonPropertyName("accepted")]
public bool Accepted { get; set; }
}
/// <summary>阶段结束(Agent → Probe):Probe 收尾并把自己一侧的列块交给 Agent。<c>sent_count</c> 是 Agent 一侧发出的包数(单向数据流用)。</summary>
public sealed class TestPhaseEndParams
{
[JsonPropertyName("test_id")]
public TestId TestId { get; set; }
[JsonPropertyName("phase")]
public int Phase { get; set; }
[JsonPropertyName("stream_id")]
public uint StreamId { get; set; }
[JsonPropertyName("sent_count")]
public long SentCount { get; set; }
}
/// <summary>Probe 收尾之后回复:它交给 Agent 的列块个数(Agent 据此等待全部列块到齐再进入下一阶段)。</summary>
public sealed class TestPhaseEndResult
{
[JsonPropertyName("fragments")]
public int Fragments { get; set; }
}
public sealed class TestAbortParams
{
[JsonPropertyName("test_id")]
public TestId TestId { get; set; }
}
/// <summary>Probe 一侧的列块放在 RawChunk 头部 params 里的保留键:本块覆盖的序号范围 [from, to)(单向数据流由 Agent 一侧按它取行)。</summary>
public static class FragmentKeys
{
public const string SeqFrom = "_seq_from";
public const string SeqTo = "_seq_to";
}
[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(LineTaskSet))]
[JsonSerializable(typeof(ClockParams))]
[JsonSerializable(typeof(ClockResult))]
[JsonSerializable(typeof(TestPhaseStartParams))]
[JsonSerializable(typeof(TestPhaseStartResult))]
[JsonSerializable(typeof(TestPhaseEndParams))]
[JsonSerializable(typeof(TestPhaseEndResult))]
[JsonSerializable(typeof(TestAbortParams))]
[JsonSerializable(typeof(TaskSet))]
[JsonSerializable(typeof(TestRunParams))]
[JsonSerializable(typeof(TestRunResult))]
[JsonSerializable(typeof(TestCancelParams))]
[JsonSerializable(typeof(ProbeDisconnectParams))]
[JsonSerializable(typeof(HostSnapshot))]
[JsonSerializable(typeof(TestStatusParams))]
[JsonSerializable(typeof(AgentEventParams))]
[JsonSerializable(typeof(ProbeAgentsParams))]
[JsonSerializable(typeof(ProbeAgentsResult))]
[JsonSerializable(typeof(AgentEnrollMeta))]
[JsonSerializable(typeof(AgentHelloMeta))]
[JsonSerializable(typeof(AgentHeartbeatExtra))]
[JsonSerializable(typeof(RawChunkHeader))]
public sealed partial class MonitorJsonContext : JsonSerializerContext;
@@ -0,0 +1,90 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
using Fleet.Core;
namespace Monitor.Protocol;
/// <summary>Task = { id, scope, kind, target, params, schedule, enabled }。参数已经按 ParamSpec 校验并补全默认值。</summary>
public sealed class TaskSpec
{
[JsonPropertyName("id")]
public TaskId Id { get; set; }
/// <summary>line | target</summary>
[JsonPropertyName("scope")]
public string Scope { get; set; } = "line";
[JsonPropertyName("kind")]
public string Kind { get; set; } = "";
[JsonPropertyName("target")]
public string? Target { get; set; }
[JsonPropertyName("params")]
public Dictionary<string, JsonElement> Params { get; set; } = [];
/// <summary>continuous | every:&lt;分钟&gt; | once</summary>
[JsonPropertyName("schedule")]
public string Schedule { get; set; } = TaskSchedule.Continuous;
[JsonPropertyName("enabled")]
public bool Enabled { get; set; } = true;
}
public sealed class PeerSpec
{
[JsonPropertyName("probe_id")]
public ProbeId ProbeId { get; set; }
/// <summary>Probe 的身份公钥(SPKI,base64)。</summary>
[JsonPropertyName("pubkey")]
public string PublicKey { get; set; } = "";
}
/// <summary>期望状态文档(Fleet state type = <c>task_set</c>):Backend 为每个 Agent 计算的有效任务集。</summary>
public sealed class TaskSet
{
public const string StateType = "task_set";
[JsonPropertyName("version")]
public long Version { get; set; }
[JsonPropertyName("tasks")]
public List<TaskSpec> Tasks { get; set; } = [];
/// <summary>允许连接到这个 Agent 的 Probe 公钥。</summary>
[JsonPropertyName("peers")]
public List<PeerSpec> Peers { get; set; } = [];
[JsonPropertyName("probe_agents_version")]
public long ProbeAgentsVersion { get; set; }
}
public static class TaskSchedule
{
public const string Continuous = "continuous";
public const string Once = "once";
public const string EveryPrefix = "every:";
public static string Every(int minutes) => EveryPrefix + minutes.ToString(CultureInfo.InvariantCulture);
/// <summary>校验调度字符串:<c>every:N</c> 的 N ≥ 60(适用于重负载 Kind)。</summary>
public static Result<int?> Validate(string schedule)
{
if (schedule is Continuous or Once)
{
return Result<int?>.Ok(null);
}
if (schedule.StartsWith(EveryPrefix, StringComparison.Ordinal)
&& int.TryParse(schedule.AsSpan(EveryPrefix.Length), NumberStyles.None, CultureInfo.InvariantCulture, out int minutes))
{
return minutes >= 60
? Result<int?>.Ok(minutes)
: Result<int?>.Fail(MonitorErrors.ValidationFailed, "every:N requires N >= 60 minutes");
}
return Result<int?>.Fail(MonitorErrors.ValidationFailed, "schedule must be continuous, once or every:<minutes>");
}
}
@@ -0,0 +1,132 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using Fleet.Core;
namespace Monitor.Protocol;
/// <summary>
/// 数据面的 TCP 连接(Fleet 之外的线格式)。连接都由 Probe 发起;TLS 是可选的(ClientHello 以 0x16 开头,
/// 明文首帧以 "MN" 开头,所以同一个端口可以区分)。首帧:
/// <c>"MN"(2) version(1) session_id(8) stream_id(4) nonce(16) mac(16)</c>,认证失败直接关闭连接;
/// 之后是 <c>VarInt 长度 | 正文</c> 的消息,回显类消息的正文是 <c>seq(u32) send_ts_us(i64) | padding</c>。
/// </summary>
public static class TcpWire
{
public const int AuthBytes = 2 + 1 + 8 + 4 + 16 + 16;
public const int NonceBytes = 16;
public const int MinEchoBody = 12;
public const int MaxMessageBytes = 1 << 20;
public static void WriteAuth(Span<byte> destination, long sessionId, uint streamId, ReadOnlySpan<byte> nonce, ReadOnlySpan<byte> key)
{
BinaryPrimitives.WriteUInt16LittleEndian(destination, UdpPacket.Magic);
destination[2] = UdpPacket.Version;
BinaryPrimitives.WriteInt64LittleEndian(destination[3..], sessionId);
BinaryPrimitives.WriteUInt32LittleEndian(destination[11..], streamId);
nonce[..NonceBytes].CopyTo(destination[15..]);
Span<byte> full = stackalloc byte[32];
HMACSHA256.HashData(key, destination[..(AuthBytes - 16)], full);
full[..16].CopyTo(destination[(AuthBytes - 16)..]);
}
/// <summary>解析首帧(MAC 要在查到会话密钥之后用 <see cref="VerifyAuth"/> 校验)。</summary>
public static bool TryParseAuth(ReadOnlySpan<byte> frame, out long sessionId, out uint streamId, out ReadOnlySpan<byte> nonce)
{
sessionId = 0;
streamId = 0;
nonce = default;
if (frame.Length != AuthBytes || BinaryPrimitives.ReadUInt16LittleEndian(frame) != UdpPacket.Magic || frame[2] != UdpPacket.Version)
{
return false;
}
sessionId = BinaryPrimitives.ReadInt64LittleEndian(frame[3..]);
streamId = BinaryPrimitives.ReadUInt32LittleEndian(frame[11..]);
nonce = frame.Slice(15, NonceBytes);
return true;
}
public static bool VerifyAuth(ReadOnlySpan<byte> frame, ReadOnlySpan<byte> key)
{
Span<byte> full = stackalloc byte[32];
HMACSHA256.HashData(key, frame[..(AuthBytes - 16)], full);
return CryptographicOperations.FixedTimeEquals(full[..16], frame[(AuthBytes - 16)..]);
}
/// <summary>回显消息正文:<c>seq | send_ts_us | padding</c>,总长度 <paramref name="size"/>。</summary>
public static void WriteEchoBody(Span<byte> destination, uint seq, long sendTsUs)
{
destination.Clear();
BinaryPrimitives.WriteUInt32LittleEndian(destination, seq);
BinaryPrimitives.WriteInt64LittleEndian(destination[4..], sendTsUs);
}
public static bool TryReadEchoBody(ReadOnlySpan<byte> body, out uint seq, out long sendTsUs)
{
seq = 0;
sendTsUs = 0;
if (body.Length < MinEchoBody)
{
return false;
}
seq = BinaryPrimitives.ReadUInt32LittleEndian(body);
sendTsUs = BinaryPrimitives.ReadInt64LittleEndian(body[4..]);
return true;
}
/// <summary>读取一条 <c>VarInt 长度 | 正文</c> 消息;连接结束或长度非法时返回 null。</summary>
public static async ValueTask<byte[]?> ReadMessageAsync(Stream stream, CancellationToken ct)
{
ulong length = 0;
int shift = 0;
byte[] one = new byte[1];
for (int i = 0; i < VarInt.MaxBytes; i++)
{
if (await stream.ReadAsync(one, ct) == 0)
{
return null;
}
length |= (ulong)(one[0] & 0x7F) << shift;
if ((one[0] & 0x80) == 0)
{
break;
}
shift += 7;
if (i == VarInt.MaxBytes - 1)
{
return null;
}
}
if (length > MaxMessageBytes)
{
return null;
}
byte[] body = new byte[length];
int read = 0;
while (read < body.Length)
{
int n = await stream.ReadAsync(body.AsMemory(read), ct);
if (n == 0)
{
return null;
}
read += n;
}
return body;
}
public static async ValueTask WriteMessageAsync(Stream stream, ReadOnlyMemory<byte> body, CancellationToken ct)
{
byte[] prefix = new byte[VarInt.MaxBytes];
int n = VarInt.Write(prefix, (ulong)body.Length);
await stream.WriteAsync(prefix.AsMemory(0, n), ct);
await stream.WriteAsync(body, ct);
}
}
@@ -0,0 +1,91 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
namespace Monitor.Protocol;
public enum UdpPacketType : byte
{
EchoRequest = 1,
EchoReply = 2,
/// <summary>单向数据(udp_train),以及 Probe 为了保持 NAT 映射和让 Agent 学到源地址而发的探活包。</summary>
Data = 3,
}
public readonly record struct UdpHeader(UdpPacketType Type, long SessionId, uint StreamId, uint Seq, long SendTsUs);
/// <summary>
/// 数据面的 UDP 包(Fleet 之外的线格式):
/// <c>magic(2) version(1) type(1) session_id(8) stream_id(4) seq(4) send_ts_us(8) | padding | mac(16)</c>,
/// mac = HMAC-SHA256(session_key, 前面全部字节) 截断到 16 字节。所有整数小端。
/// 接收方对不合法的包静默丢弃并计数,不回复(防放大)。
/// </summary>
public static class UdpPacket
{
public const ushort Magic = 0x4D4E; // "MN"
public const byte Version = 1;
public const int HeaderBytes = 28;
public const int MacBytes = 16;
public const int MinBytes = HeaderBytes + MacBytes;
public const int MaxBytes = 1472;
/// <summary>检查长度、magic、version、type 并解析头部(不校验 MAC)。</summary>
public static bool TryParseHeader(ReadOnlySpan<byte> packet, out UdpHeader header)
{
header = default;
if (packet.Length is < MinBytes or > MaxBytes
|| BinaryPrimitives.ReadUInt16LittleEndian(packet) != Magic
|| packet[2] != Version
|| packet[3] is < (byte)UdpPacketType.EchoRequest or > (byte)UdpPacketType.Data)
{
return false;
}
header = new UdpHeader(
(UdpPacketType)packet[3],
BinaryPrimitives.ReadInt64LittleEndian(packet[4..]),
BinaryPrimitives.ReadUInt32LittleEndian(packet[12..]),
BinaryPrimitives.ReadUInt32LittleEndian(packet[16..]),
BinaryPrimitives.ReadInt64LittleEndian(packet[20..]));
return true;
}
/// <summary>写入整个包(长度就是 <paramref name="destination"/> 的长度),填充为零,末尾是 MAC。</summary>
public static void Write(Span<byte> destination, in UdpHeader header, ReadOnlySpan<byte> key)
{
if (destination.Length is < MinBytes or > MaxBytes)
{
throw new ArgumentOutOfRangeException(nameof(destination), "packet size must be between 44 and 1472 bytes");
}
destination.Clear();
BinaryPrimitives.WriteUInt16LittleEndian(destination, Magic);
destination[2] = Version;
destination[3] = (byte)header.Type;
BinaryPrimitives.WriteInt64LittleEndian(destination[4..], header.SessionId);
BinaryPrimitives.WriteUInt32LittleEndian(destination[12..], header.StreamId);
BinaryPrimitives.WriteUInt32LittleEndian(destination[16..], header.Seq);
BinaryPrimitives.WriteInt64LittleEndian(destination[20..], header.SendTsUs);
Seal(destination, key);
}
/// <summary>重新计算并写入末尾的 MAC(回复包复用请求包的缓冲区时使用)。</summary>
public static void Seal(Span<byte> packet, ReadOnlySpan<byte> key)
{
Span<byte> full = stackalloc byte[32];
HMACSHA256.HashData(key, packet[..^MacBytes], full);
full[..MacBytes].CopyTo(packet[^MacBytes..]);
}
public static bool VerifyMac(ReadOnlySpan<byte> packet, ReadOnlySpan<byte> key)
{
if (packet.Length < MinBytes)
{
return false;
}
Span<byte> full = stackalloc byte[32];
HMACSHA256.HashData(key, packet[..^MacBytes], full);
return CryptographicOperations.FixedTimeEquals(full[..MacBytes], packet[^MacBytes..]);
}
}