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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@

Stricly follow the [Human Guidelines](CONTRIBUTING.md)

## Additional rules
- Do not downgrade the .NET version if the current one in the developer machine is lower than the one required by global.json.
5 changes: 5 additions & 0 deletions FmiSrl.FtpServer.Server/Abstractions/FtpCommandContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ public record FtpCommandContext(
ILogger Logger
)
{
/// <summary>
/// Gets or sets the FTP response for this command.
/// </summary>
public FtpResponse? Response { get; set; }

/// <summary>
/// Gets the authentication context for the current session.
/// </summary>
Expand Down
31 changes: 31 additions & 0 deletions FmiSrl.FtpServer.Server/Abstractions/FtpResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
namespace FmiSrl.FtpServer.Server.Abstractions;

/// <summary>
/// Represents an FTP response.
/// </summary>
public class FtpResponse
{
/// <summary>
/// Gets or sets the FTP response code.
/// </summary>
public int Code { get; set; }

/// <summary>
/// Gets or sets the response message.
/// </summary>
public string Message { get; set; }

/// <summary>
/// Initializes a new instance of the <see cref="FtpResponse"/> class.
/// </summary>
/// <param name="code">The FTP response code.</param>
/// <param name="message">The response message.</param>
public FtpResponse(int code, string message)
{
Code = code;
Message = message;
}

/// <inheritdoc/>
public override string ToString() => $"{Code} {Message}";
}
15 changes: 15 additions & 0 deletions FmiSrl.FtpServer.Server/Abstractions/IFtpCommandMiddleware.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace FmiSrl.FtpServer.Server.Abstractions;

/// <summary>
/// Defines the behavior for an FTP command middleware.
/// </summary>
public interface IFtpCommandMiddleware
{
/// <summary>
/// Invokes the middleware asynchronously.
/// </summary>
/// <param name="context">The <see cref="FtpCommandContext"/> for the current command.</param>
/// <param name="next">A delegate representing the next step in the pipeline.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task InvokeAsync(FtpCommandContext context, Func<Task> next);
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
using FmiSrl.FtpServer.Server.Abstractions;
using Microsoft.Extensions.DependencyInjection;

namespace FmiSrl.FtpServer.Server.DependencyInjection;

internal sealed class FtpServerBuilder(IServiceCollection services) : IFtpServerBuilder
{
public IServiceCollection Services { get; } = services;

public IFtpServerBuilder AddMiddleware<TMiddleware>() where TMiddleware : class, IFtpCommandMiddleware
{
Services.AddTransient<IFtpCommandMiddleware, TMiddleware>();
return this;
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using FmiSrl.FtpServer.Server.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;

Expand Down Expand Up @@ -32,6 +33,9 @@ public static IFtpServerBuilder AddFtpServer(

services.TryAddSingleton<FtpServer>();

return new FtpServerBuilder(services);
var builder = new FtpServerBuilder(services);
builder.AddMiddleware<FtpDiagnosticsMiddleware>();

return builder;
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using FmiSrl.FtpServer.Server.Abstractions;
using Microsoft.Extensions.DependencyInjection;

namespace FmiSrl.FtpServer.Server.DependencyInjection;
Expand All @@ -12,4 +13,11 @@ public interface IFtpServerBuilder
/// </summary>
/// <value>The <see cref="IServiceCollection"/> used by this builder.</value>
IServiceCollection Services { get; }

/// <summary>
/// Adds a middleware to the FTP command pipeline.
/// </summary>
/// <typeparam name="TMiddleware">The type of the middleware to add.</typeparam>
/// <returns>The <see cref="IFtpServerBuilder"/> instance.</returns>
IFtpServerBuilder AddMiddleware<TMiddleware>() where TMiddleware : class, IFtpCommandMiddleware;
}
3 changes: 2 additions & 1 deletion FmiSrl.FtpServer.Server/FtpServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ namespace FmiSrl.FtpServer.Server;
public class FtpServer(
IFileSystemProvider fileSystemProvider,
IAuthenticationProvider authenticationProvider,
IEnumerable<IFtpCommandMiddleware> middlewares,
IOptions<FtpServerConfigurationOptions> configurationOptions,
ILogger<FtpServer>? logger = null
)
Expand All @@ -35,7 +36,7 @@ public class FtpServer(
authenticationProvider ?? throw new ArgumentNullException(nameof(authenticationProvider));

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

private NetServer? _netServer;
Expand Down
82 changes: 74 additions & 8 deletions FmiSrl.FtpServer.Server/Infrastructure/FtpCommandHandler.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
using System.Net;
using FmiSrl.FtpServer.Server.Abstractions;

namespace FmiSrl.FtpServer.Server.Infrastructure;

/// <summary>
/// Handles the registration and execution of FTP commands.
/// </summary>
public class FtpCommandHandler
public class FtpCommandHandler(IEnumerable<IFtpCommandMiddleware> middlewares)
{
private readonly Dictionary<string, IFtpCommand> _commands = new(StringComparer.OrdinalIgnoreCase);
private readonly List<IFtpCommandMiddleware> _middlewares = middlewares.ToList();

/// <summary>
/// Registers an FTP command.
Expand All @@ -28,18 +30,82 @@ public void RegisterCommand(IFtpCommand command)
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task HandleCommandAsync(FtpCommandContext context)
{
if (!_commands.TryGetValue(context.Verb, out var command))
var originalSession = context.Session;
var capturingSession = new ResponseCapturingSession(originalSession, context);

// Temporarily replace the session in the context to capture responses
var contextWithCapturingSession = context with { Session = capturingSession };

var next = async () =>
{
if (!_commands.TryGetValue(context.Verb, out var command))
{
context.Response = new FtpResponse(502, "Command not implemented.");
return;
}

if (command.RequiresAuthentication && !context.Session.IsAuthenticated)
{
context.Response = new FtpResponse(530, "Not logged in.");
return;
}

await command.ExecuteAsync(contextWithCapturingSession);
};

// Build the pipeline
var pipeline = next;
for (var i = _middlewares.Count - 1; i >= 0; i--)
{
var middleware = _middlewares[i];
var currentNext = pipeline;
pipeline = () => middleware.InvokeAsync(context, currentNext);
}

await pipeline();

// If a response was captured or set, send it via the original session
if (context.Response != null)
{
await originalSession.SendResponseAsync(context.Response.Code, context.Response.Message);
}
}

private sealed class ResponseCapturingSession(IFtpSession inner, FtpCommandContext context) : IFtpSession
{
public string Id => inner.Id;
public bool IsAuthenticated { get => inner.IsAuthenticated; set => inner.IsAuthenticated = value; }
public string? Username { get => inner.Username; set => inner.Username = value; }
public string CurrentDirectory { get => inner.CurrentDirectory; set => inner.CurrentDirectory = value; }
public EndPoint RemoteEndPoint => inner.RemoteEndPoint;
public IFtpDataConnection? DataConnection { get => inner.DataConnection; set => inner.DataConnection = value; }
public IDictionary<string, object> State => inner.State;

public Task SendResponseAsync(int code, string message)
{
await context.Session.SendResponseAsync(502, "Command not implemented.");
return;
if (code is >= 100 and < 200)
{
return inner.SendResponseAsync(code, message);
}
context.Response = new FtpResponse(code, message);
return Task.CompletedTask;
}

if (command.RequiresAuthentication && !context.Session.IsAuthenticated)
public Task SendResponseAsync(string rawResponse)
{
await context.Session.SendResponseAsync(530, "Not logged in.");
return;
// Try to parse raw response if it matches "XYZ Message"
if (rawResponse.Length >= 4 && int.TryParse(rawResponse.AsSpan(0, 3), out var code) && rawResponse[3] == ' ')
{
if (code is >= 100 and < 200)
{
return inner.SendResponseAsync(rawResponse);
}
context.Response = new FtpResponse(code, rawResponse[4..].TrimEnd('\r', '\n'));
return Task.CompletedTask;
}
return inner.SendResponseAsync(rawResponse);
}

await command.ExecuteAsync(context);
public Task<IDisposable> LockSessionAsync() => inner.LockSessionAsync();
}
}
35 changes: 35 additions & 0 deletions FmiSrl.FtpServer.Server/Infrastructure/FtpDiagnostics.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using System.Diagnostics;
using System.Diagnostics.Metrics;

namespace FmiSrl.FtpServer.Server.Infrastructure;

/// <summary>
/// Provides diagnostic tools for tracing and metrics.
/// </summary>
public static class FtpDiagnostics
{
/// <summary>
/// The prefix used for all traces and metrics.
/// </summary>
public const string ServiceName = "FmiSrl.FtpServer";

/// <summary>
/// The <see cref="ActivitySource"/> for tracing.
/// </summary>
public static readonly ActivitySource ActivitySource = new(ServiceName);

/// <summary>
/// The <see cref="Meter"/> for metrics.
/// </summary>
public static readonly Meter Meter = new(ServiceName);

/// <summary>
/// Counter for commands executed.
/// </summary>
public static readonly Counter<long> CommandsExecutedCounter = Meter.CreateCounter<long>("ftp.commands.executed", description: "Total number of FTP commands executed");

/// <summary>
/// Histogram for command execution duration.
/// </summary>
public static readonly Histogram<double> CommandDurationHistogram = Meter.CreateHistogram<double>("ftp.commands.duration", unit: "ms", description: "Duration of FTP command execution");
}
47 changes: 47 additions & 0 deletions FmiSrl.FtpServer.Server/Infrastructure/FtpDiagnosticsMiddleware.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using System.Diagnostics;
using FmiSrl.FtpServer.Server.Abstractions;

namespace FmiSrl.FtpServer.Server.Infrastructure;

/// <summary>
/// A middleware that provides diagnostics for FTP commands.
/// </summary>
public class FtpDiagnosticsMiddleware : IFtpCommandMiddleware
{
/// <inheritdoc/>
public async Task InvokeAsync(FtpCommandContext context, Func<Task> next)
{
using var activity = FtpDiagnostics.ActivitySource.StartActivity($"FTP {context.Verb}");
activity?.SetTag("ftp.verb", context.Verb);
activity?.SetTag("ftp.args", context.Arguments);
activity?.SetTag("ftp.session_id", context.Session.Id);

var sw = Stopwatch.StartNew();
try
{
await next();

if (context.Response != null)
{
activity?.SetTag("ftp.response_code", context.Response.Code);
}
}
catch (Exception ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
throw;
}
finally
{
sw.Stop();
var responseCode = context.Response?.Code ?? 0;

FtpDiagnostics.CommandsExecutedCounter.Add(1,
new KeyValuePair<string, object?>("ftp.verb", context.Verb),
new KeyValuePair<string, object?>("ftp.response_code", responseCode));

FtpDiagnostics.CommandDurationHistogram.Record(sw.Elapsed.TotalMilliseconds,
new KeyValuePair<string, object?>("ftp.verb", context.Verb));
}
}
}
3 changes: 2 additions & 1 deletion FmiSrl.FtpServer.Tests.Integration/FtpServerTests.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Net;
using FluentFTP;
using FmiSrl.FtpServer.Server;
using FmiSrl.FtpServer.Server.Abstractions;
using FmiSrl.FtpServer.Server.Services;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
Expand Down Expand Up @@ -42,7 +43,7 @@ public FtpServerTests()
ServerName = "TestServer"
});

_server = new FmiSrl.FtpServer.Server.FtpServer(fileSystem, authenticator, serverOptions, NullLogger<FmiSrl.FtpServer.Server.FtpServer>.Instance);
_server = new FmiSrl.FtpServer.Server.FtpServer(fileSystem, authenticator, Enumerable.Empty<IFtpCommandMiddleware>(), serverOptions, NullLogger<FmiSrl.FtpServer.Server.FtpServer>.Instance);
}

public async ValueTask DisposeAsync()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ namespace FmiSrl.FtpServer.Tests.Unit.Infrastructure;

public class FtpCommandHandlerTests
{
private readonly FtpCommandHandler _sut = new();
private readonly FtpCommandHandler _sut = new(Enumerable.Empty<IFtpCommandMiddleware>());
private readonly IFtpSession _session = Substitute.For<IFtpSession>();
private readonly IFileSystemProvider _fileSystem = Substitute.For<IFileSystemProvider>();
private readonly IAuthenticationProvider _authenticator = Substitute.For<IAuthenticationProvider>();
Expand Down Expand Up @@ -85,6 +85,6 @@ public async Task When_command_is_registered_and_auth_ok_should_execute_command(
await _sut.HandleCommandAsync(context);

// Assert
await command.Received().ExecuteAsync(context);
await command.Received().ExecuteAsync(Arg.Any<FtpCommandContext>());
}
}
Loading
Loading