fix: 代理扩展插件只扫描局域网网段 - 新增 LanScan 统一解析局域网地址,仅放行 RFC1918,排除 169.254.0.0/16 等链路本地、回环、多播与保留地址 - 手工配置的 Networks 走同一套过滤,避免误配后全网段扫描 - 多网卡同网段去重,尾部通配符折算 CIDR 以排除网络地址与广播地址,超过 MaxHosts 时给出告警 - 抽取纯逻辑到独立文件并补充单元测试智能大石头 authored at 2026-09-29 10:15:31
diff --git a/Plugins/AgentExpansion/AgentExpansionService.cs b/Plugins/AgentExpansion/AgentExpansionService.cs
index ca0e645..eebd9ea 100644
--- a/Plugins/AgentExpansion/AgentExpansionService.cs
+++ b/Plugins/AgentExpansion/AgentExpansionService.cs
@@ -4,7 +4,6 @@ using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
-using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Text;
using System.Threading;
@@ -94,7 +93,7 @@ internal sealed class AgentExpansionService
var local = AgentInfo.GetLocal(false);
local.Server = server;
- var targets = GetTargets(set).ToList();
+ var targets = GetTargets(set, Log).ToList();
if (targets.Count == 0) return;
var localIps = GetLocalIpSet();
@@ -428,197 +427,12 @@ internal sealed class AgentExpansionService
return ips;
}
- private static IEnumerable<IPAddress> GetTargets(AgentExpansionSetting set)
+ private static IEnumerable<IPAddress> GetTargets(AgentExpansionSetting set, ILog? log)
{
- var ranges = ParseNetworks(set.Networks);
- if (ranges.Count == 0) ranges = GetLocalRanges();
+ var ranges = LanScan.ParseNetworks(set.Networks, log);
+ if (ranges.Count == 0) ranges = LanScan.GetLocalRanges();
- var maxHosts = set.MaxHosts;
- var count = 0;
- foreach (var range in ranges)
- {
- foreach (var address in range.GetAddresses())
- {
- yield return address;
- count++;
- if (maxHosts > 0 && count >= maxHosts) yield break;
- }
- }
- }
-
- private static List<NetworkRange> ParseNetworks(String? text)
- {
- var list = new List<NetworkRange>();
- if (text.IsNullOrEmpty()) return list;
-
- var parts = text.Split([',', ';', '\r', '\n', '\t', ' '], StringSplitOptions.RemoveEmptyEntries);
- foreach (var part in parts)
- {
- if (TryParseCidr(part, out var range) ||
- TryParseWildcard(part, out range) ||
- TryParseSingle(part, out range))
- list.Add(range);
- }
-
- return list;
- }
-
- private static List<NetworkRange> GetLocalRanges()
- {
- var list = new List<NetworkRange>();
-
- foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
- {
- if (nic.OperationalStatus != OperationalStatus.Up) continue;
- if (nic.NetworkInterfaceType is NetworkInterfaceType.Loopback or NetworkInterfaceType.Tunnel) continue;
-
- var props = nic.GetIPProperties();
- if (props == null) continue;
-
- foreach (var unicast in props.UnicastAddresses)
- {
- if (unicast.Address.AddressFamily != AddressFamily.InterNetwork) continue;
- if (IPAddress.IsLoopback(unicast.Address)) continue;
- if (unicast.IPv4Mask == null) continue;
- //if (unicast.PrefixOrigin == PrefixOrigin.WellKnown) continue;
- if (unicast.Address.GetAddressBytes()[0] == 169) continue;
-
- var prefix = GetPrefixLength(unicast.IPv4Mask);
- if (prefix <= 0) continue;
-
- list.Add(NetworkRange.FromCidr(unicast.Address, prefix));
- }
- }
-
- return list;
- }
-
- private static Int32 GetPrefixLength(IPAddress mask)
- {
- var bytes = mask.GetAddressBytes();
- var count = 0;
- foreach (var item in bytes)
- {
- var value = item;
- for (var i = 0; i < 8; i++)
- {
- if ((value & 0x80) == 0x80) count++;
- value <<= 1;
- }
- }
-
- return count;
- }
-
- private static Boolean TryParseSingle(String text, out NetworkRange range)
- {
- range = default;
- if (!IPAddress.TryParse(text, out var address)) return false;
-
- range = NetworkRange.FromSingle(address);
- return true;
- }
-
- private static Boolean TryParseCidr(String text, out NetworkRange range)
- {
- range = default;
- var p = text.IndexOf('/');
- if (p <= 0) return false;
-
- if (!IPAddress.TryParse(text[..p], out var address)) return false;
- if (!Int32.TryParse(text[(p + 1)..], out var prefix)) return false;
- if (prefix < 0 || prefix > 32) return false;
-
- range = NetworkRange.FromCidr(address, prefix);
- return true;
- }
-
- private static Boolean TryParseWildcard(String text, out NetworkRange range)
- {
- range = default;
- if (!text.Contains('*')) return false;
-
- var parts = text.Split('.');
- if (parts.Length != 4) return false;
-
- var startBytes = new Byte[4];
- var endBytes = new Byte[4];
-
- for (var i = 0; i < 4; i++)
- {
- if (parts[i] == "*")
- {
- startBytes[i] = 0;
- endBytes[i] = 255;
- }
- else if (Byte.TryParse(parts[i], out var value))
- {
- startBytes[i] = value;
- endBytes[i] = value;
- }
- else
- {
- return false;
- }
- }
-
- range = new NetworkRange(new IPAddress(startBytes), new IPAddress(endBytes));
- return true;
- }
-}
-
-internal readonly struct NetworkRange
-{
- public NetworkRange(IPAddress start, IPAddress end)
- {
- Start = start;
- End = end;
- }
-
- public IPAddress Start { get; }
- public IPAddress End { get; }
-
- public IEnumerable<IPAddress> GetAddresses()
- {
- var start = ToUInt32(Start);
- var end = ToUInt32(End);
- if (end < start) yield break;
-
- for (var i = start; i <= end; i++)
- {
- yield return ToIPAddress(i);
-
- if (i == UInt32.MaxValue) break;
- }
- }
-
- public static NetworkRange FromSingle(IPAddress address) => new(address, address);
-
- public static NetworkRange FromCidr(IPAddress address, Int32 prefix)
- {
- var ip = ToUInt32(address);
- var mask = prefix == 0 ? 0U : UInt32.MaxValue << (32 - prefix);
- var network = ip & mask;
- var broadcast = network | ~mask;
-
- var start = prefix >= 31 ? network : network + 1;
- var end = prefix >= 31 ? broadcast : broadcast - 1;
-
- return new NetworkRange(ToIPAddress(start), ToIPAddress(end));
- }
-
- private static UInt32 ToUInt32(IPAddress address)
- {
- var bytes = address.GetAddressBytes();
- if (BitConverter.IsLittleEndian) Array.Reverse(bytes);
- return BitConverter.ToUInt32(bytes, 0);
- }
-
- private static IPAddress ToIPAddress(UInt32 value)
- {
- var bytes = BitConverter.GetBytes(value);
- if (BitConverter.IsLittleEndian) Array.Reverse(bytes);
- return new IPAddress(bytes);
+ return LanScan.GetTargets(LanScan.Distinct(ranges), set.MaxHosts, log);
}
}
diff --git a/Plugins/AgentExpansion/AgentExpansionSetting.cs b/Plugins/AgentExpansion/AgentExpansionSetting.cs
index 4962d4e..644239e 100644
--- a/Plugins/AgentExpansion/AgentExpansionSetting.cs
+++ b/Plugins/AgentExpansion/AgentExpansionSetting.cs
@@ -16,8 +16,12 @@ public class AgentExpansionSetting : Config<AgentExpansionSetting>
[Description("扫描周期。单位秒,默认3600")]
public Int32 Period { get; set; } = 3600;
- /// <summary>目标网段。支持192.168.1.0/24或192.168.1.*,为空表示本地网段</summary>
- [Description("目标网段。支持192.168.1.0/24或192.168.1.*,为空表示本地网段")]
+ /// <summary>目标网段。支持192.168.1.0/24或192.168.1.*,为空表示本地网段。仅支持局域网网段</summary>
+ /// <remarks>
+ /// 只接受 RFC1918 私有网段(10.0.0.0/8、172.16.0.0/12、192.168.0.0/16),
+ /// 链路本地 169.254.0.0/16、回环、多播、保留以及公网地址会被忽略,避免误配导致全网段扫描。
+ /// </remarks>
+ [Description("目标网段。支持192.168.1.0/24或192.168.1.*,为空表示本地网段。仅支持局域网网段")]
public String? Networks { get; set; }
/// <summary>账号。可使用env:NAME引用环境变量</summary>
diff --git a/Plugins/AgentExpansion/LanScan.cs b/Plugins/AgentExpansion/LanScan.cs
new file mode 100644
index 0000000..c4df15a
--- /dev/null
+++ b/Plugins/AgentExpansion/LanScan.cs
@@ -0,0 +1,298 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Net.NetworkInformation;
+using System.Net.Sockets;
+using NewLife;
+using NewLife.Log;
+
+namespace AgentExpansion;
+
+/// <summary>局域网扫描地址解析。从本机网卡或配置的Networks解析出待探测的局域网地址</summary>
+/// <remarks>只扫描 RFC1918 私有网段,链路本地、回环、多播、保留地址以及公网地址一律排除</remarks>
+internal static class LanScan
+{
+ /// <summary>判断是否局域网地址</summary>
+ /// <remarks>
+ /// 仅放行 10.0.0.0/8、172.16.0.0/12、192.168.0.0/16。
+ /// 其余地址一律排除,包括:链路本地 169.254.0.0/16(APIPA,掩码多为 /16,展开后地址极多)、
+ /// 回环 127.0.0.0/8、本网络 0.0.0.0/8、运营商级 NAT 100.64.0.0/10、
+ /// 多播 224.0.0.0/4、保留 240.0.0.0/4、以及 192.0.2.0/24 等文档测试网段和任意公网地址。
+ /// </remarks>
+ /// <param name="address">待判断的 IPv4 地址</param>
+ /// <returns>是否属于局域网网段</returns>
+ public static Boolean IsLanAddress(IPAddress? address)
+ {
+ if (address == null) return false;
+ if (address.AddressFamily != AddressFamily.InterNetwork) return false;
+
+ var bytes = address.GetAddressBytes();
+ if (bytes.Length != 4) return false;
+
+ // 10.0.0.0/8
+ if (bytes[0] == 10) return true;
+
+ // 172.16.0.0/12
+ if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return true;
+
+ // 192.168.0.0/16
+ if (bytes[0] == 192 && bytes[1] == 168) return true;
+
+ return false;
+ }
+
+ /// <summary>解析配置的目标网段。支持 192.168.1.0/24、192.168.1.* 以及单个地址</summary>
+ /// <param name="text">网段文本,逗号分号空白分隔</param>
+ /// <param name="log">日志。用于提示被忽略的非局域网网段</param>
+ /// <returns>局域网网段集合</returns>
+ public static List<NetworkRange> ParseNetworks(String? text, ILog? log)
+ {
+ var list = new List<NetworkRange>();
+ if (text.IsNullOrEmpty()) return list;
+
+ var parts = text.Split([',', ';', '\r', '\n', '\t', ' '], StringSplitOptions.RemoveEmptyEntries);
+ foreach (var part in parts)
+ {
+ if (TryParseCidr(part, out var range) ||
+ TryParseWildcard(part, out range) ||
+ TryParseSingle(part, out range))
+ {
+ // 与本机网卡同一套过滤,避免误配成 169.254.0.0/16、0.0.0.0/0 等非局域网网段后全网段扫描
+ if (!IsLanAddress(range.Start))
+ {
+ log?.Warn("目标网段 {0} 不是局域网地址,已忽略", part);
+ continue;
+ }
+
+ list.Add(range);
+ }
+ }
+
+ return list;
+ }
+
+ /// <summary>获取本机所有局域网网段</summary>
+ /// <returns>本机网卡所在的局域网网段</returns>
+ public static List<NetworkRange> GetLocalRanges()
+ {
+ var list = new List<NetworkRange>();
+
+ foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
+ {
+ if (nic.OperationalStatus != OperationalStatus.Up) continue;
+ if (nic.NetworkInterfaceType is NetworkInterfaceType.Loopback or NetworkInterfaceType.Tunnel) continue;
+
+ var props = nic.GetIPProperties();
+ if (props == null) continue;
+
+ foreach (var unicast in props.UnicastAddresses)
+ {
+ if (unicast.Address.AddressFamily != AddressFamily.InterNetwork) continue;
+ if (unicast.IPv4Mask == null) continue;
+ //if (unicast.PrefixOrigin == PrefixOrigin.WellKnown) continue;
+ // 只扫描局域网网段。无线直连、蓝牙、虚拟网卡等常常回落 169.254.0.0/16,掩码还是 /16,
+ // 一旦参与扫描会展开成六万多个地址,既无意义又会刷爆日志
+ if (!IsLanAddress(unicast.Address)) continue;
+
+ var prefix = GetPrefixLength(unicast.IPv4Mask);
+ if (prefix <= 0) continue;
+
+ list.Add(NetworkRange.FromCidr(unicast.Address, prefix));
+ }
+ }
+
+ return list;
+ }
+
+ /// <summary>网段去重。多张网卡可能接入同一网段</summary>
+ /// <param name="ranges">原始网段集合</param>
+ /// <returns>去重后的网段集合</returns>
+ public static List<NetworkRange> Distinct(List<NetworkRange> ranges)
+ {
+ var list = new List<NetworkRange>();
+ foreach (var range in ranges)
+ {
+ if (!list.Any(e => e.Start.Equals(range.Start) && e.End.Equals(range.End))) list.Add(range);
+ }
+
+ return list;
+ }
+
+ /// <summary>枚举待探测地址</summary>
+ /// <param name="ranges">目标网段</param>
+ /// <param name="maxHosts">最大主机数。0或负数表示不限制</param>
+ /// <param name="log">日志。超过上限时给出提示</param>
+ /// <returns>待探测地址</returns>
+ public static IEnumerable<IPAddress> GetTargets(List<NetworkRange> ranges, Int32 maxHosts, ILog? log)
+ {
+ var count = 0;
+ foreach (var range in ranges)
+ {
+ foreach (var address in range.GetAddresses())
+ {
+ yield return address;
+ count++;
+ if (maxHosts > 0 && count >= maxHosts)
+ {
+ log?.Warn("目标地址超过上限 {0},本轮仅扫描前 {0} 个地址,请调整MaxHosts或收窄Networks", maxHosts);
+ yield break;
+ }
+ }
+ }
+ }
+
+ private static Int32 GetPrefixLength(IPAddress mask)
+ {
+ var bytes = mask.GetAddressBytes();
+ var count = 0;
+ foreach (var item in bytes)
+ {
+ var value = item;
+ for (var i = 0; i < 8; i++)
+ {
+ if ((value & 0x80) == 0x80) count++;
+ value <<= 1;
+ }
+ }
+
+ return count;
+ }
+
+ private static Boolean TryParseSingle(String text, out NetworkRange range)
+ {
+ range = default;
+ if (!IPAddress.TryParse(text, out var address)) return false;
+
+ range = NetworkRange.FromSingle(address);
+ return true;
+ }
+
+ private static Boolean TryParseCidr(String text, out NetworkRange range)
+ {
+ range = default;
+ var p = text.IndexOf('/');
+ if (p <= 0) return false;
+
+ if (!IPAddress.TryParse(text[..p], out var address)) return false;
+ if (!Int32.TryParse(text[(p + 1)..], out var prefix)) return false;
+ if (prefix < 0 || prefix > 32) return false;
+
+ range = NetworkRange.FromCidr(address, prefix);
+ return true;
+ }
+
+ private static Boolean TryParseWildcard(String text, out NetworkRange range)
+ {
+ range = default;
+ if (!text.Contains('*')) return false;
+
+ var parts = text.Split('.');
+ if (parts.Length != 4) return false;
+
+ var startBytes = new Byte[4];
+ var endBytes = new Byte[4];
+ var stars = 0;
+
+ for (var i = 0; i < 4; i++)
+ {
+ if (parts[i] == "*")
+ {
+ startBytes[i] = 0;
+ endBytes[i] = 255;
+ stars++;
+ }
+ else if (Byte.TryParse(parts[i], out var value))
+ {
+ startBytes[i] = value;
+ endBytes[i] = value;
+ }
+ else
+ {
+ return false;
+ }
+ }
+
+ var start = new IPAddress(startBytes);
+
+ // 通配符全部位于末尾时等价于 CIDR,交由 FromCidr 处理,顺带排除网络地址与广播地址
+ var trailing = 0;
+ for (var i = 3; i >= 0 && parts[i] == "*"; i--) trailing++;
+
+ range = stars == trailing && trailing < 4
+ ? NetworkRange.FromCidr(start, (4 - trailing) * 8)
+ : new NetworkRange(start, new IPAddress(endBytes));
+
+ return true;
+ }
+}
+
+/// <summary>IP地址区间</summary>
+internal readonly struct NetworkRange
+{
+ /// <summary>实例化</summary>
+ /// <param name="start">起始地址</param>
+ /// <param name="end">结束地址</param>
+ public NetworkRange(IPAddress start, IPAddress end)
+ {
+ Start = start;
+ End = end;
+ }
+
+ /// <summary>起始地址</summary>
+ public IPAddress Start { get; }
+
+ /// <summary>结束地址</summary>
+ public IPAddress End { get; }
+
+ /// <summary>枚举区间内所有地址</summary>
+ /// <returns>区间内地址</returns>
+ public IEnumerable<IPAddress> GetAddresses()
+ {
+ var start = ToUInt32(Start);
+ var end = ToUInt32(End);
+ if (end < start) yield break;
+
+ for (var i = start; i <= end; i++)
+ {
+ yield return ToIPAddress(i);
+
+ if (i == UInt32.MaxValue) break;
+ }
+ }
+
+ /// <summary>从单个地址构造区间</summary>
+ /// <param name="address">地址</param>
+ /// <returns>区间</returns>
+ public static NetworkRange FromSingle(IPAddress address) => new(address, address);
+
+ /// <summary>从CIDR构造区间,自动排除网络地址与广播地址</summary>
+ /// <param name="address">任意地址</param>
+ /// <param name="prefix">前缀长度</param>
+ /// <returns>区间</returns>
+ public static NetworkRange FromCidr(IPAddress address, Int32 prefix)
+ {
+ var ip = ToUInt32(address);
+ var mask = prefix == 0 ? 0U : UInt32.MaxValue << (32 - prefix);
+ var network = ip & mask;
+ var broadcast = network | ~mask;
+
+ var start = prefix >= 31 ? network : network + 1;
+ var end = prefix >= 31 ? broadcast : broadcast - 1;
+
+ return new NetworkRange(ToIPAddress(start), ToIPAddress(end));
+ }
+
+ private static UInt32 ToUInt32(IPAddress address)
+ {
+ var bytes = address.GetAddressBytes();
+ if (BitConverter.IsLittleEndian) Array.Reverse(bytes);
+ return BitConverter.ToUInt32(bytes, 0);
+ }
+
+ private static IPAddress ToIPAddress(UInt32 value)
+ {
+ var bytes = BitConverter.GetBytes(value);
+ if (BitConverter.IsLittleEndian) Array.Reverse(bytes);
+ return new IPAddress(bytes);
+ }
+}
diff --git a/Stardust.Tests/LanScanTests.cs b/Stardust.Tests/LanScanTests.cs
new file mode 100644
index 0000000..f5d7fd5
--- /dev/null
+++ b/Stardust.Tests/LanScanTests.cs
@@ -0,0 +1,174 @@
+using System.ComponentModel;
+using System.Net;
+using AgentExpansion;
+using NewLife.Log;
+using Xunit;
+
+namespace Stardust.Tests;
+
+/// <summary>局域网扫描地址解析测试</summary>
+/// <remarks>
+/// AgentExpansion 插件只扫描 RFC1918 局域网网段,链路本地 169.254.0.0/16 等地址必须排除,
+/// 否则无线直连/蓝牙/虚拟网卡的 /16 掩码会展开成六万多个地址,导致每小时一轮的全网段探测刷爆日志。
+/// 通过链接插件源码直测(AgentExpansion 为插件工程,不便于直接引用)。
+/// </remarks>
+public class LanScanTests
+{
+ [Theory]
+ [InlineData("10.0.0.1", true)]
+ [InlineData("10.255.255.254", true)]
+ [InlineData("172.15.255.255", false)]
+ [InlineData("172.16.0.1", true)]
+ [InlineData("172.31.255.254", true)]
+ [InlineData("172.32.0.1", false)]
+ [InlineData("192.168.1.1", true)]
+ [InlineData("192.169.1.1", false)]
+ [DisplayName("IsLanAddress_RFC1918边界")]
+ public void IsLanAddress_Rfc1918Boundary(String address, Boolean expected)
+ {
+ Assert.Equal(expected, LanScan.IsLanAddress(IPAddress.Parse(address)));
+ }
+
+ [Theory]
+ [InlineData("169.254.0.1")]
+ [InlineData("169.254.255.254")]
+ [InlineData("127.0.0.1")]
+ [InlineData("0.0.0.1")]
+ [InlineData("100.64.0.1")]
+ [InlineData("198.18.0.1")]
+ [InlineData("224.0.0.1")]
+ [InlineData("240.0.0.1")]
+ [InlineData("255.255.255.255")]
+ [InlineData("8.8.8.8")]
+ [DisplayName("IsLanAddress_非局域网地址被排除")]
+ public void IsLanAddress_NonLan_Excluded(String address)
+ {
+ Assert.False(LanScan.IsLanAddress(IPAddress.Parse(address)));
+ }
+
+ [Fact]
+ [DisplayName("IsLanAddress_空值与IPv6被排除")]
+ public void IsLanAddress_Null_Excluded()
+ {
+ Assert.False(LanScan.IsLanAddress(null));
+ Assert.False(LanScan.IsLanAddress(IPAddress.IPv6Loopback));
+ }
+
+ [Fact]
+ [DisplayName("ParseNetworks_空值_返回空集合")]
+ public void ParseNetworks_Empty_ReturnsEmpty()
+ {
+ Assert.Empty(LanScan.ParseNetworks(null, Logger.Null));
+ Assert.Empty(LanScan.ParseNetworks("", Logger.Null));
+ Assert.Empty(LanScan.ParseNetworks(" ", Logger.Null));
+ }
+
+ [Fact]
+ [DisplayName("ParseNetworks_非局域网网段被忽略")]
+ public void ParseNetworks_NonLan_Ignored()
+ {
+ var ranges = LanScan.ParseNetworks("169.254.0.0/16,0.0.0.0/0,8.8.8.8,169.254.*.*", Logger.Null);
+
+ Assert.Empty(ranges);
+ }
+
+ [Fact]
+ [DisplayName("ParseNetworks_混合配置_只保留局域网")]
+ public void ParseNetworks_Mixed_OnlyLanKept()
+ {
+ var ranges = LanScan.ParseNetworks("169.254.0.0/16,10.0.0.0/8,192.168.1.*", Logger.Null);
+
+ Assert.Equal(2, ranges.Count);
+ Assert.Equal(IPAddress.Parse("10.0.0.1"), ranges[0].Start);
+ Assert.Equal(IPAddress.Parse("10.255.255.254"), ranges[0].End);
+ Assert.Equal(IPAddress.Parse("192.168.1.1"), ranges[1].Start);
+ Assert.Equal(IPAddress.Parse("192.168.1.254"), ranges[1].End);
+ }
+
+ [Fact]
+ [DisplayName("ParseNetworks_尾部通配符_排除网络与广播地址")]
+ public void ParseNetworks_TrailingWildcard_ExcludesNetworkAndBroadcast()
+ {
+ var ranges = LanScan.ParseNetworks("192.168.*.*", Logger.Null);
+
+ var range = Assert.Single(ranges);
+ Assert.Equal(IPAddress.Parse("192.168.0.1"), range.Start);
+ Assert.Equal(IPAddress.Parse("192.168.255.254"), range.End);
+ }
+
+ [Fact]
+ [DisplayName("ParseNetworks_非连续通配符_保持原区间")]
+ public void ParseNetworks_NonContiguousWildcard_KeepsRawRange()
+ {
+ var ranges = LanScan.ParseNetworks("10.*.1.*", Logger.Null);
+
+ var range = Assert.Single(ranges);
+ Assert.Equal(IPAddress.Parse("10.0.1.0"), range.Start);
+ Assert.Equal(IPAddress.Parse("10.255.1.255"), range.End);
+ }
+
+ [Fact]
+ [DisplayName("ParseNetworks_单个地址_构成单点区间")]
+ public void ParseNetworks_SingleAddress()
+ {
+ var ranges = LanScan.ParseNetworks("192.168.1.10", Logger.Null);
+
+ var range = Assert.Single(ranges);
+ Assert.Equal(IPAddress.Parse("192.168.1.10"), range.Start);
+ Assert.Equal(IPAddress.Parse("192.168.1.10"), range.End);
+ }
+
+ [Fact]
+ [DisplayName("FromCidr_排除网络地址与广播地址")]
+ public void FromCidr_ExcludesNetworkAndBroadcast()
+ {
+ var range = NetworkRange.FromCidr(IPAddress.Parse("10.10.140.224"), 20);
+
+ Assert.Equal(IPAddress.Parse("10.10.128.1"), range.Start);
+ Assert.Equal(IPAddress.Parse("10.10.143.254"), range.End);
+ Assert.Equal(4094, range.GetAddresses().Count());
+ }
+
+ [Fact]
+ [DisplayName("Distinct_同网段重复_去重")]
+ public void Distinct_SameRange_Deduplicated()
+ {
+ var ranges = LanScan.ParseNetworks("192.168.1.0/24,192.168.1.1/24,192.168.2.0/24", Logger.Null);
+ Assert.Equal(3, ranges.Count);
+
+ var distinct = LanScan.Distinct(ranges);
+ Assert.Equal(2, distinct.Count);
+ }
+
+ [Fact]
+ [DisplayName("GetTargets_超过上限_按上限截断")]
+ public void GetTargets_OverLimit_Truncated()
+ {
+ var ranges = LanScan.ParseNetworks("192.168.1.0/24", Logger.Null);
+
+ var all = LanScan.GetTargets(ranges, 0, Logger.Null).ToList();
+ Assert.Equal(254, all.Count);
+
+ var capped = LanScan.GetTargets(ranges, 5, Logger.Null).ToList();
+ Assert.Equal(5, capped.Count);
+ Assert.Equal(IPAddress.Parse("192.168.1.1"), capped[0]);
+ Assert.Equal(IPAddress.Parse("192.168.1.5"), capped[4]);
+ }
+
+ [Fact]
+ [DisplayName("GetLocalRanges_不含链路本地等非局域网网段")]
+ public void GetLocalRanges_OnlyLan()
+ {
+ var ranges = LanScan.GetLocalRanges();
+
+ // 本机网卡解析出的网段必须全部是局域网,绝不能包含 169.254.0.0/16
+ foreach (var range in ranges)
+ {
+ Assert.True(LanScan.IsLanAddress(range.Start), $"网段 {range.Start} 不是局域网地址");
+ Assert.True(LanScan.IsLanAddress(range.End), $"网段 {range.End} 不是局域网地址");
+ }
+
+ var huge = ranges.Where(e => e.Start.GetAddressBytes()[0] == 169).ToList();
+ Assert.Empty(huge);
+ }
+}
diff --git a/Stardust.Tests/Stardust.Tests.csproj b/Stardust.Tests/Stardust.Tests.csproj
index bc1a8dc..ec41919 100644
--- a/Stardust.Tests/Stardust.Tests.csproj
+++ b/Stardust.Tests/Stardust.Tests.csproj
@@ -41,4 +41,9 @@
<Compile Include="..\StarAgent\WebPanel\WebBindHelper.cs" Link="WebPanel\WebBindHelper.cs" />
</ItemGroup>
+ <!-- AgentExpansion 为插件工程,链接其局域网地址解析源码直接测试 -->
+ <ItemGroup>
+ <Compile Include="..\Plugins\AgentExpansion\LanScan.cs" Link="AgentExpansion\LanScan.cs" />
+ </ItemGroup>
+
</Project>