From d9b16fb55de0898fe306dc34b6a883485b2487ab Mon Sep 17 00:00:00 2001 From: Kei-Luna Date: Wed, 13 May 2026 07:19:45 +0900 Subject: [PATCH] MikuSB.Loader --- Common/Configuration/ConfigContainer.cs | 13 +- .../Message/LanguageCHS.cs | 11 +- .../Message/LanguageCHT.cs | 11 +- .../Message/LanguageEN.cs | 11 +- Config/Config.json | 19 +- GameServer/Command/Commands/CommandGame.cs | 29 ++ GameServer/GameServer.csproj | 1 + MikuSB.Loader/GameLaunchService.cs | 357 ++++++++++++++++++ MikuSB.Loader/MikuSB.Loader.csproj | 16 + MikuSB.sln | 6 + MikuSB/MikuSB.csproj | 1 - Proxy/ProxyCertificateAuthority.cs | 134 ------- Proxy/WindowsSystemProxyService.cs | 132 ------- 13 files changed, 466 insertions(+), 275 deletions(-) create mode 100644 GameServer/Command/Commands/CommandGame.cs create mode 100644 MikuSB.Loader/GameLaunchService.cs create mode 100644 MikuSB.Loader/MikuSB.Loader.csproj delete mode 100644 Proxy/ProxyCertificateAuthority.cs delete mode 100644 Proxy/WindowsSystemProxyService.cs diff --git a/Common/Configuration/ConfigContainer.cs b/Common/Configuration/ConfigContainer.cs index 8aaed4c..fd22236 100644 --- a/Common/Configuration/ConfigContainer.cs +++ b/Common/Configuration/ConfigContainer.cs @@ -9,6 +9,7 @@ public class ConfigContainer public PathConfig Path { get; set; } = new(); public ServerOption ServerOption { get; set; } = new(); public ProxyOptions Proxy { get; set; } = new(); + public LoaderOptions Loader { get; set; } = new(); } public class HttpServerConfig @@ -88,8 +89,12 @@ public class ProxyOptions public bool Enabled { get; set; } = true; public int Port { get; set; } = 18888; public int ServerHttpPort { get; set; } = 21500; - public bool InstallRootCertificate { get; set; } = false; - public bool ManageSystemProxy { get; set; } = false; - public bool RestoreSystemProxyOnStop { get; set; } = false; - public string ProxyOverride { get; set; } = "localhost;127.*;10.*;192.168.*;"; +} + +public class LoaderOptions +{ + public string GamePath { get; set; } = ""; + public string[] PatchPaths { get; set; } = [@"Patch\MikuSB-Patch.dll"]; + public string[] Arguments { get; set; } = ["-FeatureLevelES31", "-channelid=seasun", "-NoSplash"]; + public bool SetAllProxy { get; set; } = true; } diff --git a/Common/Internationalization/Message/LanguageCHS.cs b/Common/Internationalization/Message/LanguageCHS.cs index b1fd691..24c96da 100644 --- a/Common/Internationalization/Message/LanguageCHS.cs +++ b/Common/Internationalization/Message/LanguageCHS.cs @@ -131,6 +131,7 @@ public class CommandTextCHS public GirlTextCHS Girl { get; } = new(); public GiveAllTextCHS GiveAll { get; } = new(); public DebugTextCHS Debug { get; } = new(); + public GameCommandTextCHS Game { get; } = new(); } #endregion @@ -272,6 +273,14 @@ public class DebugTextCHS public string FileDisabled => "个人调试文件输出已禁用。"; } +public class GameCommandTextCHS +{ + public string Desc => "使用补丁注入启动已配置的游戏"; + public string Usage => "用法: /game [额外游戏参数]"; + public string Started => "游戏已启动。PID: {0}"; + public string Failed => "游戏启动失败: {0}"; +} + #endregion -#endregion \ No newline at end of file +#endregion diff --git a/Common/Internationalization/Message/LanguageCHT.cs b/Common/Internationalization/Message/LanguageCHT.cs index b2240b9..4888fe1 100644 --- a/Common/Internationalization/Message/LanguageCHT.cs +++ b/Common/Internationalization/Message/LanguageCHT.cs @@ -131,6 +131,7 @@ public class CommandTextCHT public GirlTextCHT Girl { get; } = new(); public GiveAllTextCHT GiveAll { get; } = new(); public DebugTextCHT Debug { get; } = new(); + public GameCommandTextCHT Game { get; } = new(); } #endregion @@ -272,6 +273,14 @@ public class DebugTextCHT public string FileDisabled => "個人調試檔案輸出已停用。"; } +public class GameCommandTextCHT +{ + public string Desc => "使用補丁注入啟動已配置的遊戲"; + public string Usage => "用法: /game [額外遊戲參數]"; + public string Started => "遊戲已啟動。PID: {0}"; + public string Failed => "遊戲啟動失敗: {0}"; +} + #endregion -#endregion \ No newline at end of file +#endregion diff --git a/Common/Internationalization/Message/LanguageEN.cs b/Common/Internationalization/Message/LanguageEN.cs index 2e903eb..f5d95ea 100644 --- a/Common/Internationalization/Message/LanguageEN.cs +++ b/Common/Internationalization/Message/LanguageEN.cs @@ -90,6 +90,7 @@ public class CommandTextEN public GirlTextEN Girl { get; } = new(); public GiveAllTextEN GiveAll { get; } = new(); public DebugTextEN Debug { get; } = new(); + public GameCommandTextEN Game { get; } = new(); } #endregion @@ -238,6 +239,14 @@ public class DebugTextEN public string FileDisabled => "Personal debug file output disabled."; } +public class GameCommandTextEN +{ + public string Desc => "Launch the configured game with patch injection"; + public string Usage => "Usage: /game [extra game args]"; + public string Started => "Game launched. PID: {0}"; + public string Failed => "Failed to launch game: {0}"; +} + #endregion -#endregion \ No newline at end of file +#endregion diff --git a/Config/Config.json b/Config/Config.json index b601499..34a039c 100644 --- a/Config/Config.json +++ b/Config/Config.json @@ -38,5 +38,22 @@ "DebugMessage": true, "DebugDetailMessage": true, "DebugNoHandlerPacket": true + }, + "Proxy": { + "Enabled": true, + "Port": 18888, + "ServerHttpPort": 21500 + }, + "Loader": { + "GamePath": "", + "PatchPaths": [ + "Patch\\MikuSB-Patch.dll" + ], + "Arguments": [ + "-FeatureLevelES31", + "-channelid=seasun", + "-NoSplash" + ], + "SetAllProxy": true } -} \ No newline at end of file +} diff --git a/GameServer/Command/Commands/CommandGame.cs b/GameServer/Command/Commands/CommandGame.cs new file mode 100644 index 0000000..60d0275 --- /dev/null +++ b/GameServer/Command/Commands/CommandGame.cs @@ -0,0 +1,29 @@ +using MikuSB.Enums.Player; +using MikuSB.Internationalization; +using MikuSB.Loader; +using MikuSB.Util; + +namespace MikuSB.GameServer.Command.Commands; + +[CommandInfo("game", "Game.Command.Game.Desc", "Game.Command.Game.Usage", [], [PermEnum.Admin, PermEnum.Support])] +public class CommandGame : ICommands +{ + private static readonly Logger Logger = new("CommandManager"); + + [CommandDefault] + public async ValueTask Launch(CommandArg arg) + { + try + { + var pid = GameLaunchService.Launch(arg.Args.ToArray()); + var message = I18NManager.Translate("Game.Command.Game.Started", pid.ToString()); + Logger.Info(message); + await arg.SendMsg(message); + } + catch (Exception ex) + { + Logger.Error("Failed to launch game.", ex); + await arg.SendMsg(I18NManager.Translate("Game.Command.Game.Failed", ex.Message)); + } + } +} diff --git a/GameServer/GameServer.csproj b/GameServer/GameServer.csproj index ead8405..2128d48 100644 --- a/GameServer/GameServer.csproj +++ b/GameServer/GameServer.csproj @@ -20,6 +20,7 @@ + diff --git a/MikuSB.Loader/GameLaunchService.cs b/MikuSB.Loader/GameLaunchService.cs new file mode 100644 index 0000000..2834210 --- /dev/null +++ b/MikuSB.Loader/GameLaunchService.cs @@ -0,0 +1,357 @@ +using System.Collections; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Text; +using MikuSB.Util; + +namespace MikuSB.Loader; + +public static class GameLaunchService +{ + public static int Launch(params string[] extraGameArguments) + { + ConfigManager.LoadConfig(); + var options = LaunchOptions.FromConfig(extraGameArguments); + return Launch(options); + } + + public static int Launch(LaunchOptions options) + { + var startupInfo = new STARTUPINFOW + { + cb = Marshal.SizeOf() + }; + + var commandLine = BuildCommandLine(options.GamePath, options.GameArguments); + var workingDirectory = options.WorkingDirectory ?? Path.GetDirectoryName(options.GamePath) + ?? throw new InvalidOperationException("Unable to determine working directory."); + + var environment = BuildEnvironmentBlock(options.EnvironmentVariables); + try + { + if (!CreateProcessW( + lpApplicationName: options.GamePath, + lpCommandLine: commandLine, + lpProcessAttributes: IntPtr.Zero, + lpThreadAttributes: IntPtr.Zero, + bInheritHandles: false, + dwCreationFlags: CreationFlags.CREATE_SUSPENDED | CreationFlags.CREATE_UNICODE_ENVIRONMENT, + lpEnvironment: environment, + lpCurrentDirectory: workingDirectory, + lpStartupInfo: ref startupInfo, + lpProcessInformation: out var processInfo)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to create game process."); + } + + try + { + foreach (var patchPath in options.PatchPaths) + InjectDll(processInfo.hProcess, patchPath); + + if (ResumeThread(processInfo.hThread) == uint.MaxValue) + throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to resume game process."); + + return processInfo.dwProcessId; + } + finally + { + CloseHandle(processInfo.hThread); + CloseHandle(processInfo.hProcess); + } + } + finally + { + if (environment != IntPtr.Zero) + Marshal.FreeHGlobal(environment); + } + } + + private static void InjectDll(IntPtr processHandle, string dllPath) + { + var dllBytes = Encoding.Unicode.GetBytes(dllPath + '\0'); + var remoteBuffer = VirtualAllocEx( + processHandle, + IntPtr.Zero, + (nuint)dllBytes.Length, + AllocationType.Commit | AllocationType.Reserve, + MemoryProtection.ReadWrite); + + if (remoteBuffer == IntPtr.Zero) + throw new Win32Exception(Marshal.GetLastWin32Error(), "VirtualAllocEx failed."); + + try + { + if (!WriteProcessMemory(processHandle, remoteBuffer, dllBytes, dllBytes.Length, out var written) || + written.ToInt64() != dllBytes.Length) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "WriteProcessMemory failed."); + } + + var kernel32 = GetModuleHandleW("kernel32.dll"); + if (kernel32 == IntPtr.Zero) + throw new Win32Exception(Marshal.GetLastWin32Error(), "GetModuleHandleW(kernel32.dll) failed."); + + var loadLibrary = GetProcAddress(kernel32, "LoadLibraryW"); + if (loadLibrary == IntPtr.Zero) + throw new Win32Exception(Marshal.GetLastWin32Error(), "GetProcAddress(LoadLibraryW) failed."); + + var remoteThread = CreateRemoteThread( + processHandle, + IntPtr.Zero, + 0, + loadLibrary, + remoteBuffer, + 0, + out _); + + if (remoteThread == IntPtr.Zero) + throw new Win32Exception(Marshal.GetLastWin32Error(), "CreateRemoteThread failed."); + + try + { + var waitResult = WaitForSingleObject(remoteThread, 10_000); + if (waitResult != 0) + throw new Win32Exception($"Remote LoadLibraryW timed out or failed: {waitResult}"); + } + finally + { + CloseHandle(remoteThread); + } + } + finally + { + VirtualFreeEx(processHandle, remoteBuffer, 0, FreeType.Release); + } + } + + private static string BuildCommandLine(string exePath, IReadOnlyList gameArgs) + { + var parts = new List { Quote(exePath) }; + parts.AddRange(gameArgs.Select(Quote)); + return string.Join(' ', parts); + } + + private static IntPtr BuildEnvironmentBlock(IReadOnlyDictionary variables) + { + if (variables.Count == 0) + return IntPtr.Zero; + + var merged = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables()) + merged[(string)entry.Key] = entry.Value?.ToString() ?? string.Empty; + + foreach (var pair in variables) + merged[pair.Key] = pair.Value; + + var payload = string.Join('\0', merged.OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase) + .Select(x => $"{x.Key}={x.Value}")) + "\0\0"; + + return Marshal.StringToHGlobalUni(payload); + } + + private static string Quote(string value) + { + if (value.Length == 0) + return "\"\""; + + if (!value.Any(char.IsWhiteSpace) && !value.Contains('"')) + return value; + + return "\"" + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; + } + + [Flags] + private enum CreationFlags : uint + { + CREATE_SUSPENDED = 0x00000004, + CREATE_UNICODE_ENVIRONMENT = 0x00000400 + } + + [Flags] + private enum AllocationType : uint + { + Commit = 0x1000, + Reserve = 0x2000 + } + + [Flags] + private enum MemoryProtection : uint + { + ReadWrite = 0x04 + } + + [Flags] + private enum FreeType : uint + { + Release = 0x8000 + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct STARTUPINFOW + { + public int cb; + public string? lpReserved; + public string? lpDesktop; + public string? lpTitle; + public int dwX; + public int dwY; + public int dwXSize; + public int dwYSize; + public int dwXCountChars; + public int dwYCountChars; + public int dwFillAttribute; + public int dwFlags; + public short wShowWindow; + public short cbReserved2; + public IntPtr lpReserved2; + public IntPtr hStdInput; + public IntPtr hStdOutput; + public IntPtr hStdError; + } + + [StructLayout(LayoutKind.Sequential)] + private struct PROCESS_INFORMATION + { + public IntPtr hProcess; + public IntPtr hThread; + public int dwProcessId; + public int dwThreadId; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CreateProcessW( + string? lpApplicationName, + string lpCommandLine, + IntPtr lpProcessAttributes, + IntPtr lpThreadAttributes, + bool bInheritHandles, + CreationFlags dwCreationFlags, + IntPtr lpEnvironment, + string? lpCurrentDirectory, + ref STARTUPINFOW lpStartupInfo, + out PROCESS_INFORMATION lpProcessInformation); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr VirtualAllocEx( + IntPtr hProcess, + IntPtr lpAddress, + nuint dwSize, + AllocationType flAllocationType, + MemoryProtection flProtect); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool VirtualFreeEx( + IntPtr hProcess, + IntPtr lpAddress, + nuint dwSize, + FreeType dwFreeType); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool WriteProcessMemory( + IntPtr hProcess, + IntPtr lpBaseAddress, + byte[] lpBuffer, + int nSize, + out IntPtr lpNumberOfBytesWritten); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr GetModuleHandleW(string lpModuleName); + + [DllImport("kernel32.dll", CharSet = CharSet.Ansi, SetLastError = true)] + private static extern IntPtr GetProcAddress(IntPtr hModule, string lpProcName); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr CreateRemoteThread( + IntPtr hProcess, + IntPtr lpThreadAttributes, + nuint dwStackSize, + IntPtr lpStartAddress, + IntPtr lpParameter, + uint dwCreationFlags, + out int lpThreadId); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint ResumeThread(IntPtr hThread); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr hObject); +} + +public sealed class LaunchOptions +{ + public required string GamePath { get; init; } + public required IReadOnlyList PatchPaths { get; init; } + public string? WorkingDirectory { get; init; } + public required IReadOnlyList GameArguments { get; init; } + public required IReadOnlyDictionary EnvironmentVariables { get; init; } + + public static LaunchOptions FromConfig(IEnumerable? extraGameArguments = null) + { + var config = ConfigManager.Config; + var gamePath = ResolvePath(config.Loader.GamePath, AppContext.BaseDirectory); + var patchPaths = ResolvePatchPaths(config.Loader.PatchPaths, AppContext.BaseDirectory); + var gameArgs = new List(config.Loader.Arguments ?? []); + if (extraGameArguments is not null) + gameArgs.AddRange(extraGameArguments.Where(x => !string.IsNullOrWhiteSpace(x))); + + var env = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (config.Loader.SetAllProxy && config.Proxy.Enabled) + env["ALL_PROXY"] = $"socks5h://127.0.0.1:{config.Proxy.Port}"; + + if (string.IsNullOrWhiteSpace(gamePath)) + throw new InvalidOperationException("Loader.GamePath is not configured."); + if (!File.Exists(gamePath)) + throw new FileNotFoundException("Game executable not found.", gamePath); + if (patchPaths.Count == 0) + throw new InvalidOperationException("At least one patch path is required."); + + foreach (var patchPath in patchPaths) + { + if (!File.Exists(patchPath)) + throw new FileNotFoundException("Patch DLL not found.", patchPath); + } + + var workingDirectory = Path.GetDirectoryName(gamePath); + if (string.IsNullOrWhiteSpace(workingDirectory) || !Directory.Exists(workingDirectory)) + throw new DirectoryNotFoundException($"Working directory not found: {workingDirectory}"); + + return new LaunchOptions + { + GamePath = Path.GetFullPath(gamePath), + PatchPaths = patchPaths, + WorkingDirectory = Path.GetFullPath(workingDirectory), + GameArguments = gameArgs, + EnvironmentVariables = env + }; + } + + private static string? ResolvePath(string? value, string baseDirectory) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + + return Path.IsPathRooted(value) + ? value + : Path.GetFullPath(Path.Combine(baseDirectory, value)); + } + + private static List ResolvePatchPaths(IEnumerable? values, string baseDirectory) + { + var result = new List(); + if (values is null) + return result; + + foreach (var value in values) + { + var resolved = ResolvePath(value, baseDirectory); + if (!string.IsNullOrWhiteSpace(resolved)) + result.Add(resolved); + } + + return result; + } +} diff --git a/MikuSB.Loader/MikuSB.Loader.csproj b/MikuSB.Loader/MikuSB.Loader.csproj new file mode 100644 index 0000000..f992d9f --- /dev/null +++ b/MikuSB.Loader/MikuSB.Loader.csproj @@ -0,0 +1,16 @@ + + + + Library + net10.0 + enable + enable + MikuSB.Loader + MikuSB.Loader + + + + + + + diff --git a/MikuSB.sln b/MikuSB.sln index 8e61100..d9fa280 100644 --- a/MikuSB.sln +++ b/MikuSB.sln @@ -24,6 +24,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Proxy", "Proxy\Proxy.csproj EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MikuSB.Updater", "MikuSB.Updater\MikuSB.Updater.csproj", "{CE0F3A4B-8C55-4A31-A1B5-A0CB1C7F0A11}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MikuSB.Loader", "MikuSB.Loader\MikuSB.Loader.csproj", "{B7AE1E7E-6A42-4E64-B2B1-2EB522F9E3A1}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -62,6 +64,10 @@ Global {CE0F3A4B-8C55-4A31-A1B5-A0CB1C7F0A11}.Debug|Any CPU.Build.0 = Debug|Any CPU {CE0F3A4B-8C55-4A31-A1B5-A0CB1C7F0A11}.Release|Any CPU.ActiveCfg = Release|Any CPU {CE0F3A4B-8C55-4A31-A1B5-A0CB1C7F0A11}.Release|Any CPU.Build.0 = Release|Any CPU + {B7AE1E7E-6A42-4E64-B2B1-2EB522F9E3A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B7AE1E7E-6A42-4E64-B2B1-2EB522F9E3A1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B7AE1E7E-6A42-4E64-B2B1-2EB522F9E3A1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B7AE1E7E-6A42-4E64-B2B1-2EB522F9E3A1}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/MikuSB/MikuSB.csproj b/MikuSB/MikuSB.csproj index 19c3b36..5b3f3fa 100644 --- a/MikuSB/MikuSB.csproj +++ b/MikuSB/MikuSB.csproj @@ -50,7 +50,6 @@ Targets="Restore;Publish" RemoveProperties="PublishProfile" Properties="Configuration=$(Configuration);RuntimeIdentifier=$(RuntimeIdentifier);SelfContained=false;PublishSingleFile=true;PublishDir=$(_UpdaterPublishDir)" /> - _serverCertificates = new(StringComparer.OrdinalIgnoreCase); - private readonly ILogger _logger; - private readonly ProxyOptions _options; - private readonly X509Certificate2 _rootCertificate; - - public ProxyCertificateAuthority(IOptions options, ILogger logger) - { - _options = options.Value; - _logger = logger; - _rootCertificate = LoadOrCreateRootCertificate(); - - if (_options.InstallRootCertificate) - InstallRootCertificate(); - else - _logger.LogWarning( - "MikuSB proxy root certificate is not installed automatically. Import {CertificatePath} into CurrentUser Root to enable HTTPS interception.", - RootCerPath); - } - - public string RootCerPath => Path.Join(CertificateDirectory, "MikuSB.Proxy.Root.cer"); - public string RootCerPemPath => Path.Join(CertificateDirectory, "MikuSB.Proxy.Root.pem"); - - private static string CertificateDirectory => Path.Combine(AppContext.BaseDirectory, "proxy-certs"); - - public X509Certificate2 GetServerCertificate(string host) - { - host = host.Trim().TrimEnd('.').ToLowerInvariant(); - return _serverCertificates.GetOrAdd(host, CreateServerCertificate); - } - - private X509Certificate2 LoadOrCreateRootCertificate() - { - Directory.CreateDirectory(CertificateDirectory); - var pfxPath = Path.Combine(CertificateDirectory, "MikuSB.Proxy.Root.pfx"); - - if (File.Exists(pfxPath)) - { - var existing = X509CertificateLoader.LoadPkcs12( - File.ReadAllBytes(pfxPath), - Password, - X509KeyStorageFlags.Exportable | X509KeyStorageFlags.UserKeySet); - - if (!File.Exists(RootCerPath)) - File.WriteAllBytes(RootCerPath, existing.Export(X509ContentType.Cert)); - - if (!File.Exists(RootCerPemPath)) - { - string pemString = existing.ExportCertificatePem(); - File.WriteAllText(RootCerPemPath, pemString); - } - - return existing; - } - - using var rsa = RSA.Create(4096); - var request = new CertificateRequest( - "CN=MikuSB Proxy Root CA", - rsa, - HashAlgorithmName.SHA256, - RSASignaturePadding.Pkcs1); - - request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); - request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign | X509KeyUsageFlags.DigitalSignature, true)); - request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); - - var root = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(10)); - var exportable = X509CertificateLoader.LoadPkcs12( - root.Export(X509ContentType.Pfx, Password), - Password, - X509KeyStorageFlags.Exportable | X509KeyStorageFlags.UserKeySet); - - File.WriteAllBytes(pfxPath, exportable.Export(X509ContentType.Pfx, Password)); - File.WriteAllBytes(RootCerPath, exportable.Export(X509ContentType.Cert)); - _logger.LogInformation("Created MikuSB proxy root certificate at {CertificatePath}", RootCerPath); - - File.WriteAllText(RootCerPemPath, exportable.ExportCertificatePem()); - _logger.LogInformation("Created MikuSB proxy root certificate (PEM) at {CertificatePath}", RootCerPemPath); - return exportable; - } - - private X509Certificate2 CreateServerCertificate(string host) - { - using var rsa = RSA.Create(2048); - var request = new CertificateRequest($"CN={host}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - - var san = new SubjectAlternativeNameBuilder(); - if (IPAddress.TryParse(host, out var address)) - san.AddIpAddress(address); - else - san.AddDnsName(host); - - request.CertificateExtensions.Add(san.Build()); - request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true)); - request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, true)); - request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension([new Oid("1.3.6.1.5.5.7.3.1")], false)); - - var serial = RandomNumberGenerator.GetBytes(16); - using var certificate = request.Create( - _rootCertificate, - DateTimeOffset.UtcNow.AddDays(-1), - DateTimeOffset.UtcNow.AddYears(2), - serial); - - return X509CertificateLoader.LoadPkcs12( - certificate.CopyWithPrivateKey(rsa).Export(X509ContentType.Pfx), - null, - X509KeyStorageFlags.Exportable | X509KeyStorageFlags.UserKeySet); - } - - private void InstallRootCertificate() - { - using var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser); - store.Open(OpenFlags.ReadWrite); - var existing = store.Certificates.Find(X509FindType.FindByThumbprint, _rootCertificate.Thumbprint, false); - if (existing.Count > 0) - return; - - store.Add(_rootCertificate); - _logger.LogWarning("Installed MikuSB proxy root certificate into CurrentUser Root store. Thumbprint={Thumbprint}", _rootCertificate.Thumbprint); - } -} diff --git a/Proxy/WindowsSystemProxyService.cs b/Proxy/WindowsSystemProxyService.cs deleted file mode 100644 index 9433877..0000000 --- a/Proxy/WindowsSystemProxyService.cs +++ /dev/null @@ -1,132 +0,0 @@ -using System.Runtime.InteropServices; -using MikuSB.Configuration; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Microsoft.Win32; - -namespace MikuSB.Proxy; - -public sealed class WindowsSystemProxyService( - IOptions options, - ILogger logger) : IHostedService, IDisposable -{ - private const string InternetSettingsPath = @"Software\Microsoft\Windows\CurrentVersion\Internet Settings"; - private readonly ProxyOptions _options = options.Value; - private ConsoleCtrlHandler? _consoleCtrlHandler; - private int _proxyDisabled; - - public Task StartAsync(CancellationToken cancellationToken) - { - if (!_options.Enabled || !_options.ManageSystemProxy) - return Task.CompletedTask; - - if (!OperatingSystem.IsWindows()) - { - logger.LogWarning("System proxy management is only supported on Windows"); - return Task.CompletedTask; - } - - using var key = Registry.CurrentUser.OpenSubKey(InternetSettingsPath, writable: true); - if (key is null) - { - logger.LogWarning("Unable to open Windows Internet Settings registry key"); - return Task.CompletedTask; - } - - var proxyServer = $"http=127.0.0.1:{_options.Port};https=127.0.0.1:{_options.Port}"; - - key.SetValue("ProxyEnable", 1, RegistryValueKind.DWord); - key.SetValue("ProxyServer", proxyServer, RegistryValueKind.String); - key.SetValue("ProxyOverride", _options.ProxyOverride, RegistryValueKind.String); - NotifyProxySettingsChanged(); - AppDomain.CurrentDomain.ProcessExit += OnProcessExit; - RegisterConsoleCtrlHandler(); - - logger.LogWarning("Windows system proxy enabled for MikuSB: {ProxyServer}", proxyServer); - return Task.CompletedTask; - } - - public Task StopAsync(CancellationToken cancellationToken) - { - if (!_options.Enabled || !_options.ManageSystemProxy || !_options.RestoreSystemProxyOnStop) - return Task.CompletedTask; - - DisableSystemProxy(); - return Task.CompletedTask; - } - - public void Dispose() - { - AppDomain.CurrentDomain.ProcessExit -= OnProcessExit; - UnregisterConsoleCtrlHandler(); - - if (_options.Enabled && _options.ManageSystemProxy && _options.RestoreSystemProxyOnStop) - DisableSystemProxy(); - } - - private void OnProcessExit(object? sender, EventArgs e) - { - if (_options.Enabled && _options.ManageSystemProxy && _options.RestoreSystemProxyOnStop) - DisableSystemProxy(); - } - - private void DisableSystemProxy() - { - if (!OperatingSystem.IsWindows()) - return; - - if (Interlocked.Exchange(ref _proxyDisabled, 1) == 1) - return; - - using var key = Registry.CurrentUser.OpenSubKey(InternetSettingsPath, writable: true); - if (key is null) - return; - - key.SetValue("ProxyEnable", 0, RegistryValueKind.DWord); - key.DeleteValue("ProxyServer", throwOnMissingValue: false); - key.DeleteValue("ProxyOverride", throwOnMissingValue: false); - NotifyProxySettingsChanged(); - logger.LogWarning("Windows system proxy disabled for MikuSB shutdown"); - } - - private void RegisterConsoleCtrlHandler() - { - if (!OperatingSystem.IsWindows()) - return; - - _consoleCtrlHandler = OnConsoleCtrl; - SetConsoleCtrlHandler(_consoleCtrlHandler, add: true); - } - - private void UnregisterConsoleCtrlHandler() - { - if (!OperatingSystem.IsWindows() || _consoleCtrlHandler is null) - return; - - SetConsoleCtrlHandler(_consoleCtrlHandler, add: false); - _consoleCtrlHandler = null; - } - - private bool OnConsoleCtrl(int signal) - { - if (_options.Enabled && _options.ManageSystemProxy && _options.RestoreSystemProxyOnStop) - DisableSystemProxy(); - - return false; - } - - private static void NotifyProxySettingsChanged() - { - InternetSetOption(IntPtr.Zero, 39, IntPtr.Zero, 0); - InternetSetOption(IntPtr.Zero, 37, IntPtr.Zero, 0); - } - - private delegate bool ConsoleCtrlHandler(int signal); - - [DllImport("kernel32.dll", SetLastError = true)] - private static extern bool SetConsoleCtrlHandler(ConsoleCtrlHandler handler, bool add); - - [DllImport("wininet.dll", SetLastError = true)] - private static extern bool InternetSetOption(IntPtr internet, int option, IntPtr buffer, int bufferLength); -}