refactor!: rename the project from Monitor to MoShiro
Namespaces, assemblies, projects and directories, the solution, binaries, images, systemd units, config paths, environment variables, protocol method names, the media type and the raw chunk magic all move to MoShiro/moshiro. Existing data and deployments are not compatible and must be recreated. Also fix the architecture tests after the solution layout change and make the update tests independent of their order.
This commit is contained in:
1 parent
17b35223aa
commit
83e5120377
309 files changed
+1505
-1499
No files matched your search
@@ -0,0 +1,128 @@
|
||||
namespace MoShiro.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 MoShiro.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 MoShiro.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 MoShiro.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,35 @@
|
||||
<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>
|
||||
<!--
|
||||
构建信息:版本来自 $(Version),tag、提交、构建时间来自环境变量 MOSHIRO_TAG / MOSHIRO_COMMIT / MOSHIRO_BUILT_AT
|
||||
(scripts/publish.sh 和 CI 会设置;本地开发不设置就是空,这样本地构建是可重复的、不会每次都重新编译)。
|
||||
生成一个带常量的源文件,NativeAOT 下不用反射。
|
||||
-->
|
||||
<Target Name="WriteBuildInfo" BeforeTargets="CoreCompile">
|
||||
<PropertyGroup>
|
||||
<BuildInfoFile>$(IntermediateOutputPath)BuildInfo.g.cs</BuildInfoFile>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<BuildInfoLine Include="namespace MoShiro.Protocol%3B" />
|
||||
<BuildInfoLine Include="/// <summary>这个程序是什么时候、从哪个版本构建出来的(构建时生成)。</summary>" />
|
||||
<BuildInfoLine Include="public static class BuildInfo" />
|
||||
<BuildInfoLine Include="{" />
|
||||
<BuildInfoLine Include=" public const string Version = "$(Version)"%3B" />
|
||||
<BuildInfoLine Include=" public const string Tag = "$(MOSHIRO_TAG)"%3B" />
|
||||
<BuildInfoLine Include=" public const string Commit = "$(MOSHIRO_COMMIT)"%3B" />
|
||||
<BuildInfoLine Include=" public const string BuiltAt = "$(MOSHIRO_BUILT_AT)"%3B" />
|
||||
<BuildInfoLine Include="}" />
|
||||
</ItemGroup>
|
||||
<WriteLinesToFile File="$(BuildInfoFile)" Lines="@(BuildInfoLine)" Overwrite="true" WriteOnlyWhenDifferent="true" />
|
||||
<ItemGroup>
|
||||
<Compile Include="$(BuildInfoFile)" />
|
||||
<FileWrites Include="$(BuildInfoFile)" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -0,0 +1,45 @@
|
||||
using Fleet.Core;
|
||||
|
||||
namespace MoShiro.Protocol;
|
||||
|
||||
/// <summary>MoShiro 的错误码(在 Fleet 错误码之上扩展)。REST、JSON-RPC、OpenAPI 共用同一份。</summary>
|
||||
public static class MoShiroErrors
|
||||
{
|
||||
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 MoShiro.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,MoShiro 从 16 开始)。</summary>
|
||||
public static class MoShiroChannels
|
||||
{
|
||||
/// <summary>Agent → Backend:RawChunk(发件箱)。</summary>
|
||||
public const byte RawChunk = 16;
|
||||
|
||||
/// <summary>Probe → Agent:列块(点对点会话的发件箱)。</summary>
|
||||
public const byte ColumnBlock = 17;
|
||||
}
|
||||
|
||||
public static class MoShiroCapabilities
|
||||
{
|
||||
public const string Base = "moshiro.v1";
|
||||
public const string KindsPrefix = "moshiro.kinds:";
|
||||
public const string BlocksPrefix = "moshiro.blocks:";
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace MoShiro.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 MoShiro.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:<probe_id>" 或 "target:<task_id>":存储和分析用的序列标识。
|
||||
/// 一条线路是一对(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 MoShiro.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// RawChunk 的编解码(Probe、Agent、Backend、离线工具共用同一份代码)。
|
||||
/// 布局:<c>"MSRC" | 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 => "MSRC"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, MoShiroJsonContext.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), MoShiroJsonContext.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(MoShiroErrors.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 MoShiro.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,546 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Fleet.Core;
|
||||
|
||||
namespace MoShiro.Protocol;
|
||||
|
||||
/// <summary>moshiro.* 方法名。新增方法 = 在这里定义名称和 params/result,再在组装处加一行 <c>.On(...)</c>。</summary>
|
||||
public static class MoShiroMethods
|
||||
{
|
||||
public const string TestRun = "moshiro.test.run";
|
||||
public const string TestCancel = "moshiro.test.cancel";
|
||||
public const string ProbeDisconnect = "moshiro.probe.disconnect";
|
||||
public const string TestStatus = "moshiro.test.status";
|
||||
public const string Event = "moshiro.event";
|
||||
public const string Host = "moshiro.host";
|
||||
public const string UpdateOffer = "moshiro.update.offer";
|
||||
public const string UpdateApply = "moshiro.update.apply";
|
||||
|
||||
// Probe ↔ Agent(点对点会话)
|
||||
public const string Clock = "moshiro.clock";
|
||||
public const string TestPhase = "moshiro.test.phase";
|
||||
public const string TestPhaseEnd = "moshiro.test.phase_end";
|
||||
public const string TestAbort = "moshiro.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; } = "";
|
||||
}
|
||||
|
||||
/// <summary>期望状态文档(Fleet state type = <c>probe_agents</c>):Backend 直接下发给每个 Probe 的 Agent 列表。</summary>
|
||||
public sealed class ProbeAgentList
|
||||
{
|
||||
public const string StateType = "probe_agents";
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public long Version { get; set; }
|
||||
|
||||
[JsonPropertyName("agents")]
|
||||
public List<AgentEndpoint> Agents { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>注册元数据(MoShiro 定义,经 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>os</c> 是 linux / windows / macos / other,<c>name</c> 是「Debian 12」「Windows 11」这样的名字,
|
||||
/// <c>arch</c> 是 x64 / arm64 / arm / x86。<b>没有主机名、内核版本、具体型号</b>。另外带着这个程序自己的构建信息(版本、tag、构建时间)。
|
||||
/// </summary>
|
||||
public sealed class PlatformInfo
|
||||
{
|
||||
[JsonPropertyName("os")]
|
||||
public string Os { get; set; } = "other";
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("arch")]
|
||||
public string? Arch { get; set; }
|
||||
|
||||
/// <summary>程序的版本、tag、提交(短)和构建时间(UTC,ISO 8601):<see cref="BuildInfo"/> 构建时写进去的,没设置就是空。</summary>
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; set; }
|
||||
|
||||
[JsonPropertyName("tag")]
|
||||
public string? Tag { get; set; }
|
||||
|
||||
[JsonPropertyName("commit")]
|
||||
public string? Commit { get; set; }
|
||||
|
||||
[JsonPropertyName("built_at")]
|
||||
public string? BuiltAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 面板给节点的更新清单(<c>moshiro.update.offer</c>):<c>manifest</c> 是发布清单的原始文本,<c>signature</c> 是 base64 的签名。
|
||||
/// 节点只是把它们原样放进数据目录;验签、拉取、重启都由宿主机上的更新器完成(节点进程不执行任何命令,也不信任清单的内容)。
|
||||
/// </summary>
|
||||
public sealed class UpdateOfferParams
|
||||
{
|
||||
[JsonPropertyName("manifest")]
|
||||
public string Manifest { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("signature")]
|
||||
public string Signature { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>宿主机更新器已经验证过的可用更新。</summary>
|
||||
public sealed class UpdateAvailable
|
||||
{
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; set; }
|
||||
|
||||
[JsonPropertyName("tag")]
|
||||
public string? Tag { get; set; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; set; }
|
||||
|
||||
[JsonPropertyName("seq")]
|
||||
public long Seq { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 宿主机更新器的状态(它写在 <c>update-status/status.json</c>,节点读出来随心跳上报):
|
||||
/// <c>up_to_date</c>、<c>available</c>、<c>applying</c>、<c>failed</c>、<c>rolled_back</c>、<c>invalid</c>(清单验签或校验没过)。
|
||||
/// 没有安装更新器的节点没有这个字段。
|
||||
/// </summary>
|
||||
public sealed class UpdateStatus
|
||||
{
|
||||
[JsonPropertyName("state")]
|
||||
public string State { get; set; } = "";
|
||||
|
||||
/// <summary>正在运行的镜像 digest(更新器记录的;还没更新过时为空)。</summary>
|
||||
[JsonPropertyName("current")]
|
||||
public string? Current { get; set; }
|
||||
|
||||
[JsonPropertyName("available")]
|
||||
public UpdateAvailable? Available { get; set; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; set; }
|
||||
|
||||
[JsonPropertyName("at")]
|
||||
public long At { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Probe 的 <c>fleet.heartbeat</c> 附加字段:更新器的状态。</summary>
|
||||
public sealed class ProbeHeartbeatExtra
|
||||
{
|
||||
[JsonPropertyName("update")]
|
||||
public UpdateStatus? Update { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Probe 的 <c>fleet.hello</c> 附加字段:系统、架构和构建信息(只用于展示和更新)。</summary>
|
||||
public sealed class ProbeHelloMeta
|
||||
{
|
||||
[JsonPropertyName("platform")]
|
||||
public PlatformInfo? Platform { get; set; }
|
||||
}
|
||||
|
||||
/// <summary><c>fleet.hello</c> 的附加字段。</summary>
|
||||
public sealed class AgentHelloMeta
|
||||
{
|
||||
[JsonPropertyName("platform")]
|
||||
public PlatformInfo? Platform { get; set; }
|
||||
|
||||
[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; }
|
||||
|
||||
/// <summary>已统计接口的内核报告链路速率之和(Mbit/s);驱动不支持或无法读取时为空,不代表互联网带宽。</summary>
|
||||
[JsonPropertyName("net_link_mbps")]
|
||||
public long? NetLinkMbps { 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; }
|
||||
|
||||
[JsonPropertyName("update")]
|
||||
public UpdateStatus? Update { 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>moshiro.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(ProbeAgentList))]
|
||||
[JsonSerializable(typeof(UpdateOfferParams))]
|
||||
[JsonSerializable(typeof(UpdateStatus))]
|
||||
[JsonSerializable(typeof(ProbeHeartbeatExtra))]
|
||||
[JsonSerializable(typeof(ProbeHelloMeta))]
|
||||
[JsonSerializable(typeof(AgentEnrollMeta))]
|
||||
[JsonSerializable(typeof(PlatformInfo))]
|
||||
[JsonSerializable(typeof(AgentHelloMeta))]
|
||||
[JsonSerializable(typeof(AgentHeartbeatExtra))]
|
||||
[JsonSerializable(typeof(RawChunkHeader))]
|
||||
public sealed partial class MoShiroJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Fleet.Core;
|
||||
|
||||
namespace MoShiro.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:<分钟> | 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; } = [];
|
||||
}
|
||||
|
||||
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(MoShiroErrors.ValidationFailed, "every:N requires N >= 60 minutes");
|
||||
}
|
||||
|
||||
return Result<int?>.Fail(MoShiroErrors.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 MoShiro.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 MoShiro.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..]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user