diff --git a/.gitignore b/.gitignore index 0c1a286e..ee53b660 100644 --- a/.gitignore +++ b/.gitignore @@ -358,7 +358,7 @@ charts/default/charts/swlib-2.0.11.tgz .idea /SW.Bitween.Web/appsettings.Development.json -/SW.Bitween.Web/appsettings.DevRabbitMq.json +/SW.Bitween.Web/appsettings.Local.json /SW.Bitween.Web/adapters SW.Bitween.NativeAdapters/JsonFieldMapper/Bitween-api.code-workspace .DS_Store diff --git a/SW.Bitween.NativeAdapters/FtpProtocol.cs b/SW.Bitween.NativeAdapters/FtpProtocol.cs new file mode 100644 index 00000000..68fb7437 --- /dev/null +++ b/SW.Bitween.NativeAdapters/FtpProtocol.cs @@ -0,0 +1,28 @@ +namespace SW.Bitween.NativeAdapters; + +/// +/// Shared FTP protocol rules used by the native FTP adapters, so the handler and receiver +/// can never disagree on them. +/// +public static class FtpProtocol +{ + /// + /// Password-authenticated protocols (ftp, sftp) require a password. sftpssh authenticates + /// with a private key, so its "password" is an optional key passphrase and is not enforced. + /// + public static void EnsurePasswordProvided(string protocol, string? password) + { + if (protocol.ToLower() is "ftp" or "sftp" && string.IsNullOrEmpty(password)) + throw new ArgumentException($"Password is required for the '{protocol}' protocol."); + } + + /// + /// sftpssh authenticates with a private key, so one must be provided. ftp/sftp authenticate + /// with a password instead, so no key is required for them. + /// + public static void EnsurePrivateKeyProvided(string protocol, string? privateKey) + { + if (protocol.ToLower() is "sftpssh" && string.IsNullOrWhiteSpace(privateKey)) + throw new ArgumentException($"A private key is required for the '{protocol}' protocol."); + } +} diff --git a/SW.Bitween.NativeAdapters/RebexFtpReceiver/NativeRebexFtpReceiver.cs b/SW.Bitween.NativeAdapters/RebexFtpReceiver/NativeRebexFtpReceiver.cs new file mode 100644 index 00000000..18e8335b --- /dev/null +++ b/SW.Bitween.NativeAdapters/RebexFtpReceiver/NativeRebexFtpReceiver.cs @@ -0,0 +1,111 @@ +using System.Text; +using Rebex.Net; +using SW.PrimitiveTypes; + +namespace SW.Bitween.NativeAdapters.RebexFtpReceiver; + +public class NativeRebexFtpReceiver : INativeInfolinkReceiver +{ + private readonly string? _licenseKey; + private RebexFtpReceiverInput _options = new(); + private IFtp _ftpOrSftp = null!; + + public NativeRebexFtpReceiver(string? licenseKey = null) + { + _licenseKey = licenseKey; + } + + public async Task Initialize() + { + Rebex.Licensing.Key = _licenseKey; + FtpProtocol.EnsurePasswordProvided(_options.Protocol, _options.Password); + FtpProtocol.EnsurePrivateKeyProvided(_options.Protocol, _options.PrivateKey); + + switch (_options.Protocol.ToLower()) + { + case "sftpssh": + var sftpssh = new Sftp(); + await sftpssh.ConnectAsync(_options.Host, _options.Port ?? 22); + + var keyBytes = Encoding.UTF8.GetBytes(SshKeyNormalizer.Normalize(_options.PrivateKey)); + var privateKey = new SshPrivateKey(keyBytes, _options.Password); + await sftpssh.LoginAsync(_options.Username, privateKey); + + _ftpOrSftp = sftpssh; + break; + + case "sftp": + var sftp = new Sftp(); + await sftp.ConnectAsync(_options.Host, _options.Port ?? 22); + _ftpOrSftp = sftp; + await _ftpOrSftp.LoginAsync(_options.Username, _options.Password); + break; + + case "ftp": + var ftp = new Rebex.Net.Ftp(); + await ftp.ConnectAsync(_options.Host, _options.Port ?? 21); + _ftpOrSftp = ftp; + await _ftpOrSftp.LoginAsync(_options.Username, _options.Password); + break; + + default: + throw new ArgumentException($"Unknown protocol '{_options.Protocol}'"); + } + + if (!string.IsNullOrEmpty(_options.TargetPath)) + await _ftpOrSftp.ChangeDirectoryAsync(_options.TargetPath); + } + + public async Task Finalize() + { + await _ftpOrSftp.DisconnectAsync(); + _ftpOrSftp.Dispose(); + } + + public async Task> ListFiles() + { + var files = await _ftpOrSftp.GetListAsync(); + + return files + .Where(i => i.IsFile) + .Take(_options.BatchSize) + .Select(i => i.Name) + .ToList(); + } + + public async Task GetFile(string fileId) + { + await using var stream = new MemoryStream(); + await _ftpOrSftp.GetFileAsync(fileId, stream); + + var data = stream.ToArray(); + + return _options.ResponseEncoding.ToLower() switch + { + "base64" => new XchangeFile(Convert.ToBase64String(data), fileId), + "utf8" => new XchangeFile(Encoding.UTF8.GetString(data), fileId), + _ => throw new ArgumentException( + $"Unknown {nameof(RebexFtpReceiverInput.ResponseEncoding)} '{_options.ResponseEncoding}'") + }; + } + + public async Task DeleteFile(string fileId) + { + if (_options.CheckFileExistence && !await _ftpOrSftp.FileExistsAsync(fileId)) + return; + + if (string.IsNullOrWhiteSpace(_options.DeleteMovesFileTo)) + await _ftpOrSftp.DeleteFileAsync(fileId); + else + await _ftpOrSftp.RenameAsync(fileId, _options.DeleteMovesFileTo + "/" + fileId); + } + + public string Name => "NativeRebexFtpReceiver"; + + public void InitializeStartupValues(IDictionary settings) + { + _options = settings.ConvertTo(); + } + + public Type StartupValuesType => typeof(RebexFtpReceiverInput); +} diff --git a/SW.Bitween.NativeAdapters/RebexFtpReceiver/RebexFtpReceiverInput.cs b/SW.Bitween.NativeAdapters/RebexFtpReceiver/RebexFtpReceiverInput.cs new file mode 100644 index 00000000..41ee04b8 --- /dev/null +++ b/SW.Bitween.NativeAdapters/RebexFtpReceiver/RebexFtpReceiverInput.cs @@ -0,0 +1,37 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace SW.Bitween.NativeAdapters.RebexFtpReceiver; + +public class RebexFtpReceiverInput +{ + [Required] + public string Host { get; set; } = string.Empty; + + public int? Port { get; set; } + + [Required] + public string Username { get; set; } = string.Empty; + + [Secure] + public string? Password { get; set; } + + public string? TargetPath { get; set; } + + [DefaultValue(50)] + public int BatchSize { get; set; } = 50; + + [DefaultValue("utf8")] + public string ResponseEncoding { get; set; } = "utf8"; + + public string? DeleteMovesFileTo { get; set; } + + [DefaultValue("sftp")] + public string Protocol { get; set; } = "sftp"; + + [DefaultValue(true)] + public bool CheckFileExistence { get; set; } = true; + + [Secure] + public string? PrivateKey { get; set; } +} diff --git a/SW.Bitween.NativeAdapters/RebexFtpUploadHandler/NativeRebexFtpUploadHandler.cs b/SW.Bitween.NativeAdapters/RebexFtpUploadHandler/NativeRebexFtpUploadHandler.cs new file mode 100644 index 00000000..bee8c876 --- /dev/null +++ b/SW.Bitween.NativeAdapters/RebexFtpUploadHandler/NativeRebexFtpUploadHandler.cs @@ -0,0 +1,90 @@ +using System.Text; +using Rebex.Net; +using SW.PrimitiveTypes; + +namespace SW.Bitween.NativeAdapters.RebexFtpUploadHandler; + +public class NativeRebexFtpUploadHandler : INativeInfolinkHandler +{ + private readonly string? _licenseKey; + private RebexFtpUploadHandlerInput _options = new(); + + public NativeRebexFtpUploadHandler(string? licenseKey = null) + { + _licenseKey = licenseKey; + } + + public async Task Handle(XchangeFile xchangeFile) + { + Rebex.Licensing.Key = _licenseKey; + FtpProtocol.EnsurePasswordProvided(_options.Protocol, _options.Password); + FtpProtocol.EnsurePrivateKeyProvided(_options.Protocol, _options.PrivateKey); + + IFtp ftpOrSftp; + switch (_options.Protocol.ToLower()) + { + case "sftpssh": + var sftpssh = new Sftp(); + await sftpssh.ConnectAsync(_options.Host, _options.Port ?? 22); + + var keyBytes = Encoding.UTF8.GetBytes(SshKeyNormalizer.Normalize(_options.PrivateKey)); + var sshPrivateKey = new SshPrivateKey(keyBytes, _options.Password); + await sftpssh.LoginAsync(_options.Username, sshPrivateKey); + + ftpOrSftp = sftpssh; + break; + + case "sftp": + var sftp = new Sftp(); + await sftp.ConnectAsync(_options.Host, _options.Port ?? 22); + ftpOrSftp = sftp; + await ftpOrSftp.LoginAsync(_options.Username, _options.Password); + break; + + case "ftp": + var ftp = new Rebex.Net.Ftp(); + await ftp.ConnectAsync(_options.Host, _options.Port ?? 21); + ftpOrSftp = ftp; + await ftpOrSftp.LoginAsync(_options.Username, _options.Password); + break; + + default: + throw new ArgumentException($"Unknown protocol '{_options.Protocol}'"); + } + + var bytes = _options.DataEncoding.ToLower() switch + { + "base64" => Convert.FromBase64String(xchangeFile.Data), + "utf8" => Encoding.UTF8.GetBytes(xchangeFile.Data), + _ => throw new ArgumentException( + $"Unknown {nameof(RebexFtpUploadHandlerInput.DataEncoding)} '{_options.DataEncoding}'") + }; + + await using var stream = new MemoryStream(bytes); + + var filename = xchangeFile.Filename; + if (string.IsNullOrWhiteSpace(filename)) + { + var currentDate = DateTime.UtcNow; + filename = + $"{currentDate.Year:0000}{currentDate.Month:00}{currentDate.Day:00}{currentDate.Hour:00}{currentDate.Minute:00}{currentDate.Second:00}{currentDate.Millisecond:000}"; + } + + if (!string.IsNullOrWhiteSpace(_options.FileNamePrefix)) + filename = $"{_options.FileNamePrefix}_{filename}"; + + await ftpOrSftp.PutFileAsync(stream, $"{_options.TargetPath}/{filename}"); + + await ftpOrSftp.DisconnectAsync(); + return new XchangeFile(string.Empty); + } + + public string Name => "NativeRebexFtpUploadHandler"; + + public void InitializeStartupValues(IDictionary settings) + { + _options = settings.ConvertTo(); + } + + public Type StartupValuesType => typeof(RebexFtpUploadHandlerInput); +} diff --git a/SW.Bitween.NativeAdapters/RebexFtpUploadHandler/RebexFtpUploadHandlerInput.cs b/SW.Bitween.NativeAdapters/RebexFtpUploadHandler/RebexFtpUploadHandlerInput.cs new file mode 100644 index 00000000..92a41b17 --- /dev/null +++ b/SW.Bitween.NativeAdapters/RebexFtpUploadHandler/RebexFtpUploadHandlerInput.cs @@ -0,0 +1,31 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace SW.Bitween.NativeAdapters.RebexFtpUploadHandler; + +public class RebexFtpUploadHandlerInput +{ + [Required] + public string Host { get; set; } = string.Empty; + + public int? Port { get; set; } + + [Required] + public string Username { get; set; } = string.Empty; + + [Secure] + public string? Password { get; set; } + + public string? TargetPath { get; set; } + + public string? FileNamePrefix { get; set; } + + [DefaultValue("utf8")] + public string DataEncoding { get; set; } = "utf8"; + + [DefaultValue("sftp")] + public string Protocol { get; set; } = "sftp"; + + [Secure] + public string? PrivateKey { get; set; } +} diff --git a/SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj b/SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj index cd939126..561103de 100644 --- a/SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj +++ b/SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj @@ -17,8 +17,10 @@ - - + + + + diff --git a/SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs b/SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs index 4af32258..aaae520c 100644 --- a/SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs +++ b/SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs @@ -1,6 +1,8 @@ using Microsoft.Extensions.DependencyInjection; using SW.Bitween.NativeAdapters.HttpReceiver; using SW.Bitween.NativeAdapters.Pop3Receiver; +using SW.Bitween.NativeAdapters.RebexFtpReceiver; +using SW.Bitween.NativeAdapters.RebexFtpUploadHandler; using SW.Bitween.NativeAdapters.RebexPop3Receiver; namespace SW.Bitween.NativeAdapters; @@ -40,6 +42,12 @@ public static void AddNativeAdapters(this IServiceCollection serviceCollection, { serviceCollection.AddScoped(_ => new NativeRebexPop3Receiver(rebexLicenseKey)); serviceCollection.AddScoped(_ => new NativeRebexPop3Receiver(rebexLicenseKey)); + + serviceCollection.AddScoped(_ => new NativeRebexFtpUploadHandler(rebexLicenseKey)); + serviceCollection.AddScoped(_ => new NativeRebexFtpUploadHandler(rebexLicenseKey)); + + serviceCollection.AddScoped(_ => new NativeRebexFtpReceiver(rebexLicenseKey)); + serviceCollection.AddScoped(_ => new NativeRebexFtpReceiver(rebexLicenseKey)); } } } \ No newline at end of file diff --git a/SW.Bitween.NativeAdapters/SshKeyNormalizer.cs b/SW.Bitween.NativeAdapters/SshKeyNormalizer.cs new file mode 100644 index 00000000..dc8acebc --- /dev/null +++ b/SW.Bitween.NativeAdapters/SshKeyNormalizer.cs @@ -0,0 +1,49 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace SW.Bitween.NativeAdapters; + +/// +/// Normalizes an SSH private key coming from adapter settings into a shape SSH clients accept. +/// Shared by every native adapter that does key-based auth, so their handling can never diverge. +/// +/// Rules: +/// - Blank input -> empty string. +/// - Already multi-line -> returned untouched (never risk corrupting a well-formed key). +/// - Flattened PEM (single line, has BEGIN/END markers) -> rebuilt with the header/footer +/// preserved exactly and the base64 body re-wrapped at 64 chars. +/// - Anything else (e.g. a flattened non-PEM format) -> returned as-is rather than mangled. +/// +public static class SshKeyNormalizer +{ + private static readonly Regex PemShape = + new(@"^(-----BEGIN [^-]+-----)(.*?)(-----END [^-]+-----)$", RegexOptions.Singleline | RegexOptions.Compiled); + + public static string Normalize(string? rawKey) + { + if (string.IsNullOrWhiteSpace(rawKey)) + return string.Empty; + + var key = rawKey.Trim(); + + // Well-formed multi-line key: trust it exactly as given. + if (key.Contains('\n')) + return key; + + // Single flattened line: only reconstruct if it's a PEM key we recognize. + var match = PemShape.Match(key); + if (!match.Success) + return key; + + var header = match.Groups[1].Value.Trim(); + var body = Regex.Replace(match.Groups[2].Value, @"\s+", string.Empty); + var footer = match.Groups[3].Value.Trim(); + + var sb = new StringBuilder(); + sb.Append(header).Append('\n'); + for (var i = 0; i < body.Length; i += 64) + sb.Append(body.Substring(i, Math.Min(64, body.Length - i))).Append('\n'); + sb.Append(footer).Append('\n'); + return sb.ToString(); + } +} diff --git a/SW.Bitween.UnitTests/SshKeyNormalizerTests.cs b/SW.Bitween.UnitTests/SshKeyNormalizerTests.cs new file mode 100644 index 00000000..a0f0a12e --- /dev/null +++ b/SW.Bitween.UnitTests/SshKeyNormalizerTests.cs @@ -0,0 +1,69 @@ +using System.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SW.Bitween.NativeAdapters; + +namespace SW.Bitween.UnitTests; + +[TestClass] +public class SshKeyNormalizerTests +{ + private const string Header = "-----BEGIN OPENSSH PRIVATE KEY-----"; + private const string Footer = "-----END OPENSSH PRIVATE KEY-----"; + private const string Body = "AAAAB3NzaC1yc2EAAAADAQABAAABAQCabc123def456ghi789jkl012mno34"; + + [TestMethod] + public void WellFormedMultiLineKey_IsReturnedUnchanged() + { + var key = $"{Header}\n{Body}\n{Footer}"; + + var result = SshKeyNormalizer.Normalize(key); + + Assert.AreEqual(key, result); + // Regression: the header's internal spaces must survive (the old regex destroyed them). + StringAssert.Contains(result, Header); + } + + [TestMethod] + public void FlattenedPemKey_IsRebuiltWithHeaderFooterAndBody() + { + var flattened = $"{Header} {Body} {Footer}"; + + var result = SshKeyNormalizer.Normalize(flattened); + var lines = result.Split('\n').Where(l => l.Length > 0).ToList(); + + Assert.AreEqual(Header, lines.First()); // header intact, spaces preserved + Assert.AreEqual(Footer, lines.Last()); // footer intact + // Body reassembled with no whitespace, wrapped across the middle lines. + var rebuiltBody = string.Concat(lines.Skip(1).Take(lines.Count - 2)); + Assert.AreEqual(Body, rebuiltBody); + } + + [TestMethod] + public void FlattenedPemKey_WrapsBodyAt64Chars() + { + var longBody = new string('A', 200); + var flattened = $"{Header} {longBody} {Footer}"; + + var result = SshKeyNormalizer.Normalize(flattened); + var bodyLines = result.Split('\n').Where(l => l.Length > 0).Skip(1).SkipLast(1).ToList(); + + Assert.IsTrue(bodyLines.All(l => l.Length <= 64)); + Assert.AreEqual(longBody, string.Concat(bodyLines)); + } + + [TestMethod] + public void NullOrEmpty_ReturnsEmptyString() + { + Assert.AreEqual(string.Empty, SshKeyNormalizer.Normalize(null)); + Assert.AreEqual(string.Empty, SshKeyNormalizer.Normalize("")); + Assert.AreEqual(string.Empty, SshKeyNormalizer.Normalize(" ")); + } + + [TestMethod] + public void UnrecognizedSingleLine_IsReturnedTrimmedButUnmangled() + { + var result = SshKeyNormalizer.Normalize(" not-a-pem-key-just-text "); + + Assert.AreEqual("not-a-pem-key-just-text", result); + } +}