feat: detect an agent's public IPv4 and IPv6 addresses automatically, manual values still win [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
ci/woodpecker/push/main-amd64-build Pipeline was successful
This commit is contained in:
1 parent
9966ea5d63
commit
f03988d68b
26 files changed
+517
-33
No files matched your search
@@ -0,0 +1,106 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Net.Sockets;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Measurement;
|
||||
|
||||
/// <summary>
|
||||
/// 本机的公网地址:Agent 注册之后在 hello 里上报,面板用它自动填 <c>public_host</c> 和 <c>ipv6_host</c>,不需要管理员手填。
|
||||
/// 只列出「别人能拨进来」的地址:IPv4 要排除私网、CGNAT、回环、链路本地;IPv6 只要全球单播(2000::/3),
|
||||
/// 并且排除临时地址、tentative、deprecated 和文档/6to4/Teredo 段。读不到网卡信息时返回空,面板退回到管理员手填或观察到的来源地址。
|
||||
/// </summary>
|
||||
public static class LocalAddresses
|
||||
{
|
||||
public static IReadOnlyList<string> Sample()
|
||||
{
|
||||
var result = new List<string>();
|
||||
HashSet<string> unusable = OperatingSystem.IsLinux() ? UnusableIpv6(Read("/proc/net/if_inet6")) : [];
|
||||
try
|
||||
{
|
||||
foreach (NetworkInterface adapter in NetworkInterface.GetAllNetworkInterfaces())
|
||||
{
|
||||
if (adapter.OperationalStatus != OperationalStatus.Up || adapter.NetworkInterfaceType is NetworkInterfaceType.Loopback or NetworkInterfaceType.Tunnel)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (UnicastIPAddressInformation unicast in adapter.GetIPProperties().UnicastAddresses)
|
||||
{
|
||||
IPAddress address = unicast.Address;
|
||||
bool usable = address.AddressFamily == AddressFamily.InterNetwork
|
||||
? PublicAddress.IsPublicIpv4(address)
|
||||
: PublicAddress.IsGlobalIpv6(address) && !unusable.Contains(address.ToString()) && !IsTemporary(unicast);
|
||||
if (usable && !result.Contains(address.ToString()))
|
||||
{
|
||||
result.Add(address.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is NetworkInformationException or PlatformNotSupportedException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
// 稳定顺序:同一台机器每次上报的顺序一样,面板不会因为顺序变化而改地址
|
||||
result.Sort(StringComparer.Ordinal);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Linux 的 <c>/proc/net/if_inet6</c>:临时(0x01)、dadfailed(0x08)、deprecated(0x20)、tentative(0x40)的地址不能当作对外地址。</summary>
|
||||
public static HashSet<string> UnusableIpv6(string? procNetIfInet6)
|
||||
{
|
||||
var result = new HashSet<string>(StringComparer.Ordinal);
|
||||
if (procNetIfInet6 is null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (string line in procNetIfInet6.Split('\n'))
|
||||
{
|
||||
string[] fields = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (fields.Length != 6 || fields[0].Length != 32 || !uint.TryParse(fields[4], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out uint flags) || (flags & 0x69) == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var bytes = new byte[16];
|
||||
for (int i = 0; i < 16; i++)
|
||||
{
|
||||
bytes[i] = byte.Parse(fields[0].AsSpan(i * 2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
result.Add(new IPAddress(bytes).ToString());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Windows 和 macOS:随机后缀就是隐私(临时)地址
|
||||
private static bool IsTemporary(UnicastIPAddressInformation unicast)
|
||||
{
|
||||
try
|
||||
{
|
||||
return OperatingSystem.IsWindows()
|
||||
&& (unicast.SuffixOrigin == SuffixOrigin.Random || unicast.DuplicateAddressDetectionState is DuplicateAddressDetectionState.Tentative or DuplicateAddressDetectionState.Duplicate);
|
||||
}
|
||||
catch (PlatformNotSupportedException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? Read(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return File.Exists(path) ? File.ReadAllText(path) : null;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace MoShiro.Protocol;
|
||||
|
||||
/// <summary>哪些地址算「公网上能拨进来的地址」:Agent 上报本机地址和面板自动识别共用同一套判断。</summary>
|
||||
public static class PublicAddress
|
||||
{
|
||||
public static bool IsPublicIpv4(IPAddress address)
|
||||
{
|
||||
if (address.AddressFamily != AddressFamily.InterNetwork)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] b = address.GetAddressBytes();
|
||||
return !(b[0] is 0 or 10 or 127 or >= 224
|
||||
|| (b[0] == 100 && b[1] is >= 64 and <= 127)
|
||||
|| (b[0] == 169 && b[1] == 254)
|
||||
|| (b[0] == 172 && b[1] is >= 16 and <= 31)
|
||||
|| (b[0] == 192 && b[1] == 168)
|
||||
|| (b[0] == 192 && b[1] == 0 && b[2] is 0 or 2)
|
||||
|| (b[0] == 198 && b[1] is 18 or 19)
|
||||
|| (b[0] == 198 && b[1] == 51 && b[2] == 100)
|
||||
|| (b[0] == 203 && b[1] == 0 && b[2] == 113));
|
||||
}
|
||||
|
||||
public static bool IsGlobalIpv6(IPAddress address)
|
||||
{
|
||||
if (address.AddressFamily != AddressFamily.InterNetworkV6 || address.IsIPv4MappedToIPv6)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] b = address.GetAddressBytes();
|
||||
if ((b[0] & 0xE0) != 0x20)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2001:db8::/32 文档、2002::/16 6to4、2001::/32 Teredo
|
||||
return !((b[0] == 0x20 && b[1] == 0x01 && b[2] == 0x0d && b[3] == 0xb8)
|
||||
|| (b[0] == 0x20 && b[1] == 0x02)
|
||||
|| (b[0] == 0x20 && b[1] == 0x01 && b[2] == 0 && b[3] == 0));
|
||||
}
|
||||
}
|
||||
@@ -326,6 +326,10 @@ public sealed class AgentHelloMeta
|
||||
[JsonPropertyName("tls_spki")]
|
||||
public string? TlsSpki { get; set; }
|
||||
|
||||
/// <summary>本机的公网地址(IPv4 公网地址、IPv6 全球单播地址):面板据此自动填 <c>public_host</c> 和 <c>ipv6_host</c>,管理员手填的优先。</summary>
|
||||
[JsonPropertyName("addresses")]
|
||||
public List<string>? Addresses { get; set; }
|
||||
|
||||
[JsonPropertyName("probe_tcp_port")]
|
||||
public int? ProbeTcpPort { get; set; }
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using MoShiro.Measurement;
|
||||
|
||||
namespace MoShiro.Measurement.Tests;
|
||||
|
||||
public sealed class LocalAddressesTests
|
||||
{
|
||||
[Fact]
|
||||
public void Linux_TemporaryTentativeDeprecatedAndFailedAddressesAreNotUsable()
|
||||
{
|
||||
const string text =
|
||||
"2409c0d0000000000000000000000001 02 40 00 80 eth0\n" + // 稳定地址:可用
|
||||
"2409c0d0000000000000000000000002 02 40 00 01 eth0\n" + // 临时(隐私)地址
|
||||
"2409c0d0000000000000000000000003 02 40 00 40 eth0\n" + // tentative
|
||||
"2409c0d0000000000000000000000004 02 40 00 20 eth0\n" + // deprecated
|
||||
"2409c0d0000000000000000000000005 02 40 00 08 eth0\n"; // dad 失败
|
||||
HashSet<string> unusable = LocalAddresses.UnusableIpv6(text);
|
||||
Assert.DoesNotContain("2409:c0d0::1", unusable);
|
||||
Assert.Contains("2409:c0d0::2", unusable);
|
||||
Assert.Contains("2409:c0d0::3", unusable);
|
||||
Assert.Contains("2409:c0d0::4", unusable);
|
||||
Assert.Contains("2409:c0d0::5", unusable);
|
||||
Assert.Empty(LocalAddresses.UnusableIpv6(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sample_NeverReturnsLoopbackPrivateOrLinkLocalAddresses()
|
||||
{
|
||||
foreach (string text in LocalAddresses.Sample())
|
||||
{
|
||||
var address = System.Net.IPAddress.Parse(text);
|
||||
Assert.True(address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork
|
||||
? MoShiro.Protocol.PublicAddress.IsPublicIpv4(address)
|
||||
: MoShiro.Protocol.PublicAddress.IsGlobalIpv6(address));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Net;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Protocol.Tests;
|
||||
|
||||
public sealed class PublicAddressTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("8.8.8.8", true)]
|
||||
[InlineData("23.176.40.145", true)]
|
||||
[InlineData("10.1.2.3", false)]
|
||||
[InlineData("172.16.0.1", false)]
|
||||
[InlineData("172.32.0.1", true)]
|
||||
[InlineData("192.168.2.162", false)]
|
||||
[InlineData("100.64.0.1", false)]
|
||||
[InlineData("169.254.1.1", false)]
|
||||
[InlineData("127.0.0.1", false)]
|
||||
[InlineData("203.0.113.10", false)]
|
||||
[InlineData("224.0.0.1", false)]
|
||||
public void Ipv4_OnlyPublicAddressesCountAsReachable(string text, bool expected) =>
|
||||
Assert.Equal(expected, PublicAddress.IsPublicIpv4(IPAddress.Parse(text)));
|
||||
|
||||
[Theory]
|
||||
[InlineData("2409:8a55:6c45:62d1::69d", true)]
|
||||
[InlineData("2606:4700:4700::1111", true)]
|
||||
[InlineData("fd00::1", false)]
|
||||
[InlineData("fe80::1", false)]
|
||||
[InlineData("::1", false)]
|
||||
[InlineData("2001:db8::1", false)]
|
||||
[InlineData("2002:c000:204::1", false)]
|
||||
[InlineData("2001:0:4136:e378::1", false)]
|
||||
[InlineData("::ffff:8.8.8.8", false)]
|
||||
public void Ipv6_OnlyGlobalUnicastCountsAsReachable(string text, bool expected) =>
|
||||
Assert.Equal(expected, PublicAddress.IsGlobalIpv6(IPAddress.Parse(text)));
|
||||
}
|
||||
Reference in new issue
Block a user