NewLife/Stardust

fix: 代理扩展插件只扫描局域网网段

- 新增 LanScan 统一解析局域网地址,仅放行 RFC1918,排除 169.254.0.0/16 等链路本地、回环、多播与保留地址
- 手工配置的 Networks 走同一套过滤,避免误配后全网段扫描
- 多网卡同网段去重,尾部通配符折算 CIDR 以排除网络地址与广播地址,超过 MaxHosts 时给出告警
- 抽取纯逻辑到独立文件并补充单元测试
智能大石头 authored at 2026-09-29 10:15:31
9f7a6c5
Tree
1 Parent(s) a316df8
Summary: 5 changed files with 488 additions and 193 deletions.
Modified +5 -191
Modified +6 -2
Added +298 -0
Added +174 -0
Modified +5 -0
Modified +5 -191
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);
     }
 }
 
Modified +6 -2
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>
Added +298 -0
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);
+    }
+}
Added +174 -0
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);
+    }
+}
Modified +5 -0
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>