Compare commits

...
Author SHA1 Message Date
yingpanwang 2bd7276f6a 🚧 测试请求指定网页连接 2024-08-16 11:20:06 +08:00
wangyingpan 62fb8038be 🚧 网络环境检测服务 2024-07-26 14:45:01 +08:00
yingpanwang c3279e78c2 ✨ 添加网络测试服务 NetworkTestService (#3401)
* ✨ 添加网络测试服务 NetworkTestService

* 🚧 清理日志

* ✨ 添加dns 测试

* ⚡️ 添加 DNS Over Https 测试方式, 调整 DNS解析格式
2024-07-18 11:25:28 +08:00
lxy829 7c8f8448af 🎨 cs:go vac repair 2024-07-18 10:18:48 +08:00
RMBGAME 66fdfb728e ⬆️ Avalonia 11.1.0-rc2 2024-07-17 06:01:11 +08:00
RMBGAME 9261344a3c 🐛 修复高dpi下窗口大小及位置变化问题 2024-07-17 05:47:00 +08:00
RMBGAME 95190efa5c 🐛 doh默认地址调整
加速项目默认选中问题修复
2024-07-12 19:36:04 +08:00
ff m dd71972adc ✨ macOS 支持从 dock 栏 打开窗口显示 2024-07-11 15:43:34 +08:00
wangyingpan 6c1e6d40e9 🎨 优化令牌重置 client 代码 2024-07-02 18:07:34 +08:00
wangyingpan 187c7f3b58 🐛 刷新 access_token 时重置对应 client 状态 2024-07-02 17:16:34 +08:00
john.jiang 4e3c86d009 🎨 挂卡剩最后一个游戏不自动切换 2024-07-02 13:40:10 +08:00
26 changed files with 1805 additions and 31 deletions
+2 -2
View File
@@ -964,8 +964,8 @@
},
{
"TypeName": "bool",
"PropertyName": "UseDoh2",
"DefaultValue": "false",
"PropertyName": "UseDoh",
"DefaultValue": "true",
"DefaultValueIsConst": true,
"Summary": "启用 DNS over HTTPS",
"IsRegionOrEndregion": null,
+41 -1
View File
@@ -63,10 +63,50 @@ partial class App
#else
s.HasTrayIcon ? ShutdownMode.OnExplicitShutdown : ShutdownMode.OnMainWindowClose;
#endif
#if MACOS
if (Current!.TryGetFeature<IActivatableLifetime>() is { } activatableLifetime)
{
activatableLifetime.Activated += ActivatableLifetimeOnActivated;
// macOS 中 右键隐藏 会触发目前不需要使用
//activatableLifetime.Deactivated += ActivatableLifetimeOnDeactivated;
}
#endif
}
}
#if MACOS
/// <summary>
/// 从 macOS Dock 栏恢复窗口显示
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void ActivatableLifetimeOnActivated(object? sender, ActivatedEventArgs e)
{
switch (e.Kind)
{
case ActivationKind.Background:
case ActivationKind.Reopen:
RestoreMainWindow();
break;
}
}
///// <summary>
///// macOS 中 右键隐藏 会触发目前不需要使用
///// </summary>
///// <param name="sender"></param>
///// <param name="e"></param>
//private void ActivatableLifetimeOnDeactivated(object? sender, ActivatedEventArgs e)
//{
// switch (e.Kind)
// {
// case ActivationKind.Background:
// Console.WriteLine($"ActivatableLifetimeOnDeactivated {e.Kind}");
// break;
// }
//}
#endif
public void UpdateMenuItems()
{
MainThread2.BeginInvokeOnMainThread(() =>
@@ -14,6 +14,8 @@ public class ReactiveAppWindow<TViewModel> : AppWindow, IViewFor<TViewModel>, IV
public static readonly StyledProperty<bool> IsSaveWindowSizeProperty = AvaloniaProperty
.Register<ReactiveAppWindow<TViewModel>, bool>(nameof(IsSaveWindowSize), true);
private bool _isInitialize;
/// <summary>
/// Initializes a new instance of the <see cref="ReactiveAppWindow{TViewModel}"/> class.
/// </summary>
@@ -127,27 +129,32 @@ public class ReactiveAppWindow<TViewModel> : AppWindow, IViewFor<TViewModel>, IV
{
var primaryScreenBounds = screen.Bounds;
if (CanResize && !IsVisible)
if (CanResize && !_isInitialize && !IsVisible)
{
WindowStartupLocation = WindowStartupLocation.Manual;
var scalingOffset = 31 - ((screen.Scaling - 1) / 0.5); // appwindow偏移量
if (SizePosition.Width > 0 &&
primaryScreenBounds.Width >= SizePosition.Width)
Width = SizePosition.Width;
if (SizePosition.Height > 0 &&
primaryScreenBounds.Height >= SizePosition.Height)
Height = SizePosition.Height - 31; // appwindow偏移量
Height = SizePosition.Height - scalingOffset;
if (SizePosition.X > 0 && SizePosition.Y > 0)
{
var leftTopPoint = new PixelPoint(SizePosition.X, SizePosition.Y + 31);
var offsetY = (int)Math.Round(scalingOffset * screen.Scaling);
//var offsetY = scalingOffset;
var leftTopPoint = new PixelPoint(SizePosition.X, SizePosition.Y + offsetY);
var rightBottomPoint = new PixelPoint(SizePosition.X + (int)Width, SizePosition.Y + (int)Height);
if (primaryScreenBounds.Contains(leftTopPoint) &&
primaryScreenBounds.Contains(rightBottomPoint))
{
Position = leftTopPoint;
WindowStartupLocation = WindowStartupLocation.Manual;
}
}
_isInitialize = true;
}
}
}
@@ -12,8 +12,8 @@ public partial interface IDnsAnalysisService // 公共常量定义
const string DNS_Ali_DohAddres2 = "https://223.6.6.6/dns-query";
const string DNS_Ali_DohAddres3 = "https://223.5.5.5/dns-query";
const string Dnspod_DohAddres = "https://1.12.12.12";
const string Dnspod_DohAddres2 = "https://doh.pub";
const string Dnspod_DohAddres = "https://1.12.12.12/dns-query";
const string Dnspod_DohAddres2 = "https://doh.pub/dns-query";
const string Dnspod_DohAddres3 = "https://120.53.53.53/dns-query";
const string Google_DohAddres = "https://dns.google";
@@ -0,0 +1,22 @@
namespace BD.WTTS.Enums;
/// <summary>
/// 网络环境检查步骤
/// </summary>
public enum NetworkEnvCheckStep
{
/// <summary>
/// Hosts文件检查
/// </summary>
HostsFileCheck = 1,
/// <summary>
/// 网卡检查
/// </summary>
NetworkInterfaceCheck = 2,
/// <summary>
/// LSP检查
/// </summary>
LSPCheck = 3
}
@@ -0,0 +1,7 @@
namespace BD.WTTS.Enums;
public enum TransportProtocol
{
Tcp,
Udp,
}
@@ -0,0 +1,322 @@
using System.Net.NetworkInformation;
namespace BD.WTTS.Services.Implementation;
public class NetworkEnvCheckService
{
const string HOST_FILE_PATH =
#if WINDOWS
@"C:\Windows\System32\drivers\etc\hosts";
#else
"/etc/hosts";
#endif
public async Task CheckNetworkEnvAsync(IProgress<(NetworkEnvCheckStep Step, bool HasError, string Msg)> progress, CancellationToken cancellationToken = default)
{
await Parallel.ForAsync(
(int)NetworkEnvCheckStep.HostsFileCheck,
(int)NetworkEnvCheckStep.LSPCheck + 1,
cancellationToken,
async (step, ct) =>
{
(bool pass, string msg) = step switch
{
(int)NetworkEnvCheckStep.HostsFileCheck => await CheckHostsAsync(),
(int)NetworkEnvCheckStep.NetworkInterfaceCheck => CheckNetworkInterfaces(),
(int)NetworkEnvCheckStep.LSPCheck =>
#if WINDOWS
CheckLSP(HandleWSAProtocolInfo),
#else
(true, string.Empty),
#endif
_ => (true, string.Empty)
};
progress.Report(((NetworkEnvCheckStep)step, pass, msg));
}
).ConfigureAwait(false);
}
private static Task<(bool Pass, string Msg)> CheckHostsAsync()
{
bool hostFileExist = File.Exists(HOST_FILE_PATH);
if (!hostFileExist)
return Task.FromResult((false, "未找到 hosts 文件"));
return Task.FromResult((hostFileExist, string.Empty));
}
private static (bool Pass, string ErrorMsg) CheckNetworkInterfaces(NetworkInterface[]? interfaces = null)
{
interfaces ??= NetworkInterface.GetAllNetworkInterfaces();
if (interfaces == null || interfaces.Length == 0)
return (false, "未获取到网络适配器信息!");
// 如何判断虚拟网卡?
var useableNetworkInterfaces =
interfaces.Where(x =>
!x.Description.Contains("Virtual", StringComparison.OrdinalIgnoreCase) &&
(x.NetworkInterfaceType == NetworkInterfaceType.Ethernet || x.NetworkInterfaceType == NetworkInterfaceType.Wireless80211));
if (!useableNetworkInterfaces.Any())
return (false, "未获取到可用的以太网,无线网络适配器");
var checkResults =
useableNetworkInterfaces.Select(x => new
{
Source = x,
Result = CheckNetworkInterface(x)
});
if (checkResults.Any(x => x.Result.Pass))
return (true, string.Empty);
var firstError = checkResults.First(x => !x.Result.Pass);
return (firstError.Result.Pass, $"{firstError.Source.Name}|{firstError.Source.Description} {firstError.Result.ErrorMsg}");
}
private static (bool Pass, string ErrorMsg) CheckNetworkInterface(NetworkInterface @interface)
{
var name = @interface.Name;
var desc = @interface.Description;
var ipProps = @interface.GetIPProperties();
if (@interface.OperationalStatus != OperationalStatus.Up)
return (false, "未启用网卡");
if (ipProps == null)
return (false, $"获取网络信息失败!");
bool hasIPv4 = ipProps.UnicastAddresses
.Any(x => x.Address.AddressFamily == AddressFamily.InterNetwork);
bool hasIPv6 = ipProps.UnicastAddresses
.Any(x => x.Address.AddressFamily == AddressFamily.InterNetworkV6);
if (!hasIPv4 && !hasIPv6)
return (false, "网络适配器未启用TCP/IP服务");
// 如何判断虚拟网卡?
var dns = ipProps.DnsAddresses;
#if WINDOWS
var isDHCPEnable = ipProps.GetIPv4Properties().IsDhcpEnabled;
#endif
return (true, string.Empty);
}
private static (bool Pass, string ErrorMsg) CheckLSP(CheckLSPPredicate predicate)
{
int bufferSize = 0;
int error = 0;
// 获取 buffer 的大小
_ = WSCEnumProtocols(null, IntPtr.Zero, ref bufferSize, ref error);
IntPtr protocolBuffer = Marshal.AllocHGlobal(bufferSize);
try
{
int result = WSCEnumProtocols(null, protocolBuffer, ref bufferSize, ref error);
if (result == -1)
{
return (false, "获取LSP信息失败!");
}
int structSize = Marshal.SizeOf(typeof(WSAPROTOCOL_INFO));
int count = bufferSize / structSize;
for (int i = 0; i < count; i++)
{
IntPtr item = new(protocolBuffer + (i * structSize));
WSAPROTOCOL_INFO protocolInfo = Marshal.PtrToStructure<WSAPROTOCOL_INFO>(item);
#if DEBUG
// Console.WriteLine($"Protocol: {protocolInfo.szProtocol}");
// Console.WriteLine($"Address Family: {protocolInfo.iAddressFamily}");
// Console.WriteLine($"Socket Type: {protocolInfo.iSocketType}");
// Console.WriteLine($"Protocol: {protocolInfo.iProtocol}");
// Console.WriteLine($"Provider ID: {protocolInfo.ProviderId}");
// Console.WriteLine();
#endif
var handle = predicate(in protocolInfo);
if (!handle)
return (false, "LSP响应异常!");
}
return (true, string.Empty);
}
finally
{
Marshal.FreeHGlobal(protocolBuffer);
}
}
#region P/Invoke
/// <summary>
/// 处理判断 协议信息是否有效
/// </summary>
/// <param name="info"></param>
/// <returns></returns>
static bool HandleWSAProtocolInfo(in WSAPROTOCOL_INFO info)
{
const int MAX = 255;
StringBuilder pathBuilder = new StringBuilder(MAX);
int pathLength = MAX;
Guid queryProviderId = info.ProviderId;
int result = WSCGetProviderPath(ref queryProviderId, pathBuilder, ref pathLength, out int errorCode);
if (result != 0)
{
// 获取失败
}
// path 可能包含系统环境变量例如 %SystemRoot%
// 是否需要解析
string path = pathBuilder.ToString();
Console.WriteLine("path:" + path);
return true;
}
[DllImport("Ws2_32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
static extern int WSCEnumProtocols(
int[]? lpiProtocols,
IntPtr lpProtocolBuffer,
ref int lpdwBufferLength,
ref int lpErrno);
[DllImport("Ws2_32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
static extern int WSCGetProviderPath(
[In] ref Guid providerId,
[Out][MarshalAs(UnmanagedType.LPWStr)] StringBuilder providerDllPath,
[In, Out] ref int providerDllPathLength,
[Out] out int lpErrno
);
private delegate bool CheckLSPPredicate(in WSAPROTOCOL_INFO protocolInfo);
/// <summary>
/// WSAPROTOCOL_INFO 结构用于存储或检索给定协议的完整信息。
/// <see cref="https://learn.microsoft.com/zh-cn/windows/win32/api/winsock2/ns-winsock2-wsaprotocol_infoa"/>
/// </summary>
[SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "<挂起>")]
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct WSAPROTOCOL_INFO
{
/// <summary>
/// 描述协议提供的服务的位掩码
/// </summary>
public uint dwServiceFlags1;
/// <summary>
/// 保留给其他协议属性定义。
/// </summary>
public uint dwServiceFlags2;
/// <summary>
/// 保留给其他协议属性定义。
/// </summary>
public uint dwServiceFlags3;
/// <summary>
/// 保留给其他协议属性定义。
/// </summary>
public uint dwServiceFlags4;
/// <summary>
/// 一组标志,提供有关如何在 Winsock 目录中表示此协议的信息
/// </summary>
public uint dwProviderFlags;
/// <summary>
/// 服务提供商供应商) 分配给提供程序的 GUID
/// </summary>
public Guid ProviderId;
/// <summary>
/// 由 WS2_32.DLL 为每个WSAPROTOCOL_INFO结构分配的唯 一 标识符。
/// </summary>
public uint dwCatalogEntryId;
public WSAPROTOCOLCHAIN ProtocolChain;
/// <summary>
/// 协议版本标识符。
/// </summary>
public int iVersion;
/// <summary>
/// 要作为地址系列参数传递给 套接字 或 WSASocket 函数的
/// </summary>
public int iAddressFamily;
/// <summary>
/// 最大地址长度(以字节为单位)。
/// </summary>
public int iMaxSockAddr;
/// <summary>
/// 最小地址长度(以字节为单位)。
/// </summary>
public int iMinSockAddr;
public int iSocketType;
public int iProtocol;
public int iProtocolMaxOffset;
/// <summary>
/// 目前,这些值是 BIGENDIAN 和 LITTLEENDIAN) (清单常量,分别指示值为 0 和 1 的 big-endian 或 little-endian
/// </summary>
public int iNetworkByteOrder;
public int iSecurityScheme;
/// <summary>
/// 协议支持的最大消息大小(以字节为单位)
/// </summary>
public uint dwMessageSize;
public uint dwProviderReserved;
/// <summary>
/// 一个字符数组,其中包含标识协议的可读名称,例如“MSAFD Tcpip [UDP/IP]”。 允许的最大字符数为 WSAPROTOCOL_LEN,定义为 255。
/// </summary>
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
public string szProtocol;
}
/// <summary>
/// WSAPROTOCOLCHAIN 结构包含构成协议链的目录条目标识符的计数列表。
/// <see cref="https://learn.microsoft.com/zh-cn/windows/win32/api/winsock2/ns-winsock2-wsaprotocolchain"/>
/// </summary>
[StructLayout(LayoutKind.Sequential)]
private struct WSAPROTOCOLCHAIN
{
/// <summary>
/// 链的长度(以字节为单位)
/// 将 ChainLen 设置为零表示分层协议
/// 将 ChainLen 设置为 1 指示基本协议
/// 将 ChainLen 设置为大于 1 表示协议链
/// ChainEntries[MAX_PROTOCOL_CHAIN]
/// </summary>
public int ChainLen;
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 7)]
public uint[] ChainEntries;
}
#endregion P/Invoke
}
@@ -0,0 +1,951 @@
using STUN.Client;
using STUN.StunResult;
using System.Net.NetworkInformation;
namespace BD.WTTS.Services.Implementation;
internal partial class NetworkTestService : INetworkTestService
{
#region 常量
// 默认测试地址
const string DEFAULT_TESTSERVER_HOSTNAME = "stun.syncthing.net";
const int DEFAULT_PORT = 3478;
const int DEFAULT_TLSPORT = 5349;
// 默认端口号
const int DEFAULT_TESTSTUN3489_PORT = DEFAULT_PORT;
// 默认端口号
const int DEFAULT_TESTSTUN5389_PORT = DEFAULT_PORT;
#endregion 常量
public NetworkTestService()
{
}
#region Ping
/// <summary>
/// 将 Internet 控制消息协议 (ICMP) 回显请求消息发送到远程计算机,并等待来自该计算机的 ICMP 回送答复消息。 有关 ICMP 消息的详细说明,请参阅 RFC 792,可从 https://www.ietf.org获取。
/// </summary>
/// <param name="testHostNameOrAddress"></param>
/// <param name="timeout"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public async Task<PingReply> TestPingAsync(string testHostNameOrAddress, TimeSpan timeout, CancellationToken cancellationToken = default)
{
using Ping p = new Ping();
var reply = await p.SendPingAsync(
testHostNameOrAddress,
timeout,
cancellationToken: cancellationToken
);
return reply;
}
#endregion Ping
#region Open/Upload/Download Speed Test
public async Task<(bool success, long? delayMs)> TestOpenUrlAsync(string url, Func<HttpClient>? httpClientFunc = null, CancellationToken cancellationToken = default)
{
using var testClient = httpClientFunc?.Invoke() ?? new HttpClient();
Stopwatch watch = Stopwatch.StartNew();
try
{
var resp = await testClient.GetAsync(url, HttpCompletionOption.ResponseContentRead, cancellationToken);
watch.Stop();
resp.EnsureSuccessStatusCode();
return (true, watch.ElapsedMilliseconds);
}
catch (Exception ex)
{
Log.Error(nameof(NetworkTestService), ex, "测试打开 URL 异常");
return (false, default);
}
}
public async Task<(bool success, double? rate)> TestUploadSpeedAsync(
string uploadServerUrl,
byte[] uploadBytes,
CancellationToken cancellationToken = default
)
{
using HttpClient testClient = new HttpClient();
return await TestUploadSpeedCoreAsync(
() => testClient,
BuildUploadContent,
uploadServerUrl,
uploadBytes,
cancellationToken
);
static MultipartFormDataContent BuildUploadContent(byte[] data)
{
MultipartFormDataContent content = new MultipartFormDataContent();
content.Add(new ByteArrayContent(data));
return content;
}
}
public async Task<(bool success, double? rate)> TestDownloadSpeedAsync(string downloadUrl, CancellationToken cancellationToken = default)
{
using HttpClient testClient = new HttpClient();
return await TestDownloadSpeedCoreAsync(downloadUrl, () => testClient, cancellationToken);
}
/// <summary>
/// 测试上传速度
/// </summary>
/// <param name="httpClientFunc">自定义用于发送 http 请求的 client </param>
/// <param name="uploadContentFunc">自定义上传内容类型</param>
/// <param name="uploadServerUrl"></param>
/// <param name="uploadBytes"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
static async Task<(bool success, double? rate)> TestUploadSpeedCoreAsync(
Func<HttpClient> httpClientFunc,
Func<byte[], HttpContent> uploadContentFunc,
string uploadServerUrl,
byte[] uploadBytes,
CancellationToken cancellationToken = default
)
{
if (string.IsNullOrWhiteSpace(uploadServerUrl))
return (false, null);
var uploadContent = uploadContentFunc(uploadBytes);
try
{
var httpClient = httpClientFunc();
Stopwatch watch = Stopwatch.StartNew();
var resp = await httpClient.PostAsync(uploadServerUrl, uploadContent, cancellationToken);
resp.EnsureSuccessStatusCode();
watch.Stop();
return (true, GetRate(uploadBytes.Length, watch.Elapsed.TotalSeconds));
}
catch (Exception ex)
{
Log.Error(nameof(NetworkTestService), ex, "测试上传异常");
}
return (false, null);
}
/// <summary>
/// 测试下载速度
/// </summary>
/// <param name="url"></param>
/// <param name="httpClientFunc"></param>
/// <returns></returns>
static async Task<(bool success, double? rate)> TestDownloadSpeedCoreAsync(string url, Func<HttpClient>? httpClientFunc = null, CancellationToken cancellationToken = default)
{
var httpClient = httpClientFunc?.Invoke() ?? new HttpClient();
try
{
Stopwatch watch = Stopwatch.StartNew();
var resp = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
resp.EnsureSuccessStatusCode();
var bytesArray = await resp.Content.ReadAsByteArrayAsync(cancellationToken);
watch.Stop();
return (true, GetRate(bytesArray.Length, watch.Elapsed.TotalSeconds));
}
catch (Exception ex)
{
Log.Error(nameof(NetworkTestService), ex, "测试下载异常");
return (false, null);
}
finally
{
if (httpClientFunc == null)
httpClient.Dispose();
}
}
static double GetRate(long byteArrayLength, double seconds)
{
if (seconds == 0)
throw new ArgumentException("时间不能小于0", nameof(seconds));
long bits = byteArrayLength * 8;
double megabits = bits / 1_000_000.0; // 将比特转换为兆比特
return megabits / seconds; // 返回速率,单位是 Mbps
}
#endregion Upload/Download Speed Test
#region STUN 测试
#region RFC3489
StunClient3489? _stunClient;
/// <summary>
/// 测试 STUN 客户端 基于 RFC3489
/// </summary>
/// <param name="testServerHostName">测试服务地址</param>
/// <param name="testServerPort">测试服务端口</param>
/// <param name="localIPEndPoint">本机 IP </param>
/// <param name="force">强制刷新</param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public async Task<ClassicStunResult?> TestStunClient3489Async(
string? testServerHostName = DEFAULT_TESTSERVER_HOSTNAME,
int? testServerPort = DEFAULT_TESTSTUN3489_PORT,
IPEndPoint? localIPEndPoint = null,
bool force = false,
CancellationToken cancellationToken = default
)
{
testServerHostName ??= DEFAULT_TESTSERVER_HOSTNAME;
testServerPort ??= DEFAULT_TESTSTUN3489_PORT;
if (_stunClient == null || force)
_stunClient = await GetStunClient3489Async(testServerHostName, testServerPort.Value, localIPEndPoint);
try
{
await _stunClient.QueryAsync(cancellationToken);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
Log.Error(nameof(NetworkTestService), ex, "TestStunClient3489Async 异常");
return null;
}
return _stunClient.State;
}
private async ValueTask<StunClient3489> GetStunClient3489Async(string serverHostName, int port, IPEndPoint? localIPEndPoint = null)
{
IPEndPoint serverEndpoint = await GetServerIPEndPointAsync(serverHostName, port);
IPEndPoint localEndpoint = localIPEndPoint ?? new IPEndPoint(IPAddress.Any, default);
var client = new StunClient3489(
serverEndpoint,
localEndpoint
);
return client;
}
private async Task<IPEndPoint> GetServerIPEndPointAsync(string hostNameOrAddress, int port)
{
var serverIps = await Dns.GetHostAddressesAsync(hostNameOrAddress);
IPEndPoint serverEndpoint = new IPEndPoint(serverIps.First(), port);
return serverEndpoint;
}
#endregion RFC3489
#region RFC5389
/// <summary>
/// 测试 STUN 客户端 基于 RFC5389
/// </summary>
/// <param name="protocol"></param>
/// <param name="testServerHostName"></param>
/// <param name="testServerPort"></param>
/// <param name="localIPEndPoint"></param>
/// <param name="force"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public async Task<StunResult5389?> TestStunClient5389Async(
TransportProtocol protocol,
string? testServerHostName = DEFAULT_TESTSERVER_HOSTNAME,
int? testServerPort = DEFAULT_TESTSTUN5389_PORT,
IPEndPoint? localIPEndPoint = null,
CancellationToken cancellationToken = default
)
{
testServerHostName ??= DEFAULT_TESTSERVER_HOSTNAME;
testServerPort ??= DEFAULT_TESTSTUN5389_PORT;
IStunClient5389 client = await GetStunClient5389Async(protocol, testServerHostName, testServerPort.Value, localIPEndPoint);
if (client == null)
return null;
try
{
await client.QueryAsync(cancellationToken);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
Log.Error(nameof(NetworkTestService), ex, "TestStunClient5389Async 异常");
return null;
}
return client.State;
}
private async ValueTask<IStunClient5389> GetStunClient5389Async(TransportProtocol protocol, string serverHostName, int port, IPEndPoint? localIPEndPoint = null)
{
IPEndPoint serverEndpoint = await GetServerIPEndPointAsync(serverHostName, port);
IPEndPoint localEndpoint = localIPEndPoint ?? new IPEndPoint(IPAddress.Any, default);
IStunClient5389 client = protocol == TransportProtocol.Tcp
? new StunClient5389TCP(serverEndpoint, localEndpoint)
: new StunClient5389UDP(serverEndpoint, localEndpoint);
return client;
}
#endregion RFC5389
#endregion STUN 测试
#region DNS 查询
/// <summary>
/// 测试 DNS UDP
/// </summary>
/// <param name="testDomain">要测试的域名</param>
/// <param name="dnsServerIp">DNS 服务地址</param>
/// <param name="dnsServerPort">DNS 服务端口</param>
/// <param name="dnsRecordType"> dns 测试类型 </param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public async Task<(long DelayMs, IPAddress[] Address)> TestDNSAsync(
string testDomain,
string dnsServerIp,
int dnsServerPort,
DnsQueryAnswerRecord.DnsRecordType dnsRecordType = DnsQueryAnswerRecord.DnsRecordType.A,
CancellationToken cancellationToken = default
)
{
using UdpClient udpClient = new UdpClient(dnsServerIp, dnsServerPort);
var query = new DnsQueryUdpRequest(testDomain, dnsRecordType);
Stopwatch watch = Stopwatch.StartNew();
await udpClient.SendAsync(query, cancellationToken);
var receivedData = await udpClient.ReceiveAsync(cancellationToken);
watch.Stop();
var dnsQueryResponse = new DnsQueryUdpResponse(receivedData.Buffer);
return (watch.ElapsedMilliseconds, dnsQueryResponse.GetAddresses().ToArray());
}
/// <summary>
/// 测试 DNS Over Https
/// </summary>
/// <param name="testDomain"></param>
/// <param name="dohServer"></param>
/// <param name="dnsRecordType"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public async Task<(long DelayMs, IPAddress[] Address)> TestDNSOverHttpsAsync(
string testDomain,
string dohServer,
DnsQueryAnswerRecord.DnsRecordType dnsRecordType = DnsQueryAnswerRecord.DnsRecordType.A,
CancellationToken cancellationToken = default)
{
using HttpClient client = new HttpClient();
string queryUrl = $"{dohServer}?name={testDomain}&type={dnsRecordType}";
Stopwatch watch = Stopwatch.StartNew();
HttpResponseMessage? resp = null;
try
{
resp = await client.GetAsync(queryUrl, HttpCompletionOption.ResponseContentRead, cancellationToken);
resp.EnsureSuccessStatusCode();
}
finally
{
watch.Stop();
}
var queryResp = await resp.Content.ReadFromJsonAsync<DnsQueryJsonResponse>();
return (watch.ElapsedMilliseconds, queryResp?.GetAddresses().ToArray() ?? Array.Empty<IPAddress>());
}
#region DNS 解析定义
internal interface IDnsQueryResponse
{
List<DnsQueryAnswerRecord> Answers { get; }
IEnumerable<IPAddress> GetAddresses();
}
internal record DnsQueryUdpResponse : DnsQueryUdpRequest, IDnsQueryResponse
{
public ushort AnswerCount { get; init; } // (ushort)((_origin[6] << 8) | _origin[7]);
public ushort AuthorityCount { get; init; } // (ushort)((_origin[8] << 8) | _origin[9]);
public ushort AdditionalCount { get; init; } // (ushort)((_origin[10] << 8) | _origin[11]);
public List<DnsQueryAnswerRecord> Answers { get; init; }
private readonly byte[] _origin;
public DnsQueryUdpResponse(byte[] origin) : base(origin)
{
_origin = origin;
AnswerCount = (ushort)((origin[Position++] << 8) | origin[Position++]);
AuthorityCount = (ushort)((origin[Position++] << 8) | origin[Position++]);
AdditionalCount = (ushort)((origin[Position++] << 8) | origin[Position++]);
// 跳过查询问题部分
for (int i = 0; i < QuestionCount; i++)
{
Position = SkipName(origin, Position);
Position += 4; // 跳过类型
}
Answers = new List<DnsQueryAnswerRecord>();
Position = ParseRecords(origin, Position, AnswerCount, Answers);
Position = ParseRecords(origin, Position, AuthorityCount, Answers);
Position = ParseRecords(origin, Position, AdditionalCount, Answers);
}
private static int ParseRecords(byte[] response, int position, int recordCount, List<DnsQueryAnswerRecord> answers)
{
for (int i = 0; i < recordCount; i++)
{
position = ParseName(response, position, out var parsedName);
int @type = (response[position++] << 8) | response[position++];
int @class = (response[position++] << 8) | response[position++];
long ttl = (response[position++] << 24) | (response[position++] << 16) | (response[position++] << 8) | response[position++];
int dataLength = (response[position++] << 8) | response[position++];
if (@type == (int)DnsQueryAnswerRecord.DnsRecordType.A && dataLength == 4) // Type A (IPv4)
{
var ipAddress = new byte[4];
Array.Copy(response, position, ipAddress, 0, 4);
answers.Add(
new DnsQueryAnswerRecord.A(
parsedName!,
(DnsQueryAnswerRecord.DnsRecordType)@type,
ttl,
new IPAddress(ipAddress)
)
);
}
else if (@type == (int)DnsQueryAnswerRecord.DnsRecordType.AAAA && dataLength == 16) // Type AAAA (IPv6)
{
var ipAddress = new byte[16];
Array.Copy(response, position, ipAddress, 0, 16);
answers.Add(
new DnsQueryAnswerRecord.AAAA(
parsedName!,
(DnsQueryAnswerRecord.DnsRecordType)@type,
ttl,
new IPAddress(ipAddress)
)
);
}
else if (type == (int)DnsQueryAnswerRecord.DnsRecordType.CNAME)
{
ParseName(response, position, out var parsedCName);
if (string.IsNullOrEmpty(parsedCName))
{
Log.Error(nameof(NetworkTestService), $"解析 DNS CName字段异常 name :{parsedName}, data length :{dataLength}");
continue;
}
answers.Add(
new DnsQueryAnswerRecord.CName(
parsedName!,
(DnsQueryAnswerRecord.DnsRecordType)@type,
ttl,
parsedCName!
)
);
}
else
{
Log.Error(nameof(NetworkTestService), $"未支持的 DNS响应记录 type :{@type} ,class :{@class} name :{parsedName}, data length :{dataLength}");
continue;
}
position += dataLength;
}
return position;
}
private static int ParseName(byte[] response, int position, out string? name)
{
StringBuilder nameBuilder = new StringBuilder();
int originalPosition = position;
bool isPointer = false;
// 0表示结束
while (response[position] != 0)
{
// dns 协议 名称可能是 指针 或者 直接表示
// 如果大于等于 192 表示是指针开始
if (response[position] >= 192)
{
if (!isPointer)
{
originalPosition = position + 2;
isPointer = true;
}
// 因为 00111111 可以忽略前两位 用 0x3F 掩码获取指针
int pointer = ((response[position] & 0x3F) << 8) | response[position + 1];
position = pointer;
}
else
{
int length = response[position];
position++;
nameBuilder.Append(Encoding.ASCII.GetString(response, position, length));
position += length;
if (response[position] != 0)
{
nameBuilder.Append('.');
}
}
}
if (!isPointer)
{
position++;
}
else
{
position = originalPosition;
}
name = nameBuilder.ToString();
return position;
}
private static int SkipName(byte[] response, int position)
{
if (response[position] >= 192)
{
return position + 2; // Name is a pointer
}
while (response[position] != 0)
{
position += response[position] + 1;
}
return position + 1;
}
public IEnumerable<IPAddress> GetAddresses()
{
foreach (var answer in Answers)
{
if (answer is DnsQueryAnswerRecord.A a)
{
yield return a.Data;
}
else if (answer is DnsQueryAnswerRecord.AAAA aaaa)
{
yield return aaaa.Data;
}
}
yield break;
}
}
internal record DnsQueryUdpRequest
{
public ushort QueryId { get; init; } // (ushort)((_origin[0] << 8) | _origin[1]);
public ushort Flags { get; init; } //(ushort)((_origin[2] << 8) | _origin[3]);
public ushort QuestionCount { get; init; } // (ushort)((_origin[4] << 8) | _origin[5]);
public int Length => _origin.Length;
protected int Position { get; set; }
private readonly byte[] _origin;
public DnsQueryUdpRequest(byte[] origin)
{
_origin = origin;
Position = default;
QueryId = (ushort)((origin[Position++] << 8) | origin[Position++]);
Flags = (ushort)((origin[Position++] << 8) | origin[Position++]);
QuestionCount = (ushort)((origin[Position++] << 8) | origin[Position++]);
}
public DnsQueryUdpRequest(string domainName, DnsQueryAnswerRecord.DnsRecordType type = DnsQueryAnswerRecord.DnsRecordType.A)
: this(CreateDnsQuery(domainName, type))
{
}
public static byte[] CreateDnsQuery(string domainName, DnsQueryAnswerRecord.DnsRecordType type = DnsQueryAnswerRecord.DnsRecordType.A)
{
// 12字节报文头部
const int DNS_QUERY_HEADER_BYTECOUNT = 12;
// 2字节终止符长度
const int DNS_QUERY_END_BYTECOUNT = 2;
// 4字节查询类型和查询类字段
const int DNS_QUERY_QUERYTYPE_BYTECOUNT = 4;
// dns queryId 范围是一个16位字段,16位无符号的范围就是0-65535
var queryId = (ushort)Random.Shared.Next(0, 65536);
byte[] dnsQuery = new byte[
DNS_QUERY_HEADER_BYTECOUNT
+ domainName.Length
+ DNS_QUERY_END_BYTECOUNT
+ DNS_QUERY_QUERYTYPE_BYTECOUNT
];
BuildQueryHeader(queryId, dnsQuery);
BuildBody(domainName, dnsQuery);
return dnsQuery;
void BuildQueryHeader(ushort queryId, byte[] query)
{
// 查询 ID 是一个 16 位(2 字节)的字段。
// 为了将这个 16 位的整数值分配到字节数组 dnsQuery 的两个字节中,
// 我们需要将其高 8 位和低 8 位分别存储在数组的第一个和第二个字节中
query[0] = (byte)(queryId >> 8);
query[1] = (byte)(queryId & 0xFF);
// 标志字段(Flags) 2 - 3 包含各种控制标志
// 如查询/响应(QR)、操作码(Opcode)、授权回答(AA)、截断(TC)、递归期望(RD)、递归可用(RA)等
query[2] = 0x01;
// 问题计数(QDCOUNT)4 - 5 表示查询问题部分的数量
query[5] = 0x01;
// 其他查询报文中通常为 0
// 回答记录计数(ANCOUNT),授权记录计数(NSCOUNT),附加记录计数(ARCOUNT)
}
void BuildBody(string domainName, byte[] query)
{
// 获取域名组成部分 因为后面要根据部分长度和内容填充结果
string[] domainParts = domainName.Split('.');
int currentPosition = 12;
// 根据 dns 查询报文 每一部分开始都要填充这一部分的长度
foreach (var domainPart in domainParts)
{
// 标志当前 part 的长度
query[currentPosition++] = (byte)domainPart.Length;
// 获取填充的字节
var fillBytes = Encoding.ASCII.GetBytes(domainPart);
// 移动到当前查询体中
fillBytes.CopyTo(query, currentPosition);
// 移动当前填充开始位置
currentPosition += fillBytes.Length;
}
// 0 表示域名结束
query[currentPosition++] = 0x00;
// 填充 查询类型和查询类字段
query[currentPosition++] = 0x00;
query[currentPosition++] = (byte)type; // 表示查询类型为 A 记录(IPv4 地址)。
query[currentPosition++] = 0x00;
query[currentPosition++] = 0x01; // 表示查询类为 IN(Internet 类)。
}
}
public static implicit operator byte[](DnsQueryUdpRequest data)
{
return data._origin;
}
public static implicit operator ReadOnlyMemory<byte>(DnsQueryUdpRequest data)
{
return data._origin;
}
}
internal record DnsQueryJsonResponse : IDnsQueryResponse
{
[S_JsonProperty("Status")]
public int Status { get; init; }
[S_JsonProperty("TC")]
public bool TC { get; init; }
[S_JsonProperty("RD")]
public bool RD { get; init; }
[S_JsonProperty("RA")]
public bool RA { get; init; }
[S_JsonProperty("AD")]
public bool AD { get; init; }
[S_JsonProperty("CD")]
public bool CD { get; init; }
[S_JsonProperty("Answer")]
public List<DnsQueryAnswerRecord> Answers { get; init; }
public DnsQueryJsonResponse(
int status,
bool tc,
bool rd,
bool ra,
bool ad,
bool cd,
List<DnsQueryAnswerRecord>? answers
)
{
Status = status;
TC = tc;
RD = rd;
RA = ra;
AD = ad;
CD = cd;
Answers = answers?.ToList() ?? new();
}
public IEnumerable<IPAddress> GetAddresses()
{
return Answers.Aggregate(Enumerable.Empty<IPAddress>(), (acc, x) =>
{
if (x is DnsQueryAnswerRecord.A ipv4)
{
return acc.Append(ipv4.Data);
}
else if (x is DnsQueryAnswerRecord.AAAA ipv6)
{
return acc.Append(ipv6.Data);
}
return acc;
});
}
}
class DnsQueryAnswerRecordConverter : System.Text.Json.Serialization.JsonConverter<DnsQueryAnswerRecord>
{
public override DnsQueryAnswerRecord? Read(ref Utf8JsonReader reader, Type typeToConvert, SystemTextJsonSerializerOptions options)
{
if (typeToConvert == typeof(DnsQueryAnswerRecord))
{
if (reader.TokenType == JsonTokenType.StartObject)
{
var json = JsonDocument.ParseValue(ref reader);
if (json.RootElement.TryGetProperty("type", out var typeElement))
{
var type = typeElement.GetInt32();
if (type == (int)DnsQueryAnswerRecord.DnsRecordType.A)
{
return json.RootElement.Deserialize<DnsQueryAnswerRecord.A>();
}
else if (type == (int)DnsQueryAnswerRecord.DnsRecordType.AAAA)
{
return json.RootElement.Deserialize<DnsQueryAnswerRecord.AAAA>();
}
else if (type == (int)DnsQueryAnswerRecord.DnsRecordType.CNAME)
{
return json.RootElement.Deserialize<DnsQueryAnswerRecord.CName>();
}
}
}
}
return null;
}
public override void Write(Utf8JsonWriter writer, DnsQueryAnswerRecord value, SystemTextJsonSerializerOptions options)
{
throw new NotImplementedException();
}
}
[System.Text.Json.Serialization.JsonConverter(typeof(DnsQueryAnswerRecordConverter))]
[JsonDerivedType(typeof(DnsQueryAnswerRecord.A))]
[JsonDerivedType(typeof(DnsQueryAnswerRecord.AAAA))]
[JsonDerivedType(typeof(DnsQueryAnswerRecord.CName))]
internal record DnsQueryAnswerRecord
{
[S_JsonProperty("name")]
public string Name { get; set; }
[S_JsonProperty("type")]
public DnsRecordType Type { get; set; }
[S_JsonProperty("TTL")]
public long Ttl { get; set; }
public DnsQueryAnswerRecord(
string name,
DnsRecordType type,
long ttl
)
{
Name = name;
Type = @type;
Ttl = ttl;
//Data = data;
//DataLength = data.Length;
}
#region DnsQueryAnswerRecord 子类派生
internal record A : DnsQueryAnswerRecord
{
public A(string name, DnsRecordType type, long ttl, IPAddress data) : base(name, type, ttl)
{
Data = data;
}
[S_JsonProperty("data")]
[SystemTextJsonConverter(typeof(IPAddressConverter))]
public IPAddress Data { get; set; }
}
internal record AAAA : DnsQueryAnswerRecord
{
public AAAA(string name, DnsRecordType type, long ttl, IPAddress data) : base(name, type, ttl)
{
Data = data;
}
[S_JsonProperty("data")]
[SystemTextJsonConverter(typeof(IPAddressConverter))]
public IPAddress Data { get; set; }
}
internal record CName : DnsQueryAnswerRecord
{
public CName(string name, DnsRecordType type, long ttl, string data) : base(name, type, ttl)
{
Data = data;
}
[S_JsonProperty("data")]
public string Data { get; set; }
}
#endregion DnsQueryAnswerRecord 子类派生
class IPAddressConverter : System.Text.Json.Serialization.JsonConverter<IPAddress>
{
public override IPAddress? Read(ref Utf8JsonReader reader, Type typeToConvert, SystemTextJsonSerializerOptions options)
{
IPAddress? result = null;
// 目前只解析字符串
if (reader.TokenType == JsonTokenType.String)
{
string? ipString = reader.GetString();
result = !string.IsNullOrWhiteSpace(ipString) ? IPAddress.Parse(ipString) : default;
}
// byte 数组支持
else if (reader.TokenType == JsonTokenType.StartArray)
{
List<byte> bytes = new List<byte>();
while (reader.Read())
{
if (reader.TokenType == JsonTokenType.EndArray)
break;
if (reader.TokenType == JsonTokenType.Number)
{
bytes.Add((byte)reader.GetInt32());
}
else if (reader.TokenType == JsonTokenType.String)
{
if (byte.TryParse(reader.GetString(), out byte byteValue))
{
bytes.Add(byteValue);
}
}
}
result = bytes.Count == 4 || bytes.Count == 16 ? new IPAddress(bytes.ToArray()) : default;
}
return result;
}
public override void Write(Utf8JsonWriter writer, IPAddress value, SystemTextJsonSerializerOptions options)
{
throw new NotImplementedException();
}
}
internal enum DnsRecordClass : ushort
{
Internet = 1, // Internet(IN)
Chaos = 3, // Chaos(CH)
Hesiod = 4, // Hesiod(HS)
QCLASSNONE = 254, // QCLASS NONE
QCLASSANY = 255, // QCLASS * (ANY)
}
// 域名系统记录类型 (省略了有些未使用的类型)
internal enum DnsRecordType : byte
{
A = 1, // 指定域名对应的IPv4地址
AAAA = 28, // 指定域名对应的IPv6地址
CNAME = 5, // 别名记录,
MX = 15, // 邮件交换记录,
NS = 2, // 指定该域名由哪个DNS服务器来进行解析
TXT = 16, // 主机名或域名的说明
SOA = 6, // 起始授权机构
PTR = 12, // 反向IP查询
ANY = 255, // 所有DNS记录类型
}
}
#endregion DNS 解析定义
#endregion DNS 查询
}
@@ -0,0 +1,120 @@
using STUN.StunResult;
using System.Net.NetworkInformation;
using static BD.WTTS.Services.Implementation.NetworkTestService;
namespace BD.WTTS.Services;
internal interface INetworkTestService : IHttpRequestTestService, IStunTestService
{
/// <summary>
/// Ping
/// </summary>
/// <param name="testHostNameOrAddress"></param>
/// <param name="timeout"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
Task<PingReply> TestPingAsync(string testHostNameOrAddress, TimeSpan timeout, CancellationToken cancellationToken = default);
/// <summary>
/// 测试 DNS UDP
/// </summary>
/// <param name="testDomain"></param>
/// <param name="dnsServerIp"></param>
/// <param name="dnsServerPort"></param>
/// <param name="dnsRecordType"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
Task<(long DelayMs, IPAddress[] Address)> TestDNSAsync(
string testDomain,
string dnsServerIp,
int dnsServerPort,
DnsQueryAnswerRecord.DnsRecordType dnsRecordType = DnsQueryAnswerRecord.DnsRecordType.A,
CancellationToken cancellationToken = default
);
/// <summary>
/// 测试 DNS Over Https
/// </summary>
/// <param name="testDomain"></param>
/// <param name="dohServer"></param>
/// <param name="dnsRecordType"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
Task<(long DelayMs, IPAddress[] Address)> TestDNSOverHttpsAsync(
string testDomain,
string dohServer,
DnsQueryAnswerRecord.DnsRecordType dnsRecordType = DnsQueryAnswerRecord.DnsRecordType.A,
CancellationToken cancellationToken = default
);
}
internal interface IHttpRequestTestService
{
/// <summary>
/// 测试打开网址
/// </summary>
/// <param name="url"></param>
/// <param name="httpClientFunc"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
Task<(bool success, long? delayMs)> TestOpenUrlAsync(string url, Func<HttpClient>? httpClientFunc = null, CancellationToken cancellationToken = default);
/// <summary>
/// 测试上传速度
/// </summary>
/// <param name="uploadServerUrl"></param>
/// <param name="uploadBytes"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
Task<(bool success, double? rate)> TestUploadSpeedAsync(
string uploadServerUrl,
byte[] uploadBytes,
CancellationToken cancellationToken = default
);
/// <summary>
/// 测试下载速度
/// </summary>
/// <param name="downloadUrl"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
Task<(bool success, double? rate)> TestDownloadSpeedAsync(string downloadUrl, CancellationToken cancellationToken = default);
}
internal interface IStunTestService
{
/// <summary>
/// 测试 STUN Client RFC3489
/// </summary>
/// <param name="testServerHostName">STUN 服务地址</param>
/// <param name="testServerPort">STUN 服务端口</param>
/// <param name="localIPEndPoint">本机IP</param>
/// <param name="force">刷新测试 Client</param>
/// <remarks></remarks>
/// <returns></returns>
Task<ClassicStunResult?> TestStunClient3489Async(
string? testServerHostName = default,
int? testServerPort = default,
IPEndPoint? localIPEndPoint = default,
bool force = false,
CancellationToken cancellationToken = default
);
/// <summary>
/// 测试 STUN Client RFC5389
/// </summary>
/// <param name="protocol">使用的传输协议 Tcp,Udp</param>
/// <param name="testServerHostName">STUN 服务地址</param>
/// <param name="testServerPort">STUN 服务端口</param>
/// <param name="localIPEndPoint">本机IP</param>
/// <param name="force"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
Task<StunResult5389?> TestStunClient5389Async(
TransportProtocol protocol,
string? testServerHostName = default,
int? testServerPort = default,
IPEndPoint? localIPEndPoint = null,
CancellationToken cancellationToken = default
);
}
@@ -63,7 +63,7 @@ partial class ProxyService
string? twoLevelAgentPassword = ProxySettings.TwoLevelAgentPassword.Value;
string? proxyDNS = ProxySettings.ProxyMasterDns.Value;
bool isSupportIpv6 = await RefreshIpv6Support();
bool useDoh = ProxySettings.UseDoh2.Value;
bool useDoh = ProxySettings.UseDoh.Value;
string? customDohAddres = ProxySettings.CustomDohAddres.Value;
Lazy<IPAddress> proxyIp_ = new(() => ReverseProxySettings.GetProxyIp(proxyIp));
@@ -206,7 +206,7 @@ partial class ProxyService
}
}
string? proxyToken = proxyDomains.Any_Nullable(s => s.ProxyType == ProxyType.ServerAccelerate) ?
string? proxyToken = proxyDomains.Any_Nullable(s => s.ProxyType == ProxyType.ServerAccelerate || s.Items.Any_Nullable(x => x.ProxyType == ProxyType.ServerAccelerate)) ?
await TryRequestServerSideProxyToken() : null;
ReverseProxySettings reverseProxySettings = new(proxyDomains, scripts,
@@ -454,7 +454,7 @@ public sealed partial class ProxyService
{
if (nodes == null)
return Enumerable.Empty<string>();
return nodes.Where(s => s.ThreeStateEnable != false).SelectMany(node => new[] { node.Id.ToString() }.Concat(GetAccelerateEnableAllIds(node.Items)));
return nodes.Where(s => s.ThreeStateEnable == true).SelectMany(node => new[] { node.Id.ToString() }.Concat(GetAccelerateEnableAllIds(node.Items)));
}
private void RestoreAccelerateEnableAllIds(IEnumerable<AccelerateProjectDTO> nodes, IReadOnlyCollection<string> enableItems)
@@ -148,7 +148,7 @@ public partial interface IProxySettings
/// <summary>
/// 启用 DNS over HTTPS
/// </summary>
bool UseDoh2 { get; set; }
bool UseDoh { get; set; }
/// <summary>
/// 自定义 DNS over HTTPS 地址
@@ -268,7 +268,7 @@ public partial interface IProxySettings
/// <summary>
/// 启用 DNS over HTTPS的默认值
/// </summary>
const bool DefaultUseDoh2 = false;
const bool DefaultUseDoh = true;
/// <summary>
/// 自定义 DNS over HTTPS 地址的默认值
@@ -189,7 +189,7 @@ public sealed partial class ProxySettings_ : IProxySettings, ISettings, ISetting
/// 启用 DNS over HTTPS
/// </summary>
[MPKey(21), MP2Key(21), JsonPropertyOrder(21)]
public bool UseDoh2 { get; set; } = IProxySettings.DefaultUseDoh2;
public bool UseDoh { get; set; } = IProxySettings.DefaultUseDoh;
/// <summary>
/// 自定义 DNS over HTTPS 地址
@@ -356,8 +356,8 @@ public static partial class ProxySettings
/// <summary>
/// 启用 DNS over HTTPS
/// </summary>
public static SettingsStructProperty<bool, ProxySettings_> UseDoh2 { get; }
= new(DefaultUseDoh2);
public static SettingsStructProperty<bool, ProxySettings_> UseDoh { get; }
= new(DefaultUseDoh);
/// <summary>
/// 自定义 DNS over HTTPS 地址
@@ -69,7 +69,7 @@
<ui:SettingsExpanderItem>
<TextBlock Text="{Binding Path=Res.CommunityFix_EnableUseDoh, Mode=OneWay, Source={x:Static s:ResourceService.Current}}" />
<ui:SettingsExpanderItem.Footer>
<ToggleSwitch Classes="LeftContent" IsChecked="{Binding Source={x:Static ms:ProxySettings.UseDoh2}, Path=Value, Mode=TwoWay}" />
<ToggleSwitch Classes="LeftContent" IsChecked="{Binding Source={x:Static ms:ProxySettings.UseDoh}, Path=Value, Mode=TwoWay}" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
@@ -24,7 +24,7 @@ public sealed class Plugin : PluginBase<Plugin>, IPlugin
protected sealed override string? AuthorOriginalString => null;
public sealed override string Description => moduleName;
public sealed override string Description => moduleName + " 控制台功能实现";
public sealed override object? Icon => Resources.asf;
@@ -89,12 +89,12 @@ public sealed partial class SteamTradePageViewModel : WindowViewModel
if (_steamClient.Session == null || _steamAuthenticator == null)
return false;
// token 依旧有效 直接返回
if (!_steamClient.Session.IsAccessTokenExpired())
return true;
try
{
// token 依旧有效 直接返回
if (!_steamClient.Session.IsAccessTokenExpired())
return true;
// 是否有 refresh token
string? refreshToken = _steamClient.Session?.RefreshToken;
@@ -109,6 +109,12 @@ public sealed partial class SteamTradePageViewModel : WindowViewModel
// 使用原先获取的具体派生类型 修改数据
_steamAuthenticator.SessionData = session.ToString();
// 重置当前 client
if (!ResetCurrentClient())
{
return false;
}
// 通过原始令牌引用保存信息
await AuthenticatorHelper.SaveAuthenticator(_authenticatorDto)
.ConfigureAwait(false);
@@ -204,6 +210,30 @@ public sealed partial class SteamTradePageViewModel : WindowViewModel
IsLoading = false;
}
bool ResetCurrentClient()
{
try
{
if (_steamAuthenticator == null)
return false;
_steamClient?.Dispose();
// 重新生成 client
_steamClient = _steamAuthenticator
.GetClient(ResourceService.GetCurrentCultureSteamLanguageName());
if (_steamClient == null)
return false;
}
catch (Exception)
{
return false;
}
return true;
}
async Task<IEnumerable<SteamTradeConfirmationModel>?> GetConfirmations(WSteamClient steamClient)
{
IsLoading = true;
@@ -92,6 +92,9 @@
<AutoGen>True</AutoGen>
<DependentUpon>Resources.resx</DependentUpon>
</Compile>
<Compile Update="UI\Views\CsgoVacRepairPage.axaml.cs">
<DependentUpon>CsgoVacRepairPage.axaml</DependentUpon>
</Compile>
<Compile Update="UI\Views\GameToolsPage.axaml.cs">
<DependentUpon>GameToolsPage.axaml</DependentUpon>
</Compile>
@@ -0,0 +1,137 @@
namespace BD.WTTS.UI.ViewModels;
public sealed partial class CsgoVacRepairPageViewModel : ViewModelBase
{
string _OutputString = string.Empty;
public string OutputString
{
get => _OutputString;
set => this.RaiseAndSetIfChanged(ref _OutputString, value);
}
[Reactive]
public bool Repairing { get; set; } = false;
#if WINDOWS
string BatPath = Path.Combine(Plugin.Instance.AppDataDirectory, "BAT", $"CSGOVAC_REPAIR{FileEx.BAT}");
public void Repairs_Click()
{
OutputString = string.Empty;
Task2.InBackground(async () =>
{
await ExcuteBat();
});
}
public async Task ExcuteBat()
{
try
{
if (!File.Exists(BatPath))
{
GenerateFixScript(BatPath);
}
Repairing = true;
using var process = new Process
{
StartInfo =
{
FileName = BatPath,
RedirectStandardOutput = true,
RedirectStandardError = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
UseShellExecute = false,
CreateNoWindow = true,
Arguments = $"\"{Registry.GetValue(@"HKEY_CURRENT_USER\SOFTWARE\Valve\Steam", "SteamPath", null)}/bin\""
}
};
process.OutputDataReceived += (_, e) =>
{
OutHandle(e.Data);
};
process.ErrorDataReceived += (_, e) =>
{
OutHandle(e.Data);
};
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
await process.WaitForExitAsync();
}
finally
{
Repairing = false;
}
}
static void GenerateFixScript(string path)
{
var dirPath = Path.GetDirectoryName(path);
if (!Directory.Exists(dirPath)) Directory.CreateDirectory(dirPath!);
using var stream = new FileStream(path, FileMode.OpenOrCreate, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete);
stream.Write("""
@echo off
chcp 65001
:steam
echo info - Checking if Steam is launched.
tasklist | find /I "Steam.exe"
if errorlevel 1 goto closedstatus
if not errorlevel 1 goto killsteam
:killsteam
taskkill /F /IM Steam.exe
goto steaminstall
:closedstatus
echo info - Not Started
goto steaminstall
sc config Netman start= AUTO
sc start Netman
sc config RasMan start= AUTO
sc start RasMan
sc config TapiSrv start= AUTO
sc start TapiSrv
sc config MpsSvc start= AUTO
sc start MpsSvc
netsh advfirewall set allprofiles state on
goto steam
:steaminstall
echo info - ※^>^>^> 执行 Steam Services 修复中......
cd /d %1
steamservice /install
ping -n 2 127.0.0.1>nul
echo
steamservice /repair
echo info - ※ 开启数据执行保护
bcdedit /debug off
bcdedit.exe/set {current} nx alwayson
echo info - ※ 执行完毕
sc config "Steam Client Service" start= AUTO
sc start "Steam Client Service"
"""u8);
stream.Flush();
stream.SetLength(stream.Position);
}
void OutHandle(string? msg)
{
if (!string.IsNullOrEmpty(msg))
{
OutputString += $"{msg}\n";
if (msg.Contains("Add firewall exception failed for steamservice.exe", StringComparison.OrdinalIgnoreCase))
{
OutputString += "info - ※ 修复 Steam Services 失败\n";
OutputString += "info - ※ 请检查您的防火墙设置(关闭 \"不允许例外\" 选项)再次尝试\n";
}
}
}
#endif
}
@@ -0,0 +1,115 @@
<spp:PageBase
x:Class="BD.WTTS.UI.Views.Pages.CsgoVacRepairPage"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:i="using:Avalonia.Xaml.Interactivity"
xmlns:ia="using:Avalonia.Xaml.Interactions.Core"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:s="https://steampp.net/services"
xmlns:spp="https://steampp.net/ui"
xmlns:ui="using:FluentAvalonia.UI.Controls"
Title="CS:GO VAC 修复"
d:DesignHeight="450"
d:DesignWidth="800"
x:CompileBindings="True"
x:DataType="spp:CsgoVacRepairPageViewModel"
Description="该功能可以为玩家修复 CS:GO VAC 问题"
Subtitle="{Binding Author, Source={x:Static s:Plugin.Instance}, Mode=OneWay, Converter={StaticResource StringFormatConverter}, ConverterParameter=Plugin_Author}"
mc:Ignorable="d">
<spp:PageBase.PreviewImage>
<ui:ImageIconSource Source="avares://BD.WTTS.Client.Plugins.GameTools/UI/Assets/CounterStrike.png" />
</spp:PageBase.PreviewImage>
<spp:PageBase.ActionContent>
<StackPanel Orientation="Horizontal" Spacing="2">
<Button
Padding="8,4"
DockPanel.Dock="Right"
IsVisible="{Binding !Repairing}"
Command="{ReflectionBinding Repairs_Click}"
Theme="{StaticResource TransparentButton}">
<Panel>
<Viewbox
Width="18"
Height="18"
HorizontalAlignment="Left">
<ui:FontIcon Glyph="&#xE90f;"/>
</Viewbox>
<TextBlock
Margin="28,4,0,4"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Text="修复" />
</Panel>
</Button>
<Button
Padding="8,4"
DockPanel.Dock="Right"
IsVisible="{Binding Repairing}"
Theme="{StaticResource TransparentButton}">
<Panel>
<Viewbox
Width="18"
Height="18"
HorizontalAlignment="Left">
<ui:FontIcon Glyph="&#xE895;"/>
</Viewbox>
<TextBlock
Margin="28,4,0,4"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Text="修复中" />
</Panel>
</Button>
<DropDownButton
Padding="8"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
Content="{StaticResource More}"
FontFamily="{StaticResource SymbolThemeFontFamily}"
FontSize="20"
Theme="{StaticResource TransparentButton}">
<DropDownButton.Flyout>
<ui:FAMenuFlyout Placement="BottomEdgeAlignedRight">
<!--<ui:MenuFlyoutItem x:Name="PluginSettingButton" Text="{Binding Path=Res.Settings_Plugin, Mode=OneWay,Source={x:Static s:ResourceService.Current}}" />
<ui:MenuFlyoutSeparator />-->
<ui:MenuFlyoutItem IsEnabled="False">
<ui:MenuFlyoutItem.Text>
<MultiBinding StringFormat="{}{0}{1}">
<CompiledBinding
Mode="OneWay"
Path="Res.Plugin_Version"
Source="{x:Static s:ResourceService.Current}" />
<CompiledBinding
Mode="OneWay"
Path="Version"
Source="{x:Static s:Plugin.Instance}" />
</MultiBinding>
</ui:MenuFlyoutItem.Text>
</ui:MenuFlyoutItem>
<!--<ui:MenuFlyoutItem Text="{Binding Path=Res.ViewInStore, Mode=OneWay, Source={x:Static s:ResourceService.Current}}" />
<ui:MenuFlyoutItem Text="{Binding Path=Res.UseHelp, Mode=OneWay, Source={x:Static s:ResourceService.Current}}" />
<ui:MenuFlyoutItem Text="{Binding Path=Res.AboutThisPlugin, Mode=OneWay, Source={x:Static s:ResourceService.Current}}" />-->
</ui:FAMenuFlyout>
</DropDownButton.Flyout>
</DropDownButton>
</StackPanel>
</spp:PageBase.ActionContent>
<Panel>
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}" CornerRadius="{DynamicResource ControlCornerRadius}">
<Grid RowDefinitions="*,Auto">
<TextBox
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Stretch"
Classes="readonlybox"
Text="{Binding OutputString}"
TextAlignment="Start"
TextWrapping="Wrap" />
<!--<TextBox
Name="CommandTextBox"
Grid.Row="1"
Watermark="{Binding Path=Res.Debug_InstructionPlaceholder, Mode=OneWay, Source={x:Static s:ResourceService.Current}}" />-->
</Grid>
</Border>
</Panel>
</spp:PageBase>
@@ -0,0 +1,13 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace BD.WTTS.UI.Views.Pages;
public partial class CsgoVacRepairPage : PageBase<CsgoVacRepairPageViewModel>
{
public CsgoVacRepairPage()
{
InitializeComponent();
DataContext ??= new CsgoVacRepairPageViewModel();
}
}
@@ -134,8 +134,10 @@
<Button
Click="GameToolsPage_Click"
IsEnabled="{spp:OnPlatform Windows}"
Tag="{x:Null}"
Theme="{StaticResource NavButtonStyle}">
<Button.Tag>
<x:Type TypeName="spp:CsgoVacRepairPage" />
</Button.Tag>
<Grid RowDefinitions="3*,*">
<Border
HorizontalAlignment="Stretch"
@@ -157,7 +159,7 @@
</Grid>
</Button>
</Border>
<Border spp:Animations.EnableAnimations="True">
<Button
Click="GameToolsPage_Click"
@@ -445,10 +445,11 @@ public sealed partial class IdleCardPageViewModel : ViewModelBase
{
var multi = IdleGameList.Where(z => z.Badge.HoursPlayed >= SteamIdleSettings.MinRunTime.Value).ToList();
if (multi.Count > 1 || (multi.Count == 1 && IdleGameList.Count == 1))
var isLastSingle = multi.Count == 1 && IdleGameList.Count == 1; // 是否只剩最后一个游戏
if (multi.Count > 1 || isLastSingle)
{
idleApp = VerifyIsNext(multi, isNext);
PauseAutoNext(false);
PauseAutoNext(isLastSingle);
StartSoloIdle(idleApp);
}
else
+1
View File
@@ -110,6 +110,7 @@
<PackageReference Include="SkiaSharp" />
<PackageReference Include="Net.Codecrete.QrCodeGenerator" />
<PackageReference Include="Fleck" />
<PackageReference Include="Stun.Net" />
<PackageReference Include="System.Linq.Async" />
<!--<PackageReference Include="Portable.BouncyCastle" />-->
<PackageReference Include="ReactiveUI.Fody" />
+5 -2
View File
@@ -1,3 +1,6 @@
<Project>
<Import Project="..\ref\DirectoryPackages\Directory.Packages.props" />
</Project>
<Import Project="..\ref\DirectoryPackages\Directory.Packages.props" />
<ItemGroup>
<PackageVersion Include="Stun.Net" Version="8.0.2" />
</ItemGroup>
</Project>