Files
MoShiro/backend/shared/MoShiro.Measurement/Execution/BudgetGuard.cs
T

229 lines
8.6 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Net;
using System.Text.Json;
using MoShiro.Protocol;
namespace MoShiro.Measurement;
/// <summary>节点的本地硬上限(写在本机配置里,Backend 无法修改)。</summary>
public sealed class NodeLimits
{
public double MaxTotalMbps { get; init; } = 1200;
public long MaxTotalPps { get; init; } = 20_000;
/// <summary>目标任务允许访问的网段;默认为空,即不开放任何目标任务。</summary>
public IReadOnlyList<string> AllowedTargetCidrs { get; init; } = [];
public int MaxConcurrentTests { get; init; } = 1;
/// <summary>任务参数里的 <c>port</c> 只能取这些端口(本机配置的 listen 列表)。</summary>
public IReadOnlyList<int> AllowedQuicPorts { get; init; } = [7443];
public IReadOnlyList<int> AllowedTcpPorts { get; init; } = [7444];
}
/// <summary>被拒绝的原因,用于 <c>rejected[]</c>:<c>unsupported</c>、<c>invalid_params</c>、<c>budget_exceeded</c>、<c>policy_denied</c>。</summary>
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);
}
/// <summary>
/// 本地硬上限检查:Backend 下发的任何参数都要先经过 <see cref="ParamResolver"/> 校验,再经过这里的本地上限检查,两道都不能省略。
/// 持续任务按顺序累计码率和包速率,超出总预算的任务被拒绝(并给出原因),其余照常运行。
/// </summary>
public sealed class BudgetGuard
{
private readonly NodeLimits _limits;
private readonly IReadOnlyCollection<string> _supportedKinds;
private readonly List<(IPAddress Network, int Prefix)> _cidrs = [];
public BudgetGuard(NodeLimits limits, IReadOnlyCollection<string> supportedKinds)
{
_limits = limits;
_supportedKinds = supportedKinds;
foreach (string cidr in limits.AllowedTargetCidrs)
{
if (TryParseCidr(cidr, out IPAddress? network, out int prefix))
{
_cidrs.Add((network, prefix));
}
}
}
/// <summary>检查一个任务;<paramref name="usedMbps"/> 和 <paramref name="usedPps"/> 是已经接受的任务占用的预算,接受之后会累加。</summary>
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);
}
/// <summary>按需测试的一个阶段是否在本地上限之内(单独运行,不和持续任务累计)。</summary>
public TaskDecision CheckPhase(string kindId, IReadOnlyDictionary<string, JsonElement> parameters)
{
double mbps = 0;
long pps = 0;
return Check(new TaskSpec { Id = new TaskId("tsk_phase"), Scope = "line", Kind = kindId, Params = new Dictionary<string, JsonElement>(parameters) }, ref mbps, ref pps);
}
/// <summary>
/// 目标在静态上可不可以接受:IP 字面量直接用 <see cref="AddressAllowed"/> 判断;域名(包括 URL 里的)要解析之后才知道,
/// 这里先接受,运行时每次解析出的地址再逐个检查。
/// </summary>
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);
}
/// <summary>
/// 这个地址能不能作为单个 Agent 任务的目标。<c>allowed_target_cidrs</c> 没配置时只允许公网地址(内网、回环、链路本地、组播等一律拒绝),
/// 防止被攻破的 Backend 借 Agent 探测它所在的内网;配置了就只允许列出的网段(包括内网段,由节点所有者显式决定)。
/// </summary>
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),
];
/// <summary>不是公网:私有、回环、链路本地、运营商级 NAT、组播、保留地址。</summary>
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;
}
}