From b9daa46b0a5c72ab362ba1cb8cc50ae4adb8358b Mon Sep 17 00:00:00 2001
From: bennett <2165217440@qq.com>
Date: Sun, 18 Jan 2026 17:59:01 +0800
Subject: [PATCH] ?
---
.gitignore | 2 +
.idea/.idea.CatLink.dir/.idea/.gitignore | 15 +
.idea/.idea.CatLink.dir/.idea/encodings.xml | 4 +
.idea/.idea.CatLink.dir/.idea/indexLayout.xml | 8 +
.idea/.idea.CatLink.dir/.idea/vcs.xml | 6 +
Api/DebugController.cs | 30 ++
Api/FutariLobby.cs | 189 +++++++++++
Api/InfoController.cs | 39 +++
CatLink.csproj | 33 ++
Models/ApiException.cs | 12 +
Models/Command.cs | 15 +
Models/MechaInfo.cs | 43 +++
Models/Msg.cs | 59 ++++
Models/Proto.cs | 8 +
Models/RecruitInfo.cs | 31 ++
Models/RecruitRecord.cs | 19 ++
Models/RelayServerInfo.cs | 19 ++
Program.cs | 49 +++
README.md | 43 +++
Relay/ActiveClient.cs | 106 ++++++
Relay/FutariRelay.cs | 306 ++++++++++++++++++
appsettings.json | 12 +
22 files changed, 1048 insertions(+)
create mode 100644 .idea/.idea.CatLink.dir/.idea/.gitignore
create mode 100644 .idea/.idea.CatLink.dir/.idea/encodings.xml
create mode 100644 .idea/.idea.CatLink.dir/.idea/indexLayout.xml
create mode 100644 .idea/.idea.CatLink.dir/.idea/vcs.xml
create mode 100644 Api/DebugController.cs
create mode 100644 Api/FutariLobby.cs
create mode 100644 Api/InfoController.cs
create mode 100644 CatLink.csproj
create mode 100644 Models/ApiException.cs
create mode 100644 Models/Command.cs
create mode 100644 Models/MechaInfo.cs
create mode 100644 Models/Msg.cs
create mode 100644 Models/Proto.cs
create mode 100644 Models/RecruitInfo.cs
create mode 100644 Models/RecruitRecord.cs
create mode 100644 Models/RelayServerInfo.cs
create mode 100644 Program.cs
create mode 100644 README.md
create mode 100644 Relay/ActiveClient.cs
create mode 100644 Relay/FutariRelay.cs
create mode 100644 appsettings.json
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