Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions FmiSrl.FtpServer.Server/Commands/PasvCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ private static async Task PrepareDataConnectionAsync(FtpCommandContext context)
}

var clientIp = (context.Session.RemoteEndPoint as IPEndPoint)?.Address.ToString() ?? "";
Console.WriteLine($"[PASV-V2] PasvCommand: ListeningIp='{context.Configuration.ListeningIp}', clientIp='{clientIp}', portRange={context.Configuration.PasvMinPort}-{context.Configuration.PasvMaxPort}");

var dataConnection = new MikuPassiveDataConnection(
context.Configuration.ListeningIp,
Expand All @@ -50,6 +51,7 @@ private static async Task SendPassiveModeResponseAsync(FtpCommandContext context
var p1 = port / 256;
var p2 = port % 256;

Console.WriteLine($"[PASV-V2] PasvCommand: advertising 227 with IP={localIp}, port={port} ({p1},{p2})");
await context.Session.SendResponseAsync(227, $"Entering Passive Mode ({localIp},{p1},{p2}).");
}

Expand Down
101 changes: 84 additions & 17 deletions FmiSrl.FtpServer.Server/FtpServer.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Text;
using Miku.Core;
Expand Down Expand Up @@ -40,7 +41,7 @@ public class FtpServer(

private readonly ILogger<FtpServer> _logger = logger ?? NullLogger<FtpServer>.Instance;
private readonly FtpCommandHandler _commandHandler = new(middlewares);
private readonly Dictionary<int, IFtpSession> _sessions = [];
private readonly ConcurrentDictionary<int, IFtpSession> _sessions = new();

private NetServer? _netServer;

Expand Down Expand Up @@ -121,9 +122,16 @@ private async Task HandleClientDataReceivedAsync(NetClient client, ReadOnlyMemor
}

var ftpSession = (FtpSession)session;
using (await ftpSession.LockSessionAsync())
try
{
await ProcessClientDataAsync(client, ftpSession, data);
using (await ftpSession.LockSessionAsync())
{
await ProcessClientDataAsync(client, ftpSession, data);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception while processing data for client {Id}", client.Id);
}
}

Expand All @@ -137,7 +145,7 @@ private async Task ProcessClientDataAsync(NetClient client, FtpSession session,
{
_logger.LogWarning("Command buffer exceeded maximum length for client {Id}. Disconnecting.", client.Id);
await session.SendResponseAsync(500, "Line too long.");
HandleExplicitQuit(client);
await HandleExplicitQuitAsync(client, session);
return;
}

Expand Down Expand Up @@ -187,7 +195,7 @@ private async Task ExecuteCommandAsync(NetClient client, FtpSession session, str

if (verb == "QUIT")
{
HandleExplicitQuit(client);
await HandleExplicitQuitAsync(client, session);
}
}

Expand All @@ -199,17 +207,53 @@ private static (string Verb, string Args) ParseRequest(string rawRequest)
return (verb, args);
}

private void HandleExplicitQuit(NetClient client)
private async Task HandleExplicitQuitAsync(NetClient client, FtpSession session)
{
_logger.LogInformation("Client disconnected explicitly: {Id}", client.Id);
_sessions.Remove(client.Id);
_sessions.TryRemove(client.Id, out _);
await DisposeDataConnectionAsync(session, client.Id);
client.Stop();
}

private void HandleClientDisconnected(NetClient client, string reason)
private async void HandleClientDisconnected(NetClient client, string reason)
{
_logger.LogInformation("Client disconnected: {Id}. Reason: {Reason}", client.Id, reason);
_sessions.Remove(client.Id);
if (!_sessions.TryRemove(client.Id, out var session))
{
return;
}

var ftpSession = (FtpSession)session;
try
{
using (await ftpSession.LockSessionAsync())
{
await DisposeDataConnectionAsync(ftpSession, client.Id);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during session cleanup for client {Id}", client.Id);
}
}

private async Task DisposeDataConnectionAsync(FtpSession session, int clientId)
{
var dataConnection = session.DataConnection;
if (dataConnection is null)
{
return;
}

session.DataConnection = null;
try
{
await dataConnection.DisposeAsync();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to dispose data connection for session {Id}", clientId);
}
}

private void HandleError(Exception exception)
Expand All @@ -222,19 +266,42 @@ private void HandleError(Exception exception)
/// </summary>
/// <returns>A task that represents the asynchronous operation of stopping the server.</returns>
/// <remarks>
/// Stops the underlying <see cref="NetServer"/> and clears the server state.
/// Stops the underlying <see cref="NetServer"/>, disposes any passive data connections
/// still associated with live sessions, and clears the server state.
/// </remarks>
public Task StopAsync()
public async Task StopAsync()
{
if (_netServer is null)
var netServer = _netServer;
if (netServer is null)
{
return Task.CompletedTask;
return;
}

_netServer.Stop();
_netServer = null;
_logger.LogInformation("FTP Server stopped.");

return Task.CompletedTask;
// Snapshot sessions before Miku's Stop can start racing OnClientDisconnected callbacks
// against us. Data-connection DisposeAsync is idempotent, so double-disposal is safe.
var sessions = _sessions.Values.ToList();

netServer.Stop();

foreach (var session in sessions)
{
if (session is not FtpSession ftpSession)
{
continue;
}

try
{
await DisposeDataConnectionAsync(ftpSession, 0);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error disposing data connection during shutdown for session {Id}", ftpSession.Id);
}
}

_sessions.Clear();
_logger.LogInformation("FTP Server stopped.");
}
}
Loading
Loading