Merge pull request #6218 from duplicati/feature/add-improved-test-api

Added new destination test method.
This commit is contained in:
Kenneth Skovhede
2025-04-30 17:03:29 +02:00
committed by GitHub
6 changed files with 401 additions and 19 deletions
+2 -2
View File
@@ -192,12 +192,12 @@ namespace Duplicati.Server
filters);
}
public static IRunnerData CreateListFilesetsTask(Duplicati.Server.Serialization.Interface.IBackup backup)
public static IRunnerData CreateListFilesetsTask(Duplicati.Server.Serialization.Interface.IBackup backup, Dictionary<string, string> extraOptions = null)
{
return CreateTask(
DuplicatiOperation.ListFilesets,
backup,
new Dictionary<string, string>());
extraOptions ?? new Dictionary<string, string>());
}
public static IRunnerData CreateListFolderContents(Duplicati.Server.Serialization.Interface.IBackup backup, string[] folders, DateTime time, int pageSize, int pageOffset)
@@ -0,0 +1,41 @@
// Copyright (C) 2025, 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.
namespace Duplicati.WebserverCore.Dto.V2;
/// <summary>
/// The list filesets request DTO
/// </summary>
public sealed record DestinationTestRequestDto
{
/// <summary>
/// The destination url
/// </summary>
public required string DestinationUrl { get; init; }
/// <summary>
/// Any additional options to pass to the destination
/// </summary>
public required Dictionary<string, string> Options { get; init; } = new();
/// <summary>
/// Whether to automatically create the destination if it does not exist
/// </summary>
public required bool AutoCreate { get; init; } = false;
}
@@ -0,0 +1,125 @@
// Copyright (C) 2025, 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.
namespace Duplicati.WebserverCore.Dto.V2;
/// <summary>
/// The list filesets request DTO
/// </summary>
public sealed record DestinationTestResponseDto : ResponseEnvelope<DestinationTestResult>
{
/// <summary>
/// Creates a new instance of the DestinationTestResponseDto with the given error and status code
/// </summary>
/// <param name="error">The error message</param>
/// <param name="statusCode">The status code</param>
/// <param name="folderExists">Flag indicating if the folder exists</param>
/// <param name="hostCertificate">The host certificate, if any</param>
/// <param name="reportedHostKey">The reported host key, if any</param>
/// <param name="acceptedHostKey">The currently accepted host key, if any</param>
/// <returns>The DestinationTestResponseDto instance</returns>
public static DestinationTestResponseDto Failure(
string error,
string statusCode,
bool? folderExists = null,
string? hostCertificate = null,
string? reportedHostKey = null,
string? acceptedHostKey = null
) => new DestinationTestResponseDto()
{
Success = false,
Error = error,
StatusCode = statusCode,
Data = hostCertificate == null && reportedHostKey == null && acceptedHostKey == null
? null
: new DestinationTestResult()
{
FolderExists = folderExists,
FolderIsEmpty = null,
FolderContainsBackupFiles = null,
FolderContainsEncryptedBackupFiles = null,
HostCertificate = hostCertificate,
ReportedHostKey = reportedHostKey,
AcceptedHostKey = acceptedHostKey
}
};
/// <summary>
/// Creates a new instance of the DestinationTestResponseDto with the given error and status code
/// </summary>
/// <param name="anyFiles">Flag indicating if there are any files</param>
/// <param name="anyBackups">Flag indicating if there are any backups</param>
/// <param name="anyEncryptedFiles">Flag indicating if there are any encrypted files</param>
/// <returns>The DestinationTestResponseDto instance</returns>
public static DestinationTestResponseDto Create(bool anyFiles, bool anyBackups, bool anyEncryptedFiles)
{
return new DestinationTestResponseDto()
{
Success = true,
Error = null,
StatusCode = "OK",
Data = new DestinationTestResult()
{
FolderExists = true,
FolderIsEmpty = !anyFiles,
FolderContainsBackupFiles = anyBackups,
FolderContainsEncryptedBackupFiles = anyEncryptedFiles,
HostCertificate = null,
ReportedHostKey = null,
AcceptedHostKey = null
}
};
}
}
/// <summary>
/// The destination test result DTO
/// </summary>
public sealed record DestinationTestResult
{
/// <summary>
/// Flag indicating if the folder exists
/// </summary>
public required bool? FolderExists { get; init; }
/// <summary>
/// Flag indicating if the folder is empty
/// </summary>
public required bool? FolderIsEmpty { get; init; }
/// <summary>
/// Flag indicating if the folder contains backup files
/// </summary>
public required bool? FolderContainsBackupFiles { get; init; }
/// <summary>
/// Flag indicating if the folder contains encrypted backup files
/// </summary>
public required bool? FolderContainsEncryptedBackupFiles { get; init; }
/// <summary>
/// The host certificate, if any
/// </summary>
public string? HostCertificate { get; init; }
/// <summary>
/// The reported host key, if any
/// </summary>
public required string? ReportedHostKey { get; init; }
/// <summary>
/// The currently accepted host key, if any
/// </summary>
public required string? AcceptedHostKey { get; init; }
}
@@ -85,24 +85,31 @@ public class BackupListing : IEndpointV2
// Retries will hang the http request
extra["number-of-retries"] = "0";
var r = Runner.Run(Runner.CreateListFilesetsTask(bk), false) as IListFilesetResults;
if (r == null)
throw new ServerErrorException("No result from list operation");
try
{
var r = Runner.Run(Runner.CreateListFilesetsTask(bk, extra), false) as IListFilesetResults;
if (r == null)
throw new ServerErrorException("No result from list operation");
if (r.EncryptedFiles.HasValue && r.EncryptedFiles.Value && bk.Settings.Any(x => string.Equals("--no-encryption", x.Name, StringComparison.OrdinalIgnoreCase)))
throw new ServerErrorException("encrypted-storage");
if (r.EncryptedFiles.HasValue && r.EncryptedFiles.Value && bk.Settings.Any(x => string.Equals("--no-encryption", x.Name, StringComparison.OrdinalIgnoreCase)))
throw new UserInformationException("The remote storage is encrypted, but no passphrase is given", "EncryptedStorageNoPassphrase");
return Dto.V2.ListFilesetsResponseDto.Create(
r.Filesets
.Select(x => new Dto.V2.ListFilesetsResponseItem()
{
Version = x.Version,
Time = x.Time,
IsFullBackup = x.IsFullBackup,
FileCount = x.FileCount,
FileSizes = x.FileSizes
})
);
return Dto.V2.ListFilesetsResponseDto.Create(
r.Filesets
.Select(x => new Dto.V2.ListFilesetsResponseItem()
{
Version = x.Version,
Time = x.Time,
IsFullBackup = x.IsFullBackup,
FileCount = x.FileCount,
FileSizes = x.FileSizes
})
);
}
catch (FolderMissingException fex)
{
throw new UserInformationException("Remote folder is missing", "FolderMissing", fex);
}
}
private static Dto.V2.ListFileVersionsOutputDto ExecuteListVersions(IBackup bk, Dto.V2.ListFileVersionsRequestDto input)
@@ -0,0 +1,202 @@
// Copyright (C) 2025, 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.Interface;
using Duplicati.Library.Main;
using Duplicati.Library.Main.Volumes;
using Duplicati.Library.RestAPI;
using Duplicati.Server;
using Duplicati.Server.Database;
using Duplicati.WebserverCore.Abstractions;
using Duplicati.WebserverCore.Dto.V2;
using Microsoft.AspNetCore.Mvc;
namespace Duplicati.WebserverCore.Endpoints.V2.Backup;
public class DestinationVerify : IEndpointV2
{
public static void Map(RouteGroupBuilder group)
{
group.MapPost("/destination/test", ([FromServices] Connection connection, [FromBody] Dto.V2.DestinationTestRequestDto input, CancellationToken cancelToken)
=> ExecuteTest(input, cancelToken))
.RequireAuthorization();
}
private record TupleDisposeWrapper(IBackend Backend, IEnumerable<IGenericModule> Modules) : IDisposable
{
public void Dispose()
{
Backend.Dispose();
foreach (var n in Modules)
if (n is IDisposable disposable)
disposable.Dispose();
}
}
private static Dictionary<string, string?> ParseUrlOptions(Library.Utility.Uri uri)
{
var qp = uri.QueryParameters;
var opts = Runner.GetCommonOptions();
foreach (var k in qp.Keys.Cast<string>())
opts[k] = qp[k];
return opts;
}
private static IEnumerable<IGenericModule> ConfigureModules(IDictionary<string, string?> opts)
{
// TODO: This works because the generic modules are implemented
// with pre .NetCore logic, using static methods
// The modules are created to allow multipe dispose,
// which violates the .Net patterns
var modules = Library.DynamicLoader.GenericLoader.Modules.OfType<IConnectionModule>().ToArray();
foreach (var n in modules)
n.Configure(opts);
return modules;
}
private static async Task<TupleDisposeWrapper> GetBackend(string url, CancellationToken cancelToken)
{
var uri = new Library.Utility.Uri(url);
var opts = ParseUrlOptions(uri);
var tmp = new[] { uri };
await SecretProviderHelper.ApplySecretProviderAsync([], tmp, opts, Library.Utility.TempFolder.SystemTempPath, FIXMEGlobal.SecretProvider, cancelToken);
url = tmp[0].ToString();
var modules = ConfigureModules(opts);
var backend = Library.DynamicLoader.BackendLoader.GetBackend(url, new Dictionary<string, string>());
return new TupleDisposeWrapper(backend, modules);
}
private static async Task<DestinationTestResponseDto> ExecuteTest(DestinationTestRequestDto input, CancellationToken cancelToken)
{
TupleDisposeWrapper? wrapper = null;
try
{
wrapper = await GetBackend(input.DestinationUrl, cancelToken);
using (var b = wrapper.Backend)
{
try { await b.TestAsync(cancelToken).ConfigureAwait(false); }
catch (FolderMissingException)
{
if (!input.AutoCreate)
throw;
await b.CreateFolderAsync(cancelToken).ConfigureAwait(false);
await b.TestAsync(cancelToken).ConfigureAwait(false);
}
var anyFiles = false;
var anyBackups = false;
var anyEncryptedFiles = false;
await foreach (var f in b.ListAsync(cancelToken).ConfigureAwait(false))
{
if (f.IsFolder)
continue;
anyFiles = true;
var parsed = VolumeBase.ParseFilename(f.Name);
if (parsed != null)
{
anyBackups = true;
anyEncryptedFiles = !string.IsNullOrWhiteSpace(parsed.EncryptionModule);
break;
}
}
return DestinationTestResponseDto.Create(
anyFiles,
anyBackups,
anyEncryptedFiles
);
}
}
catch (FolderMissingException)
{
if (input.AutoCreate)
return DestinationTestResponseDto.Failure(
"Failed to create folder",
"error-creating-folder",
folderExists: false
);
return DestinationTestResponseDto.Failure(
"Folder does not exist",
"missing-folder",
folderExists: false
);
}
catch (Library.Utility.SslCertificateValidator.InvalidCertificateException icex)
{
if (string.IsNullOrWhiteSpace(icex.Certificate))
return DestinationTestResponseDto.Failure(
icex.Message,
"CertError"
);
return DestinationTestResponseDto.Failure(
"Host certificate error",
"incorrect-cert",
hostCertificate: icex.Certificate
);
}
catch (Library.Utility.HostKeyException hex)
{
if (string.IsNullOrWhiteSpace(hex.ReportedHostKey))
return DestinationTestResponseDto.Failure(
hex.Message,
"KeyError"
);
return DestinationTestResponseDto.Failure(
"Host key error",
"incorrect-host-key",
reportedHostKey: hex.ReportedHostKey,
acceptedHostKey: hex.AcceptedHostKey
);
}
catch (UserInformationException uex)
{
return DestinationTestResponseDto.Failure(
uex.Message,
uex.HelpID
);
}
catch (Exception ex)
{
return DestinationTestResponseDto.Failure(
ex.Message,
"error"
);
}
finally
{
wrapper?.Dispose();
}
}
}
@@ -37,7 +37,14 @@ public class SystemInfoProvider : ISystemInfoProvider
/// <summary>
/// The API extensions that are available
/// </summary>
private static readonly string[] APIExtensions = ["v2:backup:list-filesets", "v2:backup:list-folder", "v2:backup:list-versions", "v2:backup:search"];
private static readonly string[] APIExtensions = [
"v2:backup:list-filesets",
"v2:backup:list-folder",
"v2:backup:list-versions",
"v2:backup:search",
"v2:destination:test"
];
/// <summary>
/// The API scopes that are available
/// </summary>