feat(cordis): 接入上下文组合与模组事务热替换

This commit is contained in:
2026-08-16 23:20:40 +08:00
commit ad256f109b
676 changed files with 52168 additions and 0 deletions
@@ -0,0 +1,184 @@
using Cysharp.Threading.Tasks;
using ShrinkNetwork;
using ShrinkNetwork.ServerHost.Framework;
namespace ShrinkNetwork.ServerHost;
public sealed class AuthServerModule : IShrinkServerModule
{
public const int LoginRequestOpcode = 1201;
public const int LoginResponseOpcode = 1202;
public const int RefreshRequestOpcode = 1203;
public const int RefreshResponseOpcode = 1204;
public const string LoginRoute = "server/auth/login";
public const string RefreshRoute = "server/auth/refresh";
private static readonly string[] DefaultGrantedPermissions = { "auth.ok", "room.access" };
public string Name => "Auth";
public void ConfigureService(ServerModuleContext context, ShrinkNetworkService service, string transportName)
{
service.RegisterMessage<ServerAuthLoginRequest>(LoginRequestOpcode, LoginRoute);
service.RegisterMessage<ServerAuthLoginResponse>(LoginResponseOpcode, "server/auth/login_response");
service.RegisterMessage<ServerAuthRefreshRequest>(RefreshRequestOpcode, RefreshRoute);
service.RegisterMessage<ServerAuthRefreshResponse>(RefreshResponseOpcode, "server/auth/refresh_response");
service.RegisterRequestHandler<ServerAuthLoginRequest, ServerAuthLoginResponse>((ctx, request) =>
HandleLoginAsync(context, transportName, ctx, request));
service.RegisterRequestHandler<ServerAuthRefreshRequest, ServerAuthRefreshResponse>((ctx, request) =>
HandleRefreshAsync(context, ctx, request));
}
public UniTask StartAsync(ServerModuleContext context, CancellationToken cancellationToken)
{
return UniTask.CompletedTask;
}
private UniTask<ServerAuthLoginResponse> HandleLoginAsync(
ServerModuleContext moduleContext,
string transportName,
ShrinkNetworkContext context,
ServerAuthLoginRequest request)
{
var authToken = moduleContext.Options.SharedAuthToken;
var requiresAuth = !string.IsNullOrWhiteSpace(authToken);
if (!requiresAuth && !moduleContext.Options.AllowAnonymousWhenAuthTokenMissing)
{
return UniTask.FromResult(new ServerAuthLoginResponse
{
ErrorCode = 503,
ErrorMessage = "服务端未配置 SHRINK_SERVER_AUTH_TOKEN,且未显式允许匿名登录。",
IsAuthenticated = false
});
}
if (requiresAuth && !string.Equals(authToken, request.Token, StringComparison.Ordinal))
{
return UniTask.FromResult(new ServerAuthLoginResponse
{
ErrorCode = ShrinkRpcErrorCode.AuthenticationFailed,
ErrorMessage = "鉴权失败,口令不正确。",
IsAuthenticated = false
});
}
var authName = string.IsNullOrWhiteSpace(request.ClientName)
? $"{transportName}-{context.Session.SessionId}"
: request.ClientName.Trim();
var authState = moduleContext.AuthStore.MarkAuthenticated(context.Service, context.Session.SessionId, authName,
BuildSessionTokenTtl(moduleContext.Options));
context.Session.Items["auth.name"] = authName;
context.Session.Items["auth.at_utc"] = DateTimeOffset.UtcNow;
context.Session.SetSessionToken(authState.SessionToken, authState.SessionTokenExpiresAtUtc);
foreach (var permission in DefaultGrantedPermissions)
context.Session.GrantPermission(permission);
return UniTask.FromResult(new ServerAuthLoginResponse
{
IsAuthenticated = true,
AuthName = authName,
SessionToken = authState.SessionToken,
SessionTokenExpiresAtUnixTimeSeconds = authState.SessionTokenExpiresAtUtc.ToUnixTimeSeconds(),
RefreshRecommendedAtUnixTimeSeconds = BuildRefreshRecommendedAtUnixTimeSeconds(
authState.SessionTokenExpiresAtUtc,
moduleContext.Options),
GrantedPermissions = DefaultGrantedPermissions.ToArray(),
ServerMessage = requiresAuth ? "鉴权成功。" : "服务端已显式允许匿名登录。"
});
}
private UniTask<ServerAuthRefreshResponse> HandleRefreshAsync(
ServerModuleContext moduleContext,
ShrinkNetworkContext context,
ServerAuthRefreshRequest request)
{
var sessionToken = string.IsNullOrWhiteSpace(request.SessionToken)
? context.Packet.SessionToken
: request.SessionToken.Trim();
if (!moduleContext.AuthStore.TryRefreshSessionToken(context.Service, context.Session.SessionId, sessionToken,
BuildSessionTokenTtl(moduleContext.Options), BuildRefreshWindow(moduleContext.Options), out var authState,
out var failureReason))
{
return UniTask.FromResult(new ServerAuthRefreshResponse
{
ErrorCode = string.Equals(failureReason, "会话令牌已过期。", StringComparison.Ordinal)
? ShrinkRpcErrorCode.SessionTokenExpired
: ShrinkRpcErrorCode.AuthenticationFailed,
ErrorMessage = failureReason,
IsAuthenticated = false
});
}
context.Session.SetSessionToken(authState.SessionToken, authState.SessionTokenExpiresAtUtc);
context.Session.Items["auth.name"] = authState.AuthName;
foreach (var permission in DefaultGrantedPermissions)
context.Session.GrantPermission(permission);
return UniTask.FromResult(new ServerAuthRefreshResponse
{
IsAuthenticated = true,
AuthName = authState.AuthName,
SessionToken = authState.SessionToken,
SessionTokenExpiresAtUnixTimeSeconds = authState.SessionTokenExpiresAtUtc.ToUnixTimeSeconds(),
RefreshRecommendedAtUnixTimeSeconds = BuildRefreshRecommendedAtUnixTimeSeconds(
authState.SessionTokenExpiresAtUtc,
moduleContext.Options),
ServerMessage = "会话令牌已刷新。"
});
}
private static TimeSpan BuildSessionTokenTtl(ServerHostOptions options)
{
return TimeSpan.FromSeconds(Math.Max(60, options.SessionTokenTtlSeconds));
}
private static TimeSpan BuildRefreshWindow(ServerHostOptions options)
{
return TimeSpan.FromSeconds(Math.Max(0, options.SessionTokenRefreshWindowSeconds));
}
private static long BuildRefreshRecommendedAtUnixTimeSeconds(DateTimeOffset expiresAtUtc, ServerHostOptions options)
{
var refreshWindow = BuildRefreshWindow(options);
if (refreshWindow <= TimeSpan.Zero)
return 0;
return expiresAtUtc.Subtract(refreshWindow).ToUnixTimeSeconds();
}
}
[ShrinkNetworkMessage(AuthServerModule.LoginRequestOpcode, AuthServerModule.LoginRoute)]
public sealed class ServerAuthLoginRequest : IShrinkNetworkRequest
{
public string ClientName { get; set; } = string.Empty;
public string Token { get; set; } = string.Empty;
}
[ShrinkNetworkMessage(AuthServerModule.LoginResponseOpcode, "server/auth/login_response")]
public sealed class ServerAuthLoginResponse : ShrinkRpcResponseBase
{
public bool IsAuthenticated { get; set; }
public string AuthName { get; set; } = string.Empty;
public string SessionToken { get; set; } = string.Empty;
public long SessionTokenExpiresAtUnixTimeSeconds { get; set; }
public long RefreshRecommendedAtUnixTimeSeconds { get; set; }
public string[] GrantedPermissions { get; set; } = Array.Empty<string>();
public string ServerMessage { get; set; } = string.Empty;
}
[ShrinkNetworkMessage(AuthServerModule.RefreshRequestOpcode, AuthServerModule.RefreshRoute)]
public sealed class ServerAuthRefreshRequest : IShrinkNetworkRequest
{
public string SessionToken { get; set; } = string.Empty;
}
[ShrinkNetworkMessage(AuthServerModule.RefreshResponseOpcode, "server/auth/refresh_response")]
public sealed class ServerAuthRefreshResponse : ShrinkRpcResponseBase
{
public bool IsAuthenticated { get; set; }
public string AuthName { get; set; } = string.Empty;
public string SessionToken { get; set; } = string.Empty;
public long SessionTokenExpiresAtUnixTimeSeconds { get; set; }
public long RefreshRecommendedAtUnixTimeSeconds { get; set; }
public string ServerMessage { get; set; } = string.Empty;
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 983129d399a30af419b08bc0cb951054
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5e85da237e6287d4fbf41163901f9ff8
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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
};
}
}
@@ -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:
@@ -0,0 +1,340 @@
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using Cysharp.Threading.Tasks;
namespace ShrinkNetwork.ServerHost;
using ShrinkNetwork;
public sealed class KcpServerTransport : IShrinkNetworkAsyncTransport, IShrinkNetworkSessionControlTransport
{
private sealed class KcpSessionState : IDisposable
{
public KcpSessionState(long sessionId, IPEndPoint remoteEndPoint, ShrinkKcpPeer peer)
{
SessionId = sessionId;
RemoteEndPoint = remoteEndPoint;
Peer = peer;
}
public long SessionId { get; }
public IPEndPoint RemoteEndPoint { get; }
public ShrinkKcpPeer Peer { get; }
public string RemoteAddress => RemoteEndPoint.ToString();
public void Dispose()
{
Peer.Dispose();
}
}
private readonly object _syncRoot = new();
private readonly Dictionary<long, KcpSessionState> _sessionsById = new();
private readonly Dictionary<uint, KcpSessionState> _sessionsByConversationId = new();
private readonly UdpClient _udpClient;
private readonly ShrinkKcpTransportOptions _options;
private CancellationTokenSource? _cts;
private long _sessionIdGenerator;
public KcpServerTransport(IPAddress ipAddress, int port, ShrinkKcpTransportOptions? options = null)
{
_udpClient = new UdpClient(new IPEndPoint(ipAddress, port));
_options = (options ?? new ShrinkKcpTransportOptions()).Clone();
_options.Validate();
_udpClient.Client.ReceiveBufferSize = _options.ReceiveBufferSize;
}
public bool IsStarted { get; private set; }
public event Action<ShrinkNetworkTransportEvent>? OnEvent;
public void Start()
{
if (IsStarted)
return;
IsStarted = true;
_cts = new CancellationTokenSource();
_ = ReceiveLoopAsync(_cts.Token);
_ = UpdateLoopAsync(_cts.Token);
}
public void Stop()
{
if (!IsStarted)
return;
IsStarted = false;
_cts?.Cancel();
List<KcpSessionState> sessions;
lock (_syncRoot)
{
sessions = _sessionsById.Values.ToList();
_sessionsById.Clear();
_sessionsByConversationId.Clear();
}
foreach (var session in sessions)
{
TrySendDisconnect(session);
session.Dispose();
OnEvent?.Invoke(ShrinkNetworkTransportEvent.Disconnected(session.SessionId, session.RemoteAddress));
}
_udpClient.Close();
}
public void Send(long sessionId, byte[] packetData)
{
SendAsync(sessionId, packetData).Forget();
}
public bool DisconnectSession(long sessionId, string? reason = null)
{
KcpSessionState? session;
lock (_syncRoot)
{
_sessionsById.TryGetValue(sessionId, out session);
}
if (session == null)
return false;
DisconnectSession(sessionId, sendRemoteNotice: true, "server-side kick");
if (!string.IsNullOrWhiteSpace(reason))
ShrinkNetworkLogger.Warn($"[ShrinkNetwork][KCP-Server] Disconnect session {sessionId}: {reason}");
return true;
}
public UniTask SendAsync(long sessionId, byte[] packetData)
{
KcpSessionState session;
lock (_syncRoot)
{
if (!_sessionsById.TryGetValue(sessionId, out session!))
throw new InvalidOperationException($"Session {sessionId} is not connected.");
}
session.Peer.Send(packetData ?? Array.Empty<byte>());
return UniTask.CompletedTask;
}
private async Task ReceiveLoopAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
UdpReceiveResult result;
try
{
result = await _udpClient.ReceiveAsync(cancellationToken);
}
catch (OperationCanceledException)
{
break;
}
catch (ObjectDisposedException)
{
break;
}
catch
{
if (!IsStarted)
break;
continue;
}
try
{
HandleDatagram(result.Buffer, result.RemoteEndPoint);
}
catch (Exception ex)
{
ShrinkNetworkLogger.Exception(ex);
ShrinkNetworkLogger.Warn($"[ShrinkNetwork] KCP server datagram handling failed: {ex.Message}");
}
}
}
private async Task UpdateLoopAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
List<KcpSessionState> snapshot;
lock (_syncRoot)
{
snapshot = _sessionsById.Values.ToList();
}
foreach (var session in snapshot)
{
try
{
session.Peer.Tick(packet => OnEvent?.Invoke(ShrinkNetworkTransportEvent.Packet(session.SessionId, packet)));
if (DateTime.UtcNow.Ticks - session.Peer.LastReceiveUtcTicks >
TimeSpan.FromMilliseconds(_options.IdleTimeoutMs).Ticks)
{
DisconnectSession(session.SessionId, sendRemoteNotice: true, "idle timeout");
}
}
catch (Exception ex)
{
ShrinkNetworkLogger.Exception(ex);
DisconnectSession(session.SessionId, sendRemoteNotice: true, $"peer tick failed: {ex.GetType().Name}: {ex.Message}");
}
}
try
{
await Task.Delay(_options.UpdateIntervalMs, cancellationToken);
}
catch (OperationCanceledException)
{
break;
}
}
}
private void HandleDatagram(byte[] datagram, IPEndPoint remoteEndPoint)
{
if (datagram == null || datagram.Length == 0)
return;
if (ShrinkKcpTransportProtocol.TryReadConnectRequest(datagram, out var nonce, out var requestedConversationId))
{
HandleConnectRequest(remoteEndPoint, nonce, requestedConversationId);
return;
}
if (ShrinkKcpTransportProtocol.TryReadDisconnect(datagram, out var disconnectedConversationId))
{
HandleDisconnect(remoteEndPoint, disconnectedConversationId);
return;
}
if (!ShrinkKcpTransportProtocol.TryReadDataPacket(datagram, out var conversationId, out var payloadOffset,
out var payloadLength))
return;
KcpSessionState? session;
lock (_syncRoot)
{
_sessionsByConversationId.TryGetValue(conversationId, out session);
}
if (session == null)
return;
if (!Equals(session.RemoteEndPoint, remoteEndPoint))
return;
session.Peer.Input(datagram, payloadOffset, payloadLength,
packet => OnEvent?.Invoke(ShrinkNetworkTransportEvent.Packet(session.SessionId, packet)));
}
private void HandleConnectRequest(IPEndPoint remoteEndPoint, long nonce, uint requestedConversationId)
{
KcpSessionState? existingSession;
lock (_syncRoot)
{
existingSession = _sessionsById.Values.FirstOrDefault(x => Equals(x.RemoteEndPoint, remoteEndPoint));
}
if (existingSession != null)
{
ShrinkNetworkLogger.Info(
$"[ShrinkNetwork][KCP-Server] Reusing session {existingSession.SessionId} for endpoint {existingSession.RemoteAddress}.");
SendEnvelope(existingSession.RemoteEndPoint,
ShrinkKcpTransportProtocol.CreateConnectAccept(nonce, existingSession.Peer.ConversationId));
return;
}
var conversationId = AllocateConversationId(requestedConversationId);
var sessionId = Interlocked.Increment(ref _sessionIdGenerator);
var peer = new ShrinkKcpPeer(conversationId, _options,
payload => SendEnvelope(remoteEndPoint, ShrinkKcpTransportProtocol.CreateDataPacket(conversationId, payload)));
var session = new KcpSessionState(sessionId, remoteEndPoint, peer);
lock (_syncRoot)
{
_sessionsById[sessionId] = session;
_sessionsByConversationId[conversationId] = session;
}
SendEnvelope(remoteEndPoint, ShrinkKcpTransportProtocol.CreateConnectAccept(nonce, conversationId));
OnEvent?.Invoke(ShrinkNetworkTransportEvent.Connected(sessionId, remoteEndPoint.ToString()));
}
private void HandleDisconnect(IPEndPoint remoteEndPoint, uint conversationId)
{
KcpSessionState? session;
lock (_syncRoot)
{
_sessionsByConversationId.TryGetValue(conversationId, out session);
}
if (session == null)
return;
if (!Equals(session.RemoteEndPoint, remoteEndPoint))
return;
DisconnectSession(session.SessionId, sendRemoteNotice: false, "remote disconnect packet");
}
private void DisconnectSession(long sessionId, bool sendRemoteNotice, string reason)
{
KcpSessionState? session;
lock (_syncRoot)
{
if (!_sessionsById.Remove(sessionId, out session))
return;
_sessionsByConversationId.Remove(session.Peer.ConversationId);
}
if (sendRemoteNotice)
TrySendDisconnect(session);
ShrinkNetworkLogger.Warn(
$"[ShrinkNetwork][KCP-Server] Session {sessionId} {session.RemoteAddress} disconnected: {reason}");
session.Dispose();
OnEvent?.Invoke(ShrinkNetworkTransportEvent.Disconnected(sessionId, session.RemoteAddress));
}
private uint AllocateConversationId(uint requestedConversationId)
{
lock (_syncRoot)
{
if (requestedConversationId != 0 && !_sessionsByConversationId.ContainsKey(requestedConversationId))
return requestedConversationId;
}
while (true)
{
var conversationId = unchecked((uint)RandomNumberGenerator.GetInt32(1, int.MaxValue));
lock (_syncRoot)
{
if (!_sessionsByConversationId.ContainsKey(conversationId))
return conversationId;
}
}
}
private void TrySendDisconnect(KcpSessionState session)
{
try
{
SendEnvelope(session.RemoteEndPoint, ShrinkKcpTransportProtocol.CreateDisconnect(session.Peer.ConversationId));
}
catch
{
}
}
private void SendEnvelope(IPEndPoint remoteEndPoint, byte[] datagram)
{
_udpClient.Send(datagram, datagram.Length, remoteEndPoint);
}
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 22712a66ebe45004494fc762ea5f3de4
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,109 @@
using System.Reflection;
using ShrinkNetwork;
using ShrinkNetwork.ServerHost;
using ShrinkNetwork.ServerHost.Framework;
ShrinkNetworkLogger.InfoHandler = msg => Console.WriteLine(msg);
ShrinkNetworkLogger.WarningHandler = msg => Console.WriteLine("[Warn] " + msg);
ShrinkNetworkLogger.ErrorHandler = msg => Console.Error.WriteLine(msg);
ShrinkNetworkLogger.ExceptionHandler = ex => Console.Error.WriteLine(ex);
var config = ServerHostProperties.LoadOrCreate();
var options = config.Options;
var modules = DiscoverModules();
var app = new ShrinkDedicatedServerApp(options, modules);
await app.StartAsync();
Console.WriteLine("ShrinkNetwork 独立服务器模板已启动。");
Console.WriteLine($"配置文件:{config.FilePath}");
Console.WriteLine($"首次生成默认配置:{config.CreatedDefaultFile}");
foreach (var warning in config.Warnings)
Console.WriteLine($"[Warn] 配置文件:{warning}");
Console.WriteLine($"TCP: 0.0.0.0:{options.Port}");
Console.WriteLine($"KCP: 0.0.0.0:{options.Port}");
Console.WriteLine($"Unity 扫描输出目录:{options.ScanOutputDirectory}");
Console.WriteLine($"Unity 资产扫描目录:{options.UnityAssetsPath}");
Console.WriteLine($"共享口令鉴权已启用:{!string.IsNullOrWhiteSpace(options.SharedAuthToken)}");
Console.WriteLine($"缺少口令时允许匿名登录:{options.AllowAnonymousWhenAuthTokenMissing}");
Console.WriteLine($"会话令牌已启用:{options.EnableSessionTokens}");
Console.WriteLine($"会话令牌 TTL(秒){options.SessionTokenTtlSeconds}");
Console.WriteLine($"会话令牌续期窗口(秒){options.SessionTokenRefreshWindowSeconds}");
Console.WriteLine($"会话令牌校验失败时断开会话:{options.DisconnectOnInvalidSessionToken}");
Console.WriteLine($"协议版本范围:{options.MinProtocolVersion}-{options.MaxProtocolVersion}");
Console.WriteLine($"Schema 版本范围:{options.MinSchemaVersion}-{options.MaxSchemaVersion}");
Console.WriteLine($"TCP TLS 已启用:{options.EnableTcpTls}");
if (options.EnableTcpTls)
{
Console.WriteLine($"TCP TLS 证书路径:{options.TcpTlsCertificatePath}");
Console.WriteLine($"TCP TLS SNI/目标主机:{options.TcpTlsTargetHost}");
}
if (string.IsNullOrWhiteSpace(options.SharedAuthToken) && !options.AllowAnonymousWhenAuthTokenMissing)
{
Console.WriteLine("[Warn] 当前未配置 SHRINK_SERVER_AUTH_TOKEN,登录请求默认会被拒绝。");
}
Console.WriteLine("[Info] 配置优先级:代码默认值 < server.properties < 环境变量");
Console.WriteLine("已加载模块:");
foreach (var module in modules)
Console.WriteLine($"- {module.Name}");
Console.WriteLine("按 Ctrl+C 退出。");
using var metricsCts = new CancellationTokenSource();
if (options.DiagnosticsLogIntervalSeconds > 0)
_ = RunDiagnosticsLoopAsync(app, options.DiagnosticsLogIntervalSeconds, metricsCts.Token);
Console.CancelKeyPress += (_, args) =>
{
args.Cancel = true;
metricsCts.Cancel();
DumpDiagnostics(app);
Environment.Exit(0);
};
try
{
await Task.Delay(Timeout.Infinite, metricsCts.Token);
}
catch (OperationCanceledException)
{
}
static IShrinkServerModule[] DiscoverModules()
{
return Assembly.GetExecutingAssembly()
.GetTypes()
.Where(type => typeof(IShrinkServerModule).IsAssignableFrom(type))
.Where(type => !type.IsAbstract && !type.IsInterface)
.Where(type => type.GetConstructor(Type.EmptyTypes) != null)
.Select(type => (IShrinkServerModule)Activator.CreateInstance(type)!)
.OrderBy(module => module.Name, StringComparer.Ordinal)
.ToArray();
}
static async Task RunDiagnosticsLoopAsync(ShrinkDedicatedServerApp app, int intervalSeconds, CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
try
{
await Task.Delay(TimeSpan.FromSeconds(intervalSeconds), cancellationToken);
}
catch (OperationCanceledException)
{
break;
}
DumpDiagnostics(app);
}
}
static void DumpDiagnostics(ShrinkDedicatedServerApp app)
{
Console.WriteLine("[Metrics] ShrinkNetwork service snapshots:");
foreach (var pair in app.Context.Services.OrderBy(item => item.Key, StringComparer.OrdinalIgnoreCase))
{
var metrics = pair.Value.GetDiagnosticsSnapshot();
Console.WriteLine(
$"[Metrics][{pair.Key}] sessions={metrics.CurrentSessions} connected={metrics.SessionsConnected} disconnected={metrics.SessionsDisconnected} sent={metrics.PacketsSent}/{metrics.BytesSent}B recv={metrics.PacketsReceived}/{metrics.BytesReceived}B rpc={metrics.RpcStarted}/{metrics.RpcCompleted} timeout={metrics.RpcTimedOut} canceled={metrics.RpcCanceled} failed={metrics.RpcFailed} proto={metrics.ProtocolViolations} authRejected={metrics.AuthRejectedCount} denied={metrics.PermissionDeniedCount} handlerEx={metrics.HandlerExceptionCount} unknownOpcode={metrics.UnknownOpcodeCount} dispatchMiss={metrics.DispatchMissCount} serialization={metrics.SerializationErrorCount} queueReject={metrics.DispatchQueueRejectedCount}");
}
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 088e5ef7fb8b5014d9a5afcbb1441d4b
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,143 @@
# ShrinkNetwork.ServerHost
这是由 `ShrinkNetwork` 插件生成的独立服务器基础模板。
默认提供:
- 可编译、可启动的宿主入口
- TCP / KCP 双传输监听
- 类似 Minecraft 的 `server.properties` 文本配置
- Unity 项目扫描后的 `Generated/*` 自动装配
- 基础鉴权、会话令牌、协议版本闸门与周期性指标摘要
## 模板结构
- `Program.cs`
宿主入口,负责加载 `server.properties`、启动模块、打印配置摘要和输出指标。
- `Framework/`
宿主框架层,包括配置、鉴权状态存储与应用启动器。
- `AuthServerModule.cs`
内置鉴权模块,提供 `server/auth/login` 与 `server/auth/refresh`。
- `TcpServerTransport.cs`
TCP 传输层,可选 TLS。
- `KcpServerTransport.cs`
KCP 传输层。
- `Generated/`
Unity 扫描后产出的项目专属合约、副本、处理器与模块入口。
## 生成后你要关注的文件
- `Generated/UnityGeneratedNetworkContracts.g.cs`
当前 Unity 项目扫描得到的消息合约副本。
- `Generated/UnityGeneratedServerHandlers.g.cs`
自动生成的处理器骨架。
- `Generated/UnityGeneratedServerModule.g.cs`
自动加载的项目专属服务器模块。
说明:
- `UnityGeneratedServerModule.g.cs` 是项目级生成物,不是通用插件内置玩法模块。
- 如果你有正式业务逻辑,建议把自动生成骨架整理成你自己的正式模块文件。
## 鉴权
## 配置文件
- 默认配置文件名:`server.properties`
- 默认位置:`GeneratedServers/ShrinkNetwork.ServerHost/server.properties`
- 首次启动如果文件不存在,宿主会自动生成一份默认配置。
- 优先级:代码默认值 < `server.properties` < 环境变量
- 可通过环境变量 `SHRINK_SERVER_CONFIG_PATH` 指向自定义配置文件路径。
常用键:
- `server-port`
- `shared-auth-token`
- `enable-session-tokens`
- `session-token-ttl-seconds`
- `session-token-refresh-window-seconds`
- `enable-unity-code-scan`
- `unity-assets-path`
- `scan-output-directory`
- `enable-tcp-tls`
- `tcp-tls-certificate-path`
环境变量:
- `SHRINK_SERVER_AUTH_TOKEN`
行为:
- 默认情况下,如果未设置 `SHRINK_SERVER_AUTH_TOKEN``server/auth/login` 会直接拒绝,避免把匿名放行作为线上默认值。
- 如果你明确要跑内网演示,可在 `ServerHostOptions.AllowAnonymousWhenAuthTokenMissing = true` 后再允许匿名登录。
- 设置口令后,客户端应先完成登录,再进入后续项目逻辑。
## 会话令牌
默认行为:
- `server/auth/login` 成功后,宿主会签发一个内存态会话令牌,并把它放进响应包头与响应体。
- 已登录会话的后续消息 / RPC 必须携带当前会话令牌,否则会被宿主拒绝;默认还会直接断开该会话。
- 客户端可通过 `server/auth/refresh` 在续期窗口内轮换新令牌,避免长连接在固定 TTL 后硬过期。
关键配置:
- `ServerHostOptions.EnableSessionTokens`
- `ServerHostOptions.SessionTokenTtlSeconds`
- `ServerHostOptions.SessionTokenRefreshWindowSeconds`
- `ServerHostOptions.DisconnectOnInvalidSessionToken`
对应环境变量:
- `SHRINK_SERVER_ENABLE_SESSION_TOKENS`
- `SHRINK_SERVER_SESSION_TOKEN_TTL_SECONDS`
- `SHRINK_SERVER_SESSION_TOKEN_REFRESH_WINDOW_SECONDS`
- `SHRINK_SERVER_DISCONNECT_ON_INVALID_SESSION_TOKEN`
当前范围:
- 已覆盖“登录签发 / 包头自动携带 / 服务端逐包校验 / 显式刷新”
- 还没有接入外部身份源、分布式会话存储、多实例共享撤销表
## 协议兼容与指标
- 模板宿主默认只接受当前 `ShrinkNetworkProtocol.CurrentProtocolVersion / CurrentSchemaVersion`。
- 可通过 `ServerHostOptions.MinProtocolVersion / MaxProtocolVersion / MinSchemaVersion / MaxSchemaVersion` 调整兼容窗口。
- 检测到协议版本越界时,默认直接断开会话。
- 宿主默认每 `60` 秒输出一次基础指标摘要,也会在 `Ctrl+C` 退出前打印最后一份快照。
- 指标摘要现已包含 `authRejected`,可直接看到会话令牌校验失败次数。
## TCP TLS
- 如果设置了 `SHRINK_SERVER_TLS_CERT_PATH`,宿主会自动为 TCP 监听启用 TLS。
- 可选环境变量:
- `SHRINK_SERVER_TLS_CERT_PASSWORD`
- `SHRINK_SERVER_TLS_TARGET_HOST`
- 当前只覆盖“服务端证书 + 客户端校验”主链,还没有扩展到双向证书认证。
## 运行方式
在仓库根目录执行:
```powershell
dotnet run --project .\GeneratedServers\ShrinkNetwork.ServerHost\ShrinkNetwork.ServerHost.csproj
```
如果只想改文本配置,不想改环境变量,直接编辑:
```powershell
notepad .\GeneratedServers\ShrinkNetwork.ServerHost\server.properties
```
如果要启用共享口令鉴权:
```powershell
$env:SHRINK_SERVER_AUTH_TOKEN = "your-token"
dotnet run --project .\GeneratedServers\ShrinkNetwork.ServerHost\ShrinkNetwork.ServerHost.csproj
```
如果要直接跑运行时烟测:
```powershell
$env:SHRINK_SERVER_AUTH_TOKEN = "smoke-token"
$env:SHRINK_SERVER_SESSION_TOKEN_TTL_SECONDS = "120"
$env:SHRINK_SERVER_SESSION_TOKEN_REFRESH_WINDOW_SECONDS = "120"
dotnet run --project .\GeneratedServers\ShrinkNetwork.ServerHost\ShrinkNetwork.ServerHost.csproj
```
另开一个终端执行:
```powershell
dotnet run --project .\GeneratedServers\ShrinkNetwork.RuntimeSmoke\ShrinkNetwork.RuntimeSmoke.csproj -- 127.0.0.1 17777 smoke-token runtime-smoke
```
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 105169ed61f386646a3f1562026c28ec
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,43 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Kcp-CSharp" Version="1.0.8" />
<PackageReference Include="UniTask" Version="2.5.10" />
<PackageReference Include="MessagePack" Version="3.1.4" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Serialization\IShrinkNetworkSerializer.cs" Link="Runtime\Serialization\IShrinkNetworkSerializer.cs" />
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Transport\Abstractions\IShrinkNetworkAsyncTransport.cs" Link="Runtime\Transport\Abstractions\IShrinkNetworkAsyncTransport.cs" />
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Transport\Abstractions\IShrinkNetworkTransport.cs" Link="Runtime\Transport\Abstractions\IShrinkNetworkTransport.cs" />
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Transport\Tcp\ShrinkTcpTlsOptions.cs" Link="Runtime\Transport\Tcp\ShrinkTcpTlsOptions.cs" />
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Serialization\ShrinkJsonNetworkSerializer.cs" Link="Runtime\Serialization\ShrinkJsonNetworkSerializer.cs" />
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Transport\Kcp\ShrinkKcpPeer.cs" Link="Runtime\Transport\Kcp\ShrinkKcpPeer.cs" />
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Transport\Kcp\ShrinkKcpTransportOptions.cs" Link="Runtime\Transport\Kcp\ShrinkKcpTransportOptions.cs" />
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Transport\Kcp\ShrinkKcpTransportProtocol.cs" Link="Runtime\Transport\Kcp\ShrinkKcpTransportProtocol.cs" />
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Serialization\ShrinkMessagePackNetworkSerializer.cs" Link="Runtime\Serialization\ShrinkMessagePackNetworkSerializer.cs" />
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Metadata\ShrinkNetworkAttributes.cs" Link="Runtime\Metadata\ShrinkNetworkAttributes.cs" />
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Core\ShrinkNetworkContext.cs" Link="Runtime\Core\ShrinkNetworkContext.cs" />
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Core\ShrinkNetworkLogger.cs" Link="Runtime\Core\ShrinkNetworkLogger.cs" />
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Metadata\ShrinkNetworkMessageContracts.cs" Link="Runtime\Metadata\ShrinkNetworkMessageContracts.cs" />
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Routing\ShrinkNetworkMessageRegistry.cs" Link="Runtime\Routing\ShrinkNetworkMessageRegistry.cs" />
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Metadata\ShrinkNetworkPacket.cs" Link="Runtime\Metadata\ShrinkNetworkPacket.cs" />
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Metadata\ShrinkNetworkPermissions.cs" Link="Runtime\Metadata\ShrinkNetworkPermissions.cs" />
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Routing\ShrinkNetworkRegHelper.cs" Link="Runtime\Routing\ShrinkNetworkRegHelper.cs" />
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Routing\ShrinkNetworkGeneratedRegistry.cs" Link="Runtime\Routing\ShrinkNetworkGeneratedRegistry.cs" />
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Routing\ShrinkNetworkRouter.cs" Link="Runtime\Routing\ShrinkNetworkRouter.cs" />
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Metadata\ShrinkNetworkRpc.cs" Link="Runtime\Metadata\ShrinkNetworkRpc.cs" />
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Core\ShrinkNetworkService.cs" Link="Runtime\Core\ShrinkNetworkService.cs" />
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Core\ShrinkNetworkSession.cs" Link="Runtime\Core\ShrinkNetworkSession.cs" />
<Compile Include="..\..\Assets\Modules\ShrinkNetwork\Runtime\Metadata\ShrinkNetworkTransportEvent.cs" Link="Runtime\Metadata\ShrinkNetworkTransportEvent.cs" />
</ItemGroup>
</Project>
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 5b2ae563558cfd04cb1511ec9a57ea16
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,266 @@
using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.IO;
using System.Net;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Cryptography.X509Certificates;
using System.Threading;
using Cysharp.Threading.Tasks;
namespace ShrinkNetwork.ServerHost;
using ShrinkNetwork;
public sealed class TcpServerTransport : IShrinkNetworkAsyncTransport, IShrinkNetworkSessionControlTransport
{
private readonly ConcurrentDictionary<long, TcpClient> _clients = new();
private readonly ConcurrentDictionary<long, SemaphoreSlim> _sendLocks = new();
private readonly ConcurrentDictionary<long, Stream> _streams = new();
private readonly TcpListener _listener;
private readonly int _maxPacketSize;
private readonly ShrinkTcpTlsOptions? _tlsOptions;
private long _sessionIdGenerator;
private CancellationTokenSource? _cts;
private X509Certificate2? _serverCertificate;
public TcpServerTransport(IPAddress ipAddress, int port, int maxPacketSize = 64 * 1024,
ShrinkTcpTlsOptions? tlsOptions = null)
{
if (maxPacketSize <= 0)
throw new ArgumentOutOfRangeException(nameof(maxPacketSize));
_listener = new TcpListener(ipAddress, port);
_maxPacketSize = maxPacketSize;
_tlsOptions = tlsOptions?.Clone();
}
public bool IsStarted { get; private set; }
public event Action<ShrinkNetworkTransportEvent>? OnEvent;
public void Start()
{
if (IsStarted)
return;
IsStarted = true;
_cts = new CancellationTokenSource();
EnsureTlsCertificateLoaded();
_listener.Start();
_ = AcceptLoopAsync(_cts.Token);
}
public void Stop()
{
if (!IsStarted)
return;
IsStarted = false;
_cts?.Cancel();
_listener.Stop();
foreach (var pair in _clients)
{
pair.Value.Close();
}
_clients.Clear();
foreach (var pair in _streams)
pair.Value.Dispose();
_streams.Clear();
foreach (var pair in _sendLocks)
pair.Value.Dispose();
_sendLocks.Clear();
}
public void Send(long sessionId, byte[] packetData)
{
SendAsync(sessionId, packetData).Forget();
}
public bool DisconnectSession(long sessionId, string? reason = null)
{
if (!_clients.TryGetValue(sessionId, out var client))
return false;
try
{
if (_streams.TryRemove(sessionId, out var stream))
stream.Dispose();
client.Close();
}
catch
{
}
if (!string.IsNullOrWhiteSpace(reason))
ShrinkNetworkLogger.Warn($"[ShrinkNetwork][TCP-Server] Disconnect session {sessionId}: {reason}");
return true;
}
public async UniTask SendAsync(long sessionId, byte[] packetData)
{
if (!_clients.TryGetValue(sessionId, out var client))
throw new InvalidOperationException($"Session {sessionId} is not connected.");
if (!_streams.TryGetValue(sessionId, out var stream))
throw new InvalidOperationException($"Session {sessionId} stream is not initialized.");
if (!_sendLocks.TryGetValue(sessionId, out var sendLock))
throw new InvalidOperationException($"Session {sessionId} send lock is not initialized.");
if ((packetData?.Length ?? 0) > _maxPacketSize)
throw new InvalidOperationException($"TCP packet is too large. Size={(packetData?.Length ?? 0)}, Limit={_maxPacketSize}.");
await SendInternalAsync(stream, sendLock, packetData ?? Array.Empty<byte>(), CancellationToken.None);
}
private async Task AcceptLoopAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
TcpClient client;
try
{
client = await _listener.AcceptTcpClientAsync(cancellationToken);
}
catch (OperationCanceledException)
{
break;
}
catch
{
if (!IsStarted)
break;
ShrinkNetworkLogger.Warn("[ShrinkNetwork][TCP-Server] Accept failed, retrying.");
continue;
}
var sessionId = Interlocked.Increment(ref _sessionIdGenerator);
client.NoDelay = true;
var stream = await CreateServerStreamAsync(client, cancellationToken);
_clients[sessionId] = client;
_streams[sessionId] = stream;
_sendLocks[sessionId] = new SemaphoreSlim(1, 1);
var remoteAddress = client.Client.RemoteEndPoint?.ToString() ?? "unknown";
OnEvent?.Invoke(ShrinkNetworkTransportEvent.Connected(sessionId, remoteAddress));
_ = ReceiveLoopAsync(sessionId, client, stream, cancellationToken);
}
}
private async Task<Stream> CreateServerStreamAsync(TcpClient client, CancellationToken cancellationToken)
{
Stream stream = client.GetStream();
var tlsOptions = _tlsOptions;
if (tlsOptions == null || !tlsOptions.Enabled)
return stream;
if (_serverCertificate == null)
throw new InvalidOperationException("TCP TLS is enabled, but the server certificate is not loaded.");
var sslStream = new SslStream(stream, false);
await sslStream.AuthenticateAsServerAsync(_serverCertificate, false, tlsOptions.EnabledProtocols,
tlsOptions.CheckCertificateRevocation);
return sslStream;
}
private void EnsureTlsCertificateLoaded()
{
var tlsOptions = _tlsOptions;
if (tlsOptions == null || !tlsOptions.Enabled)
{
_serverCertificate = null;
return;
}
if (string.IsNullOrWhiteSpace(tlsOptions.ServerCertificatePath))
throw new InvalidOperationException("TCP TLS is enabled, but ServerCertificatePath is empty.");
_serverCertificate = new X509Certificate2(tlsOptions.ServerCertificatePath, tlsOptions.ServerCertificatePassword);
}
private async Task ReceiveLoopAsync(long sessionId, TcpClient client, Stream stream, CancellationToken cancellationToken)
{
Exception? disconnectException = null;
try
{
var lengthBuffer = new byte[4];
while (!cancellationToken.IsCancellationRequested)
{
await ReadExactlyAsync(stream, lengthBuffer, cancellationToken);
var length = BinaryPrimitives.ReadInt32LittleEndian(lengthBuffer);
if (length <= 0)
throw new InvalidDataException($"Invalid packet length: {length}");
if (length > _maxPacketSize)
throw new InvalidDataException($"TCP packet length exceeded limit. Length={length}, Limit={_maxPacketSize}");
var payload = new byte[length];
await ReadExactlyAsync(stream, payload, cancellationToken);
OnEvent?.Invoke(ShrinkNetworkTransportEvent.Packet(sessionId, payload));
}
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
disconnectException = ex;
}
finally
{
if (_clients.TryRemove(sessionId, out var removed))
{
if (_streams.TryRemove(sessionId, out var ownedStream))
ownedStream.Dispose();
if (_sendLocks.TryRemove(sessionId, out var sendLock))
sendLock.Dispose();
var remoteAddress = removed.Client.RemoteEndPoint?.ToString() ?? "unknown";
if (disconnectException != null)
{
ShrinkNetworkLogger.Warn(
$"[ShrinkNetwork][TCP-Server] Session {sessionId} {remoteAddress} disconnected: {disconnectException.GetType().Name}: {disconnectException.Message}");
}
removed.Close();
OnEvent?.Invoke(ShrinkNetworkTransportEvent.Disconnected(sessionId, remoteAddress));
}
}
}
private static async Task SendInternalAsync(Stream stream, SemaphoreSlim sendLock, byte[] packetData,
CancellationToken cancellationToken)
{
await sendLock.WaitAsync(cancellationToken);
try
{
var header = new byte[4];
BinaryPrimitives.WriteInt32LittleEndian(header, packetData.Length);
await stream.WriteAsync(header, cancellationToken);
if (packetData.Length > 0)
await stream.WriteAsync(packetData, cancellationToken);
await stream.FlushAsync(cancellationToken);
}
finally
{
sendLock.Release();
}
}
private static async Task ReadExactlyAsync(Stream stream, byte[] buffer, CancellationToken cancellationToken)
{
var offset = 0;
while (offset < buffer.Length)
{
var read = await stream.ReadAsync(buffer.AsMemory(offset, buffer.Length - offset), cancellationToken);
if (read <= 0)
throw new IOException("Remote closed.");
offset += read;
}
}
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 6083285ae11674f40b2db7dba5818bfa
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,37 @@
# ShrinkNetwork server.properties
# 类似 Minecraft 的 key=value 配置文件。
# 优先级:代码默认值 < 本文件 < 环境变量。
# 修改后需要重启服务器进程。
# 基础网络
server-port=17777
# 代码扫描与生成
unity-assets-path=../../Assets
scan-output-directory=Generated
enable-unity-code-scan=true
# 登录与会话
shared-auth-token=
allow-anonymous-when-auth-token-missing=false
enable-session-tokens=true
session-token-ttl-seconds=1800
session-token-refresh-window-seconds=300
disconnect-on-invalid-session-token=true
# 协议兼容
min-protocol-version=1
max-protocol-version=1
min-schema-version=1
max-schema-version=1
disconnect-on-protocol-violation=true
# 观测
diagnostics-log-interval-seconds=60
# TCP TLS
enable-tcp-tls=false
tcp-tls-target-host=
tcp-tls-certificate-path=
tcp-tls-certificate-password=
tcp-tls-check-certificate-revocation=false
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: f19321a4ef92c44409dc7f9a14fe3e57
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant: