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);
+ }
+}