diff --git a/.gitignore b/.gitignore index a9d86ac..bfca920 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ /recruit.log /Folder.DotSettings.user +/bin/ +/obj diff --git a/.idea/.idea.CatLink.dir/.idea/.gitignore b/.idea/.idea.CatLink.dir/.idea/.gitignore new file mode 100644 index 0000000..0eda1fa --- /dev/null +++ b/.idea/.idea.CatLink.dir/.idea/.gitignore @@ -0,0 +1,15 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# Rider 忽略的文件 +/modules.xml +/.idea.CatLink.iml +/contentModel.xml +/projectSettingsUpdater.xml +# 已忽略包含查询文件的默认文件夹 +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ diff --git a/.idea/.idea.CatLink.dir/.idea/encodings.xml b/.idea/.idea.CatLink.dir/.idea/encodings.xml new file mode 100644 index 0000000..df87cf9 --- /dev/null +++ b/.idea/.idea.CatLink.dir/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/.idea.CatLink.dir/.idea/indexLayout.xml b/.idea/.idea.CatLink.dir/.idea/indexLayout.xml new file mode 100644 index 0000000..7b08163 --- /dev/null +++ b/.idea/.idea.CatLink.dir/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/.idea.CatLink.dir/.idea/vcs.xml b/.idea/.idea.CatLink.dir/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/.idea.CatLink.dir/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Api/DebugController.cs b/Api/DebugController.cs new file mode 100644 index 0000000..9701baf --- /dev/null +++ b/Api/DebugController.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Mvc; + +namespace CatLink.Api +{ + [ApiController] + [Route("debug")] + public class DebugController : ControllerBase + { + private readonly ILogger _logger; + + public DebugController(ILogger logger) + { + _logger = logger; + } + + [HttpGet] + public IActionResult GetDebugInfo() + { + return Ok(new + { + timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + uptime = Environment.TickCount64, + os = Environment.OSVersion.ToString(), + processorCount = Environment.ProcessorCount, + workingSet = Environment.WorkingSet, + gcMemory = GC.GetTotalMemory(false) + }); + } + } +} \ No newline at end of file diff --git a/Api/FutariLobby.cs b/Api/FutariLobby.cs new file mode 100644 index 0000000..4cdeea8 --- /dev/null +++ b/Api/FutariLobby.cs @@ -0,0 +1,189 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using CatLink.Models; +using Microsoft.AspNetCore.Mvc; + +namespace CatLink.Api +{ + [ApiController] + [Route("recruit")] + public class FutariLobby : ControllerBase + { + private static readonly Dictionary _recruits = new(); + private static readonly object _recruitsLock = new(); + private static readonly string _logFilePath = "recruit.log"; + private readonly ILogger _logger; + private readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public FutariLobby(ILogger logger) + { + _logger = logger; + } + + [HttpPost("start")] + public IActionResult StartRecruit([FromBody] JsonElement json) + { + try + { + var record = JsonSerializer.Deserialize(json.GetRawText(), _jsonOptions); + if (record == null) + { + throw new ApiException(400, "Invalid request body"); + } + + record.Time = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + var key = record.RecruitInfo.MechaInfo.IpAddress; + + lock (_recruitsLock) + { + var isNew = !_recruits.ContainsKey(key); + _recruits[key] = record; + + if (isNew) + { + LogRecruit("START", record); + } + } + + // Hash user IDs (commented out - client already sends numeric IDs) + // if (record.RecruitInfo.MechaInfo.UserIDs != null) + // { + // record.RecruitInfo.MechaInfo.UserIDs = record.RecruitInfo.MechaInfo.UserIDs + // .Select(HashUserId) + // .ToList(); + // } + + return Ok(new { success = true }); + } + catch (ApiException ex) + { + return StatusCode(ex.Code, ex.Message); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error starting recruit"); + return StatusCode(500, "Internal server error"); + } + } + + [HttpPost("finish")] + public IActionResult FinishRecruit([FromBody] JsonElement json) + { + try + { + var record = JsonSerializer.Deserialize(json.GetRawText(), _jsonOptions); + if (record == null) + { + throw new ApiException(400, "Invalid request body"); + } + + var key = record.RecruitInfo.MechaInfo.IpAddress; + + lock (_recruitsLock) + { + if (!_recruits.ContainsKey(key)) + { + throw new ApiException(404, "Recruit not found"); + } + + var existing = _recruits[key]; + if (existing.Keychip != record.Keychip) + { + throw new ApiException(403, "Keychip mismatch"); + } + + _recruits.Remove(key); + LogRecruit("FINISH", existing); + } + + return Ok(new { success = true }); + } + catch (ApiException ex) + { + return StatusCode(ex.Code, ex.Message); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error finishing recruit"); + return StatusCode(500, "Internal server error"); + } + } + + [HttpGet("list")] + public IActionResult GetRecruitList() + { + try + { + var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var timeout = 30000; // 30 seconds + + List records; + + lock (_recruitsLock) + { + // Remove expired records + var expiredKeys = _recruits + .Where(kvp => now - kvp.Value.Time > timeout) + .Select(kvp => kvp.Key) + .ToList(); + + foreach (var key in expiredKeys) + { + _recruits.Remove(key); + } + + records = _recruits.Values.ToList(); + } + + // Return NDJSON format (newline-delimited JSON) + var sb = new System.Text.StringBuilder(); + foreach (var record in records) + { + var json = System.Text.Json.JsonSerializer.Serialize(record, new System.Text.Json.JsonSerializerOptions + { + PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase + }); + sb.AppendLine(json); + } + + return Content(sb.ToString(), "text/plain"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting recruit list"); + return StatusCode(500, "Internal server error"); + } + } + + private static string HashUserId(long userId) + { + var hash = MD5.HashData(Encoding.UTF8.GetBytes(userId.ToString())); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + + private void LogRecruit(string action, RecruitRecord record) + { + try + { + var line = $"[{DateTimeOffset.UtcNow:yyyy-MM-dd HH:mm:ss}] {action} {record.RecruitInfo.MechaInfo.IpAddress} {record.Keychip} MusicID={record.RecruitInfo.MusicID}"; + + lock (_recruitsLock) + { + System.IO.File.AppendAllText(_logFilePath, line + Environment.NewLine); + } + + _logger.LogInformation(line); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error writing to recruit log"); + } + } + } +} \ No newline at end of file diff --git a/Api/InfoController.cs b/Api/InfoController.cs new file mode 100644 index 0000000..aea75f3 --- /dev/null +++ b/Api/InfoController.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Mvc; +using System.Text.Json.Serialization; + +namespace CatLink.Api +{ + public class RelayInfoResponse + { + [JsonPropertyName("relayHost")] + public string RelayHost { get; set; } = string.Empty; + + [JsonPropertyName("relayPort")] + public int RelayPort { get; set; } + } + + [ApiController] + [Route("info")] + public class InfoController : ControllerBase + { + private readonly IConfiguration _configuration; + + public InfoController(IConfiguration configuration) + { + _configuration = configuration; + } + + [HttpGet] + public IActionResult GetInfo() + { + var host = _configuration.GetValue("Host") ?? "localhost"; + var relayPort = _configuration.GetValue("RelayPort"); + + return Ok(new RelayInfoResponse + { + RelayHost = host, + RelayPort = relayPort + }); + } + } +} \ No newline at end of file diff --git a/CatLink.csproj b/CatLink.csproj new file mode 100644 index 0000000..4259db7 --- /dev/null +++ b/CatLink.csproj @@ -0,0 +1,33 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Models/ApiException.cs b/Models/ApiException.cs new file mode 100644 index 0000000..4e9ee2f --- /dev/null +++ b/Models/ApiException.cs @@ -0,0 +1,12 @@ +namespace CatLink.Models +{ + public class ApiException : Exception + { + public int Code { get; } + + public ApiException(int code, string message) : base(message) + { + Code = code; + } + } +} \ No newline at end of file diff --git a/Models/Command.cs b/Models/Command.cs new file mode 100644 index 0000000..b72e5e1 --- /dev/null +++ b/Models/Command.cs @@ -0,0 +1,15 @@ +namespace CatLink.Models +{ + public static class Command + { + public const int CTL_START = 1; + public const int CTL_BIND = 2; + public const int CTL_HEARTBEAT = 3; + public const int CTL_TCP_CONNECT = 4; + public const int CTL_TCP_ACCEPT = 5; + public const int CTL_TCP_ACCEPT_ACK = 6; + public const int CTL_TCP_CLOSE = 7; + public const int DATA_SEND = 21; + public const int DATA_BROADCAST = 22; + } +} \ No newline at end of file diff --git a/Models/MechaInfo.cs b/Models/MechaInfo.cs new file mode 100644 index 0000000..0744fa9 --- /dev/null +++ b/Models/MechaInfo.cs @@ -0,0 +1,43 @@ +using System.Text.Json.Serialization; + +namespace CatLink.Models +{ + public class MechaInfo + { + [JsonPropertyName("IsJoin")] + public bool IsJoin { get; set; } + + [JsonPropertyName("IpAddress")] + public uint IpAddress { get; set; } + + [JsonPropertyName("MusicID")] + public int MusicID { get; set; } + + [JsonPropertyName("Entrys")] + public List Entrys { get; set; } = new(); + + [JsonPropertyName("UserIDs")] + public List UserIDs { get; set; } = new(); + + [JsonPropertyName("UserNames")] + public List UserNames { get; set; } = new(); + + [JsonPropertyName("IconIDs")] + public List IconIDs { get; set; } = new(); + + [JsonPropertyName("FumenDifs")] + public List FumenDifs { get; set; } = new(); + + [JsonPropertyName("Rateing")] + public List Rateing { get; set; } = new(); + + [JsonPropertyName("ClassValue")] + public List ClassValue { get; set; } = new(); + + [JsonPropertyName("MaxClassValue")] + public List MaxClassValue { get; set; } = new(); + + [JsonPropertyName("UserType")] + public List UserType { get; set; } = new(); + } +} \ No newline at end of file diff --git a/Models/Msg.cs b/Models/Msg.cs new file mode 100644 index 0000000..7fd7bf4 --- /dev/null +++ b/Models/Msg.cs @@ -0,0 +1,59 @@ +namespace CatLink.Models +{ + public class Msg + { + public int Version { get; set; } = 1; + public int Cmd { get; set; } + public int Proto { get; set; } + public int Sid { get; set; } + public uint Src { get; set; } + public ushort SrcPort { get; set; } + public uint Dst { get; set; } + public ushort DstPort { get; set; } + public string? Data { get; set; } + + public static Msg FromString(string line) + { + if (string.IsNullOrWhiteSpace(line)) + { + throw new ArgumentException("Line cannot be null or empty", nameof(line)); + } + + var parts = line.Split(','); + var msg = new Msg(); + + if (parts.Length > 0 && !string.IsNullOrWhiteSpace(parts[0])) msg.Version = int.Parse(parts[0]); + if (parts.Length > 1 && !string.IsNullOrWhiteSpace(parts[1])) msg.Cmd = int.Parse(parts[1]); + if (parts.Length > 2 && !string.IsNullOrWhiteSpace(parts[2])) msg.Proto = int.Parse(parts[2]); + if (parts.Length > 3 && !string.IsNullOrWhiteSpace(parts[3])) msg.Sid = int.Parse(parts[3]); + if (parts.Length > 4 && !string.IsNullOrWhiteSpace(parts[4])) msg.Src = uint.Parse(parts[4]); + if (parts.Length > 5 && !string.IsNullOrWhiteSpace(parts[5])) msg.SrcPort = ushort.Parse(parts[5]); + if (parts.Length > 6 && !string.IsNullOrWhiteSpace(parts[6])) msg.Dst = uint.Parse(parts[6]); + if (parts.Length > 7 && !string.IsNullOrWhiteSpace(parts[7])) msg.DstPort = ushort.Parse(parts[7]); + if (parts.Length > 16 && !string.IsNullOrWhiteSpace(parts[16])) msg.Data = parts[16]; + + return msg; + } + + public override string ToString() + { + return $"{Version},{Cmd},{Proto},{Sid},{Src},{SrcPort},{Dst},{DstPort},,,,,,,,,,{Data}"; + } + + public Msg CloneWithNewData(string? newData) + { + return new Msg + { + Version = Version, + Cmd = Cmd, + Proto = Proto, + Sid = Sid, + Src = Src, + SrcPort = SrcPort, + Dst = Dst, + DstPort = DstPort, + Data = newData + }; + } + } +} \ No newline at end of file diff --git a/Models/Proto.cs b/Models/Proto.cs new file mode 100644 index 0000000..5a616d0 --- /dev/null +++ b/Models/Proto.cs @@ -0,0 +1,8 @@ +namespace CatLink.Models +{ + public static class Proto + { + public const int TCP = 6; + public const int UDP = 17; + } +} \ No newline at end of file diff --git a/Models/RecruitInfo.cs b/Models/RecruitInfo.cs new file mode 100644 index 0000000..39f1578 --- /dev/null +++ b/Models/RecruitInfo.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; + +namespace CatLink.Models +{ + public class RecruitInfo + { + [JsonPropertyName("MechaInfo")] + public MechaInfo MechaInfo { get; set; } = new(); + + [JsonPropertyName("MusicID")] + public int MusicID { get; set; } + + [JsonPropertyName("GroupID")] + public int GroupID { get; set; } + + [JsonPropertyName("EventModeID")] + public bool EventModeID { get; set; } + + [JsonPropertyName("JoinNumber")] + public int JoinNumber { get; set; } + + [JsonPropertyName("PartyStance")] + public int PartyStance { get; set; } + + [JsonPropertyName("_startTimeTicks")] + public long StartTimeTicks { get; set; } + + [JsonPropertyName("_recvTimeTicks")] + public long RecvTimeTicks { get; set; } + } +} \ No newline at end of file diff --git a/Models/RecruitRecord.cs b/Models/RecruitRecord.cs new file mode 100644 index 0000000..befb54a --- /dev/null +++ b/Models/RecruitRecord.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace CatLink.Models +{ + public class RecruitRecord + { + [JsonPropertyName("RecruitInfo")] + public RecruitInfo RecruitInfo { get; set; } = new(); + + [JsonPropertyName("Keychip")] + public string Keychip { get; set; } = string.Empty; + + [JsonPropertyName("Server")] + public RelayServerInfo Server { get; set; } = new(); + + [JsonPropertyName("Time")] + public long Time { get; set; } + } +} \ No newline at end of file diff --git a/Models/RelayServerInfo.cs b/Models/RelayServerInfo.cs new file mode 100644 index 0000000..7075264 --- /dev/null +++ b/Models/RelayServerInfo.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace CatLink.Models +{ + public class RelayServerInfo + { + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("addr")] + public string Addr { get; set; } = string.Empty; + + [JsonPropertyName("port")] + public int Port { get; set; } + + [JsonPropertyName("official")] + public bool Official { get; set; } + } +} \ No newline at end of file diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..b94c0b2 --- /dev/null +++ b/Program.cs @@ -0,0 +1,49 @@ +using CatLink.Relay; + +var builder = WebApplication.CreateBuilder(args); + +// Add services +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +// Configure middleware +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); +app.UseAuthorization(); +app.MapControllers(); + +// Get configuration +var httpPort = builder.Configuration.GetValue("HttpPort", 20100); +var relayPort = builder.Configuration.GetValue("RelayPort", 20101); + +var logger = app.Services.GetRequiredService>(); + +// Start TCP relay server +var cts = new CancellationTokenSource(); +var relayTask = Task.Run(async () => +{ + var relay = new FutariRelay(relayPort, cts.Token, app.Services.GetRequiredService>(), app.Services); + await relay.StartAsync(); +}, cts.Token); + +logger.LogInformation("CatLink Relay Server starting..."); +logger.LogInformation("HTTP API listening on port {HttpPort}", httpPort); +logger.LogInformation("TCP Relay listening on port {RelayPort}", relayPort); + +// Handle shutdown +AppDomain.CurrentDomain.ProcessExit += (sender, e) => +{ + logger.LogInformation("Shutting down..."); + cts.Cancel(); +}; + +// Run the web server +app.Run($"http://0.0.0.0:{httpPort}"); \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..cf955fb --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# CatLink + +街机游戏联机中继服务器的 .NET 版本实现。 + +## 功能 + +- **TCP 中继服务器**:处理客户端连接和数据转发 +- **HTTP API 服务器**:提供招募管理和服务器信息接口 +- **协议支持**:兼容原版 Worldlink 协议 + +## 端口配置 + +- HTTP API: 20100 +- TCP 中继: 20101 + +## 运行 + +```bash +dotnet run +``` + +## API 接口 + +### 招募管理 + +- `POST /recruit/start` - 开始招募 +- `POST /recruit/finish` - 结束招募 +- `GET /recruit/list` - 获取招募列表 + +### 服务器信息 + +- `GET /info` - 获取服务器信息 +- `GET /debug` - 获取调试信息 + +## 项目结构 + +``` +CatLink/ +├── Models/ # 数据模型 +├── Relay/ # TCP 中继服务器 +├── Api/ # HTTP API 控制器 +└── Utils/ # 工具类 +``` \ No newline at end of file diff --git a/Relay/ActiveClient.cs b/Relay/ActiveClient.cs new file mode 100644 index 0000000..6043486 --- /dev/null +++ b/Relay/ActiveClient.cs @@ -0,0 +1,106 @@ +using System.Net.Sockets; +using System.Text; +using CatLink.Models; +using Microsoft.Extensions.Logging; + +namespace CatLink.Relay +{ + public class ActiveClient + { + private readonly ILogger _logger; + private readonly Socket _socket; + private readonly StreamReader _reader; + private readonly StreamWriter _writer; + private readonly object _writeLock = new(); + private readonly Thread _thread; + private readonly Action _messageHandler; + private readonly Action _disconnectHandler; + + public string ClientKey { get; } + public uint StubIp { get; } + public Dictionary TcpStreams { get; } = new(); + public HashSet PendingStreams { get; } = new(); + public long LastHeartbeat { get; set; } + + public ActiveClient( + string clientKey, + uint stubIp, + Socket socket, + Action messageHandler, + Action disconnectHandler, + ILogger logger) + { + ClientKey = clientKey; + StubIp = stubIp; + _socket = socket; + _messageHandler = messageHandler; + _disconnectHandler = disconnectHandler; + _logger = logger; + + var stream = new NetworkStream(socket); + _reader = new StreamReader(stream, Encoding.UTF8); + _writer = new StreamWriter(stream, Encoding.UTF8) { AutoFlush = true }; + + LastHeartbeat = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + _thread = new Thread(HandleMessages) + { + IsBackground = true + }; + _thread.Start(); + } + + private void HandleMessages() + { + try + { + while (_socket.Connected) + { + var line = _reader.ReadLine(); + if (line == null) break; + + var msg = Msg.FromString(line); + _messageHandler(this, msg); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error handling messages for client {ClientKey}", ClientKey); + } + finally + { + _disconnectHandler(this); + } + } + + public void Send(Msg msg) + { + lock (_writeLock) + { + try + { + _writer.WriteLine(msg.ToString()); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error sending message to client {ClientKey}", ClientKey); + Disconnect(); + } + } + } + + public void Disconnect() + { + try + { + _socket.Close(); + _reader.Dispose(); + _writer.Dispose(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error disconnecting client {ClientKey}", ClientKey); + } + } + } +} \ No newline at end of file diff --git a/Relay/FutariRelay.cs b/Relay/FutariRelay.cs new file mode 100644 index 0000000..89ca3d7 --- /dev/null +++ b/Relay/FutariRelay.cs @@ -0,0 +1,306 @@ +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; +using CatLink.Models; +using Microsoft.Extensions.Logging; + +namespace CatLink.Relay +{ + public class FutariRelay + { + private readonly ILogger _logger; + private readonly IServiceProvider _serviceProvider; + private readonly int _port; + private readonly CancellationToken _cancellationToken; + private readonly Dictionary _clients = new(); + private readonly object _clientsLock = new(); + + public FutariRelay(int port, CancellationToken cancellationToken, ILogger logger, IServiceProvider serviceProvider) + { + _port = port; + _cancellationToken = cancellationToken; + _logger = logger; + _serviceProvider = serviceProvider; + } + + public async Task StartAsync() + { + var listener = new TcpListener(IPAddress.Any, _port); + listener.Start(); + + _logger.LogInformation("TCP Relay server started on port {Port}", _port); + + try + { + while (!_cancellationToken.IsCancellationRequested) + { + var socket = await listener.AcceptSocketAsync(_cancellationToken); + _ = Task.Run(() => HandleClientAsync(socket), _cancellationToken); + } + } + finally + { + listener.Stop(); + } + } + + private async Task HandleClientAsync(Socket socket) + { + socket.ReceiveTimeout = 20000; + socket.NoDelay = true; + + try + { + using var stream = new NetworkStream(socket); + using var reader = new StreamReader(stream); + using var writer = new StreamWriter(stream) { AutoFlush = true }; + + var firstLine = await reader.ReadLineAsync(); + if (string.IsNullOrEmpty(firstLine) || string.IsNullOrWhiteSpace(firstLine)) + { + _logger.LogWarning("Received empty line from client"); + socket.Close(); + return; + } + + var msg = Msg.FromString(firstLine); + + if (msg.Cmd != Command.CTL_START) + { + _logger.LogWarning("First message was not CTL_START"); + socket.Close(); + return; + } + + var clientKey = msg.Data; + if (string.IsNullOrEmpty(clientKey)) + { + _logger.LogWarning("Client key is empty"); + socket.Close(); + return; + } + + var stubIp = KeychipToStubIp(clientKey); + + var clientLogger = _serviceProvider.GetRequiredService>(); + var client = new ActiveClient( + clientKey, + stubIp, + socket, + HandleMessage, + HandleDisconnect, + clientLogger + ); + + lock (_clientsLock) + { + if (_clients.ContainsKey(stubIp)) + { + _logger.LogWarning("Client with stub IP {StubIp} already exists", stubIp); + client.Disconnect(); + return; + } + + _clients[stubIp] = client; + } + + _logger.LogInformation("Client registered: {ClientKey} -> {StubIp}", clientKey, stubIp); + + // Send version confirmation + client.Send(new Msg + { + Cmd = Command.CTL_START, + Data = "version=1" + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error handling client connection"); + socket.Close(); + } + } + + private void HandleMessage(ActiveClient client, Msg msg) + { + switch (msg.Cmd) + { + case Command.CTL_HEARTBEAT: + client.LastHeartbeat = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + client.Send(new Msg { Cmd = Command.CTL_HEARTBEAT }); + break; + + case Command.CTL_TCP_CONNECT: + HandleTcpConnect(client, msg); + break; + + case Command.CTL_TCP_ACCEPT: + HandleTcpAccept(client, msg); + break; + + case Command.DATA_SEND: + HandleDataSend(client, msg); + break; + + case Command.DATA_BROADCAST: + HandleDataBroadcast(client, msg); + break; + + case Command.CTL_TCP_CLOSE: + HandleTcpClose(client, msg); + break; + } + } + + private void HandleTcpConnect(ActiveClient client, Msg msg) + { + ActiveClient? target; + lock (_clientsLock) + { + _clients.TryGetValue(msg.Dst, out target); + } + + if (target == null) + { + _logger.LogWarning("TCP connect: target not found {Dst}", msg.Dst); + return; + } + + if (client.TcpStreams.ContainsKey(msg.Sid) || client.PendingStreams.Contains(msg.Sid)) + { + _logger.LogWarning("TCP connect: stream ID already in use {Sid}", msg.Sid); + return; + } + + client.PendingStreams.Add(msg.Sid); + + var forwardMsg = msg.CloneWithNewData(msg.Data); + forwardMsg.Src = client.StubIp; + target.Send(forwardMsg); + + _logger.LogDebug("TCP connect: {Src} -> {Dst}, stream {Sid}", client.StubIp, msg.Dst, msg.Sid); + } + + private void HandleTcpAccept(ActiveClient client, Msg msg) + { + ActiveClient? target; + lock (_clientsLock) + { + _clients.TryGetValue(msg.Dst, out target); + } + + if (target == null) + { + _logger.LogWarning("TCP accept: target not found {Dst}", msg.Dst); + return; + } + + if (!target.PendingStreams.Contains(msg.Sid)) + { + _logger.LogWarning("TCP accept: stream not in pending {Sid}", msg.Sid); + return; + } + + target.PendingStreams.Remove(msg.Sid); + + // Establish bidirectional stream mapping + target.TcpStreams[msg.Sid] = client.StubIp; + client.TcpStreams[msg.Sid] = target.StubIp; + + var forwardMsg = msg.CloneWithNewData(msg.Data); + forwardMsg.Src = client.StubIp; + target.Send(forwardMsg); + + _logger.LogDebug("TCP accept: {Src} <-> {Dst}, stream {Sid}", client.StubIp, msg.Dst, msg.Sid); + } + + private void HandleDataSend(ActiveClient client, Msg msg) + { + if (!client.TcpStreams.TryGetValue(msg.Sid, out var targetStubIp)) + { + _logger.LogWarning("Data send: stream not found {Sid}", msg.Sid); + return; + } + + ActiveClient? target; + lock (_clientsLock) + { + _clients.TryGetValue(targetStubIp, out target); + } + + if (target == null) + { + _logger.LogWarning("Data send: target not found {TargetStubIp}", targetStubIp); + return; + } + + var forwardMsg = msg.CloneWithNewData(msg.Data); + forwardMsg.Src = client.StubIp; + forwardMsg.Dst = target.StubIp; + target.Send(forwardMsg); + } + + private void HandleDataBroadcast(ActiveClient client, Msg msg) + { + List clientsCopy; + lock (_clientsLock) + { + clientsCopy = _clients.Values.ToList(); + } + + var forwardMsg = msg.CloneWithNewData(msg.Data); + forwardMsg.Src = client.StubIp; + + foreach (var c in clientsCopy) + { + if (c.StubIp != client.StubIp) + { + c.Send(forwardMsg); + } + } + + _logger.LogDebug("Data broadcast from {Src}", client.StubIp); + } + + private void HandleTcpClose(ActiveClient client, Msg msg) + { + if (client.TcpStreams.TryGetValue(msg.Sid, out var targetStubIp)) + { + client.TcpStreams.Remove(msg.Sid); + + ActiveClient? target; + lock (_clientsLock) + { + _clients.TryGetValue(targetStubIp, out target); + } + + if (target != null) + { + target.TcpStreams.Remove(msg.Sid); + var forwardMsg = msg.CloneWithNewData(msg.Data); + forwardMsg.Src = client.StubIp; + forwardMsg.Dst = target.StubIp; + target.Send(forwardMsg); + } + } + + _logger.LogDebug("TCP close: stream {Sid}", msg.Sid); + } + + private void HandleDisconnect(ActiveClient client) + { + lock (_clientsLock) + { + _clients.Remove(client.StubIp); + } + + _logger.LogInformation("Client disconnected: {ClientKey}", client.ClientKey); + } + + private static uint KeychipToStubIp(string keychip) + { + var hash = MD5.HashData(Encoding.UTF8.GetBytes(keychip)); + return (uint)((hash[0] << 24) | (hash[1] << 16) | (hash[2] << 8) | hash[3]); + } + } +} \ No newline at end of file diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..d14d7a8 --- /dev/null +++ b/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Host": "10.0.0.103", + "HttpPort": 20100, + "RelayPort": 20101 +} \ No newline at end of file