using System.Net; using System.Text.Json; using MoShiro.Protocol; namespace MoShiro.Measurement; /// 节点的本地硬上限(写在本机配置里,Backend 无法修改)。 public sealed class NodeLimits { public double MaxTotalMbps { get; init; } = 1200; public long MaxTotalPps { get; init; } = 20_000; /// 目标任务允许访问的网段;默认为空,即不开放任何目标任务。 public IReadOnlyList AllowedTargetCidrs { get; init; } = []; public int MaxConcurrentTests { get; init; } = 1; /// 任务参数里的 port 只能取这些端口(本机配置的 listen 列表)。 public IReadOnlyList AllowedQuicPorts { get; init; } = [7443]; public IReadOnlyList AllowedTcpPorts { get; init; } = [7444]; } /// 被拒绝的原因,用于 rejected[]:unsupported、invalid_params、budget_exceeded、policy_denied。 public static class RejectReasons { public const string Unsupported = "unsupported"; public const string InvalidParams = "invalid_params"; public const string BudgetExceeded = "budget_exceeded"; public const string PolicyDenied = "policy_denied"; } public sealed record TaskDecision(bool Accepted, string? Reason, ParamValues? Resolved, KindSpec? Kind) { public static TaskDecision Reject(string reason) => new(false, reason, null, null); } /// /// 本地硬上限检查:Backend 下发的任何参数都要先经过 校验,再经过这里的本地上限检查,两道都不能省略。 /// 持续任务按顺序累计码率和包速率,超出总预算的任务被拒绝(并给出原因),其余照常运行。 /// public sealed class BudgetGuard { private readonly NodeLimits _limits; private readonly IReadOnlyCollection _supportedKinds; private readonly List<(IPAddress Network, int Prefix)> _cidrs = []; public BudgetGuard(NodeLimits limits, IReadOnlyCollection supportedKinds) { _limits = limits; _supportedKinds = supportedKinds; foreach (string cidr in limits.AllowedTargetCidrs) { if (TryParseCidr(cidr, out IPAddress? network, out int prefix)) { _cidrs.Add((network, prefix)); } } } /// 检查一个任务; 和 是已经接受的任务占用的预算,接受之后会累加。 public TaskDecision Check(TaskSpec task, ref double usedMbps, ref long usedPps) { KindSpec? kind = Kinds.Find(task.Kind); if (kind is null || !_supportedKinds.Contains(task.Kind)) { return TaskDecision.Reject(RejectReasons.Unsupported); } ParamResolution resolved = ParamResolver.Resolve(kind.Params, task.Params); if (!resolved.IsValid) { return TaskDecision.Reject(RejectReasons.InvalidParams); } if (task.Scope == "target") { if (!TargetAllowed(task.Target)) { return TaskDecision.Reject(RejectReasons.PolicyDenied); } } else if (!PortAllowed(kind, resolved.Values!)) { return TaskDecision.Reject(RejectReasons.PolicyDenied); } double mbps = kind.BudgetBitsPerSecond(resolved.Values!) / 1_000_000.0; long pps = EstimatePps(kind.Id, resolved.Values!); if (usedMbps + mbps > _limits.MaxTotalMbps || usedPps + pps > _limits.MaxTotalPps) { return TaskDecision.Reject(RejectReasons.BudgetExceeded); } usedMbps += mbps; usedPps += pps; return new TaskDecision(true, null, resolved.Values, kind); } /// 按需测试的一个阶段是否在本地上限之内(单独运行,不和持续任务累计)。 public TaskDecision CheckPhase(string kindId, IReadOnlyDictionary parameters) { double mbps = 0; long pps = 0; return Check(new TaskSpec { Id = new TaskId("tsk_phase"), Scope = "line", Kind = kindId, Params = new Dictionary(parameters) }, ref mbps, ref pps); } /// /// 目标在静态上可不可以接受:IP 字面量直接用 判断;域名(包括 URL 里的)要解析之后才知道, /// 这里先接受,运行时每次解析出的地址再逐个检查。 /// public bool TargetAllowed(string? target) { if (!TargetHost.TryParse(target, out string? host, out _, out _)) { return false; } return !IPAddress.TryParse(host, out IPAddress? address) || AddressAllowed(address); } /// /// 这个地址能不能作为单个 Agent 任务的目标。allowed_target_cidrs 没配置时只允许公网地址(内网、回环、链路本地、组播等一律拒绝), /// 防止被攻破的 Backend 借 Agent 探测它所在的内网;配置了就只允许列出的网段(包括内网段,由节点所有者显式决定)。 /// public bool AddressAllowed(IPAddress address) { if (address.IsIPv4MappedToIPv6) { address = address.MapToIPv4(); } if (_limits.AllowedTargetCidrs.Count == 0) { return IsPublic(address); } foreach ((IPAddress network, int prefix) in _cidrs) { if (InCidr(address, network, prefix)) { return true; } } return false; } private static readonly (IPAddress Network, int Prefix)[] s_nonPublic = [ (IPAddress.Parse("0.0.0.0"), 8), (IPAddress.Parse("10.0.0.0"), 8), (IPAddress.Parse("100.64.0.0"), 10), (IPAddress.Parse("127.0.0.0"), 8), (IPAddress.Parse("169.254.0.0"), 16), (IPAddress.Parse("172.16.0.0"), 12), (IPAddress.Parse("192.0.0.0"), 24), (IPAddress.Parse("192.168.0.0"), 16), (IPAddress.Parse("198.18.0.0"), 15), (IPAddress.Parse("224.0.0.0"), 3), (IPAddress.Parse("::"), 128), (IPAddress.Parse("::1"), 128), (IPAddress.Parse("fc00::"), 7), (IPAddress.Parse("fe80::"), 10), (IPAddress.Parse("ff00::"), 8), ]; /// 不是公网:私有、回环、链路本地、运营商级 NAT、组播、保留地址。 private static bool IsPublic(IPAddress address) { foreach ((IPAddress network, int prefix) in s_nonPublic) { if (InCidr(address, network, prefix)) { return false; } } return true; } private bool PortAllowed(KindSpec kind, ParamValues values) { if (!kind.Params.Any(p => p.Name == "port")) { return true; } int port = (int)values.GetInt("port"); return kind.Blocks.Transport == "quic_datagram" ? _limits.AllowedQuicPorts.Contains(port) : _limits.AllowedTcpPorts.Contains(port); } private static long EstimatePps(string kindId, ParamValues values) => kindId switch { "quic_ping" or "tcp_ping" => Kinds.PeakRateHz(kindId, values) * 2, "icmp_ping" => values.GetInt("rate_hz") * 2, "quic_stream" => (long)(values.GetNumber("mbps") * 1_000_000 / 8 / Math.Max(1, values.GetInt("size"))), "tcp_connect" => values.GetInt("per_minute") * 6 / 60, "http_request" => Math.Max(1, values.GetInt("rate_max_per_min") * 20 / 60), _ => 1, }; private static bool TryParseCidr(string text, out IPAddress network, out int prefix) { network = IPAddress.None; prefix = 0; int slash = text.IndexOf('/', StringComparison.Ordinal); if (slash < 0 || !IPAddress.TryParse(text.AsSpan(0, slash), out IPAddress? parsed) || !int.TryParse(text.AsSpan(slash + 1), out prefix)) { return false; } network = parsed; return prefix >= 0 && prefix <= parsed.GetAddressBytes().Length * 8; } private static bool InCidr(IPAddress address, IPAddress network, int prefix) { byte[] a = address.GetAddressBytes(); byte[] n = network.GetAddressBytes(); if (a.Length != n.Length) { return false; } int bits = prefix; for (int i = 0; i < a.Length && bits > 0; i++, bits -= 8) { int mask = bits >= 8 ? 0xFF : (0xFF << (8 - bits)) & 0xFF; if ((a[i] & mask) != (n[i] & mask)) { return false; } } return true; } }