diff --git a/GameFrameX.Localization/Keys.StartUp.cs b/GameFrameX.Localization/Keys.StartUp.cs
index c32e1bb91..f171fdcf6 100644
--- a/GameFrameX.Localization/Keys.StartUp.cs
+++ b/GameFrameX.Localization/Keys.StartUp.cs
@@ -40,6 +40,85 @@ public static partial class Keys
///
public static class StartUp
{
+ ///
+ /// 启动期 TLS 证书错误消息。
+ ///
+ /// TLS certificate error messages used during startup.
+ public static class Certificates
+ {
+ ///
+ /// 已启用 {0},但证书路径配置 {1} 为空。
+ ///
+ ///
+ /// {0} is enabled but certificate path setting {1} is empty.
+ /// 键名: StartUp.Certificates.PathMissing
+ /// 参数: {0} - 协议名称,{1} - 配置项名称或文件路径
+ ///
+ public const string PathMissing = "StartUp.Certificates.PathMissing";
+
+ ///
+ /// {0} 证书文件不存在:{1}。
+ ///
+ ///
+ /// {0} certificate file does not exist: {1}.
+ /// 键名: StartUp.Certificates.FileNotFound
+ /// 参数: {0} - 协议名称,{1} - 配置项名称或文件路径
+ ///
+ public const string FileNotFound = "StartUp.Certificates.FileNotFound";
+
+ ///
+ /// {0} PEM 证书必须配置独立私钥路径 {1}。
+ ///
+ ///
+ /// {0} PEM certificate requires a separate private key path in {1}.
+ /// 键名: StartUp.Certificates.KeyPathMissing
+ /// 参数: {0} - 协议名称,{1} - 配置项名称或文件路径
+ ///
+ public const string KeyPathMissing = "StartUp.Certificates.KeyPathMissing";
+
+ ///
+ /// {0} PEM 私钥文件不存在:{1}。
+ ///
+ ///
+ /// {0} PEM private key file does not exist: {1}.
+ /// 键名: StartUp.Certificates.KeyFileNotFound
+ /// 参数: {0} - 协议名称,{1} - 配置项名称或文件路径
+ ///
+ public const string KeyFileNotFound = "StartUp.Certificates.KeyFileNotFound";
+
+ ///
+ /// {0} 证书不包含私钥:{1}。
+ ///
+ ///
+ /// {0} certificate has no private key: {1}.
+ /// 键名: StartUp.Certificates.PrivateKeyMissing
+ /// 参数: {0} - 协议名称,{1} - 配置项名称或文件路径
+ ///
+ public const string PrivateKeyMissing = "StartUp.Certificates.PrivateKeyMissing";
+
+ ///
+ /// {0} 证书加载失败(证书或私钥损坏、密码错误或私钥不匹配):{1}。
+ ///
+ ///
+ /// {0} certificate failed to load (invalid certificate or key, incorrect password, or mismatched private key): {1}.
+ /// 键名: StartUp.Certificates.LoadFailed
+ /// 参数: {0} - 协议名称,{1} - 配置项名称或文件路径
+ ///
+ public const string LoadFailed = "StartUp.Certificates.LoadFailed";
+
+ ///
+ /// {0} 私钥使用了传统 OpenSSL 加密格式(Proc-Type/DEK-Info 头),不受支持,请先用 openssl pkcs8 -topk8 转换为加密 PKCS#8 格式:{1}。
+ ///
+ ///
+ /// {0} private key uses the legacy OpenSSL encrypted format (Proc-Type/DEK-Info headers), which is not supported;
+ /// convert it to encrypted PKCS#8 with openssl pkcs8 -topk8 first: {1}.
+ /// 键名: StartUp.Certificates.TraditionalEncryptedKeyUnsupported
+ /// 参数: {0} - 协议名称,{1} - 私钥文件路径
+ ///
+ public const string TraditionalEncryptedKeyUnsupported = "StartUp.Certificates.TraditionalEncryptedKeyUnsupported";
+
+ }
+
///
/// 服务器类型:{0} 停止! 终止原因:{1} 配置信息: {2}
///
@@ -430,6 +509,16 @@ public static class TcpServer
///
public static class WebSocketServer
{
+ ///
+ /// 已配置 WSS TLS 监听,端口:{0}。
+ ///
+ ///
+ /// WSS TLS listener configured on port {0}.
+ /// 键名: StartUp.WebSocketServer.SecureListenerConfigured
+ /// 参数: {0} - WSS 端口
+ ///
+ public const string SecureListenerConfigured = "StartUp.WebSocketServer.SecureListenerConfigured";
+
///
/// 启动WebSocket服务器 类型: {0}, 端口: {1}
///
@@ -588,36 +677,6 @@ public static class HttpExceptions
/// 用途: 当HTTP地址不以/结束时抛出异常
///
public const string AddressMustEndWithSlash = "StartUp.HttpExceptions.AddressMustEndWithSlash";
-
- ///
- /// HTTPS port [{0}] is configured but HttpsCertPath (PFX file path) is missing
- ///
- ///
- /// 键名: StartUp.HttpExceptions.HttpsCertPathMissing
- /// 用途: 配置了 HTTPS 端口但未配置证书路径时抛出异常
- /// 参数: {0} - HTTPS 端口
- ///
- public const string HttpsCertPathMissing = "StartUp.HttpExceptions.HttpsCertPathMissing";
-
- ///
- /// HTTPS certificate file not found: {0}
- ///
- ///
- /// 键名: StartUp.HttpExceptions.HttpsCertFileNotFound
- /// 用途: HTTPS 证书文件不存在时抛出异常
- /// 参数: {0} - 证书文件路径
- ///
- public const string HttpsCertFileNotFound = "StartUp.HttpExceptions.HttpsCertFileNotFound";
-
- ///
- /// HTTPS certificate failed to load (wrong password or corrupted file): {0}
- ///
- ///
- /// 键名: StartUp.HttpExceptions.HttpsCertLoadFailed
- /// 用途: HTTPS 证书加载失败时抛出异常
- /// 参数: {0} - 证书文件路径
- ///
- public const string HttpsCertLoadFailed = "StartUp.HttpExceptions.HttpsCertLoadFailed";
}
///
diff --git a/GameFrameX.Localization/Localization/Messages/Resources.resx b/GameFrameX.Localization/Localization/Messages/Resources.resx
index 1ba20a78c..683cfe32c 100644
--- a/GameFrameX.Localization/Localization/Messages/Resources.resx
+++ b/GameFrameX.Localization/Localization/Messages/Resources.resx
@@ -762,15 +762,6 @@
Address must start with slash. Address: {0}.
-
- HTTPS port [{0}] is configured but HttpsCertPath (PFX file path) is missing; cannot enable HTTPS listening.
-
-
- HTTPS certificate file not found: {0}
-
-
- HTTPS certificate failed to load (wrong password or corrupted file): {0}
-
HTTP signature validation is enabled but HttpSignSecret is not configured; every request is rejected (fail-closed).
@@ -2403,4 +2394,28 @@
Bot-{0} post-resume re-enter response MatchId={1} Started={2} ErrorCode={3}
+
+ {0} is enabled but certificate path setting {1} is empty.
+
+
+ {0} certificate file does not exist: {1}.
+
+
+ {0} PEM certificate requires a separate private key path in {1}.
+
+
+ {0} PEM private key file does not exist: {1}.
+
+
+ {0} certificate has no private key: {1}.
+
+
+ {0} certificate failed to load (invalid certificate or key, incorrect password, or mismatched private key): {1}.
+
+
+ {0} private key uses the legacy OpenSSL encrypted format (Proc-Type/DEK-Info headers), which is not supported; convert it to encrypted PKCS#8 with openssl pkcs8 -topk8 first: {1}.
+
+
+ WSS TLS listener configured on port {0}.
+
diff --git a/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx b/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx
index eef8a8585..13bf5e41f 100644
--- a/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx
+++ b/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx
@@ -815,15 +815,6 @@
地址必须以斜杠开头。地址:{0}。
-
- 已配置 HTTPS 端口 [{0}],但 HttpsCertPath(PFX 文件路径)未配置,无法启用 HTTPS 监听。
-
-
- HTTPS 证书文件不存在:{0}
-
-
- HTTPS 证书加载失败(密码错误或文件损坏):{0}
-
HTTP 签名校验已开启,但 HttpSignSecret 未配置,请求一律拒绝(fail-closed)。
@@ -2399,4 +2390,28 @@
机器人-{0}恢复后重入场响应 MatchId={1} Started={2} ErrorCode={3}
+
+ 已启用 {0},但证书路径配置 {1} 为空。
+
+
+ {0} 证书文件不存在:{1}。
+
+
+ {0} PEM 证书必须配置独立私钥路径 {1}。
+
+
+ {0} PEM 私钥文件不存在:{1}。
+
+
+ {0} 证书不包含私钥:{1}。
+
+
+ {0} 证书加载失败(证书或私钥损坏、密码错误或私钥不匹配):{1}。
+
+
+ {0} 私钥使用了传统 OpenSSL 加密格式(Proc-Type/DEK-Info 头),不受支持;请先使用 openssl pkcs8 -topk8 转换为加密 PKCS#8 格式:{1}。
+
+
+ 已配置 WSS TLS 监听,端口:{0}。
+
diff --git a/GameFrameX.StartUp/AppStartUpBase.ByHTTPServer.cs b/GameFrameX.StartUp/AppStartUpBase.ByHTTPServer.cs
index 24749efbb..845278506 100644
--- a/GameFrameX.StartUp/AppStartUpBase.ByHTTPServer.cs
+++ b/GameFrameX.StartUp/AppStartUpBase.ByHTTPServer.cs
@@ -333,41 +333,16 @@ private void ConfigureMetricsPortListen(KestrelServerOptions options)
}
///
- /// 加载 HTTPS 证书(PFX),供 监听使用;任一配置错误在 Kestrel 监听前 fail-fast。
+ /// 加载 HTTPS 的 PFX 或 PEM 证书,在 Kestrel 监听前校验证书配置。
///
- ///
- /// Loads the HTTPS certificate (PFX) for the listener; any configuration error
- /// fails fast before Kestrel starts listening.
- /// 调用前提: 的 HttpsPort 大于 0。
- ///
+ /// Loads the HTTPS PFX or PEM certificate and validates configuration before Kestrel listens.
/// 应用配置 / Application setting
- /// 已加载的证书 / The loaded certificate
- /// 证书路径为空 / The certificate path is empty
- /// 证书文件不存在 / The certificate file does not exist
- /// 证书加载失败(密码错误或文件损坏)/ The certificate failed to load (wrong password or corrupted file)
+ /// 包含私钥的证书 / Certificate with its private key
+ /// 证书配置或加载失败 / Certificate configuration or loading failed
internal static X509Certificate2 LoadHttpsCertificate(AppSetting setting)
{
- if (setting.HttpsCertPath.IsNullOrWhiteSpace())
- {
- // Localization: StartUp.HttpExceptions.HttpsCertPathMissing - 已配置 HTTPS 端口 [{0}],但 HttpsCertPath 未配置(PFX 文件路径),无法启用 HTTPS 监听
- throw new InvalidOperationException(LocalizationService.GetString(Localization.Keys.StartUp.HttpExceptions.HttpsCertPathMissing, setting.HttpsPort));
- }
-
- if (!File.Exists(setting.HttpsCertPath))
- {
- // Localization: StartUp.HttpExceptions.HttpsCertFileNotFound - HTTPS 证书文件不存在: {0}
- throw new InvalidOperationException(LocalizationService.GetString(Localization.Keys.StartUp.HttpExceptions.HttpsCertFileNotFound, setting.HttpsCertPath));
- }
-
- try
- {
- return X509CertificateLoader.LoadPkcs12FromFile(setting.HttpsCertPath, setting.HttpsCertPassword, X509KeyStorageFlags.DefaultKeySet, Pkcs12LoaderLimits.Defaults);
- }
- catch (Exception e)
- {
- // Localization: StartUp.HttpExceptions.HttpsCertLoadFailed - HTTPS 证书加载失败(密码错误或文件损坏): {0}
- throw new InvalidOperationException(LocalizationService.GetString(Localization.Keys.StartUp.HttpExceptions.HttpsCertLoadFailed, setting.HttpsCertPath), e);
- }
+ return ServerCertificateLoader.Load("HTTPS", setting.HttpsCertPath, setting.HttpsCertificateKeyPath,
+ setting.HttpsCertPassword, nameof(setting.HttpsCertPath), nameof(setting.HttpsCertificateKeyPath));
}
///
diff --git a/GameFrameX.StartUp/AppStartUpBase.ByServer.cs b/GameFrameX.StartUp/AppStartUpBase.ByServer.cs
index 0bd6cfb4c..7e581549b 100644
--- a/GameFrameX.StartUp/AppStartUpBase.ByServer.cs
+++ b/GameFrameX.StartUp/AppStartUpBase.ByServer.cs
@@ -29,6 +29,8 @@
using System.Runtime.CompilerServices;
+using System.Security.Authentication;
+using System.Security.Cryptography.X509Certificates;
using GameFrameX.Foundation.Logger;
using GameFrameX.Foundation.Localization.Core;
using GameFrameX.Network;
@@ -122,6 +124,10 @@ protected async Task StopServerAsync()
_gameServer = null;
}
+ // 所有 WebSocket 连接停止后再释放 TLS 证书。
+ _wssCertificate?.Dispose();
+ _wssCertificate = null;
+
// HTTP 宿主最后停:此刻同 Role 的能力族(Actor/业务服务)与 socket 宿主均已停止,满足「HTTP 停机晚于其它服务」的顺序约束
await StopHttpServerAsync();
}
@@ -419,6 +425,12 @@ protected async Task InvokeMessageHandlerAsync(IMessageHandler handler, INetwork
/// 用于管理游戏服务器生命周期的主机实例 / Host instance for managing the game server lifecycle
private IHost _gameServer;
+ ///
+ /// WSS 监听持有的证书,停机后释放。
+ ///
+ /// Certificate owned by the WSS listener and released after shutdown.
+ private X509Certificate2 _wssCertificate;
+
///
/// 启动 TCP 服务器。
///
@@ -434,23 +446,32 @@ protected async Task InvokeMessageHandlerAsync(IMessageHandler handler, INetwork
private async Task StartServerCoreAsync(List baseHandler, Func httpFactory, List aopHandlerTypes = null, LogLevel minimumLevelLogLevel = LogLevel.Debug)
{
var multipleServerHostBuilder = MultipleServerHostBuilder.Create();
- ConfigureTcpServer(multipleServerHostBuilder);
- ConfigureKcpServer(multipleServerHostBuilder);
- ConfigureInnerKcpServer(multipleServerHostBuilder);
- ConfigureWebSocketServer(multipleServerHostBuilder);
+ try
+ {
+ ConfigureTcpServer(multipleServerHostBuilder);
+ ConfigureKcpServer(multipleServerHostBuilder);
+ ConfigureInnerKcpServer(multipleServerHostBuilder);
+ ConfigureWebSocketServer(multipleServerHostBuilder);
- // await StartHttpServerAsync(hostBuilder,baseHandler, httpFactory, aopHandlerTypes, minimumLevelLogLevel);
- await StartHttpServerAsync(baseHandler, httpFactory, aopHandlerTypes, minimumLevelLogLevel);
+ // await StartHttpServerAsync(hostBuilder,baseHandler, httpFactory, aopHandlerTypes, minimumLevelLogLevel);
+ await StartHttpServerAsync(baseHandler, httpFactory, aopHandlerTypes, minimumLevelLogLevel);
- // 配置日志
- ConfigureHostLogging(multipleServerHostBuilder, minimumLevelLogLevel);
- // 配置监控和跟踪
- multipleServerHostBuilder.ConfigureServices(services => { services.AddServiceDefaults(Setting.IsOpenTelemetry, Setting.IsOpenTelemetryMetrics, Setting.IsOpenTelemetryTracing); });
+ // 配置日志
+ ConfigureHostLogging(multipleServerHostBuilder, minimumLevelLogLevel);
+ // 配置监控和跟踪
+ multipleServerHostBuilder.ConfigureServices(services => { services.AddServiceDefaults(Setting.IsOpenTelemetry, Setting.IsOpenTelemetryMetrics, Setting.IsOpenTelemetryTracing); });
- // 构建并启动服务器
- _gameServer = multipleServerHostBuilder.Build();
+ // 构建并启动服务器
+ _gameServer = multipleServerHostBuilder.Build();
- await _gameServer.StartAsync();
+ await _gameServer.StartAsync();
+ }
+ catch when (_wssCertificate != null)
+ {
+ // WSS 启动中断时停止已启动的监听,保证预加载证书不泄漏。
+ await StopServerAsync();
+ throw;
+ }
}
///
@@ -756,7 +777,7 @@ private static ValueTask VerifyInterNodeHandshakeCredentialAsync(INetworkM
///
/// Configure the WebSocket server. Extracted from to keep cognitive complexity under the Sonar S3776 threshold.
/// 多服务器主机构建器 / Multiple server host builder
- private void ConfigureWebSocketServer(MultipleServerHostBuilder multipleServerHostBuilder)
+ internal void ConfigureWebSocketServer(MultipleServerHostBuilder multipleServerHostBuilder)
{
if (!Setting.IsEnableWebSocket)
{
@@ -765,11 +786,23 @@ private void ConfigureWebSocketServer(MultipleServerHostBuilder multipleServerHo
return;
}
- // 检查WebSocket端口是否可用
- if (Setting.WsPort is > 0 and < ushort.MaxValue && NetHelper.PortIsAvailable(Setting.WsPort))
+ // WSS 配置独立预加载,证书错误必须在任何 WebSocket 监听开始前暴露。
+ _wssCertificate = Setting.WssPort > 0 ? LoadWssCertificate(Setting) : null;
+ if (_wssCertificate != null)
+ {
+ LogHelper.Info(LocalizationService.GetString(Localization.Keys.StartUp.WebSocketServer.SecureListenerConfigured, Setting.WssPort));
+ }
+
+ var enablePlainListener = Setting.WsPort is > 0 and < ushort.MaxValue && NetHelper.PortIsAvailable(Setting.WsPort);
+
+ // 检查明文端口,保持存量端口不可用时的日志与跳过行为。
+ if (enablePlainListener || _wssCertificate != null)
{
- // Localization: StartUp.WebSocketServer.StartingServer - 启动WebSocket服务器 类型: {0}, 端口: {1}
- LogHelper.Info(LocalizationService.GetString(Localization.Keys.StartUp.WebSocketServer.StartingServer, ServerType, Setting.WsPort));
+ if (enablePlainListener)
+ {
+ // Localization: StartUp.WebSocketServer.StartingServer - 启动WebSocket服务器 类型: {0}, 端口: {1}
+ LogHelper.Info(LocalizationService.GetString(Localization.Keys.StartUp.WebSocketServer.StartingServer, ServerType, Setting.WsPort));
+ }
// 配置并启动WebSocket服务器
multipleServerHostBuilder.AddWebSocketServer(builder =>
@@ -781,19 +814,38 @@ private void ConfigureWebSocketServer(MultipleServerHostBuilder multipleServerHo
{
serviceCollection.Configure(options =>
{
- var listenOptions = new ListenOptions
+ if (enablePlainListener)
{
- Ip = "Any",
- Port = Setting.WsPort,
- };
- options.AddListener(listenOptions);
+ options.AddListener(new ListenOptions { Ip = "Any", Port = Setting.WsPort });
+ }
+
+ if (_wssCertificate != null)
+ {
+ // SuperSocket 根据 AuthenticationOptions 装配 SslStreamInitializer。
+ options.AddListener(new ListenOptions
+ {
+ Ip = "Any",
+ Port = Setting.WssPort,
+ // EnabledSslProtocols 必须显式设置:StreamInitializersFactory 以其非 None 作为启用 TLS 的判定,
+ // 只赋 ServerCertificate 时该监听仍是明文 TCP(wss 握手将超时)。
+ AuthenticationOptions = new ServerAuthenticationOptions
+ {
+ ServerCertificate = _wssCertificate,
+ EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13,
+ },
+ });
+ }
});
});
});
- // Localization: StartUp.WebSocketServer.StartupComplete - 启动WebSocket服务器完成 类型: {0}, 端口: {1}
- LogHelper.Info(LocalizationService.GetString(Localization.Keys.StartUp.WebSocketServer.StartupComplete, ServerType, Setting.WsPort));
+ if (enablePlainListener)
+ {
+ // Localization: StartUp.WebSocketServer.StartupComplete - 启动WebSocket服务器完成 类型: {0}, 端口: {1}
+ LogHelper.Info(LocalizationService.GetString(Localization.Keys.StartUp.WebSocketServer.StartupComplete, ServerType, Setting.WsPort));
+ }
}
- else
+
+ if (!enablePlainListener)
{
// Localization: StartUp.WebSocketServer.StartupFailed - 启动WebSocket服务器失败 类型: {0}, 端口: {1}, 原因: 端口无效或被占用
LogHelper.Warning(LocalizationService.GetString(Localization.Keys.StartUp.WebSocketServer.StartupFailed, ServerType, Setting.WsPort));
@@ -801,6 +853,19 @@ private void ConfigureWebSocketServer(MultipleServerHostBuilder multipleServerHo
}
}
+ ///
+ /// 加载独立于 HTTPS 配置的 WSS 证书。
+ ///
+ /// Loads the WSS certificate independently of HTTPS configuration.
+ /// 应用配置 / Application setting
+ /// 包含私钥的证书 / Certificate with its private key
+ /// 证书配置或加载失败 / Certificate configuration or loading failed
+ internal static X509Certificate2 LoadWssCertificate(AppSetting setting)
+ {
+ return ServerCertificateLoader.Load("WSS", setting.WssCertFilePath, setting.WssCertificateKeyPath,
+ setting.WssCertificatePassword, nameof(setting.WssCertFilePath), nameof(setting.WssCertificateKeyPath));
+ }
+
///
/// 端口不可用时记录占用该端口的进程详情。
///
@@ -869,4 +934,4 @@ private async ValueTask WebSocketMessageHandlerAsync(WebSocketSession session, W
}
#endregion
-}
\ No newline at end of file
+}
diff --git a/GameFrameX.StartUp/ServerCertificateLoader.cs b/GameFrameX.StartUp/ServerCertificateLoader.cs
new file mode 100644
index 000000000..037ce9865
--- /dev/null
+++ b/GameFrameX.StartUp/ServerCertificateLoader.cs
@@ -0,0 +1,165 @@
+// ==========================================================================================
+// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利
+// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights
+// 均受中华人民共和国及相关国际法律法规保护。
+// are protected by the laws of the People's Republic of China and relevant international regulations.
+// 使用本项目须严格遵守相应法律法规及开源许可证之规定。
+// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses.
+// 本项目采用 Apache License 2.0 单协议分发,
+// This project is licensed solely under the Apache License 2.0,
+// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。
+// please refer to the LICENSE file in the root directory of the source code for the full license text.
+// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、
+// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order,
+// 侵犯他人合法权益等法律法规所禁止的行为!
+// or infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations!
+// 因基于本项目二次开发所产生的一切法律纠纷与责任,
+// Any legal disputes and liabilities arising from secondary development based on this project
+// 本项目组织与贡献者概不承担。
+// shall be borne solely by the developer; the project organization and contributors assume no responsibility.
+// GitHub 仓库:https://github.com/GameFrameX
+// GitHub Repository: https://github.com/GameFrameX
+// Gitee 仓库:https://gitee.com/GameFrameX
+// Gitee Repository: https://gitee.com/GameFrameX
+// CNB 仓库:https://cnb.cool/GameFrameX
+// CNB Repository: https://cnb.cool/GameFrameX
+// 官方文档:https://gameframex.doc.alianblank.com/
+// Official Documentation: https://gameframex.doc.alianblank.com/
+// ==========================================================================================
+
+
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using GameFrameX.Foundation.Localization.Core;
+using CertificateKeys = GameFrameX.Localization.Keys.StartUp.Certificates;
+
+namespace GameFrameX.StartUp;
+
+///
+/// 启动期共用的服务器证书加载器。
+///
+/// Shared server certificate loader used during startup.
+internal static class ServerCertificateLoader
+{
+ ///
+ /// 加载 PFX 或独立证书与私钥组成的 PEM 证书,任一错误立即阻断启动。
+ ///
+ /// Loads PFX or PEM certificate and key files, failing startup immediately on any error.
+ /// 协议名称 / Protocol name
+ /// 证书路径 / Certificate path
+ /// PEM 私钥路径 / PEM private key path
+ /// PFX 或加密私钥密码 / PFX or encrypted private key password
+ /// 证书配置项名称 / Certificate setting name
+ /// 私钥配置项名称 / Private key setting name
+ /// 包含私钥的证书 / Certificate with its private key
+ /// 配置无效或加载失败 / Invalid configuration or loading failure
+ internal static X509Certificate2 Load(string protocol, string certificatePath, string keyPath, string password,
+ string certificateSettingName, string keySettingName)
+ {
+ if (string.IsNullOrWhiteSpace(certificatePath))
+ {
+ throw new InvalidOperationException(LocalizationService.GetString(CertificateKeys.PathMissing, protocol, certificateSettingName));
+ }
+
+ if (!File.Exists(certificatePath))
+ {
+ throw new InvalidOperationException(LocalizationService.GetString(CertificateKeys.FileNotFound, protocol, certificatePath));
+ }
+
+ // PEM 扩展名明确要求独立私钥,避免漏配时被当作 PFX 或从证书文件隐式读取私钥。
+ var extension = Path.GetExtension(certificatePath);
+ var isPem = extension.Equals(".pem", StringComparison.OrdinalIgnoreCase) || extension.Equals(".crt", StringComparison.OrdinalIgnoreCase);
+ if (isPem && string.IsNullOrWhiteSpace(keyPath))
+ {
+ throw new InvalidOperationException(LocalizationService.GetString(CertificateKeys.KeyPathMissing, protocol, keySettingName));
+ }
+
+ if (isPem && !File.Exists(keyPath))
+ {
+ throw new InvalidOperationException(LocalizationService.GetString(CertificateKeys.KeyFileNotFound, protocol, keyPath));
+ }
+
+ // 私钥文本只读取一次:传统加密格式识别在 try 外完成,避免其专属错误被 LoadFailed 双重包装掩盖。
+ var pemKeyText = isPem ? File.ReadAllText(keyPath) : null;
+ if (isPem && !IsPkcs8EncryptedKey(pemKeyText) && IsTraditionalOpenSslEncryptedKey(pemKeyText))
+ {
+ // .NET 仅支持 RFC 7468 加密 PKCS#8(BEGIN ENCRYPTED PRIVATE KEY);
+ // 传统 OpenSSL 加密私钥(Proc-Type/DEK-Info 头)交给 CreateFromPemFile 只会得到误导性加载失败,
+ // 这里明确拒绝并给出转换指引。
+ throw new InvalidOperationException(LocalizationService.GetString(CertificateKeys.TraditionalEncryptedKeyUnsupported, protocol, keyPath));
+ }
+
+ X509Certificate2 loadedCertificate;
+ try
+ {
+ loadedCertificate = isPem
+ ? LoadPemCertificate(certificatePath, keyPath, password, pemKeyText)
+ : X509CertificateLoader.LoadPkcs12FromFile(certificatePath, password, X509KeyStorageFlags.DefaultKeySet, Pkcs12LoaderLimits.Defaults);
+ }
+ catch (Exception exception)
+ {
+ throw new InvalidOperationException(LocalizationService.GetString(CertificateKeys.LoadFailed, protocol, certificatePath), exception);
+ }
+
+ // 私钥校验放在 try/catch 之外:PrivateKeyMissing 是独立的配置结论,
+ // 被 LoadFailed 包装会让该消息不可达且误导排障方向。
+ if (!loadedCertificate.HasPrivateKey)
+ {
+ loadedCertificate.Dispose();
+ throw new InvalidOperationException(LocalizationService.GetString(CertificateKeys.PrivateKeyMissing, protocol, certificatePath));
+ }
+
+ return loadedCertificate;
+ }
+
+ ///
+ /// 判断私钥文本是否为 RFC 7468 加密 PKCS#8 格式。
+ ///
+ /// Determines whether the key text is an RFC 7468 encrypted PKCS#8 PEM.
+ /// 私钥文件文本 / Private key file text
+ /// 是加密 PKCS#8 返回 true / Returns true for encrypted PKCS#8
+ private static bool IsPkcs8EncryptedKey(string keyText)
+ {
+ return keyText.Contains("-----BEGIN ENCRYPTED PRIVATE KEY-----", StringComparison.Ordinal);
+ }
+
+ ///
+ /// 判断私钥文本是否为传统 OpenSSL 加密格式(Proc-Type/DEK-Info 头)。
+ ///
+ /// Determines whether the key text uses the legacy OpenSSL encrypted format (Proc-Type/DEK-Info headers).
+ /// 私钥文件文本 / Private key file text
+ /// 是传统加密格式返回 true / Returns true for the legacy encrypted format
+ private static bool IsTraditionalOpenSslEncryptedKey(string keyText)
+ {
+ return keyText.Contains("Proc-Type:", StringComparison.Ordinal) || keyText.Contains("DEK-Info:", StringComparison.Ordinal);
+ }
+
+ ///
+ /// 加载 PEM 并转换为带持久私钥关联的证书,兼容 Windows SChannel。
+ ///
+ /// Loads PEM and converts it to a certificate with a persisted key association for Windows SChannel.
+ /// PEM 证书路径 / PEM certificate path
+ /// PEM 私钥路径 / PEM private key path
+ /// 加密私钥密码 / Encrypted private key password
+ /// 已读取的私钥文件文本 / The already-read private key file text
+ /// 重载后的证书 / Reloaded certificate
+ private static X509Certificate2 LoadPemCertificate(string certificatePath, string keyPath, string password, string keyText)
+ {
+ // 根据私钥头识别是否加密,允许未加密私钥使用空密码或已有密码配置。
+ using (var certificate = IsPkcs8EncryptedKey(keyText)
+ ? X509Certificate2.CreateFromEncryptedPemFile(certificatePath, password.AsSpan(), keyPath)
+ : X509Certificate2.CreateFromPemFile(certificatePath, keyPath))
+ {
+ var certificateBytes = certificate.Export(X509ContentType.Pfx);
+ try
+ {
+ // 不使用 EphemeralKeySet:SChannel 无法使用 PEM 默认产生的临时私钥。
+ return X509CertificateLoader.LoadPkcs12(certificateBytes, null, X509KeyStorageFlags.DefaultKeySet, Pkcs12LoaderLimits.Defaults);
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(certificateBytes);
+ }
+ }
+ }
+}
diff --git a/GameFrameX.Utility/Setting/AppSetting.cs b/GameFrameX.Utility/Setting/AppSetting.cs
index 738dcaa43..236b7875f 100644
--- a/GameFrameX.Utility/Setting/AppSetting.cs
+++ b/GameFrameX.Utility/Setting/AppSetting.cs
@@ -93,12 +93,12 @@ public bool IsLocal(int serverId)
}
///
- /// 将对象序列化为JSON字符串;凭据字段(数据库密码、HTTP 签名密钥、节点间鉴权密钥、HTTPS 证书密码)已脱敏,
+ /// 将对象序列化为JSON字符串;凭据字段(数据库密码、HTTP 签名密钥、节点间鉴权密钥、HTTPS / WSS 证书密码)已脱敏,
/// 输出可安全进入日志。
///
///
/// Serializes the object to a JSON string with credential fields (database password, HTTP signature secret,
- /// inter-node auth secret, HTTPS certificate password) masked; the output is safe for logging.
+ /// inter-node auth secret, HTTPS / WSS certificate passwords) masked; the output is safe for logging.
///
/// JSON字符串(凭据已脱敏)/ JSON string (credentials masked)
public override string ToString()
@@ -107,12 +107,12 @@ public override string ToString()
}
///
- /// 将对象序列化为格式化的JSON字符串;凭据字段(数据库密码、HTTP 签名密钥、节点间鉴权密钥、HTTPS 证书密码)已脱敏,
+ /// 将对象序列化为格式化的JSON字符串;凭据字段(数据库密码、HTTP 签名密钥、节点间鉴权密钥、HTTPS / WSS 证书密码)已脱敏,
/// 输出可安全进入日志。
///
///
/// Serializes the object to a formatted JSON string with credential fields (database password, HTTP signature secret,
- /// inter-node auth secret, HTTPS certificate password) masked; the output is safe for logging.
+ /// inter-node auth secret, HTTPS / WSS certificate passwords) masked; the output is safe for logging.
///
/// 格式化的JSON字符串(凭据已脱敏)/ Formatted JSON string (credentials masked)
public string ToFormatString()
@@ -127,15 +127,15 @@ public string ToFormatString()
///
/// 创建用于序列化输出的脱敏浅拷贝:、、
- /// 、 四个凭据字段的非空值替换为
+ /// 、、 等凭据字段的非空值替换为
/// (null 与空串原样保留,用于区分"未配置")。
/// 安全约定:后续在 新增凭据类字段时,必须同步加入本方法的掩码清单,
/// 否则其明文将随启动/停止日志输出。
///
///
- /// Creates a masked shallow copy for serialization output: non-empty values of the four credential fields
+ /// Creates a masked shallow copy for serialization output: non-empty values of the credential fields
/// (, , ,
- /// ) are replaced with (null and empty strings
+ /// , ) are replaced with (null and empty strings
/// are kept as-is to distinguish "not configured").
/// Convention: any new credential-like field added to must be added to this mask list,
/// otherwise its plaintext would leak through startup/shutdown logs.
@@ -148,6 +148,7 @@ private AppSetting CreateMaskedCloneForSerialization()
maskedClone.HttpSignSecret = MaskSensitiveValue(HttpSignSecret);
maskedClone.InterNodeAuthSecret = MaskSensitiveValue(InterNodeAuthSecret);
maskedClone.HttpsCertPassword = MaskSensitiveValue(HttpsCertPassword);
+ maskedClone.WssCertificatePassword = MaskSensitiveValue(WssCertificatePassword);
return maskedClone;
}
@@ -631,28 +632,36 @@ private static string MaskSensitiveValue(string value)
public ushort HttpsPort { get; set; }
///
- /// HTTPS 证书文件路径(PFX 格式)。
+ /// HTTPS 证书文件路径(PFX 或 PEM 格式)。
/// HttpsPort 大于 0 时必填;启动期校验,路径为空 / 文件缺失 / 加载失败均 fail fast。
///
///
- /// HTTPS certificate file path (PFX format).
+ /// HTTPS certificate file path (PFX or PEM format).
/// Required when HttpsPort is greater than 0; validated at startup — empty path, missing file
/// or load failure all fail fast.
///
- [Option(nameof(HttpsCertPath), DefaultValue = "", Description = "HTTPS 证书文件路径(PFX格式),HttpsPort>0 时必填")]
+ [Option(nameof(HttpsCertPath), DefaultValue = "", Description = "HTTPS 证书文件路径(PFX或PEM格式),HttpsPort>0 时必填")]
[SettingFieldLevel(SettingFieldLevel.ProcessLevel)]
public string HttpsCertPath { get; set; } = string.Empty;
///
- /// HTTPS 证书文件密码(PFX 格式,可为空)。
+ /// HTTPS PFX 或 PEM 加密私钥密码(可为空)。
///
///
- /// HTTPS certificate file password (PFX format, may be empty).
+ /// HTTPS PFX or encrypted PEM private key password (may be empty).
///
[Option(nameof(HttpsCertPassword), DefaultValue = "", Description = "HTTPS 证书文件密码,可为空")]
[SettingFieldLevel(SettingFieldLevel.ProcessLevel)]
public string HttpsCertPassword { get; set; } = string.Empty;
+ ///
+ /// HTTPS PEM 独立私钥路径,证书为 .pem 或 .crt 时必填。
+ ///
+ /// HTTPS PEM private key path, required for .pem or .crt certificates.
+ [Option(nameof(HttpsCertificateKeyPath), DefaultValue = "", Description = "HTTPS PEM 独立私钥路径,证书为 .pem 或 .crt 时必填")]
+ [SettingFieldLevel(SettingFieldLevel.ProcessLevel)]
+ public string HttpsCertificateKeyPath { get; set; } = string.Empty;
+
///
/// HTTP 请求体最大字节数。
///
@@ -838,15 +847,31 @@ private static string MaskSensitiveValue(string value)
public ushort WssPort { get; set; }
///
- /// Wss使用的证书路径
+ /// WSS 证书路径(PFX 或 PEM),WssPort 大于 0 时必填
///
///
- /// Certificate file path for WSS.
+ /// WSS certificate file path (PFX or PEM), required when WssPort is greater than 0.
///
[Option(nameof(WssCertFilePath), Description = "Wss 使用的证书路径")]
[SettingFieldLevel(SettingFieldLevel.RoleLevel)]
public string WssCertFilePath { get; set; }
+ ///
+ /// WSS PEM 独立私钥路径,证书为 .pem 或 .crt 时必填。
+ ///
+ /// WSS PEM private key path, required for .pem or .crt certificates.
+ [Option(nameof(WssCertificateKeyPath), DefaultValue = "", Description = "WSS PEM 独立私钥路径,证书为 .pem 或 .crt 时必填")]
+ [SettingFieldLevel(SettingFieldLevel.RoleLevel)]
+ public string WssCertificateKeyPath { get; set; } = string.Empty;
+
+ ///
+ /// WSS PFX 或 PEM 加密私钥密码,可为空。
+ ///
+ /// WSS PFX or encrypted PEM private key password, may be empty.
+ [Option(nameof(WssCertificatePassword), DefaultValue = "", Description = "WSS PFX 或 PEM 加密私钥密码,可为空")]
+ [SettingFieldLevel(SettingFieldLevel.RoleLevel)]
+ public string WssCertificatePassword { get; set; } = string.Empty;
+
///
/// 数据库地址
///
diff --git a/README.ja.md b/README.ja.md
index 4d86a8f2b..18a0f41ad 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -205,11 +205,16 @@ GameFrameX はコマンドライン引数(`--Key=Value`)で設定を行い
| `InterNodeAuthSecret` | ノード間 RPC 認証 共有シークレット(プロトコルハンドシェイク HMAC。空 = 無効)。クラスタ内の全ノードで同一値を設定 | なし | `s3cr3t` |
| `IsEnableWebSocket` | WebSocket を有効化 | `false` | `true` |
| `WsPort` | WebSocket ポート | `8889` | `29300` |
+| `WssPort` | WSS TLS ポート(`0` は無効、`IsEnableWebSocket=true` が必要) | `0` | `8444` |
+| `WssCertFilePath` | WSS 証明書パス(PFX または PEM)、`WssPort > 0` の場合は必須 | なし | `/certs/wss/fullchain.pem` |
+| `WssCertificateKeyPath` | WSS PEM の独立した秘密鍵パス、`.pem` / `.crt` 証明書では必須 | なし | `/certs/wss/privkey.pem` |
+| `WssCertificatePassword` | WSS PFX または暗号化 PEM 秘密鍵のパスワード(暗号化 PKCS#8 形式のみ) | なし | |
| `IsEnableHttp` | HTTP サービスを有効化 | `true` | `true` |
| `HttpPort` | HTTP サービスポート | `8080` | `28080` |
| `HttpsPort` | HTTPS サービスポート | なし | `443` |
-| `HttpsCertPath` | HTTPS 証明書ファイルパス(PFX)、`HttpsPort > 0` の場合は必須 | なし | `/path/cert.pfx` |
-| `HttpsCertPassword` | HTTPS 証明書パスワード(任意) | なし | |
+| `HttpsCertPath` | HTTPS 証明書パス(PFX または PEM)、`HttpsPort > 0` の場合は必須 | なし | `/path/cert.pfx` |
+| `HttpsCertPassword` | HTTPS PFX または暗号化 PEM 秘密鍵のパスワード(暗号化 PKCS#8 形式のみ) | なし | |
+| `HttpsCertificateKeyPath` | HTTPS PEM の独立した秘密鍵パス、`.pem` / `.crt` 証明書では必須 | なし | `/certs/https/privkey.pem` |
| `HttpSignSecret` | HTTP 署名シークレット(HMAC-SHA256)、署名検証有効時は必須。空の場合はすべて拒否(fail-closed) | なし | `a-strong-secret` |
| `HttpUrl` | API ルートパス | `/game/api/` | `/game/api/` |
| `HttpIsDevelopment` | HTTP 開発モード(Swagger を有効化) | `false` | `true` |
@@ -219,6 +224,29 @@ GameFrameX はコマンドライン引数(`--Key=Value`)で設定を行い
| `OnlineTenantId` | Online Runtime 認可テナント ID(スコープトリプル) | `0` | `1` |
| `OnlineAppId` | Online Runtime 認可アプリ ID(スコープトリプル) | `0` | `1` |
+#### nginx なしで HTTPS / WSS に直接接続
+
+サーバーは nginx プロキシを使わずに証明書を直接読み込めます。`IsEnableHttp=true` として `HttpsPort` と `HttpsCertPath` を設定し、`IsEnableWebSocket=true` として `WssPort` と `WssCertFilePath` を設定します。HTTPS と WSS の証明書設定は独立しており、WSS が HTTPS の設定を代用することはありません。平文の `HttpPort` と `WsPort` リスナーも引き続き利用できます。`WssPort` を省略するか `0` にすると、WebSocket の動作は従来どおりです。
+
+```json
+{
+ "IsEnableHttp": true,
+ "HttpPort": 8080,
+ "HttpsPort": 8443,
+ "HttpsCertPath": "/certs/https/fullchain.pem",
+ "HttpsCertificateKeyPath": "/certs/https/privkey.pem",
+ "HttpsCertPassword": "",
+ "IsEnableWebSocket": true,
+ "WsPort": 8889,
+ "WssPort": 8444,
+ "WssCertFilePath": "/certs/wss/fullchain.pem",
+ "WssCertificateKeyPath": "/certs/wss/privkey.pem",
+ "WssCertificatePassword": ""
+}
+```
+
+証明書パスの拡張子が `.pem` または `.crt`(大文字と小文字を区別しない)の場合、独立した秘密鍵パスが必須です。それ以外は PFX として読み込みます。暗号化 PEM 秘密鍵(RFC 7468 暗号化 PKCS#8、`BEGIN ENCRYPTED PRIVATE KEY`)には `HttpsCertPassword` / `WssCertificatePassword` を指定します。従来の OpenSSL 暗号化秘密鍵(Proc-Type/DEK-Info ヘッダー)は拒否されるため、先に `openssl pkcs8 -topk8` で変換してください。暗号化されていない鍵ではパスワードを空にできます。既存の PFX 設定は変更不要で、同じパスワード項目を使用し、秘密鍵パスは不要です。パスやファイルの欠落、無効な証明書や鍵、誤ったパスワードは起動時に即座にエラーとなります。クライアントは証明書を信頼し、証明書に含まれるホスト名で接続する必要があります。
+
#### データベース設定
| 設定項目 | 説明 | デフォルト | 例 |
diff --git a/README.ko.md b/README.ko.md
index 5414ebc99..8fe22358a 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -206,15 +206,43 @@ GameFrameX는 명령줄 인수(`--Key=Value`)로 설정합니다. 모든 설정
| `InterNodeAuthSecret` | 노드 간 RPC 인증 공유 시크릿(프로토콜 핸드셰이크 HMAC. 비어 있으면 비활성). 클러스터 내 모든 노드가 동일값을 설정해야 함 | 없음 | `s3cr3t` |
| `IsEnableWebSocket` | WebSocket 활성화 | `false` | `true` |
| `WsPort` | WebSocket 포트 | `8889` | `29300` |
+| `WssPort` | WSS TLS 포트 (`0`은 비활성화, `IsEnableWebSocket=true` 필요) | `0` | `8444` |
+| `WssCertFilePath` | WSS 인증서 경로(PFX 또는 PEM), `WssPort > 0`이면 필수 | 없음 | `/certs/wss/fullchain.pem` |
+| `WssCertificateKeyPath` | WSS PEM의 별도 개인 키 경로, `.pem` / `.crt` 인증서에 필수 | 없음 | `/certs/wss/privkey.pem` |
+| `WssCertificatePassword` | WSS PFX 또는 암호화된 PEM 개인 키 비밀번호(암호화 PKCS#8 형식만 지원) | 없음 | |
| `IsEnableHttp` | HTTP 서비스 활성화 | `true` | `true` |
| `HttpPort` | HTTP 서비스 포트 | `8080` | `28080` |
| `HttpsPort` | HTTPS 서비스 포트 | 없음 | `443` |
-| `HttpsCertPath` | HTTPS 인증서 파일 경로(PFX), `HttpsPort > 0`이면 필수 | 없음 | `/path/cert.pfx` |
-| `HttpsCertPassword` | HTTPS 인증서 비밀번호(선택) | 없음 | |
+| `HttpsCertPath` | HTTPS 인증서 경로(PFX 또는 PEM), `HttpsPort > 0`이면 필수 | 없음 | `/path/cert.pfx` |
+| `HttpsCertPassword` | HTTPS PFX 또는 암호화된 PEM 개인 키 비밀번호(암호화 PKCS#8 형식만 지원) | 없음 | |
+| `HttpsCertificateKeyPath` | HTTPS PEM의 별도 개인 키 경로, `.pem` / `.crt` 인증서에 필수 | 없음 | `/certs/https/privkey.pem` |
| `HttpSignSecret` | HTTP 서명 시크릿(HMAC-SHA256), 서명 검증 활성화 시 필수. 비어 있으면 모두 거부(fail-closed) | 없음 | `a-strong-secret` |
| `HttpUrl` | API 루트 경로 | `/game/api/` | `/game/api/` |
| `HttpIsDevelopment` | HTTP 개발 모드(Swagger 활성화) | `false` | `true` |
+#### nginx 없이 HTTPS / WSS 직접 연결
+
+서버는 nginx 프록시 없이 인증서를 직접 로드할 수 있습니다. `IsEnableHttp=true`에서 `HttpsPort`와 `HttpsCertPath`를, `IsEnableWebSocket=true`에서 `WssPort`와 `WssCertFilePath`를 설정합니다. HTTPS와 WSS 인증서 설정은 독립적이며 WSS는 HTTPS 설정을 대신 사용하지 않습니다. 평문 `HttpPort` 및 `WsPort` 리스너도 계속 사용할 수 있습니다. `WssPort`를 생략하거나 `0`으로 설정하면 기존 WebSocket 동작이 유지됩니다.
+
+```json
+{
+ "IsEnableHttp": true,
+ "HttpPort": 8080,
+ "HttpsPort": 8443,
+ "HttpsCertPath": "/certs/https/fullchain.pem",
+ "HttpsCertificateKeyPath": "/certs/https/privkey.pem",
+ "HttpsCertPassword": "",
+ "IsEnableWebSocket": true,
+ "WsPort": 8889,
+ "WssPort": 8444,
+ "WssCertFilePath": "/certs/wss/fullchain.pem",
+ "WssCertificateKeyPath": "/certs/wss/privkey.pem",
+ "WssCertificatePassword": ""
+}
+```
+
+인증서 경로가 `.pem` 또는 `.crt`로 끝나면(대소문자 구분 없음) 별도 개인 키 경로가 필요하며, 나머지 확장자는 PFX로 로드합니다. 암호화된 PEM 키(RFC 7468 암호화 PKCS#8, `BEGIN ENCRYPTED PRIVATE KEY`)에는 `HttpsCertPassword` / `WssCertificatePassword`를 지정합니다. 기존 OpenSSL 암호화 개인 키(Proc-Type/DEK-Info 헤더)는 거부되므로 먼저 `openssl pkcs8 -topk8`로 변환하세요. 암호화되지 않은 키는 비밀번호를 비워 둘 수 있습니다. 기존 PFX 설정은 변경할 필요 없이 같은 비밀번호 필드를 사용하며 개인 키 경로는 필요하지 않습니다. 경로나 파일 누락, 잘못된 인증서나 키, 틀린 비밀번호는 시작 단계에서 즉시 오류를 발생시킵니다. 클라이언트는 인증서를 신뢰하고 인증서에 포함된 호스트 이름으로 접속해야 합니다.
+
#### 데이터베이스 설정
| 설정 항목 | 설명 | 기본값 | 예시 |
diff --git a/README.md b/README.md
index ec53522ef..97411fdac 100644
--- a/README.md
+++ b/README.md
@@ -242,11 +242,16 @@ GameFrameX uses command-line arguments (`--Key=Value`) for configuration. All co
| `InterNodeAuthSecret` | Inter-node RPC auth shared secret (HMAC on the protocol handshake; empty = disabled). All cluster nodes must share it | `` | `s3cr3t` |
| `IsEnableWebSocket` | Enable WebSocket | `false` | `true` |
| `WsPort` | WebSocket port | `8889` | `29300` |
+| `WssPort` | WSS TLS port (`0` disables WSS; requires `IsEnableWebSocket=true`) | `0` | `8444` |
+| `WssCertFilePath` | WSS certificate path (PFX or PEM), required when `WssPort > 0` | None | `/certs/wss/fullchain.pem` |
+| `WssCertificateKeyPath` | Separate WSS PEM private key path, required for `.pem` / `.crt` | None | `/certs/wss/privkey.pem` |
+| `WssCertificatePassword` | WSS PFX or encrypted PEM private key password (encrypted PKCS#8 only) | None | |
| `IsEnableHttp` | Enable HTTP service | `true` | `true` |
| `HttpPort` | HTTP service port | `8080` | `28080` |
| `HttpsPort` | HTTPS service port (real TLS listener; certificate required) | None | `443` |
-| `HttpsCertPath` | HTTPS certificate file path (PFX), required when `HttpsPort > 0` | None | `/path/cert.pfx` |
-| `HttpsCertPassword` | HTTPS certificate password (optional) | None | `` |
+| `HttpsCertPath` | HTTPS certificate path (PFX or PEM), required when `HttpsPort > 0` | None | `/path/cert.pfx` |
+| `HttpsCertPassword` | HTTPS PFX or encrypted PEM private key password (encrypted PKCS#8 only) | None | |
+| `HttpsCertificateKeyPath` | Separate HTTPS PEM private key path, required for `.pem` / `.crt` | None | `/certs/https/privkey.pem` |
| `HttpSignSecret` | HMAC-SHA256 secret for HTTP signature validation (required once signing is enabled; blank = fail-closed) | None | `a-strong-secret` |
| `HttpUrl` | API root path | `/game/api/` | `/game/api/` |
| `HttpIsDevelopment` | HTTP development mode (enables Swagger) | `false` | `true` |
@@ -256,6 +261,29 @@ GameFrameX uses command-line arguments (`--Key=Value`) for configuration. All co
| `OnlineTenantId` | Online runtime authorized tenant id (scope triple) | `0` | `1` |
| `OnlineAppId` | Online runtime authorized app id (scope triple) | `0` | `1` |
+#### Direct HTTPS / WSS without nginx
+
+The server can load certificates directly without an nginx proxy. With `IsEnableHttp=true`, configure `HttpsPort` and `HttpsCertPath`; with `IsEnableWebSocket=true`, configure `WssPort` and `WssCertFilePath`. HTTPS and WSS certificate settings are independent: WSS never falls back to HTTPS. The plaintext `HttpPort` and `WsPort` listeners remain available. If `WssPort` is omitted or `0`, WebSocket behavior is unchanged.
+
+```json
+{
+ "IsEnableHttp": true,
+ "HttpPort": 8080,
+ "HttpsPort": 8443,
+ "HttpsCertPath": "/certs/https/fullchain.pem",
+ "HttpsCertificateKeyPath": "/certs/https/privkey.pem",
+ "HttpsCertPassword": "",
+ "IsEnableWebSocket": true,
+ "WsPort": 8889,
+ "WssPort": 8444,
+ "WssCertFilePath": "/certs/wss/fullchain.pem",
+ "WssCertificateKeyPath": "/certs/wss/privkey.pem",
+ "WssCertificatePassword": ""
+}
+```
+
+Certificate paths ending in `.pem` or `.crt` (case-insensitive) require a separate private key path; other extensions use PFX. For encrypted PEM keys (RFC 7468 encrypted PKCS#8, `BEGIN ENCRYPTED PRIVATE KEY`), supply `HttpsCertPassword` / `WssCertificatePassword`; legacy OpenSSL encrypted keys (Proc-Type/DEK-Info headers) are rejected — convert them first with `openssl pkcs8 -topk8`. Leave the password empty for unencrypted keys. Existing PFX configurations need no changes and use the same password fields, without key paths. Missing paths or files, invalid certificates or keys, and incorrect passwords fail startup. Clients must trust the certificate and use a hostname covered by it.
+
#### Database Configuration
| Option | Description | Default | Example |
diff --git a/README.zh-CN.md b/README.zh-CN.md
index ed80270e9..1dd07b9d2 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -209,11 +209,16 @@ GameFrameX 使用命令行参数 (`--Key=Value`) 进行配置,所有配置项
| `InterNodeAuthSecret` | 节点间 RPC 鉴权共享密钥(协议握手帧 HMAC;空 = 不启用)。集群内所有节点必须配置一致 | 无 | `s3cr3t` |
| `IsEnableWebSocket` | 是否启用 WebSocket | `false` | `true` |
| `WsPort` | WebSocket 端口 | `8889` | `29300` |
+| `WssPort` | WSS TLS 端口(`0` 表示不启用;要求 `IsEnableWebSocket=true`) | `0` | `8444` |
+| `WssCertFilePath` | WSS 证书路径(PFX 或 PEM),`WssPort > 0` 时必填 | 无 | `/certs/wss/fullchain.pem` |
+| `WssCertificateKeyPath` | WSS PEM 独立私钥路径,`.pem` / `.crt` 证书必填 | 无 | `/certs/wss/privkey.pem` |
+| `WssCertificatePassword` | WSS PFX 或 PEM 加密私钥密码(仅限加密 PKCS#8 格式) | 无 | |
| `IsEnableHttp` | 是否启用 HTTP 服务 | `true` | `true` |
| `HttpPort` | HTTP 服务端口 | `8080` | `28080` |
| `HttpsPort` | HTTPS 服务端口 | 无 | `443` |
-| `HttpsCertPath` | HTTPS 证书文件路径(PFX),`HttpsPort > 0` 时必填 | 无 | `/path/cert.pfx` |
-| `HttpsCertPassword` | HTTPS 证书密码(可选) | 无 | |
+| `HttpsCertPath` | HTTPS 证书路径(PFX 或 PEM),`HttpsPort > 0` 时必填 | 无 | `/path/cert.pfx` |
+| `HttpsCertPassword` | HTTPS PFX 或 PEM 加密私钥密码(仅限加密 PKCS#8 格式) | 无 | |
+| `HttpsCertificateKeyPath` | HTTPS PEM 独立私钥路径,`.pem` / `.crt` 证书必填 | 无 | `/certs/https/privkey.pem` |
| `HttpSignSecret` | HTTP 签名密钥(HMAC-SHA256),开启签名校验后必填;为空时一律拒绝(fail-closed) | 无 | `a-strong-secret` |
| `HttpUrl` | API 接口根路径 | `/game/api/` | `/game/api/` |
| `HttpIsDevelopment` | HTTP 开发模式(启用 Swagger) | `false` | `true` |
@@ -223,6 +228,29 @@ GameFrameX 使用命令行参数 (`--Key=Value`) 进行配置,所有配置项
| `OnlineTenantId` | Online Runtime 授权租户 ID(作用域三元组) | `0` | `1` |
| `OnlineAppId` | Online Runtime 授权应用 ID(作用域三元组) | `0` | `1` |
+#### 无 nginx 直连 HTTPS / WSS
+
+服务器可直接装载证书,无需 nginx 代理。设置 `IsEnableHttp=true` 后配置 `HttpsPort` 与 `HttpsCertPath`;设置 `IsEnableWebSocket=true` 后配置 `WssPort` 与 `WssCertFilePath`。HTTPS 与 WSS 证书配置相互独立,WSS 不回落到 HTTPS 配置。明文 `HttpPort` 与 `WsPort` 监听继续可用。省略 `WssPort` 或设为 `0` 时,WebSocket 行为保持不变。
+
+```json
+{
+ "IsEnableHttp": true,
+ "HttpPort": 8080,
+ "HttpsPort": 8443,
+ "HttpsCertPath": "/certs/https/fullchain.pem",
+ "HttpsCertificateKeyPath": "/certs/https/privkey.pem",
+ "HttpsCertPassword": "",
+ "IsEnableWebSocket": true,
+ "WsPort": 8889,
+ "WssPort": 8444,
+ "WssCertFilePath": "/certs/wss/fullchain.pem",
+ "WssCertificateKeyPath": "/certs/wss/privkey.pem",
+ "WssCertificatePassword": ""
+}
+```
+
+证书路径以 `.pem` 或 `.crt` 结尾(不区分大小写)时必须提供独立私钥路径,其他扩展名按 PFX 加载。加密 PEM 私钥(RFC 7468 加密 PKCS#8,`BEGIN ENCRYPTED PRIVATE KEY`)使用 `HttpsCertPassword` / `WssCertificatePassword`;传统 OpenSSL 加密私钥(Proc-Type/DEK-Info 头)会被拒绝,请先用 `openssl pkcs8 -topk8` 转换。未加密私钥可留空密码。存量 PFX 配置无需修改,仍使用对应密码字段,不需要私钥路径。路径或文件缺失、证书或私钥无效、密码错误均在启动期立即失败。客户端必须信任证书,并使用证书覆盖的主机名访问。
+
#### 数据库配置
| 配置项 | 说明 | 默认值 | 示例 |
diff --git a/README.zh-TW.md b/README.zh-TW.md
index cec7756e7..92a745ac1 100644
--- a/README.zh-TW.md
+++ b/README.zh-TW.md
@@ -208,15 +208,43 @@ GameFrameX 使用命令列參數 (`--Key=Value`) 進行配置,所有配置項
| `InterNodeAuthSecret` | 節點間 RPC 鑑權共享密鑰(協議握手幀 HMAC;空 = 不啟用)。叢集內所有節點必須配置一致 | 無 | `s3cr3t` |
| `IsEnableWebSocket` | 是否啟用 WebSocket | `false` | `true` |
| `WsPort` | WebSocket 埠 | `8889` | `29300` |
+| `WssPort` | WSS TLS 埠(`0` 表示不啟用;要求 `IsEnableWebSocket=true`) | `0` | `8444` |
+| `WssCertFilePath` | WSS 憑證路徑(PFX 或 PEM),`WssPort > 0` 時必填 | 無 | `/certs/wss/fullchain.pem` |
+| `WssCertificateKeyPath` | WSS PEM 獨立私鑰路徑,`.pem` / `.crt` 憑證必填 | 無 | `/certs/wss/privkey.pem` |
+| `WssCertificatePassword` | WSS PFX 或 PEM 加密私鑰密碼(僅限加密 PKCS#8 格式) | 無 | |
| `IsEnableHttp` | 是否啟用 HTTP 服務 | `true` | `true` |
| `HttpPort` | HTTP 服務埠 | `8080` | `28080` |
| `HttpsPort` | HTTPS 服務埠 | 無 | `443` |
-| `HttpsCertPath` | HTTPS 憑證檔案路徑(PFX),`HttpsPort > 0` 時必填 | 無 | `/path/cert.pfx` |
-| `HttpsCertPassword` | HTTPS 憑證密碼(可選) | 無 | |
+| `HttpsCertPath` | HTTPS 憑證路徑(PFX 或 PEM),`HttpsPort > 0` 時必填 | 無 | `/path/cert.pfx` |
+| `HttpsCertPassword` | HTTPS PFX 或 PEM 加密私鑰密碼(僅限加密 PKCS#8 格式) | 無 | |
+| `HttpsCertificateKeyPath` | HTTPS PEM 獨立私鑰路徑,`.pem` / `.crt` 憑證必填 | 無 | `/certs/https/privkey.pem` |
| `HttpSignSecret` | HTTP 簽章密鑰(HMAC-SHA256),開啟簽章校驗後必填;為空時一律拒絕(fail-closed) | 無 | `a-strong-secret` |
| `HttpUrl` | API 介面根路徑 | `/game/api/` | `/game/api/` |
| `HttpIsDevelopment` | HTTP 開發模式(啟用 Swagger) | `false` | `true` |
+#### 無 nginx 直連 HTTPS / WSS
+
+伺服器可直接載入憑證,無需 nginx 代理。設定 `IsEnableHttp=true` 後配置 `HttpsPort` 與 `HttpsCertPath`;設定 `IsEnableWebSocket=true` 後配置 `WssPort` 與 `WssCertFilePath`。HTTPS 與 WSS 憑證設定相互獨立,WSS 不會回退至 HTTPS 設定。明文 `HttpPort` 與 `WsPort` 監聽繼續可用。省略 `WssPort` 或設為 `0` 時,WebSocket 行為維持不變。
+
+```json
+{
+ "IsEnableHttp": true,
+ "HttpPort": 8080,
+ "HttpsPort": 8443,
+ "HttpsCertPath": "/certs/https/fullchain.pem",
+ "HttpsCertificateKeyPath": "/certs/https/privkey.pem",
+ "HttpsCertPassword": "",
+ "IsEnableWebSocket": true,
+ "WsPort": 8889,
+ "WssPort": 8444,
+ "WssCertFilePath": "/certs/wss/fullchain.pem",
+ "WssCertificateKeyPath": "/certs/wss/privkey.pem",
+ "WssCertificatePassword": ""
+}
+```
+
+憑證路徑以 `.pem` 或 `.crt` 結尾(不區分大小寫)時必須提供獨立私鑰路徑,其他副檔名以 PFX 載入。加密 PEM 私鑰(RFC 7468 加密 PKCS#8,`BEGIN ENCRYPTED PRIVATE KEY`)使用 `HttpsCertPassword` / `WssCertificatePassword`;傳統 OpenSSL 加密私鑰(Proc-Type/DEK-Info 標頭)會被拒絕,請先用 `openssl pkcs8 -topk8` 轉換。未加密私鑰可留空密碼。既有 PFX 設定無需修改,仍使用對應密碼欄位,不需要私鑰路徑。路徑或檔案缺失、憑證或私鑰無效、密碼錯誤均會在啟動時立即失敗。用戶端必須信任憑證,並使用憑證涵蓋的主機名稱存取。
+
#### 資料庫配置
| 配置項 | 說明 | 預設值 | 範例 |
diff --git a/Tests/GameFrameX.Tests/StartUp/HttpsCertificateLoadTests.cs b/Tests/GameFrameX.Tests/StartUp/HttpsCertificateLoadTests.cs
index dcb8cf6ba..4e762c7a0 100644
--- a/Tests/GameFrameX.Tests/StartUp/HttpsCertificateLoadTests.cs
+++ b/Tests/GameFrameX.Tests/StartUp/HttpsCertificateLoadTests.cs
@@ -27,7 +27,6 @@
// Official Documentation: https://gameframex.doc.alianblank.com/
// ==========================================================================================
-using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using GameFrameX.StartUp;
using GameFrameX.Utility.Setting;
@@ -35,78 +34,190 @@
namespace GameFrameX.Tests.StartUp;
///
-/// HTTPS 证书加载 fail-fast 校验测试(HttpsPort>0 时 Kestrel HTTPS 真监听的前置校验)。
+/// HTTPS 证书启动期加载与失败矩阵。
///
-///
-/// Tests for the HTTPS certificate fail-fast validation (prerequisite check of the real
-/// Kestrel HTTPS listening when HttpsPort > 0).
-///
+/// Startup loading and failure matrix for HTTPS certificates.
public class HttpsCertificateLoadTests : IDisposable
{
- private readonly string _pfxPath;
+ private TestServerCertificateFiles _files;
- public HttpsCertificateLoadTests()
+ // 路径校验用例不依赖证书生成,仅在需要真实证书时创建临时文件。
+ private TestServerCertificateFiles Files => _files ??= new TestServerCertificateFiles();
+
+ /// 清理临时证书文件。
+ /// Cleans up temporary certificate files.
+ public void Dispose()
{
- _pfxPath = Path.Combine(Path.GetTempPath(), $"gfx911-{Guid.NewGuid():N}.pfx");
- using (var rsa = RSA.Create(2048))
- {
- var request = new CertificateRequest("CN=gameframex-https-test", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
- using (var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1)))
- {
- File.WriteAllBytes(_pfxPath, certificate.Export(X509ContentType.Pfx, "test-password"));
- }
- }
+ _files?.Dispose();
}
- public void Dispose()
+ /// 空白证书路径必须阻断启动。
+ /// Blank certificate paths must fail startup.
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void LoadHttpsCertificate_ShouldThrow_WhenCertificatePathIsMissing(string path)
{
- if (File.Exists(_pfxPath))
- {
- File.Delete(_pfxPath);
- }
+ var setting = new AppSetting { HttpsPort = 8443, HttpsCertPath = path };
+ var exception = Assert.Throws(() => AppStartUpBase.LoadHttpsCertificate(setting));
+ Assert.Contains("HttpsCertPath", exception.Message);
}
+ /// 不存在的证书路径必须阻断启动。
+ /// Missing certificate files must fail startup.
[Fact]
- public void LoadHttpsCertificate_ShouldThrow_WhenCertPathIsMissing()
+ public void LoadHttpsCertificate_ShouldThrow_WhenCertificateFileDoesNotExist()
{
- var setting = new AppSetting { HttpsPort = 8443, HttpsCertPath = string.Empty };
-
+ var path = Path.Combine(Path.GetTempPath(), $"gfx987-{Guid.NewGuid():N}.missing");
+ var setting = new AppSetting { HttpsPort = 8443, HttpsCertPath = path };
var exception = Assert.Throws(() => AppStartUpBase.LoadHttpsCertificate(setting));
+ Assert.Contains(path, exception.Message);
+ }
- Assert.Contains("HttpsCertPath", exception.Message);
+ /// PFX 密码错误必须阻断启动并保留底层异常。
+ /// Incorrect PFX passwords must fail startup and retain the underlying exception.
+ [Fact]
+ public void LoadHttpsCertificate_ShouldThrow_WhenPasswordIsWrong()
+ {
+ var setting = new AppSetting { HttpsPort = 8443, HttpsCertPath = Files.CertificatePath, HttpsCertPassword = "wrong-password" };
+ var exception = Assert.Throws(() => AppStartUpBase.LoadHttpsCertificate(setting));
+ Assert.Contains(Files.CertificatePath, exception.Message);
+ Assert.NotNull(exception.InnerException);
}
+ /// 有效 PFX 应保留证书身份与私钥。
+ /// Valid PFX must retain the certificate identity and private key.
[Fact]
- public void LoadHttpsCertificate_ShouldThrow_WhenCertFileDoesNotExist()
+ public void LoadHttpsCertificate_ShouldReturnCertificate_WhenPfxIsValid()
{
- var setting = new AppSetting { HttpsPort = 8443, HttpsCertPath = "/nonexistent/path/cert.pfx" };
+ var setting = new AppSetting { HttpsPort = 8443, HttpsCertPath = Files.CertificatePath, HttpsCertPassword = TestServerCertificateFiles.Password };
+ using (var certificate = AppStartUpBase.LoadHttpsCertificate(setting))
+ {
+ AssertCertificate(certificate);
+ }
+ }
- var exception = Assert.Throws(() => AppStartUpBase.LoadHttpsCertificate(setting));
+ /// PEM 支持两种扩展名以及加密与未加密私钥。
+ /// PEM supports both extensions with encrypted and unencrypted private keys.
+ [Theory]
+ [InlineData(false, false)]
+ [InlineData(false, true)]
+ [InlineData(true, false)]
+ [InlineData(true, true)]
+ public void LoadHttpsCertificate_ShouldReturnCertificate_WhenPemIsValid(bool encrypted, bool useCrtExtension)
+ {
+ var setting = new AppSetting
+ {
+ HttpsPort = 8443,
+ HttpsCertPath = useCrtExtension ? Files.CrtPath : Files.PemPath,
+ HttpsCertificateKeyPath = encrypted ? Files.EncryptedKeyPath : Files.KeyPath,
+ HttpsCertPassword = encrypted ? TestServerCertificateFiles.Password : string.Empty,
+ };
+ using (var certificate = AppStartUpBase.LoadHttpsCertificate(setting))
+ {
+ AssertCertificate(certificate);
+ }
+ }
- Assert.Contains("/nonexistent/path/cert.pfx", exception.Message);
+ /// PEM 必须显式配置独立私钥路径。
+ /// PEM requires an explicitly configured separate private key path.
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void LoadHttpsCertificate_ShouldThrow_WhenPemKeyPathIsMissing(string keyPath)
+ {
+ var setting = new AppSetting { HttpsPort = 8443, HttpsCertPath = Files.PemPath, HttpsCertificateKeyPath = keyPath };
+ var exception = Assert.Throws(() => AppStartUpBase.LoadHttpsCertificate(setting));
+ Assert.Contains("HttpsCertificateKeyPath", exception.Message);
}
+ /// 私钥文件不存在时必须阻断启动。
+ /// Missing private key files must fail startup.
[Fact]
- public void LoadHttpsCertificate_ShouldThrow_WhenPasswordIsWrong()
+ public void LoadHttpsCertificate_ShouldThrow_WhenPemKeyFileDoesNotExist()
{
- var setting = new AppSetting { HttpsPort = 8443, HttpsCertPath = _pfxPath, HttpsCertPassword = "wrong-password" };
-
+ var keyPath = Files.KeyPath + ".missing";
+ var setting = new AppSetting { HttpsPort = 8443, HttpsCertPath = Files.PemPath, HttpsCertificateKeyPath = keyPath };
var exception = Assert.Throws(() => AppStartUpBase.LoadHttpsCertificate(setting));
+ Assert.Contains(keyPath, exception.Message);
+ }
- Assert.Contains(_pfxPath, exception.Message);
+ /// 加密私钥密码错误或缺失时必须阻断启动。
+ /// Incorrect or missing encrypted private key passwords must fail startup.
+ [Theory]
+ [InlineData("wrong-password")]
+ [InlineData("")]
+ [InlineData(null)]
+ public void LoadHttpsCertificate_ShouldThrow_WhenPemKeyPasswordIsWrong(string password)
+ {
+ var setting = new AppSetting
+ {
+ HttpsPort = 8443, HttpsCertPath = Files.PemPath,
+ HttpsCertificateKeyPath = Files.EncryptedKeyPath, HttpsCertPassword = password,
+ };
+ var exception = Assert.Throws(() => AppStartUpBase.LoadHttpsCertificate(setting));
+ Assert.Contains(Files.PemPath, exception.Message);
Assert.NotNull(exception.InnerException);
}
+ /// 损坏证书或不含私钥的 PFX 必须阻断启动。
+ /// Corrupted certificates and PFX without private keys must fail startup.
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void LoadHttpsCertificate_ShouldThrow_WhenCertificateIsUnusable(bool publicOnly)
+ {
+ var path = publicOnly ? Files.PublicCertificatePath : Files.CertificatePath;
+ if (!publicOnly)
+ {
+ File.WriteAllText(path, "invalid-certificate");
+ }
+
+ var setting = new AppSetting { HttpsPort = 8443, HttpsCertPath = path, HttpsCertPassword = TestServerCertificateFiles.Password };
+ var exception = Assert.Throws(() => AppStartUpBase.LoadHttpsCertificate(setting));
+ Assert.Contains(path, exception.Message);
+ if (publicOnly)
+ {
+ // 无私钥属独立配置结论:直接抛 PrivateKeyMissing,不被 LoadFailed 包装(无底层异常)。
+ Assert.Null(exception.InnerException);
+ }
+ else
+ {
+ // 加载失败保留底层异常便于排障。
+ Assert.NotNull(exception.InnerException);
+ }
+ }
+
+ /// 传统 OpenSSL 加密私钥必须被明确拒绝并给出转换指引。
+ /// Legacy OpenSSL encrypted keys must be rejected with a conversion hint.
[Fact]
- public void LoadHttpsCertificate_ShouldReturnCertificate_WhenPfxIsValid()
+ public void LoadHttpsCertificate_ShouldThrow_WhenPemKeyUsesTraditionalOpenSslEncryption()
{
- var setting = new AppSetting { HttpsPort = 8443, HttpsCertPath = _pfxPath, HttpsCertPassword = "test-password" };
+ var setting = new AppSetting
+ {
+ HttpsPort = 8443,
+ HttpsCertPath = Files.PemPath,
+ HttpsCertificateKeyPath = Files.TraditionalEncryptedKeyPath,
+ HttpsCertPassword = TestServerCertificateFiles.Password,
+ };
+ var exception = Assert.Throws(() => AppStartUpBase.LoadHttpsCertificate(setting));
+ // 传统格式专属错误在加载前抛出:不被 LoadFailed 包装,且给出 openssl pkcs8 转换指引(命令文本不随语言变化)。
+ Assert.Contains(Files.TraditionalEncryptedKeyPath, exception.Message);
+ Assert.Contains("openssl pkcs8 -topk8", exception.Message);
+ Assert.Null(exception.InnerException);
+ }
- using (var certificate = AppStartUpBase.LoadHttpsCertificate(setting))
+ /// 验证身份与可用私钥。
+ /// Checks certificate identity and private key availability.
+ private static void AssertCertificate(X509Certificate2 certificate)
+ {
+ Assert.Equal("CN=localhost", certificate.Subject);
+ Assert.True(certificate.HasPrivateKey);
+ using (var privateKey = certificate.GetRSAPrivateKey())
{
- Assert.NotNull(certificate);
- Assert.Equal("CN=gameframex-https-test", certificate.Subject);
- Assert.True(certificate.HasPrivateKey);
+ Assert.NotNull(privateKey);
}
}
}
diff --git a/Tests/GameFrameX.Tests/StartUp/TestServerCertificateFiles.cs b/Tests/GameFrameX.Tests/StartUp/TestServerCertificateFiles.cs
new file mode 100644
index 000000000..0df5c445d
--- /dev/null
+++ b/Tests/GameFrameX.Tests/StartUp/TestServerCertificateFiles.cs
@@ -0,0 +1,112 @@
+// ==========================================================================================
+// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利
+// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights
+// 均受中华人民共和国及相关国际法律法规保护。
+// are protected by the laws of the People's Republic of China and relevant international regulations.
+// 使用本项目须严格遵守相应法律法规及开源许可证之规定。
+// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses.
+// 本项目采用 Apache License 2.0 单协议分发,
+// This project is licensed solely under the Apache License 2.0,
+// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。
+// please refer to the LICENSE file in the root directory of the source code for the full license text.
+// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、
+// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order,
+// 侵犯他人合法权益等法律法规所禁止的行为!
+// or infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations!
+// 因基于本项目二次开发所产生的一切法律纠纷与责任,
+// Any legal disputes and liabilities arising from secondary development based on this project
+// 本项目组织与贡献者概不承担。
+// shall be borne solely by the developer; the project organization and contributors assume no responsibility.
+// GitHub 仓库:https://github.com/GameFrameX
+// GitHub Repository: https://github.com/GameFrameX
+// Gitee 仓库:https://gitee.com/GameFrameX
+// Gitee Repository: https://gitee.com/GameFrameX
+// CNB 仓库:https://cnb.cool/GameFrameX
+// CNB Repository: https://cnb.cool/GameFrameX
+// 官方文档:https://gameframex.doc.alianblank.com/
+// Official Documentation: https://gameframex.doc.alianblank.com/
+// ==========================================================================================
+
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+
+namespace GameFrameX.Tests.StartUp;
+
+///
+/// 证书加载与握手测试使用的临时自签证书文件。
+///
+/// Temporary self-signed certificate files for loading and handshake tests.
+internal sealed class TestServerCertificateFiles : IDisposable
+{
+ private readonly string _directory = Path.Combine(Path.GetTempPath(), $"gfx987-{Guid.NewGuid():N}");
+
+ /// 测试密码。
+ /// Test password.
+ internal const string Password = "test-password";
+
+ /// PFX 文件路径。
+ /// PFX file path.
+ internal string CertificatePath => Path.Combine(_directory, "certificate.pfx");
+
+ /// PEM 证书路径。
+ /// PEM certificate path.
+ internal string PemPath => Path.Combine(_directory, "fullchain.pem");
+
+ /// 大写 CRT 扩展名证书路径。
+ /// Certificate path with an uppercase CRT extension.
+ internal string CrtPath => Path.Combine(_directory, "certificate.CRT");
+
+ /// 未加密私钥路径。
+ /// Unencrypted private key path.
+ internal string KeyPath => Path.Combine(_directory, "privkey.pem");
+
+ /// 加密私钥路径。
+ /// Encrypted private key path.
+ internal string EncryptedKeyPath => Path.Combine(_directory, "encrypted-key.pem");
+
+ /// 传统 OpenSSL 加密格式私钥路径(Proc-Type/DEK-Info 头)。
+ /// Legacy OpenSSL encrypted private key path (Proc-Type/DEK-Info headers).
+ internal string TraditionalEncryptedKeyPath => Path.Combine(_directory, "traditional-encrypted-key.pem");
+
+ /// 不含私钥的 PFX 路径。
+ /// PFX path without a private key.
+ internal string PublicCertificatePath => Path.Combine(_directory, "public.pfx");
+
+ /// 构造具有 localhost 身份的测试证书。
+ /// Creates a test certificate for localhost.
+ internal TestServerCertificateFiles()
+ {
+ Directory.CreateDirectory(_directory);
+ using (var privateKey = RSA.Create(2048))
+ {
+ var request = new CertificateRequest("CN=localhost", privateKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+ var names = new SubjectAlternativeNameBuilder();
+ names.AddDnsName("localhost");
+ names.AddIpAddress(System.Net.IPAddress.Loopback);
+ request.CertificateExtensions.Add(names.Build());
+ using (var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1)))
+ {
+ File.WriteAllBytes(CertificatePath, certificate.Export(X509ContentType.Pfx, Password));
+ File.WriteAllText(PemPath, certificate.ExportCertificatePem());
+ File.WriteAllText(CrtPath, certificate.ExportCertificatePem());
+ File.WriteAllText(KeyPath, privateKey.ExportPkcs8PrivateKeyPem());
+ File.WriteAllText(EncryptedKeyPath, privateKey.ExportEncryptedPkcs8PrivateKeyPem(Password,
+ new PbeParameters(PbeEncryptionAlgorithm.Aes256Cbc, HashAlgorithmName.SHA256, 1000)));
+ // 传统 OpenSSL 加密格式:识别只依赖文件头,内容无需真实可解密。
+ File.WriteAllText(TraditionalEncryptedKeyPath,
+ "-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\nDEK-Info: DES-EDE3-CBC,0123456789ABCDEF\n\ndHVzdA==\n-----END RSA PRIVATE KEY-----\n");
+ using (var publicCertificate = X509CertificateLoader.LoadCertificate(certificate.RawData))
+ {
+ File.WriteAllBytes(PublicCertificatePath, publicCertificate.Export(X509ContentType.Pfx, Password));
+ }
+ }
+ }
+ }
+
+ /// 删除所有临时证书与私钥文件。
+ /// Deletes all temporary certificate and private key files.
+ public void Dispose()
+ {
+ Directory.Delete(_directory, true);
+ }
+}
diff --git a/Tests/GameFrameX.Tests/StartUp/WebSocketCertificateTestStartUp.cs b/Tests/GameFrameX.Tests/StartUp/WebSocketCertificateTestStartUp.cs
new file mode 100644
index 000000000..4994caef7
--- /dev/null
+++ b/Tests/GameFrameX.Tests/StartUp/WebSocketCertificateTestStartUp.cs
@@ -0,0 +1,64 @@
+// ==========================================================================================
+// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利
+// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights
+// 均受中华人民共和国及相关国际法律法规保护。
+// are protected by the laws of the People's Republic of China and relevant international regulations.
+// 使用本项目须严格遵守相应法律法规及开源许可证之规定。
+// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses.
+// 本项目采用 Apache License 2.0 单协议分发,
+// This project is licensed solely under the Apache License 2.0,
+// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。
+// please refer to the LICENSE file in the root directory of the source code for the full license text.
+// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、
+// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order,
+// 侵犯他人合法权益等法律法规所禁止的行为!
+// or infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations!
+// 因基于本项目二次开发所产生的一切法律纠纷与责任,
+// Any legal disputes and liabilities arising from secondary development based on this project
+// 本项目组织与贡献者概不承担。
+// shall be borne solely by the developer; the project organization and contributors assume no responsibility.
+// GitHub 仓库:https://github.com/GameFrameX
+// GitHub Repository: https://github.com/GameFrameX
+// Gitee 仓库:https://gitee.com/GameFrameX
+// Gitee Repository: https://gitee.com/GameFrameX
+// CNB 仓库:https://cnb.cool/GameFrameX
+// CNB Repository: https://cnb.cool/GameFrameX
+// 官方文档:https://gameframex.doc.alianblank.com/
+// Official Documentation: https://gameframex.doc.alianblank.com/
+// ==========================================================================================
+
+using GameFrameX.StartUp;
+using GameFrameX.Utility.Setting;
+
+namespace GameFrameX.Tests.StartUp;
+
+///
+/// 仅使用生产 WebSocket 装配与证书停机路径的测试启动器。
+///
+/// Test startup using the production WebSocket configuration and certificate shutdown paths.
+internal sealed class WebSocketCertificateTestStartUp : AppStartUpBase
+{
+ /// 设置测试配置,跳过进程初始化。
+ /// Sets test configuration without process initialization.
+ /// 测试配置 / Test setting
+ internal WebSocketCertificateTestStartUp(AppSetting setting)
+ {
+ Setting = setting;
+ }
+
+ /// 测试只单独装配 WebSocket,启动入口保持空实现。
+ /// Tests configure WebSocket separately, so the startup entry point does nothing.
+ /// 已完成的任务 / Completed task
+ public override Task StartAsync()
+ {
+ return Task.CompletedTask;
+ }
+
+ /// 在测试宿主停止后释放生产启动器持有的证书。
+ /// Releases the startup-owned certificate after the test host stops.
+ /// 停机任务 / Shutdown task
+ internal Task ShutdownAsync()
+ {
+ return StopServerAsync();
+ }
+}
diff --git a/Tests/GameFrameX.Tests/StartUp/WssCertificateLoadTests.cs b/Tests/GameFrameX.Tests/StartUp/WssCertificateLoadTests.cs
new file mode 100644
index 000000000..956f75364
--- /dev/null
+++ b/Tests/GameFrameX.Tests/StartUp/WssCertificateLoadTests.cs
@@ -0,0 +1,245 @@
+// ==========================================================================================
+// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利
+// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights
+// 均受中华人民共和国及相关国际法律法规保护。
+// are protected by the laws of the People's Republic of China and relevant international regulations.
+// 使用本项目须严格遵守相应法律法规及开源许可证之规定。
+// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses.
+// 本项目采用 Apache License 2.0 单协议分发,
+// This project is licensed solely under the Apache License 2.0,
+// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。
+// please refer to the LICENSE file in the root directory of the source code for the full license text.
+// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、
+// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order,
+// 侵犯他人合法权益等法律法规所禁止的行为!
+// or infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations!
+// 因基于本项目二次开发所产生的一切法律纠纷与责任,
+// Any legal disputes and liabilities arising from secondary development based on this project
+// 本项目组织与贡献者概不承担。
+// shall be borne solely by the developer; the project organization and contributors assume no responsibility.
+// GitHub 仓库:https://github.com/GameFrameX
+// GitHub Repository: https://github.com/GameFrameX
+// Gitee 仓库:https://gitee.com/GameFrameX
+// Gitee Repository: https://gitee.com/GameFrameX
+// CNB 仓库:https://cnb.cool/GameFrameX
+// CNB Repository: https://cnb.cool/GameFrameX
+// 官方文档:https://gameframex.doc.alianblank.com/
+// Official Documentation: https://gameframex.doc.alianblank.com/
+// ==========================================================================================
+
+using System.Security.Cryptography.X509Certificates;
+using GameFrameX.StartUp;
+using GameFrameX.Utility.Setting;
+
+namespace GameFrameX.Tests.StartUp;
+
+///
+/// WSS 证书启动期加载与失败矩阵。
+///
+/// Startup loading and failure matrix for WSS certificates.
+public class WssCertificateLoadTests : IDisposable
+{
+ private TestServerCertificateFiles _files;
+
+ // 路径校验用例不依赖证书生成,仅在需要真实证书时创建临时文件。
+ private TestServerCertificateFiles Files => _files ??= new TestServerCertificateFiles();
+
+ /// 清理临时证书文件。
+ /// Cleans up temporary certificate files.
+ public void Dispose()
+ {
+ _files?.Dispose();
+ }
+
+ /// 空白证书路径必须阻断启动。
+ /// Blank certificate paths must fail startup.
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void LoadWssCertificate_ShouldThrow_WhenCertificatePathIsMissing(string path)
+ {
+ var setting = new AppSetting { WssPort = 8443, WssCertFilePath = path };
+ var exception = Assert.Throws(() => AppStartUpBase.LoadWssCertificate(setting));
+ Assert.Contains("WssCertFilePath", exception.Message);
+ }
+
+ /// 不存在的证书路径必须阻断启动。
+ /// Missing certificate files must fail startup.
+ [Fact]
+ public void LoadWssCertificate_ShouldThrow_WhenCertificateFileDoesNotExist()
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"gfx987-{Guid.NewGuid():N}.missing");
+ var setting = new AppSetting { WssPort = 8443, WssCertFilePath = path };
+ var exception = Assert.Throws(() => AppStartUpBase.LoadWssCertificate(setting));
+ Assert.Contains(path, exception.Message);
+ }
+
+ /// PFX 密码错误必须阻断启动并保留底层异常。
+ /// Incorrect PFX passwords must fail startup and retain the underlying exception.
+ [Fact]
+ public void LoadWssCertificate_ShouldThrow_WhenPasswordIsWrong()
+ {
+ var setting = new AppSetting { WssPort = 8443, WssCertFilePath = Files.CertificatePath, WssCertificatePassword = "wrong-password" };
+ var exception = Assert.Throws(() => AppStartUpBase.LoadWssCertificate(setting));
+ Assert.Contains(Files.CertificatePath, exception.Message);
+ Assert.NotNull(exception.InnerException);
+ }
+
+ /// 有效 PFX 应保留证书身份与私钥。
+ /// Valid PFX must retain the certificate identity and private key.
+ [Fact]
+ public void LoadWssCertificate_ShouldReturnCertificate_WhenPfxIsValid()
+ {
+ var setting = new AppSetting { WssPort = 8443, WssCertFilePath = Files.CertificatePath, WssCertificatePassword = TestServerCertificateFiles.Password };
+ using (var certificate = AppStartUpBase.LoadWssCertificate(setting))
+ {
+ AssertCertificate(certificate);
+ }
+ }
+
+ /// PEM 支持两种扩展名以及加密与未加密私钥。
+ /// PEM supports both extensions with encrypted and unencrypted private keys.
+ [Theory]
+ [InlineData(false, false)]
+ [InlineData(false, true)]
+ [InlineData(true, false)]
+ [InlineData(true, true)]
+ public void LoadWssCertificate_ShouldReturnCertificate_WhenPemIsValid(bool encrypted, bool useCrtExtension)
+ {
+ var setting = new AppSetting
+ {
+ WssPort = 8443,
+ WssCertFilePath = useCrtExtension ? Files.CrtPath : Files.PemPath,
+ WssCertificateKeyPath = encrypted ? Files.EncryptedKeyPath : Files.KeyPath,
+ WssCertificatePassword = encrypted ? TestServerCertificateFiles.Password : string.Empty,
+ };
+ using (var certificate = AppStartUpBase.LoadWssCertificate(setting))
+ {
+ AssertCertificate(certificate);
+ }
+ }
+
+ /// PEM 必须显式配置独立私钥路径。
+ /// PEM requires an explicitly configured separate private key path.
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void LoadWssCertificate_ShouldThrow_WhenPemKeyPathIsMissing(string keyPath)
+ {
+ var setting = new AppSetting { WssPort = 8443, WssCertFilePath = Files.PemPath, WssCertificateKeyPath = keyPath };
+ var exception = Assert.Throws(() => AppStartUpBase.LoadWssCertificate(setting));
+ Assert.Contains("WssCertificateKeyPath", exception.Message);
+ }
+
+ /// 私钥文件不存在时必须阻断启动。
+ /// Missing private key files must fail startup.
+ [Fact]
+ public void LoadWssCertificate_ShouldThrow_WhenPemKeyFileDoesNotExist()
+ {
+ var keyPath = Files.KeyPath + ".missing";
+ var setting = new AppSetting { WssPort = 8443, WssCertFilePath = Files.PemPath, WssCertificateKeyPath = keyPath };
+ var exception = Assert.Throws(() => AppStartUpBase.LoadWssCertificate(setting));
+ Assert.Contains(keyPath, exception.Message);
+ }
+
+ /// 加密私钥密码错误或缺失时必须阻断启动。
+ /// Incorrect or missing encrypted private key passwords must fail startup.
+ [Theory]
+ [InlineData("wrong-password")]
+ [InlineData("")]
+ [InlineData(null)]
+ public void LoadWssCertificate_ShouldThrow_WhenPemKeyPasswordIsWrong(string password)
+ {
+ var setting = new AppSetting
+ {
+ WssPort = 8443, WssCertFilePath = Files.PemPath,
+ WssCertificateKeyPath = Files.EncryptedKeyPath, WssCertificatePassword = password,
+ };
+ var exception = Assert.Throws(() => AppStartUpBase.LoadWssCertificate(setting));
+ Assert.Contains(Files.PemPath, exception.Message);
+ Assert.NotNull(exception.InnerException);
+ }
+
+ /// 损坏证书或不含私钥的 PFX 必须阻断启动,且各自保留专属错误语义。
+ /// Corrupted certificates and PFX without private keys must fail startup with their specific error semantics.
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void LoadWssCertificate_ShouldThrow_WhenCertificateIsUnusable(bool publicOnly)
+ {
+ var path = publicOnly ? Files.PublicCertificatePath : Files.CertificatePath;
+ if (!publicOnly)
+ {
+ File.WriteAllText(path, "invalid-certificate");
+ }
+
+ var setting = new AppSetting { WssPort = 8443, WssCertFilePath = path, WssCertificatePassword = TestServerCertificateFiles.Password };
+ var exception = Assert.Throws(() => AppStartUpBase.LoadWssCertificate(setting));
+ Assert.Contains(path, exception.Message);
+ if (publicOnly)
+ {
+ // 无私钥属独立配置结论:直接抛 PrivateKeyMissing,不被 LoadFailed 包装(无底层异常)。
+ Assert.Null(exception.InnerException);
+ }
+ else
+ {
+ // 加载失败保留底层异常便于排障。
+ Assert.NotNull(exception.InnerException);
+ }
+ }
+
+ /// 传统 OpenSSL 加密私钥必须被明确拒绝并给出转换指引。
+ /// Legacy OpenSSL encrypted keys must be rejected with a conversion hint.
+ [Fact]
+ public void LoadWssCertificate_ShouldThrow_WhenPemKeyUsesTraditionalOpenSslEncryption()
+ {
+ var setting = new AppSetting
+ {
+ WssPort = 8443,
+ WssCertFilePath = Files.PemPath,
+ WssCertificateKeyPath = Files.TraditionalEncryptedKeyPath,
+ WssCertificatePassword = TestServerCertificateFiles.Password,
+ };
+ var exception = Assert.Throws(() => AppStartUpBase.LoadWssCertificate(setting));
+ // 传统格式专属错误在加载前抛出:不被 LoadFailed 包装,且给出 openssl pkcs8 转换指引(命令文本不随语言变化)。
+ Assert.Contains(Files.TraditionalEncryptedKeyPath, exception.Message);
+ Assert.Contains("openssl pkcs8 -topk8", exception.Message);
+ Assert.Null(exception.InnerException);
+ }
+
+ /// HTTPS 配置完整也不得替代缺失的 WSS 配置。
+ /// Valid HTTPS settings must never replace missing WSS settings.
+ [Fact]
+ public void LoadWssCertificate_ShouldNotFallBackToHttpsConfiguration()
+ {
+ var setting = new AppSetting
+ {
+ WssPort = 8443, HttpsCertPath = Files.CertificatePath,
+ HttpsCertPassword = TestServerCertificateFiles.Password,
+ HttpsCertificateKeyPath = Files.KeyPath,
+ };
+ var exception = Assert.Throws(() => AppStartUpBase.LoadWssCertificate(setting));
+ Assert.Contains(nameof(AppSetting.WssCertFilePath), exception.Message);
+
+ setting.WssCertFilePath = Files.PemPath;
+ exception = Assert.Throws(() => AppStartUpBase.LoadWssCertificate(setting));
+ Assert.Contains(nameof(AppSetting.WssCertificateKeyPath), exception.Message);
+
+ setting.WssCertificateKeyPath = Files.EncryptedKeyPath;
+ Assert.Throws(() => AppStartUpBase.LoadWssCertificate(setting));
+ }
+
+ /// 验证身份与可用私钥。
+ /// Checks certificate identity and private key availability.
+ private static void AssertCertificate(X509Certificate2 certificate)
+ {
+ Assert.Equal("CN=localhost", certificate.Subject);
+ Assert.True(certificate.HasPrivateKey);
+ using (var privateKey = certificate.GetRSAPrivateKey())
+ {
+ Assert.NotNull(privateKey);
+ }
+ }
+}
diff --git a/Tests/GameFrameX.Tests/StartUp/WssListenerIntegrationTests.cs b/Tests/GameFrameX.Tests/StartUp/WssListenerIntegrationTests.cs
new file mode 100644
index 000000000..ef96e8b3e
--- /dev/null
+++ b/Tests/GameFrameX.Tests/StartUp/WssListenerIntegrationTests.cs
@@ -0,0 +1,176 @@
+// ==========================================================================================
+// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利
+// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights
+// 均受中华人民共和国及相关国际法律法规保护。
+// are protected by the laws of the People's Republic of China and relevant international regulations.
+// 使用本项目须严格遵守相应法律法规及开源许可证之规定。
+// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses.
+// 本项目采用 Apache License 2.0 单协议分发,
+// This project is licensed solely under the Apache License 2.0,
+// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。
+// please refer to the LICENSE file in the root directory of the source code for the full license text.
+// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、
+// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order,
+// 侵犯他人合法权益等法律法规所禁止的行为!
+// or infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations!
+// 因基于本项目二次开发所产生的一切法律纠纷与责任,
+// Any legal disputes and liabilities arising from secondary development based on this project
+// 本项目组织与贡献者概不承担。
+// shall be borne solely by the developer; the project organization and contributors assume no responsibility.
+// GitHub 仓库:https://github.com/GameFrameX
+// GitHub Repository: https://github.com/GameFrameX
+// Gitee 仓库:https://gitee.com/GameFrameX
+// Gitee Repository: https://gitee.com/GameFrameX
+// CNB 仓库:https://cnb.cool/GameFrameX
+// CNB Repository: https://cnb.cool/GameFrameX
+// 官方文档:https://gameframex.doc.alianblank.com/
+// Official Documentation: https://gameframex.doc.alianblank.com/
+// ==========================================================================================
+
+using System.Net;
+using System.Net.Sockets;
+using System.Net.WebSockets;
+using GameFrameX.SuperSocket.Server.Host;
+using GameFrameX.Utility.Setting;
+
+namespace GameFrameX.Tests.StartUp;
+
+///
+/// 使用生产监听装配路径与真实客户端验证 WS / WSS 握手。
+///
+/// Verifies WS / WSS handshakes using production listener configuration and real clients.
+[Collection(nameof(StartUpStaticStateCollection))]
+public class WssListenerIntegrationTests
+{
+ /// PFX、普通 PEM、加密 PEM 均可与明文端口同时完成真实握手。
+ /// PFX, unencrypted PEM and encrypted PEM support real handshakes alongside the plaintext port.
+ /// 证书格式:0 为 PFX,1 为 PEM,2 为加密 PEM / Certificate format: 0 PFX, 1 PEM, 2 encrypted PEM
+ [Theory]
+ [InlineData(0)]
+ [InlineData(1)]
+ [InlineData(2)]
+ public async Task WebSocketServer_WithWssPort_ShouldAcceptSecureAndPlainHandshakes(int format)
+ {
+ using (var files = new TestServerCertificateFiles())
+ {
+ var plainPort = GetAvailablePort();
+ var securePort = GetAvailablePort();
+ while (securePort == plainPort)
+ {
+ securePort = GetAvailablePort();
+ }
+
+ var setting = new AppSetting
+ {
+ IsEnableWebSocket = true,
+ WsPort = plainPort,
+ WssPort = securePort,
+ WssCertFilePath = format == 0 ? files.CertificatePath : files.PemPath,
+ WssCertificateKeyPath = format == 2 ? files.EncryptedKeyPath : files.KeyPath,
+ WssCertificatePassword = format == 1 ? string.Empty : TestServerCertificateFiles.Password,
+ // HTTPS 配置故意无效,证明 WSS 的 TLS 参数不依赖 HTTPS。
+ HttpsCertPath = "missing-https.pfx",
+ HttpsCertPassword = "wrong-https-password",
+ };
+ var startup = new WebSocketCertificateTestStartUp(setting);
+ var builder = MultipleServerHostBuilder.Create();
+ startup.ConfigureWebSocketServer(builder);
+ using (var host = builder.Build())
+ {
+ try
+ {
+ await host.StartAsync();
+ using (var expectedCertificate = GameFrameX.StartUp.AppStartUpBase.LoadWssCertificate(setting))
+ {
+ await AssertHandshakeAsync("wss", securePort, expectedCertificate.Thumbprint);
+ await AssertHandshakeAsync("ws", plainPort);
+ }
+ }
+ finally
+ {
+ await host.StopAsync();
+ await startup.ShutdownAsync();
+ }
+ }
+ }
+ }
+
+ /// 不配置 WSS 端口时保持明文监听,并忽略未启用的证书配置。
+ /// Without a WSS port, plaintext listening remains and unused certificate settings are ignored.
+ [Fact]
+ public async Task WebSocketServer_WithoutWssPort_ShouldKeepPlainHandshake()
+ {
+ var setting = new AppSetting
+ {
+ IsEnableWebSocket = true, WsPort = GetAvailablePort(), WssPort = 0,
+ WssCertFilePath = "missing-wss.pem", WssCertificateKeyPath = "missing-private-key.pem",
+ };
+ var startup = new WebSocketCertificateTestStartUp(setting);
+ var builder = MultipleServerHostBuilder.Create();
+ startup.ConfigureWebSocketServer(builder);
+ using (var host = builder.Build())
+ {
+ try
+ {
+ await host.StartAsync();
+ await AssertHandshakeAsync("ws", setting.WsPort);
+ }
+ finally
+ {
+ await host.StopAsync();
+ await startup.ShutdownAsync();
+ }
+ }
+ }
+
+ /// 启用 WSS 时错误证书配置必须在构建和监听前失败。
+ /// Invalid WSS certificate configuration must fail before host building or listening.
+ [Fact]
+ public void ConfigureWebSocketServer_WithInvalidWssCertificate_ShouldFailBeforeBuild()
+ {
+ var startup = new WebSocketCertificateTestStartUp(new AppSetting { IsEnableWebSocket = true, WsPort = 0, WssPort = 8443 });
+ var exception = Assert.Throws(() => startup.ConfigureWebSocketServer(MultipleServerHostBuilder.Create()));
+ Assert.Contains(nameof(AppSetting.WssCertFilePath), exception.Message);
+ }
+
+ /// 关闭 WebSocket 时不得读取 WSS 证书配置。
+ /// Disabled WebSocket must not load WSS certificate configuration.
+ [Fact]
+ public void ConfigureWebSocketServer_WhenDisabled_ShouldIgnoreWssCertificate()
+ {
+ var startup = new WebSocketCertificateTestStartUp(new AppSetting { IsEnableWebSocket = false, WssPort = 8443 });
+ startup.ConfigureWebSocketServer(MultipleServerHostBuilder.Create());
+ }
+
+ /// 使用真实客户端完成握手,并固定信任当前测试自签证书。
+ /// Completes a real handshake, pinning trust to the current self-signed test certificate.
+ private static async Task AssertHandshakeAsync(string scheme, ushort port, string thumbprint = null)
+ {
+ using (var client = new ClientWebSocket())
+ {
+ if (thumbprint != null)
+ {
+ // 仅信任本例生成的自签证书,避免全局关闭证书验证。
+ client.Options.RemoteCertificateValidationCallback = (_, certificate, _, _) => certificate?.GetCertHashString() == thumbprint;
+ }
+
+ using (var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(10)))
+ {
+ await client.ConnectAsync(new Uri($"{scheme}://localhost:{port}/"), cancellation.Token);
+ Assert.Equal(WebSocketState.Open, client.State);
+ await client.CloseAsync(WebSocketCloseStatus.NormalClosure, "测试完成", cancellation.Token);
+ }
+ }
+ }
+
+ /// 申请并释放本机临时端口。
+ /// Allocates and releases a local ephemeral port.
+ private static ushort GetAvailablePort()
+ {
+ using (var listener = new TcpListener(IPAddress.Loopback, 0))
+ {
+ listener.Start();
+ return checked((ushort)((IPEndPoint)listener.LocalEndpoint).Port);
+ }
+ }
+}
diff --git a/Tests/GameFrameX.Tests/Utility/AppSettingTests.cs b/Tests/GameFrameX.Tests/Utility/AppSettingTests.cs
index 0fedeaba8..51ee7bc20 100644
--- a/Tests/GameFrameX.Tests/Utility/AppSettingTests.cs
+++ b/Tests/GameFrameX.Tests/Utility/AppSettingTests.cs
@@ -124,6 +124,9 @@ public void DefaultConfigurationProperties_ShouldHaveCorrectValues()
Assert.Equal(string.Empty, appSetting.HttpSignSecret);
Assert.Equal(string.Empty, appSetting.HttpsCertPath);
Assert.Equal(string.Empty, appSetting.HttpsCertPassword);
+ Assert.Equal(string.Empty, appSetting.HttpsCertificateKeyPath);
+ Assert.Equal(string.Empty, appSetting.WssCertificateKeyPath);
+ Assert.Equal(string.Empty, appSetting.WssCertificatePassword);
}
///
@@ -196,6 +199,9 @@ public void NetworkAndPortProperties_CanBeSet_ShouldUpdateValues()
appSetting.HttpsPort = 443;
appSetting.HttpsCertPath = "/path/to/https-cert.pfx";
appSetting.HttpsCertPassword = "cert-password";
+ appSetting.HttpsCertificateKeyPath = "/path/to/https-key.pem";
+ appSetting.WssCertificateKeyPath = "/path/to/wss-key.pem";
+ appSetting.WssCertificatePassword = "wss-password";
appSetting.MetricsPort = 9091;
appSetting.WsPort = 8081;
appSetting.WssPort = 8443;
@@ -212,6 +218,9 @@ public void NetworkAndPortProperties_CanBeSet_ShouldUpdateValues()
Assert.Equal((ushort)443, appSetting.HttpsPort);
Assert.Equal("/path/to/https-cert.pfx", appSetting.HttpsCertPath);
Assert.Equal("cert-password", appSetting.HttpsCertPassword);
+ Assert.Equal("/path/to/https-key.pem", appSetting.HttpsCertificateKeyPath);
+ Assert.Equal("/path/to/wss-key.pem", appSetting.WssCertificateKeyPath);
+ Assert.Equal("wss-password", appSetting.WssCertificatePassword);
Assert.Equal((ushort)9091, appSetting.MetricsPort);
Assert.Equal((ushort)8081, appSetting.WsPort);
Assert.Equal((ushort)8443, appSetting.WssPort);
@@ -298,6 +307,7 @@ public void ToString_WithCredentialFields_ShouldMaskSensitiveValues()
appSetting.HttpSignSecret = "http-sign-secret-plain";
appSetting.InterNodeAuthSecret = "inter-node-auth-secret-plain";
appSetting.HttpsCertPassword = "https-cert-password-plain";
+ appSetting.WssCertificatePassword = "wss-certificate-password-plain";
// Act
var json = appSetting.ToString();
@@ -308,10 +318,13 @@ public void ToString_WithCredentialFields_ShouldMaskSensitiveValues()
Assert.DoesNotContain("http-sign-secret-plain", json);
Assert.DoesNotContain("inter-node-auth-secret-plain", json);
Assert.DoesNotContain("https-cert-password-plain", json);
+ Assert.DoesNotContain("wss-certificate-password-plain", json);
+ Assert.Contains($"\"{nameof(AppSetting.WssCertificatePassword)}\":\"***\"", json);
Assert.DoesNotContain("db-password-plain", formattedJson);
Assert.DoesNotContain("http-sign-secret-plain", formattedJson);
Assert.DoesNotContain("inter-node-auth-secret-plain", formattedJson);
Assert.DoesNotContain("https-cert-password-plain", formattedJson);
+ Assert.DoesNotContain("wss-certificate-password-plain", formattedJson);
// 已配置的字段以掩码出现,保留"已配置"的运维可见性
Assert.Contains("\"***\"", json);
@@ -325,7 +338,7 @@ public void ToString_WithCredentialFields_ShouldMaskSensitiveValues()
public void ToString_WithUnsetCredentialFields_ShouldKeepEmptyValues()
{
// Arrange:默认构造——DataBasePassword 为 null(JsonHelper 跳过 null 属性),
- // InterNodeAuthSecret/HttpSignSecret/HttpsCertPassword 为空串(保留输出空值)
+ // InterNodeAuthSecret/HttpSignSecret/HttpsCertPassword/WssCertificatePassword 为空串(保留输出空值)
var appSetting = new AppSetting();
// Act
@@ -333,6 +346,7 @@ public void ToString_WithUnsetCredentialFields_ShouldKeepEmptyValues()
// Assert:空串凭据字段保留空值输出(紧凑 JSON 无空格分隔),未配置不误报为已配置(不出现掩码)
Assert.Contains($"\"{nameof(AppSetting.InterNodeAuthSecret)}\":\"\"", json);
+ Assert.Contains($"\"{nameof(AppSetting.WssCertificatePassword)}\":\"\"", json);
Assert.DoesNotContain("***", json);
}
@@ -345,6 +359,7 @@ public void ToString_AfterMasking_ShouldNotMutateOriginalSetting()
// Arrange
var appSetting = new AppSetting();
appSetting.DataBasePassword = "db-password-plain";
+ appSetting.WssCertificatePassword = "wss-password-plain";
// Act
appSetting.ToString();
@@ -352,5 +367,6 @@ public void ToString_AfterMasking_ShouldNotMutateOriginalSetting()
// Assert
Assert.Equal("db-password-plain", appSetting.DataBasePassword);
+ Assert.Equal("wss-password-plain", appSetting.WssCertificatePassword);
}
}