From 43785baa994d5b008511aff32643647763675ab9 Mon Sep 17 00:00:00 2001 From: savedo Date: Thu, 7 May 2026 19:07:02 +0200 Subject: [PATCH 1/2] feat: created middlewares and enabled for metrics and traces. --- .../Abstractions/FtpCommandContext.cs | 5 ++ .../Abstractions/FtpResponse.cs | 31 +++++++ .../Abstractions/IFtpCommandMiddleware.cs | 15 ++++ .../DependencyInjection/FtpServerBuilder.cs | 7 ++ .../FtpServerServiceCollectionExtensions.cs | 6 +- .../DependencyInjection/IFtpServerBuilder.cs | 8 ++ FmiSrl.FtpServer.Server/FtpServer.cs | 3 +- .../Infrastructure/FtpCommandHandler.cs | 82 +++++++++++++++++-- .../Infrastructure/FtpDiagnostics.cs | 35 ++++++++ .../FtpDiagnosticsMiddleware.cs | 47 +++++++++++ .../FtpServerTests.cs | 3 +- .../Infrastructure/FtpCommandHandlerTests.cs | 4 +- .../Infrastructure/FtpMiddlewareTests.cs | 61 ++++++++++++++ global.json | 2 +- 14 files changed, 295 insertions(+), 14 deletions(-) create mode 100644 FmiSrl.FtpServer.Server/Abstractions/FtpResponse.cs create mode 100644 FmiSrl.FtpServer.Server/Abstractions/IFtpCommandMiddleware.cs create mode 100644 FmiSrl.FtpServer.Server/Infrastructure/FtpDiagnostics.cs create mode 100644 FmiSrl.FtpServer.Server/Infrastructure/FtpDiagnosticsMiddleware.cs create mode 100644 FmiSrl.FtpServer.Tests.Unit/Infrastructure/FtpMiddlewareTests.cs diff --git a/FmiSrl.FtpServer.Server/Abstractions/FtpCommandContext.cs b/FmiSrl.FtpServer.Server/Abstractions/FtpCommandContext.cs index f6d43c0..8e2dd56 100644 --- a/FmiSrl.FtpServer.Server/Abstractions/FtpCommandContext.cs +++ b/FmiSrl.FtpServer.Server/Abstractions/FtpCommandContext.cs @@ -22,6 +22,11 @@ public record FtpCommandContext( ILogger Logger ) { + /// + /// Gets or sets the FTP response for this command. + /// + public FtpResponse? Response { get; set; } + /// /// Gets the authentication context for the current session. /// diff --git a/FmiSrl.FtpServer.Server/Abstractions/FtpResponse.cs b/FmiSrl.FtpServer.Server/Abstractions/FtpResponse.cs new file mode 100644 index 0000000..3972d78 --- /dev/null +++ b/FmiSrl.FtpServer.Server/Abstractions/FtpResponse.cs @@ -0,0 +1,31 @@ +namespace FmiSrl.FtpServer.Server.Abstractions; + +/// +/// Represents an FTP response. +/// +public class FtpResponse +{ + /// + /// Gets or sets the FTP response code. + /// + public int Code { get; set; } + + /// + /// Gets or sets the response message. + /// + public string Message { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The FTP response code. + /// The response message. + public FtpResponse(int code, string message) + { + Code = code; + Message = message; + } + + /// + public override string ToString() => $"{Code} {Message}"; +} diff --git a/FmiSrl.FtpServer.Server/Abstractions/IFtpCommandMiddleware.cs b/FmiSrl.FtpServer.Server/Abstractions/IFtpCommandMiddleware.cs new file mode 100644 index 0000000..8cef08d --- /dev/null +++ b/FmiSrl.FtpServer.Server/Abstractions/IFtpCommandMiddleware.cs @@ -0,0 +1,15 @@ +namespace FmiSrl.FtpServer.Server.Abstractions; + +/// +/// Defines the behavior for an FTP command middleware. +/// +public interface IFtpCommandMiddleware +{ + /// + /// Invokes the middleware asynchronously. + /// + /// The for the current command. + /// A delegate representing the next step in the pipeline. + /// A task that represents the asynchronous operation. + Task InvokeAsync(FtpCommandContext context, Func next); +} diff --git a/FmiSrl.FtpServer.Server/DependencyInjection/FtpServerBuilder.cs b/FmiSrl.FtpServer.Server/DependencyInjection/FtpServerBuilder.cs index 4f78094..dc345ce 100644 --- a/FmiSrl.FtpServer.Server/DependencyInjection/FtpServerBuilder.cs +++ b/FmiSrl.FtpServer.Server/DependencyInjection/FtpServerBuilder.cs @@ -1,3 +1,4 @@ +using FmiSrl.FtpServer.Server.Abstractions; using Microsoft.Extensions.DependencyInjection; namespace FmiSrl.FtpServer.Server.DependencyInjection; @@ -5,4 +6,10 @@ namespace FmiSrl.FtpServer.Server.DependencyInjection; internal sealed class FtpServerBuilder(IServiceCollection services) : IFtpServerBuilder { public IServiceCollection Services { get; } = services; + + public IFtpServerBuilder AddMiddleware() where TMiddleware : class, IFtpCommandMiddleware + { + Services.AddTransient(); + return this; + } } diff --git a/FmiSrl.FtpServer.Server/DependencyInjection/FtpServerServiceCollectionExtensions.cs b/FmiSrl.FtpServer.Server/DependencyInjection/FtpServerServiceCollectionExtensions.cs index 9980939..e343812 100644 --- a/FmiSrl.FtpServer.Server/DependencyInjection/FtpServerServiceCollectionExtensions.cs +++ b/FmiSrl.FtpServer.Server/DependencyInjection/FtpServerServiceCollectionExtensions.cs @@ -1,3 +1,4 @@ +using FmiSrl.FtpServer.Server.Infrastructure; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -32,6 +33,9 @@ public static IFtpServerBuilder AddFtpServer( services.TryAddSingleton(); - return new FtpServerBuilder(services); + var builder = new FtpServerBuilder(services); + builder.AddMiddleware(); + + return builder; } } diff --git a/FmiSrl.FtpServer.Server/DependencyInjection/IFtpServerBuilder.cs b/FmiSrl.FtpServer.Server/DependencyInjection/IFtpServerBuilder.cs index c4c3e55..a85fdfb 100644 --- a/FmiSrl.FtpServer.Server/DependencyInjection/IFtpServerBuilder.cs +++ b/FmiSrl.FtpServer.Server/DependencyInjection/IFtpServerBuilder.cs @@ -1,3 +1,4 @@ +using FmiSrl.FtpServer.Server.Abstractions; using Microsoft.Extensions.DependencyInjection; namespace FmiSrl.FtpServer.Server.DependencyInjection; @@ -12,4 +13,11 @@ public interface IFtpServerBuilder /// /// The used by this builder. IServiceCollection Services { get; } + + /// + /// Adds a middleware to the FTP command pipeline. + /// + /// The type of the middleware to add. + /// The instance. + IFtpServerBuilder AddMiddleware() where TMiddleware : class, IFtpCommandMiddleware; } diff --git a/FmiSrl.FtpServer.Server/FtpServer.cs b/FmiSrl.FtpServer.Server/FtpServer.cs index 798c680..2dc5e3e 100644 --- a/FmiSrl.FtpServer.Server/FtpServer.cs +++ b/FmiSrl.FtpServer.Server/FtpServer.cs @@ -22,6 +22,7 @@ namespace FmiSrl.FtpServer.Server; public class FtpServer( IFileSystemProvider fileSystemProvider, IAuthenticationProvider authenticationProvider, + IEnumerable middlewares, IOptions configurationOptions, ILogger? logger = null ) @@ -35,7 +36,7 @@ public class FtpServer( authenticationProvider ?? throw new ArgumentNullException(nameof(authenticationProvider)); private readonly ILogger _logger = logger ?? NullLogger.Instance; - private readonly FtpCommandHandler _commandHandler = new(); + private readonly FtpCommandHandler _commandHandler = new(middlewares); private readonly Dictionary _sessions = []; private NetServer? _netServer; diff --git a/FmiSrl.FtpServer.Server/Infrastructure/FtpCommandHandler.cs b/FmiSrl.FtpServer.Server/Infrastructure/FtpCommandHandler.cs index 5adc88a..52467e4 100644 --- a/FmiSrl.FtpServer.Server/Infrastructure/FtpCommandHandler.cs +++ b/FmiSrl.FtpServer.Server/Infrastructure/FtpCommandHandler.cs @@ -1,3 +1,4 @@ +using System.Net; using FmiSrl.FtpServer.Server.Abstractions; namespace FmiSrl.FtpServer.Server.Infrastructure; @@ -5,9 +6,10 @@ namespace FmiSrl.FtpServer.Server.Infrastructure; /// /// Handles the registration and execution of FTP commands. /// -public class FtpCommandHandler +public class FtpCommandHandler(IEnumerable middlewares) { private readonly Dictionary _commands = new(StringComparer.OrdinalIgnoreCase); + private readonly List _middlewares = middlewares.ToList(); /// /// Registers an FTP command. @@ -28,18 +30,82 @@ public void RegisterCommand(IFtpCommand command) /// A task that represents the asynchronous operation. 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 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 LockSessionAsync() => inner.LockSessionAsync(); } } diff --git a/FmiSrl.FtpServer.Server/Infrastructure/FtpDiagnostics.cs b/FmiSrl.FtpServer.Server/Infrastructure/FtpDiagnostics.cs new file mode 100644 index 0000000..0481d33 --- /dev/null +++ b/FmiSrl.FtpServer.Server/Infrastructure/FtpDiagnostics.cs @@ -0,0 +1,35 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace FmiSrl.FtpServer.Server.Infrastructure; + +/// +/// Provides diagnostic tools for tracing and metrics. +/// +public static class FtpDiagnostics +{ + /// + /// The prefix used for all traces and metrics. + /// + public const string ServiceName = "FmiSrl.FtpServer"; + + /// + /// The for tracing. + /// + public static readonly ActivitySource ActivitySource = new(ServiceName); + + /// + /// The for metrics. + /// + public static readonly Meter Meter = new(ServiceName); + + /// + /// Counter for commands executed. + /// + public static readonly Counter CommandsExecutedCounter = Meter.CreateCounter("ftp.commands.executed", description: "Total number of FTP commands executed"); + + /// + /// Histogram for command execution duration. + /// + public static readonly Histogram CommandDurationHistogram = Meter.CreateHistogram("ftp.commands.duration", unit: "ms", description: "Duration of FTP command execution"); +} diff --git a/FmiSrl.FtpServer.Server/Infrastructure/FtpDiagnosticsMiddleware.cs b/FmiSrl.FtpServer.Server/Infrastructure/FtpDiagnosticsMiddleware.cs new file mode 100644 index 0000000..e33ff26 --- /dev/null +++ b/FmiSrl.FtpServer.Server/Infrastructure/FtpDiagnosticsMiddleware.cs @@ -0,0 +1,47 @@ +using System.Diagnostics; +using FmiSrl.FtpServer.Server.Abstractions; + +namespace FmiSrl.FtpServer.Server.Infrastructure; + +/// +/// A middleware that provides diagnostics for FTP commands. +/// +public class FtpDiagnosticsMiddleware : IFtpCommandMiddleware +{ + /// + public async Task InvokeAsync(FtpCommandContext context, Func 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("ftp.verb", context.Verb), + new KeyValuePair("ftp.response_code", responseCode)); + + FtpDiagnostics.CommandDurationHistogram.Record(sw.Elapsed.TotalMilliseconds, + new KeyValuePair("ftp.verb", context.Verb)); + } + } +} diff --git a/FmiSrl.FtpServer.Tests.Integration/FtpServerTests.cs b/FmiSrl.FtpServer.Tests.Integration/FtpServerTests.cs index 412e442..f53aa35 100644 --- a/FmiSrl.FtpServer.Tests.Integration/FtpServerTests.cs +++ b/FmiSrl.FtpServer.Tests.Integration/FtpServerTests.cs @@ -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; @@ -42,7 +43,7 @@ public FtpServerTests() ServerName = "TestServer" }); - _server = new FmiSrl.FtpServer.Server.FtpServer(fileSystem, authenticator, serverOptions, NullLogger.Instance); + _server = new FmiSrl.FtpServer.Server.FtpServer(fileSystem, authenticator, Enumerable.Empty(), serverOptions, NullLogger.Instance); } public async ValueTask DisposeAsync() diff --git a/FmiSrl.FtpServer.Tests.Unit/Infrastructure/FtpCommandHandlerTests.cs b/FmiSrl.FtpServer.Tests.Unit/Infrastructure/FtpCommandHandlerTests.cs index 21c2b46..02f9ba5 100644 --- a/FmiSrl.FtpServer.Tests.Unit/Infrastructure/FtpCommandHandlerTests.cs +++ b/FmiSrl.FtpServer.Tests.Unit/Infrastructure/FtpCommandHandlerTests.cs @@ -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()); private readonly IFtpSession _session = Substitute.For(); private readonly IFileSystemProvider _fileSystem = Substitute.For(); private readonly IAuthenticationProvider _authenticator = Substitute.For(); @@ -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()); } } diff --git a/FmiSrl.FtpServer.Tests.Unit/Infrastructure/FtpMiddlewareTests.cs b/FmiSrl.FtpServer.Tests.Unit/Infrastructure/FtpMiddlewareTests.cs new file mode 100644 index 0000000..77364ab --- /dev/null +++ b/FmiSrl.FtpServer.Tests.Unit/Infrastructure/FtpMiddlewareTests.cs @@ -0,0 +1,61 @@ +using FmiSrl.FtpServer.Server.Abstractions; +using FmiSrl.FtpServer.Server.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Xunit; + +namespace FmiSrl.FtpServer.Tests.Unit.Infrastructure; + +public class FtpMiddlewareTests +{ + private readonly IFtpSession _session = Substitute.For(); + private readonly IFileSystemProvider _fileSystem = Substitute.For(); + private readonly IAuthenticationProvider _authenticator = Substitute.For(); + + [Fact] + public async Task Middleware_should_be_able_to_manipulate_response() + { + // Arrange + var middleware = new TestManipulationMiddleware(); + var sut = new FtpCommandHandler(new[] { middleware }); + + var command = Substitute.For(); + command.Verbs.Returns(["TEST"]); + command.ExecuteAsync(Arg.Any()).Returns(async call => + { + var ctx = call.Arg(); + await ctx.Session.SendResponseAsync(200, "Original Message"); + }); + sut.RegisterCommand(command); + + var context = new FtpCommandContext( + _session, + "TEST", + "", + _fileSystem, + _authenticator, + new(), + NullLogger.Instance + ); + + // Act + await sut.HandleCommandAsync(context); + + // Assert + await _session.Received().SendResponseAsync(201, "Manipulated Message"); + } + + private class TestManipulationMiddleware : IFtpCommandMiddleware + { + public async Task InvokeAsync(FtpCommandContext context, Func next) + { + await next(); + + if (context.Response != null) + { + context.Response.Code = 201; + context.Response.Message = "Manipulated Message"; + } + } + } +} diff --git a/global.json b/global.json index 95365e3..43a2c0d 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.203", + "version": "10.0.101", "rollForward": "latestMajor", "allowPrerelease": false } From 9efce5db6419f8a3dc56d077563f7a4a5b51754d Mon Sep 17 00:00:00 2001 From: savedo Date: Mon, 18 May 2026 15:13:50 +0200 Subject: [PATCH 2/2] docs: update AGENTS.md with additional rules on .NET version requirements --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index c881496..d4cbc87 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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.