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 AllowedUdpPorts { 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);
}
public bool TargetAllowed(string? target)
{
if (target is null)
{
return false;
}
string host = target;
if (IPEndPoint.TryParse(target, out IPEndPoint? endpoint))
{
host = endpoint.Address.ToString();
}
if (!IPAddress.TryParse(host, out IPAddress? address))
{
return false;
}
foreach ((IPAddress network, int prefix) in _cidrs)
{
if (InCidr(address, network, prefix))
{
return true;
}
}
return false;
}
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 == "udp" ? _limits.AllowedUdpPorts.Contains(port) : _limits.AllowedTcpPorts.Contains(port);
}
private static long EstimatePps(string kindId, ParamValues values) => kindId switch
{
"udp_echo" or "tcp_echo" => values.GetInt("rate_hz") * 2,
"icmp_echo" => values.GetInt("rate_hz") * 2,
"udp_train" => (long)(values.GetNumber("mbps") * 1_000_000 / 8 / Math.Max(1, values.GetInt("size"))),
"connect" => values.GetInt("per_minute") * 6 / 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;
}
}