This commit is contained in:
2026-01-18 17:59:01 +08:00
parent ef2e821611
commit b9daa46b0a
22 changed files with 1048 additions and 0 deletions

2
.gitignore vendored
View File

@@ -1,2 +1,4 @@
/recruit.log
/Folder.DotSettings.user
/bin/
/obj

15
.idea/.idea.CatLink.dir/.idea/.gitignore generated vendored Normal file
View File

@@ -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/

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
</project>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="UserContentModel">
<attachedFolders />
<explicitIncludes />
<explicitExcludes />
</component>
</project>

6
.idea/.idea.CatLink.dir/.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

30
Api/DebugController.cs Normal file
View File

@@ -0,0 +1,30 @@
using Microsoft.AspNetCore.Mvc;
namespace CatLink.Api
{
[ApiController]
[Route("debug")]
public class DebugController : ControllerBase
{
private readonly ILogger<DebugController> _logger;
public DebugController(ILogger<DebugController> 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)
});
}
}
}

189
Api/FutariLobby.cs Normal file
View File

@@ -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<uint, RecruitRecord> _recruits = new();
private static readonly object _recruitsLock = new();
private static readonly string _logFilePath = "recruit.log";
private readonly ILogger<FutariLobby> _logger;
private readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public FutariLobby(ILogger<FutariLobby> logger)
{
_logger = logger;
}
[HttpPost("start")]
public IActionResult StartRecruit([FromBody] JsonElement json)
{
try
{
var record = JsonSerializer.Deserialize<RecruitRecord>(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<RecruitRecord>(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<RecruitRecord> 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");
}
}
}
}

39
Api/InfoController.cs Normal file
View File

@@ -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<string>("Host") ?? "localhost";
var relayPort = _configuration.GetValue<int>("RelayPort");
return Ok(new RelayInfoResponse
{
RelayHost = host,
RelayPort = relayPort
});
}
}
}

33
CatLink.csproj Normal file
View File

@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Text.Json" Version="9.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
</ItemGroup>
<ItemGroup>
<Compile Remove="obj\**" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Remove="obj\**" />
</ItemGroup>
<ItemGroup>
<Content Remove="obj\**" />
</ItemGroup>
<ItemGroup>
<None Remove="obj\**" />
</ItemGroup>
</Project>

12
Models/ApiException.cs Normal file
View File

@@ -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;
}
}
}

15
Models/Command.cs Normal file
View File

@@ -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;
}
}

43
Models/MechaInfo.cs Normal file
View File

@@ -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<bool> Entrys { get; set; } = new();
[JsonPropertyName("UserIDs")]
public List<long> UserIDs { get; set; } = new();
[JsonPropertyName("UserNames")]
public List<string> UserNames { get; set; } = new();
[JsonPropertyName("IconIDs")]
public List<int> IconIDs { get; set; } = new();
[JsonPropertyName("FumenDifs")]
public List<int> FumenDifs { get; set; } = new();
[JsonPropertyName("Rateing")]
public List<double> Rateing { get; set; } = new();
[JsonPropertyName("ClassValue")]
public List<int> ClassValue { get; set; } = new();
[JsonPropertyName("MaxClassValue")]
public List<int> MaxClassValue { get; set; } = new();
[JsonPropertyName("UserType")]
public List<int> UserType { get; set; } = new();
}
}

59
Models/Msg.cs Normal file
View File

@@ -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
};
}
}
}

8
Models/Proto.cs Normal file
View File

@@ -0,0 +1,8 @@
namespace CatLink.Models
{
public static class Proto
{
public const int TCP = 6;
public const int UDP = 17;
}
}

31
Models/RecruitInfo.cs Normal file
View File

@@ -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; }
}
}

19
Models/RecruitRecord.cs Normal file
View File

@@ -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; }
}
}

19
Models/RelayServerInfo.cs Normal file
View File

@@ -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; }
}
}

49
Program.cs Normal file
View File

@@ -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<int>("HttpPort", 20100);
var relayPort = builder.Configuration.GetValue<int>("RelayPort", 20101);
var logger = app.Services.GetRequiredService<ILogger<Program>>();
// Start TCP relay server
var cts = new CancellationTokenSource();
var relayTask = Task.Run(async () =>
{
var relay = new FutariRelay(relayPort, cts.Token, app.Services.GetRequiredService<ILogger<FutariRelay>>(), 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}");

43
README.md Normal file
View File

@@ -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/ # 工具类
```

106
Relay/ActiveClient.cs Normal file
View File

@@ -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<ActiveClient> _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<ActiveClient, Msg> _messageHandler;
private readonly Action<ActiveClient> _disconnectHandler;
public string ClientKey { get; }
public uint StubIp { get; }
public Dictionary<int, uint> TcpStreams { get; } = new();
public HashSet<int> PendingStreams { get; } = new();
public long LastHeartbeat { get; set; }
public ActiveClient(
string clientKey,
uint stubIp,
Socket socket,
Action<ActiveClient, Msg> messageHandler,
Action<ActiveClient> disconnectHandler,
ILogger<ActiveClient> 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);
}
}
}
}

306
Relay/FutariRelay.cs Normal file
View File

@@ -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<FutariRelay> _logger;
private readonly IServiceProvider _serviceProvider;
private readonly int _port;
private readonly CancellationToken _cancellationToken;
private readonly Dictionary<uint, ActiveClient> _clients = new();
private readonly object _clientsLock = new();
public FutariRelay(int port, CancellationToken cancellationToken, ILogger<FutariRelay> 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<ILogger<ActiveClient>>();
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<ActiveClient> 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]);
}
}
}

12
appsettings.json Normal file
View File

@@ -0,0 +1,12 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Host": "10.0.0.103",
"HttpPort": 20100,
"RelayPort": 20101
}