This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
using Cysharp.Threading.Tasks;
|
||||
using ShrinkNetwork;
|
||||
|
||||
namespace ShrinkNetwork.ServerHost.Framework;
|
||||
|
||||
public interface IShrinkServerModule
|
||||
{
|
||||
string Name { get; }
|
||||
|
||||
void ConfigureService(ServerModuleContext context, ShrinkNetworkService service, string transportName);
|
||||
|
||||
UniTask StartAsync(ServerModuleContext context, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c849f6eb91ed5824db0497318345601b
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,152 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using ShrinkNetwork;
|
||||
|
||||
namespace ShrinkNetwork.ServerHost.Framework;
|
||||
|
||||
public sealed class ServerAuthStore
|
||||
{
|
||||
public sealed class AuthState
|
||||
{
|
||||
public string AuthName { get; set; } = string.Empty;
|
||||
public bool IsAuthenticated { get; set; }
|
||||
public DateTimeOffset AuthenticatedAtUtc { get; set; }
|
||||
public DateTimeOffset LastSeenAtUtc { get; set; }
|
||||
public string SessionToken { get; set; } = string.Empty;
|
||||
public DateTimeOffset SessionTokenExpiresAtUtc { get; set; }
|
||||
}
|
||||
|
||||
private readonly ConcurrentDictionary<(ShrinkNetworkService service, long sessionId), AuthState> _states = new();
|
||||
|
||||
public AuthState MarkAuthenticated(ShrinkNetworkService service, long sessionId, string authName, TimeSpan tokenTtl)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var state = new AuthState
|
||||
{
|
||||
AuthName = authName,
|
||||
IsAuthenticated = true,
|
||||
AuthenticatedAtUtc = now,
|
||||
LastSeenAtUtc = now,
|
||||
SessionToken = CreateSessionToken(),
|
||||
SessionTokenExpiresAtUtc = now.Add(tokenTtl)
|
||||
};
|
||||
_states[(service, sessionId)] = state;
|
||||
return CloneState(state);
|
||||
}
|
||||
|
||||
public void Remove(ShrinkNetworkService service, long sessionId)
|
||||
{
|
||||
_states.TryRemove((service, sessionId), out _);
|
||||
}
|
||||
|
||||
public bool IsAuthenticated(ShrinkNetworkService service, long sessionId)
|
||||
{
|
||||
return _states.TryGetValue((service, sessionId), out var state) && state.IsAuthenticated;
|
||||
}
|
||||
|
||||
public bool TryGetState(ShrinkNetworkService service, long sessionId, out AuthState state)
|
||||
{
|
||||
if (_states.TryGetValue((service, sessionId), out var current))
|
||||
{
|
||||
state = CloneState(current);
|
||||
return true;
|
||||
}
|
||||
|
||||
state = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryValidateSessionToken(ShrinkNetworkService service, long sessionId, string? sessionToken, out AuthState state,
|
||||
out string failureReason)
|
||||
{
|
||||
if (!_states.TryGetValue((service, sessionId), out var current) || !current.IsAuthenticated)
|
||||
{
|
||||
state = null!;
|
||||
failureReason = "会话未认证。";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sessionToken))
|
||||
{
|
||||
state = null!;
|
||||
failureReason = "缺少会话令牌。";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(current.SessionToken, sessionToken.Trim(), StringComparison.Ordinal))
|
||||
{
|
||||
state = null!;
|
||||
failureReason = "会话令牌不匹配。";
|
||||
return false;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (current.SessionTokenExpiresAtUtc <= now)
|
||||
{
|
||||
state = null!;
|
||||
failureReason = "会话令牌已过期。";
|
||||
return false;
|
||||
}
|
||||
|
||||
current.LastSeenAtUtc = now;
|
||||
state = CloneState(current);
|
||||
failureReason = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryRefreshSessionToken(ShrinkNetworkService service, long sessionId, string? sessionToken, TimeSpan tokenTtl,
|
||||
TimeSpan refreshWindow, out AuthState state, out string failureReason)
|
||||
{
|
||||
if (!TryValidateSessionToken(service, sessionId, sessionToken, out _, out failureReason))
|
||||
{
|
||||
state = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
var key = (service, sessionId);
|
||||
if (!_states.TryGetValue(key, out var current))
|
||||
{
|
||||
state = null!;
|
||||
failureReason = "认证状态不存在。";
|
||||
return false;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (refreshWindow > TimeSpan.Zero && current.SessionTokenExpiresAtUtc - now > refreshWindow)
|
||||
{
|
||||
state = null!;
|
||||
failureReason = "当前还不在会话令牌续期窗口内。";
|
||||
return false;
|
||||
}
|
||||
|
||||
current.SessionToken = CreateSessionToken();
|
||||
current.SessionTokenExpiresAtUtc = now.Add(tokenTtl);
|
||||
current.LastSeenAtUtc = now;
|
||||
state = CloneState(current);
|
||||
failureReason = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static AuthState CloneState(AuthState state)
|
||||
{
|
||||
return new AuthState
|
||||
{
|
||||
AuthName = state.AuthName,
|
||||
IsAuthenticated = state.IsAuthenticated,
|
||||
AuthenticatedAtUtc = state.AuthenticatedAtUtc,
|
||||
LastSeenAtUtc = state.LastSeenAtUtc,
|
||||
SessionToken = state.SessionToken,
|
||||
SessionTokenExpiresAtUtc = state.SessionTokenExpiresAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
private static string CreateSessionToken()
|
||||
{
|
||||
var buffer = new byte[32];
|
||||
RandomNumberGenerator.Fill(buffer);
|
||||
return Convert.ToBase64String(buffer)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 911b8227233322049a930fb0ed3c2701
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,145 @@
|
||||
using ShrinkNetwork;
|
||||
|
||||
namespace ShrinkNetwork.ServerHost.Framework;
|
||||
|
||||
public sealed class ServerHostOptions
|
||||
{
|
||||
public int Port { get; set; } = 17777;
|
||||
public string UnityAssetsPath { get; set; } = ResolveDefaultUnityAssetsPath();
|
||||
public string ScanOutputDirectory { get; set; } = ResolveDefaultScanOutputDirectory();
|
||||
public string? SharedAuthToken { get; set; }
|
||||
public bool AllowAnonymousWhenAuthTokenMissing { get; set; }
|
||||
public bool EnableSessionTokens { get; set; } = true;
|
||||
public int SessionTokenTtlSeconds { get; set; } = 1800;
|
||||
public int SessionTokenRefreshWindowSeconds { get; set; } = 300;
|
||||
public bool DisconnectOnInvalidSessionToken { get; set; } = true;
|
||||
public bool EnableUnityCodeScan { get; set; } = true;
|
||||
public int MinProtocolVersion { get; set; } = ShrinkNetworkProtocol.CurrentProtocolVersion;
|
||||
public int MaxProtocolVersion { get; set; } = ShrinkNetworkProtocol.CurrentProtocolVersion;
|
||||
public int MinSchemaVersion { get; set; } = ShrinkNetworkProtocol.CurrentSchemaVersion;
|
||||
public int MaxSchemaVersion { get; set; } = ShrinkNetworkProtocol.CurrentSchemaVersion;
|
||||
public bool DisconnectOnProtocolViolation { get; set; } = true;
|
||||
public int DiagnosticsLogIntervalSeconds { get; set; } = 60;
|
||||
public bool EnableTcpTls { get; set; }
|
||||
public string? TcpTlsTargetHost { get; set; }
|
||||
public string? TcpTlsCertificatePath { get; set; }
|
||||
public string? TcpTlsCertificatePassword { get; set; }
|
||||
public bool TcpTlsCheckCertificateRevocation { get; set; }
|
||||
|
||||
public void ApplyEnvironmentOverrides()
|
||||
{
|
||||
SharedAuthToken = ReadOptionalStringFromEnvironment("SHRINK_SERVER_AUTH_TOKEN", SharedAuthToken);
|
||||
AllowAnonymousWhenAuthTokenMissing = ReadBoolFromEnvironment(
|
||||
"SHRINK_SERVER_ALLOW_ANONYMOUS_WHEN_AUTH_TOKEN_MISSING",
|
||||
AllowAnonymousWhenAuthTokenMissing);
|
||||
EnableSessionTokens = ReadBoolFromEnvironment("SHRINK_SERVER_ENABLE_SESSION_TOKENS", EnableSessionTokens);
|
||||
SessionTokenTtlSeconds = ReadIntFromEnvironment("SHRINK_SERVER_SESSION_TOKEN_TTL_SECONDS", SessionTokenTtlSeconds, 60);
|
||||
SessionTokenRefreshWindowSeconds = ReadIntFromEnvironment(
|
||||
"SHRINK_SERVER_SESSION_TOKEN_REFRESH_WINDOW_SECONDS",
|
||||
SessionTokenRefreshWindowSeconds,
|
||||
0);
|
||||
DisconnectOnInvalidSessionToken = ReadBoolFromEnvironment(
|
||||
"SHRINK_SERVER_DISCONNECT_ON_INVALID_SESSION_TOKEN",
|
||||
DisconnectOnInvalidSessionToken);
|
||||
EnableUnityCodeScan = ReadBoolFromEnvironment("SHRINK_SERVER_ENABLE_UNITY_CODE_SCAN", EnableUnityCodeScan);
|
||||
MinProtocolVersion = ReadIntFromEnvironment("SHRINK_SERVER_MIN_PROTOCOL_VERSION", MinProtocolVersion, 1);
|
||||
MaxProtocolVersion = ReadIntFromEnvironment("SHRINK_SERVER_MAX_PROTOCOL_VERSION", MaxProtocolVersion, 1);
|
||||
MinSchemaVersion = ReadIntFromEnvironment("SHRINK_SERVER_MIN_SCHEMA_VERSION", MinSchemaVersion, 1);
|
||||
MaxSchemaVersion = ReadIntFromEnvironment("SHRINK_SERVER_MAX_SCHEMA_VERSION", MaxSchemaVersion, 1);
|
||||
DisconnectOnProtocolViolation = ReadBoolFromEnvironment(
|
||||
"SHRINK_SERVER_DISCONNECT_ON_PROTOCOL_VIOLATION",
|
||||
DisconnectOnProtocolViolation);
|
||||
DiagnosticsLogIntervalSeconds = ReadIntFromEnvironment(
|
||||
"SHRINK_SERVER_DIAGNOSTICS_LOG_INTERVAL_SECONDS",
|
||||
DiagnosticsLogIntervalSeconds,
|
||||
0);
|
||||
EnableTcpTls = ReadBoolFromEnvironment("SHRINK_SERVER_ENABLE_TCP_TLS", EnableTcpTls);
|
||||
TcpTlsTargetHost = ReadOptionalStringFromEnvironment("SHRINK_SERVER_TLS_TARGET_HOST", TcpTlsTargetHost);
|
||||
TcpTlsCertificatePath = ReadOptionalStringFromEnvironment("SHRINK_SERVER_TLS_CERT_PATH", TcpTlsCertificatePath);
|
||||
TcpTlsCertificatePassword = ReadOptionalStringFromEnvironment(
|
||||
"SHRINK_SERVER_TLS_CERT_PASSWORD",
|
||||
TcpTlsCertificatePassword);
|
||||
TcpTlsCheckCertificateRevocation = ReadBoolFromEnvironment(
|
||||
"SHRINK_SERVER_TLS_CHECK_CERTIFICATE_REVOCATION",
|
||||
TcpTlsCheckCertificateRevocation);
|
||||
}
|
||||
|
||||
public static string ResolveHostProjectDirectory()
|
||||
{
|
||||
var resolved = TryFindProjectDirectory(AppContext.BaseDirectory);
|
||||
if (!string.IsNullOrWhiteSpace(resolved))
|
||||
return resolved;
|
||||
|
||||
resolved = TryFindProjectDirectory(Environment.CurrentDirectory);
|
||||
if (!string.IsNullOrWhiteSpace(resolved))
|
||||
return resolved;
|
||||
|
||||
return Environment.CurrentDirectory;
|
||||
}
|
||||
|
||||
public static string ResolveDefaultConfigFilePath()
|
||||
{
|
||||
return Path.Combine(ResolveHostProjectDirectory(), ServerHostProperties.DefaultFileName);
|
||||
}
|
||||
|
||||
public static string ResolveDefaultUnityAssetsPath()
|
||||
{
|
||||
return Path.GetFullPath(Path.Combine(ResolveHostProjectDirectory(), "..", "..", "Assets"));
|
||||
}
|
||||
|
||||
public static string ResolveDefaultScanOutputDirectory()
|
||||
{
|
||||
return Path.GetFullPath(Path.Combine(ResolveHostProjectDirectory(), "Generated"));
|
||||
}
|
||||
|
||||
private static string? TryFindProjectDirectory(string startDirectory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(startDirectory) || !Directory.Exists(startDirectory))
|
||||
return null;
|
||||
|
||||
var directory = new DirectoryInfo(Path.GetFullPath(startDirectory));
|
||||
for (var depth = 0; directory != null && depth < 8; depth++, directory = directory.Parent)
|
||||
{
|
||||
var candidate = Path.Combine(directory.FullName, "ShrinkNetwork.ServerHost.csproj");
|
||||
if (File.Exists(candidate))
|
||||
return directory.FullName;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int ReadIntFromEnvironment(string name, int fallback, int minValue)
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable(name);
|
||||
if (!int.TryParse(raw, out var value))
|
||||
return fallback;
|
||||
|
||||
return Math.Max(minValue, value);
|
||||
}
|
||||
|
||||
private static bool ReadBoolFromEnvironment(string name, bool fallback)
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable(name);
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return fallback;
|
||||
|
||||
if (bool.TryParse(raw, out var parsed))
|
||||
return parsed;
|
||||
|
||||
return raw switch
|
||||
{
|
||||
"1" => true,
|
||||
"0" => false,
|
||||
_ => fallback
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ReadOptionalStringFromEnvironment(string name, string? fallback)
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable(name);
|
||||
if (raw == null)
|
||||
return fallback;
|
||||
|
||||
return string.IsNullOrWhiteSpace(raw) ? string.Empty : raw.Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 515c40ff2d019da4985e05bd55a5443f
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,280 @@
|
||||
using System.Text;
|
||||
|
||||
namespace ShrinkNetwork.ServerHost.Framework;
|
||||
|
||||
public sealed class ServerHostPropertiesLoadResult
|
||||
{
|
||||
public required ServerHostOptions Options { get; init; }
|
||||
public required string FilePath { get; init; }
|
||||
public bool CreatedDefaultFile { get; init; }
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
public static class ServerHostProperties
|
||||
{
|
||||
public const string DefaultFileName = "server.properties";
|
||||
|
||||
public static ServerHostPropertiesLoadResult LoadOrCreate()
|
||||
{
|
||||
var filePath = ResolveFilePath();
|
||||
var options = new ServerHostOptions();
|
||||
var warnings = new List<string>();
|
||||
var createdDefaultFile = false;
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
var directory = Path.GetDirectoryName(filePath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
File.WriteAllText(filePath, BuildDefaultFile(filePath, options), Encoding.UTF8);
|
||||
createdDefaultFile = true;
|
||||
}
|
||||
|
||||
ApplyFile(filePath, options, warnings);
|
||||
options.ApplyEnvironmentOverrides();
|
||||
NormalizeOptions(options);
|
||||
|
||||
return new ServerHostPropertiesLoadResult
|
||||
{
|
||||
Options = options,
|
||||
FilePath = filePath,
|
||||
CreatedDefaultFile = createdDefaultFile,
|
||||
Warnings = warnings
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveFilePath()
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable("SHRINK_SERVER_CONFIG_PATH");
|
||||
if (!string.IsNullOrWhiteSpace(configured))
|
||||
return Path.GetFullPath(configured.Trim());
|
||||
|
||||
var currentDirectoryCandidate = Path.Combine(Environment.CurrentDirectory, DefaultFileName);
|
||||
if (File.Exists(currentDirectoryCandidate))
|
||||
return currentDirectoryCandidate;
|
||||
|
||||
return ServerHostOptions.ResolveDefaultConfigFilePath();
|
||||
}
|
||||
|
||||
private static void ApplyFile(string filePath, ServerHostOptions options, List<string> warnings)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(filePath) ?? Environment.CurrentDirectory;
|
||||
var lines = File.ReadAllLines(filePath);
|
||||
for (var index = 0; index < lines.Length; index++)
|
||||
{
|
||||
var rawLine = lines[index].Trim();
|
||||
if (string.IsNullOrWhiteSpace(rawLine) || rawLine.StartsWith("#", StringComparison.Ordinal) ||
|
||||
rawLine.StartsWith(";", StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
var separatorIndex = rawLine.IndexOf('=');
|
||||
if (separatorIndex <= 0)
|
||||
{
|
||||
warnings.Add($"第 {index + 1} 行不是有效的 key=value,将忽略。");
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = rawLine[..separatorIndex].Trim();
|
||||
var value = rawLine[(separatorIndex + 1)..].Trim();
|
||||
ApplyValue(options, directory, key, value, warnings);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyValue(ServerHostOptions options, string directory, string key, string value, List<string> warnings)
|
||||
{
|
||||
switch (key)
|
||||
{
|
||||
case "server-port":
|
||||
options.Port = ParseInt(value, options.Port, 1, warnings, key);
|
||||
break;
|
||||
case "unity-assets-path":
|
||||
options.UnityAssetsPath = ResolvePath(directory, value, options.UnityAssetsPath);
|
||||
break;
|
||||
case "scan-output-directory":
|
||||
options.ScanOutputDirectory = ResolvePath(directory, value, options.ScanOutputDirectory);
|
||||
break;
|
||||
case "shared-auth-token":
|
||||
options.SharedAuthToken = value;
|
||||
break;
|
||||
case "allow-anonymous-when-auth-token-missing":
|
||||
options.AllowAnonymousWhenAuthTokenMissing = ParseBool(value, options.AllowAnonymousWhenAuthTokenMissing, warnings, key);
|
||||
break;
|
||||
case "enable-session-tokens":
|
||||
options.EnableSessionTokens = ParseBool(value, options.EnableSessionTokens, warnings, key);
|
||||
break;
|
||||
case "session-token-ttl-seconds":
|
||||
options.SessionTokenTtlSeconds = ParseInt(value, options.SessionTokenTtlSeconds, 60, warnings, key);
|
||||
break;
|
||||
case "session-token-refresh-window-seconds":
|
||||
options.SessionTokenRefreshWindowSeconds = ParseInt(value, options.SessionTokenRefreshWindowSeconds, 0, warnings, key);
|
||||
break;
|
||||
case "disconnect-on-invalid-session-token":
|
||||
options.DisconnectOnInvalidSessionToken = ParseBool(value, options.DisconnectOnInvalidSessionToken, warnings, key);
|
||||
break;
|
||||
case "enable-unity-code-scan":
|
||||
options.EnableUnityCodeScan = ParseBool(value, options.EnableUnityCodeScan, warnings, key);
|
||||
break;
|
||||
case "min-protocol-version":
|
||||
options.MinProtocolVersion = ParseInt(value, options.MinProtocolVersion, 1, warnings, key);
|
||||
break;
|
||||
case "max-protocol-version":
|
||||
options.MaxProtocolVersion = ParseInt(value, options.MaxProtocolVersion, 1, warnings, key);
|
||||
break;
|
||||
case "min-schema-version":
|
||||
options.MinSchemaVersion = ParseInt(value, options.MinSchemaVersion, 1, warnings, key);
|
||||
break;
|
||||
case "max-schema-version":
|
||||
options.MaxSchemaVersion = ParseInt(value, options.MaxSchemaVersion, 1, warnings, key);
|
||||
break;
|
||||
case "disconnect-on-protocol-violation":
|
||||
options.DisconnectOnProtocolViolation = ParseBool(value, options.DisconnectOnProtocolViolation, warnings, key);
|
||||
break;
|
||||
case "diagnostics-log-interval-seconds":
|
||||
options.DiagnosticsLogIntervalSeconds = ParseInt(value, options.DiagnosticsLogIntervalSeconds, 0, warnings, key);
|
||||
break;
|
||||
case "enable-tcp-tls":
|
||||
options.EnableTcpTls = ParseBool(value, options.EnableTcpTls, warnings, key);
|
||||
break;
|
||||
case "tcp-tls-target-host":
|
||||
options.TcpTlsTargetHost = NullIfEmpty(value);
|
||||
break;
|
||||
case "tcp-tls-certificate-path":
|
||||
options.TcpTlsCertificatePath = ResolvePath(directory, value, options.TcpTlsCertificatePath);
|
||||
break;
|
||||
case "tcp-tls-certificate-password":
|
||||
options.TcpTlsCertificatePassword = value;
|
||||
break;
|
||||
case "tcp-tls-check-certificate-revocation":
|
||||
options.TcpTlsCheckCertificateRevocation = ParseBool(value, options.TcpTlsCheckCertificateRevocation, warnings, key);
|
||||
break;
|
||||
default:
|
||||
warnings.Add($"未识别的配置键:{key}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void NormalizeOptions(ServerHostOptions options)
|
||||
{
|
||||
options.SharedAuthToken = NullIfEmpty(options.SharedAuthToken);
|
||||
options.TcpTlsTargetHost = NullIfEmpty(options.TcpTlsTargetHost);
|
||||
options.TcpTlsCertificatePath = NullIfEmpty(options.TcpTlsCertificatePath);
|
||||
options.TcpTlsCertificatePassword = NullIfEmpty(options.TcpTlsCertificatePassword);
|
||||
options.SessionTokenRefreshWindowSeconds = Math.Max(0, options.SessionTokenRefreshWindowSeconds);
|
||||
options.SessionTokenTtlSeconds = Math.Max(60, options.SessionTokenTtlSeconds);
|
||||
options.Port = Math.Clamp(options.Port, 1, 65535);
|
||||
options.DiagnosticsLogIntervalSeconds = Math.Max(0, options.DiagnosticsLogIntervalSeconds);
|
||||
if (options.MaxProtocolVersion < options.MinProtocolVersion)
|
||||
options.MaxProtocolVersion = options.MinProtocolVersion;
|
||||
if (options.MaxSchemaVersion < options.MinSchemaVersion)
|
||||
options.MaxSchemaVersion = options.MinSchemaVersion;
|
||||
if (options.EnableTcpTls && string.IsNullOrWhiteSpace(options.TcpTlsCertificatePath))
|
||||
options.EnableTcpTls = false;
|
||||
}
|
||||
|
||||
private static string BuildDefaultFile(string filePath, ServerHostOptions options)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(filePath) ?? Environment.CurrentDirectory;
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("# ShrinkNetwork server.properties");
|
||||
builder.AppendLine("# 类似 Minecraft 的 key=value 配置文件。");
|
||||
builder.AppendLine("# 优先级:代码默认值 < 本文件 < 环境变量。");
|
||||
builder.AppendLine("# 修改后需要重启服务器进程。");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("# 基础网络");
|
||||
builder.AppendLine($"server-port={options.Port}");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("# 代码扫描与生成");
|
||||
builder.AppendLine($"unity-assets-path={ToRelativePath(directory, options.UnityAssetsPath)}");
|
||||
builder.AppendLine($"scan-output-directory={ToRelativePath(directory, options.ScanOutputDirectory)}");
|
||||
builder.AppendLine($"enable-unity-code-scan={options.EnableUnityCodeScan.ToString().ToLowerInvariant()}");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("# 登录与会话");
|
||||
builder.AppendLine("shared-auth-token=");
|
||||
builder.AppendLine($"allow-anonymous-when-auth-token-missing={options.AllowAnonymousWhenAuthTokenMissing.ToString().ToLowerInvariant()}");
|
||||
builder.AppendLine($"enable-session-tokens={options.EnableSessionTokens.ToString().ToLowerInvariant()}");
|
||||
builder.AppendLine($"session-token-ttl-seconds={options.SessionTokenTtlSeconds}");
|
||||
builder.AppendLine($"session-token-refresh-window-seconds={options.SessionTokenRefreshWindowSeconds}");
|
||||
builder.AppendLine($"disconnect-on-invalid-session-token={options.DisconnectOnInvalidSessionToken.ToString().ToLowerInvariant()}");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("# 协议兼容");
|
||||
builder.AppendLine($"min-protocol-version={options.MinProtocolVersion}");
|
||||
builder.AppendLine($"max-protocol-version={options.MaxProtocolVersion}");
|
||||
builder.AppendLine($"min-schema-version={options.MinSchemaVersion}");
|
||||
builder.AppendLine($"max-schema-version={options.MaxSchemaVersion}");
|
||||
builder.AppendLine($"disconnect-on-protocol-violation={options.DisconnectOnProtocolViolation.ToString().ToLowerInvariant()}");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("# 观测");
|
||||
builder.AppendLine($"diagnostics-log-interval-seconds={options.DiagnosticsLogIntervalSeconds}");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("# TCP TLS");
|
||||
builder.AppendLine($"enable-tcp-tls={options.EnableTcpTls.ToString().ToLowerInvariant()}");
|
||||
builder.AppendLine("tcp-tls-target-host=");
|
||||
builder.AppendLine("tcp-tls-certificate-path=");
|
||||
builder.AppendLine("tcp-tls-certificate-password=");
|
||||
builder.AppendLine($"tcp-tls-check-certificate-revocation={options.TcpTlsCheckCertificateRevocation.ToString().ToLowerInvariant()}");
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static int ParseInt(string raw, int fallback, int minValue, List<string> warnings, string key)
|
||||
{
|
||||
if (!int.TryParse(raw, out var value))
|
||||
{
|
||||
warnings.Add($"配置 {key} 不是有效整数,将回退为 {fallback}。");
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Math.Max(minValue, value);
|
||||
}
|
||||
|
||||
private static bool ParseBool(string raw, bool fallback, List<string> warnings, string key)
|
||||
{
|
||||
if (TryParseBool(raw, out var parsed))
|
||||
return parsed;
|
||||
|
||||
warnings.Add($"配置 {key} 不是有效布尔值,将回退为 {fallback}。");
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private static bool TryParseBool(string raw, out bool value)
|
||||
{
|
||||
if (bool.TryParse(raw, out value))
|
||||
return true;
|
||||
|
||||
switch (raw.Trim().ToLowerInvariant())
|
||||
{
|
||||
case "1":
|
||||
case "yes":
|
||||
case "on":
|
||||
value = true;
|
||||
return true;
|
||||
case "0":
|
||||
case "no":
|
||||
case "off":
|
||||
value = false;
|
||||
return true;
|
||||
default:
|
||||
value = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolvePath(string baseDirectory, string raw, string? fallback)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return fallback ?? string.Empty;
|
||||
|
||||
return Path.GetFullPath(Path.IsPathRooted(raw) ? raw : Path.Combine(baseDirectory, raw));
|
||||
}
|
||||
|
||||
private static string ToRelativePath(string baseDirectory, string targetPath)
|
||||
{
|
||||
var relative = Path.GetRelativePath(baseDirectory, targetPath);
|
||||
return relative.Replace('\\', '/');
|
||||
}
|
||||
|
||||
private static string? NullIfEmpty(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f4f074bc9686c304d8875fa11b191fdb
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,24 @@
|
||||
using ShrinkNetwork;
|
||||
|
||||
namespace ShrinkNetwork.ServerHost.Framework;
|
||||
|
||||
public sealed class ServerModuleContext
|
||||
{
|
||||
private readonly Dictionary<string, ShrinkNetworkService> _services = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public ServerModuleContext(ServerHostOptions options, ServerAuthStore authStore)
|
||||
{
|
||||
Options = options;
|
||||
AuthStore = authStore;
|
||||
}
|
||||
|
||||
public ServerHostOptions Options { get; }
|
||||
public ServerAuthStore AuthStore { get; }
|
||||
public UnityNetworkScanManifest? ScanManifest { get; set; }
|
||||
public IReadOnlyDictionary<string, ShrinkNetworkService> Services => _services;
|
||||
|
||||
public void RegisterService(string transportName, ShrinkNetworkService service)
|
||||
{
|
||||
_services[transportName] = service;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e12dcb63434cc454bbee36f180eb6534
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,123 @@
|
||||
using Cysharp.Threading.Tasks;
|
||||
using ShrinkNetwork;
|
||||
|
||||
namespace ShrinkNetwork.ServerHost.Framework;
|
||||
|
||||
public sealed class ShrinkDedicatedServerApp
|
||||
{
|
||||
private readonly List<IShrinkServerModule> _modules;
|
||||
private readonly ServerModuleContext _context;
|
||||
|
||||
public ShrinkDedicatedServerApp(ServerHostOptions options, params IShrinkServerModule[] modules)
|
||||
{
|
||||
_modules = modules.ToList();
|
||||
_context = new ServerModuleContext(options, new ServerAuthStore());
|
||||
}
|
||||
|
||||
public ServerModuleContext Context => _context;
|
||||
|
||||
public async UniTask StartAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_context.Options.EnableUnityCodeScan)
|
||||
{
|
||||
var manifest = UnityNetworkCodeScanner.Scan(_context.Options.UnityAssetsPath);
|
||||
UnityNetworkCodeScanner.WriteOutputs(manifest, _context.Options.ScanOutputDirectory);
|
||||
_context.ScanManifest = manifest;
|
||||
}
|
||||
|
||||
var tcpService = CreateService("TCP");
|
||||
var kcpService = CreateService("KCP");
|
||||
|
||||
_context.RegisterService("TCP", tcpService);
|
||||
_context.RegisterService("KCP", kcpService);
|
||||
|
||||
foreach (var module in _modules)
|
||||
{
|
||||
module.ConfigureService(_context, tcpService, "TCP");
|
||||
module.ConfigureService(_context, kcpService, "KCP");
|
||||
}
|
||||
|
||||
tcpService.BindTransport(new TcpServerTransport(System.Net.IPAddress.Any, _context.Options.Port,
|
||||
tlsOptions: BuildTcpTlsOptions()));
|
||||
kcpService.BindTransport(new KcpServerTransport(System.Net.IPAddress.Any, _context.Options.Port, new ShrinkKcpTransportOptions
|
||||
{
|
||||
Interval = 10,
|
||||
UpdateIntervalMs = 10,
|
||||
IdleTimeoutMs = 15000,
|
||||
HandshakeRetryMs = 250,
|
||||
ConnectTimeoutMs = 4000
|
||||
}));
|
||||
|
||||
foreach (var module in _modules)
|
||||
module.StartAsync(_context, cancellationToken).Forget();
|
||||
|
||||
await UniTask.CompletedTask;
|
||||
}
|
||||
|
||||
private ShrinkNetworkService CreateService(string transportName)
|
||||
{
|
||||
var service = new ShrinkNetworkService(new ShrinkJsonNetworkSerializer(),
|
||||
new ShrinkNetworkMessageRegistry(),
|
||||
new ShrinkNetworkRouter());
|
||||
service.MinProtocolVersion = _context.Options.MinProtocolVersion;
|
||||
service.MaxProtocolVersion = _context.Options.MaxProtocolVersion;
|
||||
service.MinSchemaVersion = _context.Options.MinSchemaVersion;
|
||||
service.MaxSchemaVersion = _context.Options.MaxSchemaVersion;
|
||||
service.DisconnectOnProtocolViolation = _context.Options.DisconnectOnProtocolViolation;
|
||||
service.IncomingPacketValidator = (session, packet) => ValidateIncomingPacket(service, session, packet);
|
||||
|
||||
service.OnSessionConnected += session =>
|
||||
{
|
||||
session.SetPeerKind(ShrinkNetworkPeerKind.Client);
|
||||
ShrinkNetworkLogger.Info($"[{transportName}] Client connected: {session.SessionId} {session.RemoteAddress}");
|
||||
};
|
||||
service.OnSessionDisconnected += session =>
|
||||
{
|
||||
_context.AuthStore.Remove(service, session.SessionId);
|
||||
ShrinkNetworkLogger.Info($"[{transportName}] Client disconnected: {session.SessionId}");
|
||||
};
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
private ShrinkIncomingPacketValidationResult ValidateIncomingPacket(
|
||||
ShrinkNetworkService service,
|
||||
ShrinkNetworkSession session,
|
||||
ShrinkNetworkPacket packet)
|
||||
{
|
||||
if (!_context.Options.EnableSessionTokens)
|
||||
return ShrinkIncomingPacketValidationResult.Allow;
|
||||
if (packet.Kind == ShrinkNetworkPacketKind.Response)
|
||||
return ShrinkIncomingPacketValidationResult.Allow;
|
||||
if (string.Equals(packet.Route, AuthServerModule.LoginRoute, StringComparison.Ordinal) ||
|
||||
string.Equals(packet.Route, AuthServerModule.RefreshRoute, StringComparison.Ordinal))
|
||||
return ShrinkIncomingPacketValidationResult.Allow;
|
||||
if (!_context.AuthStore.TryGetState(service, session.SessionId, out _))
|
||||
return ShrinkIncomingPacketValidationResult.Allow;
|
||||
|
||||
if (_context.AuthStore.TryValidateSessionToken(service, session.SessionId, packet.SessionToken, out var authState,
|
||||
out var failureReason))
|
||||
{
|
||||
session.SetSessionToken(authState.SessionToken, authState.SessionTokenExpiresAtUtc);
|
||||
return ShrinkIncomingPacketValidationResult.Allow;
|
||||
}
|
||||
|
||||
return ShrinkIncomingPacketValidationResult.Reject(failureReason,
|
||||
_context.Options.DisconnectOnInvalidSessionToken);
|
||||
}
|
||||
|
||||
private ShrinkTcpTlsOptions? BuildTcpTlsOptions()
|
||||
{
|
||||
if (!_context.Options.EnableTcpTls)
|
||||
return null;
|
||||
|
||||
return new ShrinkTcpTlsOptions
|
||||
{
|
||||
Enabled = true,
|
||||
TargetHost = _context.Options.TcpTlsTargetHost,
|
||||
ServerCertificatePath = _context.Options.TcpTlsCertificatePath,
|
||||
ServerCertificatePassword = _context.Options.TcpTlsCertificatePassword,
|
||||
CheckCertificateRevocation = _context.Options.TcpTlsCheckCertificateRevocation
|
||||
};
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6706e29aa428c5e41a63e978feb7d5a0
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,145 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ShrinkNetwork.ServerHost.Framework;
|
||||
|
||||
public sealed class UnityNetworkScanManifest
|
||||
{
|
||||
public string AssetsPath { get; set; } = string.Empty;
|
||||
public DateTimeOffset ScannedAtUtc { get; set; }
|
||||
public List<UnityNetworkMessageEntry> Messages { get; set; } = new();
|
||||
public List<UnityNetworkSubscriberEntry> Subscribers { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class UnityNetworkMessageEntry
|
||||
{
|
||||
public string FilePath { get; set; } = string.Empty;
|
||||
public string TypeName { get; set; } = string.Empty;
|
||||
public int Opcode { get; set; }
|
||||
public string Route { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class UnityNetworkSubscriberEntry
|
||||
{
|
||||
public string FilePath { get; set; } = string.Empty;
|
||||
public string MemberName { get; set; } = string.Empty;
|
||||
public string Authority { get; set; } = string.Empty;
|
||||
public string Permission { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public static class UnityNetworkCodeScanner
|
||||
{
|
||||
private static readonly Regex MessageRegex = new(
|
||||
@"\[ShrinkNetworkMessage\((?<opcode>-?\d+)\s*,\s*""(?<route>[^""]+)""\)\][\s\S]*?(?:class|struct)\s+(?<name>\w+)",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex SubscriberRegex = new(
|
||||
@"\[ShrinkNetworkSubscribe\((?<args>.*?)\)\][\s\S]*?(?:UniTask<.*?>|UniTask|void|Task<.*?>|Task)\s+(?<name>\w+)\s*\(",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex AuthorityRegex = new(
|
||||
@"Authority\s*=\s*ShrinkNetworkAuthority\.(?<value>\w+)",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex PermissionRegex = new(
|
||||
@"Permission\s*=\s*""(?<value>[^""]+)""",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
public static UnityNetworkScanManifest Scan(string assetsPath)
|
||||
{
|
||||
var manifest = new UnityNetworkScanManifest
|
||||
{
|
||||
AssetsPath = assetsPath,
|
||||
ScannedAtUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
if (!Directory.Exists(assetsPath))
|
||||
return manifest;
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(assetsPath, "*.cs", SearchOption.AllDirectories))
|
||||
{
|
||||
if (file.IndexOf($"{Path.DirectorySeparatorChar}Editor{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
continue;
|
||||
|
||||
var content = File.ReadAllText(file);
|
||||
var relativePath = Path.GetRelativePath(assetsPath, file).Replace('\\', '/');
|
||||
|
||||
foreach (Match match in MessageRegex.Matches(content))
|
||||
{
|
||||
manifest.Messages.Add(new UnityNetworkMessageEntry
|
||||
{
|
||||
FilePath = relativePath,
|
||||
TypeName = match.Groups["name"].Value,
|
||||
Opcode = int.Parse(match.Groups["opcode"].Value),
|
||||
Route = match.Groups["route"].Value
|
||||
});
|
||||
}
|
||||
|
||||
foreach (Match match in SubscriberRegex.Matches(content))
|
||||
{
|
||||
var args = match.Groups["args"].Value;
|
||||
manifest.Subscribers.Add(new UnityNetworkSubscriberEntry
|
||||
{
|
||||
FilePath = relativePath,
|
||||
MemberName = match.Groups["name"].Value,
|
||||
Authority = AuthorityRegex.Match(args).Groups["value"].Value,
|
||||
Permission = PermissionRegex.Match(args).Groups["value"].Value
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
manifest.Messages = manifest.Messages
|
||||
.OrderBy(item => item.Opcode)
|
||||
.ThenBy(item => item.TypeName, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
manifest.Subscribers = manifest.Subscribers
|
||||
.OrderBy(item => item.Permission, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.MemberName, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
public static void WriteOutputs(UnityNetworkScanManifest manifest, string outputDirectory)
|
||||
{
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
|
||||
var jsonPath = Path.Combine(outputDirectory, "unity-network-scan.json");
|
||||
var markdownPath = Path.Combine(outputDirectory, "UNITY_NETWORK_SCAN.md");
|
||||
|
||||
File.WriteAllText(jsonPath, JsonSerializer.Serialize(manifest, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
}));
|
||||
|
||||
var markdown = new StringBuilder();
|
||||
markdown.AppendLine("# Unity 网络代码扫描清单");
|
||||
markdown.AppendLine();
|
||||
markdown.AppendLine($"扫描时间:{manifest.ScannedAtUtc:yyyy-MM-dd HH:mm:ss} UTC");
|
||||
markdown.AppendLine($"Assets 路径:`{manifest.AssetsPath}`");
|
||||
markdown.AppendLine();
|
||||
markdown.AppendLine("## 网络消息");
|
||||
markdown.AppendLine();
|
||||
|
||||
foreach (var message in manifest.Messages)
|
||||
markdown.AppendLine($"- `{message.Opcode}` `{message.Route}` `{message.TypeName}` [{message.FilePath}]");
|
||||
|
||||
markdown.AppendLine();
|
||||
markdown.AppendLine("## 订阅与权限");
|
||||
markdown.AppendLine();
|
||||
|
||||
if (manifest.Subscribers.Count == 0)
|
||||
{
|
||||
markdown.AppendLine("- 本次扫描未发现 `[ShrinkNetworkSubscribe]`。");
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var subscriber in manifest.Subscribers)
|
||||
markdown.AppendLine($"- `{subscriber.MemberName}` authority=`{subscriber.Authority}` permission=`{subscriber.Permission}` [{subscriber.FilePath}]");
|
||||
}
|
||||
|
||||
File.WriteAllText(markdownPath, markdown.ToString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a958cc5087369284e8ec56efb9e33a45
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user