Files
duplicati/Duplicati/Library/Backend/AlternativeFTP/AlternativeFTPBackend.cs
T

622 lines
25 KiB
C#

// Copyright (C) 2024, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using Duplicati.Library.Common.IO;
using Duplicati.Library.Interface;
using Duplicati.Library.Utility;
using FluentFTP;
using FluentFTP.Client.BaseClient;
using FluentFTP.Exceptions;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Security;
using System.Security.Authentication;
using System.Threading;
using System.Threading.Tasks;
using CoreUtility = Duplicati.Library.Utility.Utility;
using Uri = System.Uri;
namespace Duplicati.Library.Backend.AlternativeFTP
{
// ReSharper disable once RedundantExtendsListEntry
public class AlternativeFtpBackend : IBackend, IStreamingBackend
{
private System.Net.NetworkCredential _userInfo;
private const string OPTION_ACCEPT_SPECIFIED_CERTIFICATE = "accept-specified-ssl-hash"; // Global option
private const string OPTION_ACCEPT_ANY_CERTIFICATE = "accept-any-ssl-certificate"; // Global option
private const FtpDataConnectionType DEFAULT_DATA_CONNECTION_TYPE = FtpDataConnectionType.AutoPassive;
private const FtpEncryptionMode DEFAULT_ENCRYPTION_MODE = FtpEncryptionMode.None;
private const SslProtocols DEFAULT_SSL_PROTOCOLS = SslProtocols.Default;
private const string CONFIG_KEY_AFTP_ENCRYPTION_MODE = "aftp-encryption-mode";
private const string CONFIG_KEY_AFTP_DATA_CONNECTION_TYPE = "aftp-data-connection-type";
private const string CONFIG_KEY_AFTP_SSL_PROTOCOLS = "aftp-ssl-protocols";
private const string CONFIG_KEY_AFTP_UPLOAD_DELAY = "aftp-upload-delay";
private const string CONFIG_KEY_AFTP_LOGTOCONSOLE = "aftp-log-to-console";
private const string CONFIG_KEY_AFTP_LOGPRIVATEINFOTOCONSOLE = "aftp-log-privateinfo-to-console";
private const string TEST_FILE_NAME = "duplicati-access-privileges-test.tmp";
private const string TEST_FILE_CONTENT = "This file is used by Duplicati to test access permissions and can be safely deleted.";
// ReSharper disable InconsistentNaming
private static readonly string DEFAULT_DATA_CONNECTION_TYPE_STRING = DEFAULT_DATA_CONNECTION_TYPE.ToString();
private static readonly string DEFAULT_ENCRYPTION_MODE_STRING = DEFAULT_ENCRYPTION_MODE.ToString();
private static readonly string DEFAULT_SSL_PROTOCOLS_STRING = DEFAULT_SSL_PROTOCOLS.ToString();
private static readonly string DEFAULT_UPLOAD_DELAY_STRING = "0s";
// ReSharper restore InconsistentNaming
private readonly string _url;
private readonly bool _listVerify = true;
private readonly FtpConfig _ftpConfig;
private readonly TimeSpan _uploadWaitTime;
private readonly bool _logToConsole = false;
private readonly bool _logPrivateInfoToConsole = false;
private readonly byte[] _copybuffer = new byte[CoreUtility.DEFAULT_BUFFER_SIZE];
private readonly bool _accepAllCertificates;
private readonly string[] _validHashes;
/// <summary>
/// The localized name to display for this backend
/// </summary>
public string DisplayName
{
get { return Strings.DisplayName; }
}
/// <summary>
/// The protocol key, eg. ftp, http or ssh
/// </summary>
public string ProtocolKey
{
get { return "aftp"; }
}
private AsyncFtpClient Client
{ get; set; }
public IList<ICommandLineArgument> SupportedCommands
{
get
{
return new List<ICommandLineArgument>(new ICommandLineArgument[] {
new CommandLineArgument("auth-password", CommandLineArgument.ArgumentType.Password, Strings.DescriptionAuthPasswordShort, Strings.DescriptionAuthPasswordLong),
new CommandLineArgument("auth-username", CommandLineArgument.ArgumentType.String, Strings.DescriptionAuthUsernameShort, Strings.DescriptionAuthUsernameLong),
new CommandLineArgument("disable-upload-verify", CommandLineArgument.ArgumentType.Boolean, Strings.DescriptionDisableUploadVerifyShort, Strings.DescriptionDisableUploadVerifyLong),
new CommandLineArgument(CONFIG_KEY_AFTP_DATA_CONNECTION_TYPE, CommandLineArgument.ArgumentType.Enumeration, Strings.DescriptionFtpDataConnectionTypeShort, Strings.DescriptionFtpDataConnectionTypeLong, DEFAULT_DATA_CONNECTION_TYPE_STRING, null, Enum.GetNames(typeof(FtpDataConnectionType))),
new CommandLineArgument(CONFIG_KEY_AFTP_ENCRYPTION_MODE, CommandLineArgument.ArgumentType.Enumeration, Strings.DescriptionFtpEncryptionModeShort, Strings.DescriptionFtpEncryptionModeLong, DEFAULT_ENCRYPTION_MODE_STRING, null, Enum.GetNames(typeof(FtpEncryptionMode))),
new CommandLineArgument(CONFIG_KEY_AFTP_SSL_PROTOCOLS, CommandLineArgument.ArgumentType.Flags, Strings.DescriptionSslProtocolsShort, Strings.DescriptionSslProtocolsLong, DEFAULT_SSL_PROTOCOLS_STRING, null, Enum.GetNames(typeof(SslProtocols))),
new CommandLineArgument(CONFIG_KEY_AFTP_UPLOAD_DELAY, CommandLineArgument.ArgumentType.Timespan, Strings.DescriptionUploadDelayShort, Strings.DescriptionUploadDelayLong, DEFAULT_UPLOAD_DELAY_STRING),
new CommandLineArgument(CONFIG_KEY_AFTP_LOGTOCONSOLE, CommandLineArgument.ArgumentType.Boolean, Strings.DescriptionLogToConsoleShort, Strings.DescriptionLogToConsoleLong),
new CommandLineArgument(CONFIG_KEY_AFTP_LOGPRIVATEINFOTOCONSOLE, CommandLineArgument.ArgumentType.Boolean, Strings.DescriptionLogPrivateInfoToConsoleShort, Strings.DescriptionLogPrivateInfoToConsoleLong, "false"),
});
}
}
/// <summary>
/// Initialize a new instance.
/// </summary>
public AlternativeFtpBackend()
{
}
/// <summary>
/// Initialize a new instance/
/// </summary>
/// <param name="url">Configured url.</param>
/// <param name="options">Configured options. cannot be null.</param>
public AlternativeFtpBackend(string url, Dictionary<string, string> options)
{
_accepAllCertificates = CoreUtility.ParseBoolOption(options, OPTION_ACCEPT_ANY_CERTIFICATE);
string certHash;
options.TryGetValue(OPTION_ACCEPT_SPECIFIED_CERTIFICATE, out certHash);
_validHashes = certHash == null ? null : certHash.Split(new[] { ",", ";" }, StringSplitOptions.RemoveEmptyEntries);
var u = new Utility.Uri(url);
u.RequireHost();
if (!string.IsNullOrEmpty(u.Username))
{
_userInfo = new System.Net.NetworkCredential();
_userInfo.UserName = u.Username;
if (!string.IsNullOrEmpty(u.Password))
_userInfo.Password = u.Password;
else if (options.ContainsKey("auth-password"))
_userInfo.Password = options["auth-password"];
}
else
{
if (options.ContainsKey("auth-username"))
{
_userInfo = new System.Net.NetworkCredential();
_userInfo.UserName = options["auth-username"];
if (options.ContainsKey("auth-password"))
_userInfo.Password = options["auth-password"];
}
}
//Bugfix, see http://connect.microsoft.com/VisualStudio/feedback/details/695227/networkcredential-default-constructor-leaves-domain-null-leading-to-null-object-reference-exceptions-in-framework-code
if (_userInfo != null)
_userInfo.Domain = "";
_url = u.SetScheme("ftp").SetQuery(null).SetCredentials(null, null).ToString();
_url = Common.IO.Util.AppendDirSeparator(_url, "/");
_listVerify = !CoreUtility.ParseBoolOption(options, "disable-upload-verify");
if (options.TryGetValue(CONFIG_KEY_AFTP_UPLOAD_DELAY, out var uploadWaitTimeString) && !string.IsNullOrWhiteSpace(uploadWaitTimeString))
_uploadWaitTime = Duplicati.Library.Utility.Timeparser.ParseTimeSpan(uploadWaitTimeString);
// Process the aftp-data-connection-type option
string dataConnectionTypeString;
FtpDataConnectionType dataConnectionType;
if (!options.TryGetValue(CONFIG_KEY_AFTP_DATA_CONNECTION_TYPE, out dataConnectionTypeString) || string.IsNullOrWhiteSpace(dataConnectionTypeString))
{
dataConnectionTypeString = null;
}
if (dataConnectionTypeString == null || !Enum.TryParse(dataConnectionTypeString, true, out dataConnectionType))
{
dataConnectionType = DEFAULT_DATA_CONNECTION_TYPE;
}
// Process the aftp-encryption-mode option
string encryptionModeString;
FtpEncryptionMode encryptionMode;
if (!options.TryGetValue(CONFIG_KEY_AFTP_ENCRYPTION_MODE, out encryptionModeString) || string.IsNullOrWhiteSpace(encryptionModeString))
{
encryptionModeString = null;
}
if (encryptionModeString == null || !Enum.TryParse(encryptionModeString, true, out encryptionMode))
{
encryptionMode = DEFAULT_ENCRYPTION_MODE;
}
// Process the aftp-ssl-protocols option
string sslProtocolsString;
SslProtocols sslProtocols;
if (!options.TryGetValue(CONFIG_KEY_AFTP_SSL_PROTOCOLS, out sslProtocolsString) || string.IsNullOrWhiteSpace(sslProtocolsString))
{
sslProtocolsString = null;
}
if (sslProtocolsString == null || !Enum.TryParse(sslProtocolsString, true, out sslProtocols))
{
sslProtocols = DEFAULT_SSL_PROTOCOLS;
}
if (Utility.Utility.ParseBoolOption(options, CONFIG_KEY_AFTP_LOGTOCONSOLE))
{
_logToConsole = true;
}
if (Utility.Utility.ParseBoolOption(options, CONFIG_KEY_AFTP_LOGPRIVATEINFOTOCONSOLE))
{
_logPrivateInfoToConsole = true;
}
_ftpConfig = new FtpConfig
{
DataConnectionType = dataConnectionType,
EncryptionMode = encryptionMode,
SslProtocols = sslProtocols,
LogToConsole = _logToConsole,
};
if (_logPrivateInfoToConsole)
{
_ftpConfig.LogHost = _ftpConfig.LogPassword = _ftpConfig.LogUserName = true;
}
}
public IEnumerable<IFileEntry> List()
{
return List("");
}
public IEnumerable<IFileEntry> List(string filename)
{
return List(filename, false);
}
private IEnumerable<IFileEntry> List(string filename, bool stripFile)
{
var list = new List<IFileEntry>();
string remotePath = filename;
var ftpClient = CreateClient();
// Get the remote path
var url = new Uri(this._url);
remotePath = "/" + this.GetUnescapedAbsolutePath(url);
if (!string.IsNullOrEmpty(filename))
{
if (!stripFile)
{
// Append the filename
remotePath += filename;
}
else if (filename.Contains("/"))
{
remotePath += filename.Substring(0, filename.LastIndexOf("/", StringComparison.Ordinal));
}
// else: stripping the filename in this case ignoring it
}
foreach (FtpListItem item in ftpClient.GetListing(remotePath, FtpListOption.Modify | FtpListOption.Size).Await())
{
switch (item.Type)
{
case FtpObjectType.Directory:
{
if (item.Name == "." || item.Name == "..")
{
continue;
}
list.Add(new FileEntry(item.Name, -1, new DateTime(), item.Modified)
{
IsFolder = true,
});
break;
}
case FtpObjectType.File:
{
list.Add(new FileEntry(item.Name, item.Size, new DateTime(), item.Modified));
break;
}
case FtpObjectType.Link:
{
if (item.Name == "." || item.Name == "..")
{
continue;
}
if (item.LinkObject != null)
{
switch (item.LinkObject.Type)
{
case FtpObjectType.Directory:
{
if (item.Name == "." || item.Name == "..")
{
continue;
}
list.Add(new FileEntry(item.Name, -1, new DateTime(), item.Modified)
{
IsFolder = true,
});
break;
}
case FtpObjectType.File:
{
list.Add(new FileEntry(item.Name, item.Size, new DateTime(), item.Modified));
break;
}
}
}
break;
}
}
}
return list;
}
public async Task PutAsync(string remotename, Stream input, CancellationToken cancelToken)
{
string remotePath = remotename;
long streamLen;
var ftpClient = CreateClient();
try
{
streamLen = input.Length;
}
catch (NotSupportedException) { streamLen = -1; }
// Get the remote path
remotePath = "";
if (!string.IsNullOrEmpty(remotename))
{
// Append the filename
remotePath += remotename;
}
var status = await ftpClient.UploadStream(input, remotePath, FtpRemoteExists.Overwrite, createRemoteDir: false, token: cancelToken, progress: null).ConfigureAwait(false);
if (status != FtpStatus.Success)
{
throw new UserInformationException(string.Format(Strings.ErrorWriteFile, remotename), "AftpPutFailure");
}
// Wait for the upload, if required
if (_uploadWaitTime.Ticks > 0)
{
Thread.Sleep(_uploadWaitTime);
}
if (_listVerify)
{
// check remote file size; matching file size indicates completion
var remoteSize = await ftpClient.GetFileSize(remotePath, -1, cancelToken);
if (streamLen != remoteSize)
{
throw new UserInformationException(Strings.ListVerifySizeFailure(remotename, remoteSize, streamLen), "AftpListVerifySizeFailure");
}
}
}
public async Task PutAsync(string remotename, string localname, CancellationToken cancelToken)
{
using (FileStream fs = File.Open(localname, FileMode.Open, FileAccess.Read, FileShare.Read))
{
await PutAsync(remotename, fs, cancelToken);
}
}
public void Get(string remotename, Stream output)
{
var ftpClient = CreateClient();
// Get the remote path
var remotePath = "";
if (!string.IsNullOrEmpty(remotename))
{
// Append the filename
remotePath += remotename;
}
using (var inputStream = ftpClient.OpenRead(remotePath).Await())
{
try
{
CoreUtility.CopyStream(inputStream, output, false, _copybuffer);
}
finally
{
inputStream.Close();
}
}
}
public void Get(string remotename, string localname)
{
using (FileStream fs = File.Open(localname, FileMode.Create, FileAccess.Write, FileShare.None))
{
Get(remotename, fs);
}
}
public void Delete(string remotename)
{
var ftpClient = CreateClient();
// Get the remote path
var remotePath = "";
if (!string.IsNullOrEmpty(remotename))
{
// Append the filename
remotePath += remotename;
}
ftpClient.DeleteFile(remotePath).Await();
}
/// <summary>
/// A localized description of the backend, for display in the usage information
/// </summary>
public string Description
{
get
{
return Strings.Description;
}
}
public string[] DNSName
{
get { return new string[] { new Uri(_url).Host }; }
}
/// <summary>
/// Test FTP access permissions.
/// </summary>
public void Test()
{
var list = List();
// Delete test file if exists
if (list.Any(entry => entry.Name == TEST_FILE_NAME))
{
try
{
Delete(TEST_FILE_NAME);
}
catch (Exception e)
{
if (e.InnerException != null) { e = e.InnerException; }
throw new Exception(string.Format(Strings.ErrorDeleteFile, e.Message), e);
}
}
// Test write permissions
using (var testStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(TEST_FILE_CONTENT)))
{
try
{
PutAsync(TEST_FILE_NAME, testStream, CancellationToken.None).Await();
}
catch (Exception e)
{
if (e.InnerException != null) { e = e.InnerException; }
throw new Exception(string.Format(Strings.ErrorWriteFile, e.Message), e);
}
}
// Test read permissions
using (var testStream = new MemoryStream())
{
try
{
Get(TEST_FILE_NAME, testStream);
var readValue = System.Text.Encoding.UTF8.GetString(testStream.ToArray());
if (readValue != TEST_FILE_CONTENT)
throw new Exception("Test file corrupted.");
}
catch (Exception e)
{
if (e.InnerException != null) { e = e.InnerException; }
throw new Exception(string.Format(Strings.ErrorReadFile, e.Message), e);
}
}
// Cleanup
try
{
Delete(TEST_FILE_NAME);
}
catch (Exception e)
{
if (e.InnerException != null) { e = e.InnerException; }
throw new Exception(string.Format(Strings.ErrorDeleteFile, e.Message), e);
}
}
public void CreateFolder()
{
var client = CreateClient(false);
var url = new Uri(_url);
// Get the remote path
var remotePath = this.GetUnescapedAbsolutePath(url);
// Try to create the directory
client.CreateDirectory(remotePath, true).Await();
}
public void Dispose()
{
if (Client != null)
Client.Dispose();
Client = null;
_userInfo = null;
}
private AsyncFtpClient CreateClient(bool setWorkingDirectory = true)
{
var uri = new Uri(_url);
if (this.Client == null) // Create connection if it doesn't exist yet
{
var ftpClient = new AsyncFtpClient
{
Host = uri.Host,
Port = uri.Port == -1 ? 21 : uri.Port,
Credentials = _userInfo,
Config = _ftpConfig,
};
ftpClient.ValidateCertificate += HandleValidateCertificate;
this.Client = ftpClient;
} // else reuse existing connection
if (setWorkingDirectory)
{
// Change working directory to the remote path
// Do this every time to prevent issues when FtpClient silently reconnects after failure.
var remotePath = this.GetUnescapedAbsolutePath(uri);
try
{
this.Client.SetWorkingDirectory(remotePath).Await();
}
catch (FtpCommandException ex)
{
if (ex.CompletionCode == "550")
{
throw new FolderMissingException(Strings.MissingFolderError(remotePath, ex.Message), ex);
}
throw;
}
}
return this.Client;
}
private string GetUnescapedAbsolutePath(Uri uri)
{
string absolutePath = Uri.UnescapeDataString(uri.AbsolutePath);
return absolutePath.EndsWith("/", StringComparison.Ordinal) ? absolutePath.Substring(0, absolutePath.Length - 1) : absolutePath;
}
private void HandleValidateCertificate(BaseFtpClient control, FtpSslValidationEventArgs e)
{
if (e.PolicyErrors == SslPolicyErrors.None || _accepAllCertificates)
{
e.Accept = true;
return;
}
try
{
var certHash = (_validHashes != null && _validHashes.Length > 0) ? CoreUtility.ByteArrayAsHexString(e.Certificate.GetCertHash()) : null;
if (certHash != null)
{
if (_validHashes.Any(hash => !string.IsNullOrEmpty(hash) && certHash.Equals(hash, StringComparison.OrdinalIgnoreCase)))
{
e.Accept = true;
}
}
}
catch
{
e.Accept = false;
}
}
}
}