High-bandwidth transfer system (#6373)

* WebSocket-based data transfer system

* Move resource downloads/uploads to the new transfer system

Should drastically increase the permitted practical size

* Transfer impl for Lidgren

* Async impl for receive stream

* Use unbounded channel for Lidgren

* Add metrics

* More comments

* Add serverside stream limit to avoid being a DoS vector

* Fix tests

* Oops forgot to actually implement sequence channels in NetMessage

* Doc comment for NetMessage.SequenceChannel

* Release notes
This commit is contained in:
Pieter-Jan Briers
2026-01-19 20:44:44 +01:00
committed by GitHub
parent 48654ac424
commit dc1464b462
49 changed files with 2219 additions and 72 deletions

View File

@@ -9,6 +9,7 @@ using Robust.Server.DataMetrics;
using Robust.Server.GameObjects;
using Robust.Server.GameStates;
using Robust.Server.Log;
using Robust.Server.Network.Transfer;
using Robust.Server.Placement;
using Robust.Server.Player;
using Robust.Server.Scripting;
@@ -29,6 +30,7 @@ using Robust.Shared.Localization;
using Robust.Shared.Log;
using Robust.Shared.Map;
using Robust.Shared.Network;
using Robust.Shared.Network.Transfer;
using Robust.Shared.Player;
using Robust.Shared.Profiling;
using Robust.Shared.Prototypes;
@@ -107,6 +109,8 @@ namespace Robust.Server
[Dependency] private readonly UploadedContentManager _uploadedContMan = default!;
[Dependency] private readonly NetworkResourceManager _netResMan = default!;
[Dependency] private readonly IReflectionManager _refMan = default!;
[Dependency] private readonly ITransferManager _transfer = default!;
[Dependency] private readonly ServerTransferTestManager _transferTest = default!;
private readonly Stopwatch _uptimeStopwatch = new();
@@ -293,6 +297,9 @@ namespace Robust.Server
return true;
}
_transfer.Initialize();
_transferTest.Initialize();
var dataDir = Options.LoadConfigAndUserData
? _commandLineArgs?.DataDir ?? PathHelpers.ExecutableRelativeFile("data")
: null;
@@ -773,6 +780,8 @@ namespace Robust.Server
_modLoader.BroadcastUpdate(ModUpdateLevel.FramePostEngine, frameEventArgs);
_transfer.FrameUpdate();
_metricsManager.FrameUpdate();
}

View File

@@ -0,0 +1,122 @@
using System;
using System.Net;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using Robust.Server.ServerStatus;
using Robust.Shared;
using Robust.Shared.Configuration;
using Robust.Shared.Log;
using Robust.Shared.Network;
using Robust.Shared.Network.Messages.Transfer;
using Robust.Shared.Network.Transfer;
using Robust.Shared.Utility;
namespace Robust.Server.Network.Transfer;
internal sealed class ServerTransferImplWebSocket : TransferImplWebSocket
{
private readonly IConfigurationManager _cfg;
private readonly INetManager _netManager;
private readonly SemaphoreSlim _apiLock = new(1, 1);
private readonly TaskCompletionSource _connectTcs = new();
// To authenticate the client doing the HTTP request,
// we ask that they provide a key we gave them via Lidgren traffic.
public byte[]? Key;
public ServerTransferImplWebSocket(
ISawmill sawmill,
BaseTransferManager parent,
IConfigurationManager cfg,
INetManager netManager,
INetChannel channel)
: base(sawmill, parent, channel)
{
_cfg = cfg;
_netManager = netManager;
}
public override Task ServerInit()
{
Key = RandomNumberGenerator.GetBytes(RandomKeyBytes);
var uriBuilder = new UriBuilder(string.Concat(
_cfg.GetCVar(CVars.TransferHttpEndpoint).TrimEnd("/"),
ServerTransferManager.TransferApiUrl));
uriBuilder.Scheme = uriBuilder.Scheme switch
{
"http" => "ws",
"https" => "wss",
_ => throw new InvalidOperationException($"Invalid API endpoint scheme: {uriBuilder.Scheme}")
};
var url = uriBuilder.ToString();
Sawmill.Verbose($"Transfer API URL is '{url}'");
var initMsg = new MsgTransferInit();
initMsg.HttpInfo = (url, Key);
_netManager.ServerSendMessage(initMsg, Channel);
return _connectTcs.Task;
}
public override Task ClientInit(CancellationToken cancel)
{
throw new NotSupportedException();
}
public async Task HandleApiRequest(NetUserId userId, IStatusHandlerContext context)
{
using var _ = await _apiLock.WaitGuardAsync();
if (Key == null)
{
Sawmill.Warning($"HTTP request failed: UserID '{userId}' tried to connect twice");
await context.RespondErrorAsync(HttpStatusCode.BadRequest);
return;
}
if (!context.RequestHeaders.TryGetValue(KeyHeaderName, out var keyValue) || keyValue is not [{ } keyValueStr])
{
await context.RespondErrorAsync(HttpStatusCode.BadRequest);
return;
}
var buf = new byte[RandomKeyBytes];
if (!Convert.TryFromBase64String(keyValueStr, buf, out var written) || written != RandomKeyBytes)
{
Sawmill.Verbose("HTTP request failed: key is not valid base64 or wrong length");
await context.RespondErrorAsync(HttpStatusCode.BadRequest);
return;
}
if (!CryptographicOperations.FixedTimeEquals(buf, Key))
{
Sawmill.Warning("HTTP request failed: key is wrong");
await context.RespondErrorAsync(HttpStatusCode.Unauthorized);
return;
}
Sawmill.Debug("Client connect to transfer WS channel: {UserId}", userId);
WebSocket = await context.AcceptWebSocketAsync();
// We've connected.
// Clear key so this can't be reconnected to.
Key = null;
_connectTcs.TrySetResult();
ReadThread();
}
public override void Dispose()
{
_connectTcs.TrySetCanceled();
}
}

View File

@@ -0,0 +1,171 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Threading.Tasks;
using Robust.Server.ServerStatus;
using Robust.Shared;
using Robust.Shared.Asynchronous;
using Robust.Shared.Configuration;
using Robust.Shared.Log;
using Robust.Shared.Network;
using Robust.Shared.Network.Messages.Transfer;
using Robust.Shared.Network.Transfer;
namespace Robust.Server.Network.Transfer;
internal sealed class ServerTransferManager : BaseTransferManager, ITransferManager
{
internal const string TransferApiUrl = "/rt_transfer_init";
private readonly IConfigurationManager _cfg;
private readonly IStatusHost _statusHost;
private readonly IServerNetManager _netManager;
private readonly Dictionary<NetUserId, Player> _onlinePlayers = new();
internal ServerTransferManager(IConfigurationManager cfg, IStatusHost statusHost, IServerNetManager netManager, ILogManager logManager, ITaskManager taskManager)
: base(logManager, NetMessageAccept.Server, taskManager)
{
_cfg = cfg;
_statusHost = statusHost;
_netManager = netManager;
}
public void Initialize()
{
_netManager.RegisterNetMessage<MsgTransferInit>();
_netManager.RegisterNetMessage<MsgTransferData>(RxTransferData, NetMessageAccept.Server | NetMessageAccept.Handshake);
_netManager.RegisterNetMessage<MsgTransferAckInit>(RxTransferAckInit, NetMessageAccept.Server | NetMessageAccept.Handshake);
_statusHost.AddHandler(HandleRequest);
_netManager.Disconnect += NetManagerOnDisconnect;
}
private void RxTransferData(MsgTransferData message)
{
if (!_onlinePlayers.TryGetValue(message.MsgChannel.UserId, out var player)
|| player.Impl is not TransferImplLidgren lidgren)
{
message.MsgChannel.Disconnect("Not lidgren");
return;
}
lidgren.ReceiveData(message);
}
private void RxTransferAckInit(MsgTransferAckInit message)
{
if (!_onlinePlayers.TryGetValue(message.MsgChannel.UserId, out var player)
|| player.Impl is not TransferImplLidgren lidgren)
{
message.MsgChannel.Disconnect("Not lidgren");
return;
}
lidgren.ReceiveInitAck();
}
public Stream StartTransfer(INetChannel channel, TransferStartInfo startInfo)
{
if (!_onlinePlayers.TryGetValue(channel.UserId, out var player))
throw new InvalidOperationException("Player is not connected yet!");
return player.Impl.StartTransfer(startInfo);
}
private async Task<bool> HandleRequest(IStatusHandlerContext context)
{
if (context.Url.AbsolutePath != TransferApiUrl)
return false;
if (!context.IsWebSocketRequest)
{
Sawmill.Verbose("HTTP request failed: not a websocket request");
await context.RespondErrorAsync(HttpStatusCode.BadRequest);
return true;
}
if (!context.RequestHeaders.TryGetValue(TransferImplWebSocket.UserIdHeaderName, out var userIdValue)
|| userIdValue.Count != 1)
{
Sawmill.Verbose("HTTP request failed: missing RT-UserId");
await context.RespondErrorAsync(HttpStatusCode.BadRequest);
return true;
}
if (!Guid.TryParse(userIdValue[0], out var userId))
{
Sawmill.Verbose($"HTTP request failed: UserID '{userId}' invalid");
await context.RespondErrorAsync(HttpStatusCode.BadRequest);
return true;
}
if (!_onlinePlayers.TryGetValue(new NetUserId(userId), out var player))
{
Sawmill.Warning($"HTTP request failed: UserID '{userId}' not online");
await context.RespondErrorAsync(HttpStatusCode.BadRequest);
return true;
}
if (player.Impl is not ServerTransferImplWebSocket serverWs)
{
Sawmill.Warning($"HTTP request failed: UserID '{userId}' is not using websocket transfer");
await context.RespondErrorAsync(HttpStatusCode.Unauthorized);
return true;
}
await serverWs.HandleApiRequest(new NetUserId(userId), context);
return true;
}
public async Task ServerHandshake(INetChannel channel)
{
if (_onlinePlayers.ContainsKey(channel.UserId))
throw new InvalidOperationException("We already have data for this user??");
var transferHttpEnabled = _cfg.GetCVar(CVars.TransferHttp);
BaseTransferImpl impl;
if (transferHttpEnabled)
{
impl = new ServerTransferImplWebSocket(Sawmill, this, _cfg, _netManager, channel);
}
else
{
impl = new TransferImplLidgren(Sawmill, channel, this, _netManager);
}
impl.MaxChannelCount = _cfg.GetCVar(CVars.TransferStreamLimit);
var datum = new Player
{
Impl = impl,
};
_onlinePlayers.Add(channel.UserId, datum);
await impl.ServerInit();
}
public event Action ClientHandshakeComplete
{
add { }
remove { }
}
private void NetManagerOnDisconnect(object? sender, NetDisconnectedArgs e)
{
if (!_onlinePlayers.Remove(e.Channel.UserId, out var player))
return;
Sawmill.Debug("Cleaning up connection for channel {Player} that disconnected", e.Channel);
player.Impl.Dispose();
}
private sealed class Player
{
public required BaseTransferImpl Impl;
}
}

View File

@@ -0,0 +1,23 @@
using Robust.Server.Console;
using Robust.Server.Player;
using Robust.Shared.Log;
using Robust.Shared.Network;
using Robust.Shared.Network.Transfer;
namespace Robust.Server.Network.Transfer;
internal sealed class ServerTransferTestManager(
ITransferManager manager,
ILogManager logManager,
IConGroupController controller,
IPlayerManager playerManager)
: TransferTestManager(manager, logManager)
{
protected override bool PermissionCheck(INetChannel channel)
{
if (!playerManager.TryGetSessionByChannel(channel, out var session))
return false;
return controller.CanCommand(session, TransferTestCommand.CommandKey);
}
}

View File

@@ -1,4 +1,5 @@
using Robust.Shared.Input;
using Robust.Shared.Network;
using Robust.Shared.Player;
namespace Robust.Server.Player;
@@ -10,4 +11,6 @@ namespace Robust.Server.Player;
public interface IPlayerManager : ISharedPlayerManager
{
BoundKeyMap KeyMap { get; }
internal void MarkPlayerResourcesSent(INetChannel channel);
}

View File

@@ -120,13 +120,34 @@ namespace Robust.Server.Player
private void HandlePlayerListReq(MsgPlayerListReq message)
{
var channel = message.MsgChannel;
var session = (CommonSession) GetSessionByChannel(channel);
session.InitialPlayerListReqDone = true;
if (!session.InitialResourcesDone)
return;
SendPlayerList(channel, session);
}
public void MarkPlayerResourcesSent(INetChannel channel)
{
var session = (CommonSession) GetSessionByChannel(channel);
session.InitialResourcesDone = true;
if (!session.InitialPlayerListReqDone)
return;
SendPlayerList(channel, session);
}
private void SendPlayerList(INetChannel channel, CommonSession session)
{
var players = Sessions;
var netMsg = new MsgPlayerList();
// client session is complete, set their status accordingly.
// This is done before the packet is built, so that the client
// can see themselves Connected.
var session = GetSessionByChannel(channel);
session.ConnectedTime = DateTime.UtcNow;
SetStatus(session, SessionStatus.Connected);

View File

@@ -5,6 +5,7 @@ using Robust.Server.DataMetrics;
using Robust.Server.GameObjects;
using Robust.Server.GameStates;
using Robust.Server.Localization;
using Robust.Server.Network.Transfer;
using Robust.Server.Placement;
using Robust.Server.Player;
using Robust.Server.Prototypes;
@@ -25,6 +26,7 @@ using Robust.Shared.IoC;
using Robust.Shared.Localization;
using Robust.Shared.Map;
using Robust.Shared.Network;
using Robust.Shared.Network.Transfer;
using Robust.Shared.Player;
using Robust.Shared.Prototypes;
using Robust.Shared.Reflection;
@@ -102,6 +104,8 @@ namespace Robust.Server
deps.Register<IHWId, DummyHWId>();
deps.Register<ILocalizationManager, ServerLocalizationManager>();
deps.Register<ILocalizationManagerInternal, ServerLocalizationManager>();
deps.Register<ITransferManager, ServerTransferManager>();
deps.Register<ServerTransferTestManager>();
}
}
}

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.WebSockets;
using System.Threading.Tasks;
using Microsoft.Extensions.Primitives;
@@ -25,6 +26,8 @@ namespace Robust.Server.ServerStatus
IDictionary<string, string> ResponseHeaders { get; }
bool KeepAlive { get; set; }
bool IsWebSocketRequest { get; }
Task<T?> RequestBodyJsonAsync<T>();
Task RespondNoContentAsync();
@@ -54,5 +57,7 @@ namespace Robust.Server.ServerStatus
Task RespondJsonAsync(object jsonData, HttpStatusCode code = HttpStatusCode.OK);
Task<Stream> RespondStreamAsync(HttpStatusCode code = HttpStatusCode.OK);
Task<WebSocket> AcceptWebSocketAsync();
}
}

View File

@@ -14,6 +14,7 @@ using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Mime;
using System.Net.WebSockets;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
@@ -242,6 +243,7 @@ namespace Robust.Server.ServerStatus
public Uri Url => _context.Request.Url!;
public bool IsGetLike => RequestMethod == HttpMethod.Head || RequestMethod == HttpMethod.Get;
public IReadOnlyDictionary<string, StringValues> RequestHeaders { get; }
public bool IsWebSocketRequest => _context.Request.IsWebSocketRequest;
public bool KeepAlive
{
@@ -353,6 +355,12 @@ namespace Robust.Server.ServerStatus
return Task.FromResult(_context.Response.OutputStream);
}
public async Task<WebSocket> AcceptWebSocketAsync()
{
var context = await _context.AcceptWebSocketAsync(null);
return context.WebSocket;
}
private void RespondShared()
{
foreach (var (header, value) in _responseHeaders)

View File

@@ -1,77 +1,153 @@
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using Robust.Server.Console;
using Robust.Server.Player;
using Robust.Shared;
using Robust.Shared.Configuration;
using Robust.Shared.IoC;
using Robust.Shared.Network;
using Robust.Shared.Network.Transfer;
using Robust.Shared.Player;
using Robust.Shared.Upload;
using Robust.Shared.Utility;
using Robust.Shared.ViewVariables;
namespace Robust.Server.Upload;
public sealed class NetworkResourcesUploadedEvent
{
public ICommonSession Session { get; }
public ImmutableArray<(ResPath Relative, byte[] Data)> Files { get; }
internal NetworkResourcesUploadedEvent(ICommonSession session, ImmutableArray<(ResPath, byte[])> files)
{
Session = session;
Files = files;
}
}
public sealed class NetworkResourceManager : SharedNetworkResourceManager
{
internal const int AckInitial = 1;
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly IServerNetManager _serverNetManager = default!;
[Dependency] private readonly IConfigurationManager _cfgManager = default!;
[Dependency] private readonly IConGroupController _controller = default!;
[Obsolete("Use ResourcesUploaded instead")]
public event Action<ICommonSession, NetworkResourceUploadMessage>? OnResourceUploaded;
public event Action<NetworkResourcesUploadedEvent>? ResourcesUploaded;
[ViewVariables] public bool Enabled { get; private set; } = true;
[ViewVariables] public float SizeLimit { get; private set; }
public override void Initialize()
internal event Action<INetChannel, int>? AckReceived;
internal override void Initialize()
{
base.Initialize();
TransferManager.RegisterTransferMessage(TransferKeyNetworkDownload);
TransferManager.RegisterTransferMessage(TransferKeyNetworkUpload, ReceiveUpload);
_cfgManager.OnValueChanged(CVars.ResourceUploadingEnabled, value => Enabled = value, true);
_cfgManager.OnValueChanged(CVars.ResourceUploadingLimitMb, value => SizeLimit = value, true);
_serverNetManager.RegisterNetMessage<NetworkResourceAckMessage>(RxAck);
}
/// <summary>
/// Callback for when a client attempts to upload a resource.
/// </summary>
/// <param name="msg"></param>
/// <exception cref="NotImplementedException"></exception>
protected override void ResourceUploadMsg(NetworkResourceUploadMessage msg)
private void RxAck(NetworkResourceAckMessage message)
{
AckReceived?.Invoke(message.MsgChannel, message.Key);
}
private async void ReceiveUpload(TransferReceivedEvent transfer)
{
// Do not allow uploading any new resources if it has been disabled.
// Note: Any resources uploaded before being disabled will still be kept and sent.
if (!Enabled)
{
transfer.Channel.Disconnect("Resource upload not enabled.");
return;
}
if (!_playerManager.TryGetSessionByChannel(msg.MsgChannel, out var session))
if (!_playerManager.TryGetSessionByChannel(transfer.Channel, out var session))
{
transfer.Channel.Disconnect("Not in-game");
return;
}
if (!_controller.CanCommand(session, "uploadfile"))
{
transfer.Channel.Disconnect("Not authorized");
return;
}
// Ensure the data is under the current size limit, if it's currently enabled.
if (SizeLimit > 0f && msg.Data.Length * BytesToMegabytes > SizeLimit)
return;
Sawmill.Verbose("Ingesting file uploads from {Session}", session);
base.ResourceUploadMsg(msg);
List<(ResPath Relative, byte[] Data)> ingested;
await using (var stream = transfer.DataStream)
{
ingested = await IngestFileStream(stream);
}
Sawmill.Verbose("Ingesting file uploads complete, distributing...");
// Now we broadcast the message!
foreach (var channel in _serverNetManager.Channels)
{
channel.SendMessage(msg);
SendToPlayer(channel, ingested);
}
OnResourceUploaded?.Invoke(session, msg);
#pragma warning disable CS0618 // Type or member is obsolete
if (OnResourceUploaded != null)
{
foreach (var (relative, data) in ingested)
{
OnResourceUploaded?.Invoke(session, new NetworkResourceUploadMessage
{
MsgChannel = session.Channel,
Data = data,
RelativePath = relative
});
}
}
#pragma warning restore CS0618 // Type or member is obsolete
ResourcesUploaded?.Invoke(new NetworkResourcesUploadedEvent(session, [..ingested]));
}
internal void SendToNewUser(INetChannel channel)
protected override void ValidateUpload(uint size)
{
foreach (var (path, data) in ContentRoot.GetAllFiles())
{
var msg = new NetworkResourceUploadMessage();
msg.RelativePath = path;
msg.Data = data;
channel.SendMessage(msg);
}
if (SizeLimit > 0f && size * BytesToMegabytes > SizeLimit)
throw new Exception("File upload too large!");
}
internal bool SendToNewUser(INetChannel channel)
{
var allFiles = ContentRoot.GetAllFiles().ToList();
if (allFiles.Count == 0)
return false;
SendToPlayer(channel, allFiles, AckInitial);
return true;
}
private async void SendToPlayer(INetChannel channel, List<(ResPath Relative, byte[] Data)> files, int ack = 0)
{
await using var stream = TransferManager.StartTransfer(channel,
new TransferStartInfo
{
MessageKey = TransferKeyNetworkDownload
});
var ackBytes = new byte[4];
BinaryPrimitives.WriteInt32LittleEndian(ackBytes, ack);
await stream.WriteAsync(ackBytes);
await WriteFileStream(stream, files);
}
}

View File

@@ -1,4 +1,5 @@
using Robust.Shared.IoC;
using Robust.Server.Player;
using Robust.Shared.IoC;
using Robust.Shared.Network;
namespace Robust.Server.Upload;
@@ -9,20 +10,36 @@ namespace Robust.Server.Upload;
internal sealed class UploadedContentManager
{
[Dependency] private readonly IServerNetManager _netManager = default!;
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly GamePrototypeLoadManager _prototypeLoadManager = default!;
[Dependency] private readonly NetworkResourceManager _networkResourceManager = default!;
public void Initialize()
{
_netManager.Connected += NetManagerOnConnected;
_networkResourceManager.AckReceived += OnAckReceived;
}
private void OnAckReceived(INetChannel channel, int ack)
{
if (ack != NetworkResourceManager.AckInitial)
return;
ResourcesReady(channel);
}
private void NetManagerOnConnected(object? sender, NetChannelArgs e)
{
// This just shells out to the other managers, ensuring they are ordered properly.
// Resources must be done before prototypes.
// Note: both net messages sent here are on the same group and are therefore ordered.
_networkResourceManager.SendToNewUser(e.Channel);
_prototypeLoadManager.SendToNewUser(e.Channel);
var sentAny = _networkResourceManager.SendToNewUser(e.Channel);
if (!sentAny)
ResourcesReady(e.Channel);
}
private void ResourcesReady(INetChannel channel)
{
_prototypeLoadManager.SendToNewUser(channel);
_playerManager.MarkPlayerResourcesSent(channel);
}
}