?
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,2 +1,4 @@
|
||||
/recruit.log
|
||||
/Folder.DotSettings.user
|
||||
/bin/
|
||||
/obj
|
||||
|
||||
15
.idea/.idea.CatLink.dir/.idea/.gitignore
generated
vendored
Normal file
15
.idea/.idea.CatLink.dir/.idea/.gitignore
generated
vendored
Normal 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/
|
||||
4
.idea/.idea.CatLink.dir/.idea/encodings.xml
generated
Normal file
4
.idea/.idea.CatLink.dir/.idea/encodings.xml
generated
Normal 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>
|
||||
8
.idea/.idea.CatLink.dir/.idea/indexLayout.xml
generated
Normal file
8
.idea/.idea.CatLink.dir/.idea/indexLayout.xml
generated
Normal 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
6
.idea/.idea.CatLink.dir/.idea/vcs.xml
generated
Normal 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
30
Api/DebugController.cs
Normal 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
189
Api/FutariLobby.cs
Normal 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
39
Api/InfoController.cs
Normal 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
33
CatLink.csproj
Normal 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
12
Models/ApiException.cs
Normal 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
15
Models/Command.cs
Normal 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
43
Models/MechaInfo.cs
Normal 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
59
Models/Msg.cs
Normal 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
8
Models/Proto.cs
Normal 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
31
Models/RecruitInfo.cs
Normal 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
19
Models/RecruitRecord.cs
Normal 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
19
Models/RelayServerInfo.cs
Normal 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
49
Program.cs
Normal 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
43
README.md
Normal 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
106
Relay/ActiveClient.cs
Normal 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
306
Relay/FutariRelay.cs
Normal 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
12
appsettings.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Host": "10.0.0.103",
|
||||
"HttpPort": 20100,
|
||||
"RelayPort": 20101
|
||||
}
|
||||
Reference in New Issue
Block a user