mirror of
https://github.com/duplicati/duplicati.git
synced 2025-11-27 19:10:29 +08:00
Ensures that long-running HTTP requests, such as downloads and uploads, do not timeout prematurely. This change configures specific HttpClient instances with an infinite timeout to accommodate operations that may take an extended period. The default 100s timeout is retained for some operations like registration, where a long delay may indicate a problem.
252 lines
12 KiB
C#
252 lines
12 KiB
C#
// 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 System.Reflection;
|
|
using System.Web;
|
|
using Duplicati.Library.Interface;
|
|
using Duplicati.Library.Utility;
|
|
using VaultSharp;
|
|
using VaultSharp.V1.AuthMethods;
|
|
using VaultSharp.V1.AuthMethods.AppRole;
|
|
using VaultSharp.V1.AuthMethods.Token;
|
|
|
|
namespace Duplicati.Library.SecretProvider;
|
|
|
|
/// <summary>
|
|
/// A secret provider that retrieves secrets from HashiCorp Vault
|
|
/// </summary>
|
|
public class HCVaultSecretProvider : ISecretProvider
|
|
{
|
|
/// <inheritdoc />
|
|
public string Key => "hcv";
|
|
|
|
/// <inheritdoc />
|
|
public string DisplayName => Strings.HCVaultSecretProvider.DisplayName;
|
|
|
|
/// <inheritdoc />
|
|
public string Description => Strings.HCVaultSecretProvider.Description;
|
|
|
|
/// <summary>
|
|
/// The configuration for the secret provider; null if not initialized
|
|
/// </summary>
|
|
private IVaultClient? _client;
|
|
|
|
/// <summary>
|
|
/// The list of secrets to fetch
|
|
/// </summary>
|
|
private IReadOnlyList<string>? _secrets;
|
|
|
|
/// <summary>
|
|
/// Whether the secrets are case sensitive
|
|
/// </summary>
|
|
private bool _caseSensitive;
|
|
|
|
/// <summary>
|
|
/// The mount point to use
|
|
/// </summary>
|
|
private string? _mountPoint;
|
|
|
|
/// <summary>
|
|
/// Constants for environment variables
|
|
/// </summary>
|
|
private static class EnvConstants
|
|
{
|
|
/// <summary>
|
|
/// The client ID for the HashiCorp Vault
|
|
/// </summary>
|
|
public const string HCP_CLIENT_ID = "HCP_CLIENT_ID";
|
|
/// <summary>
|
|
/// The client secret for the HashiCorp Vault
|
|
/// </summary>
|
|
public const string HCP_CLIENT_SECRET = "HCP_CLIENT_SECRET";
|
|
}
|
|
|
|
/// <summary>
|
|
/// The connection types
|
|
/// </summary>
|
|
public enum ConnectionType
|
|
{
|
|
/// <summary>
|
|
/// Use HTTPS
|
|
/// </summary>
|
|
Https,
|
|
/// <summary>
|
|
/// Use HTTP
|
|
/// </summary>
|
|
Http
|
|
};
|
|
|
|
/// <summary>
|
|
/// Mapper for the command line arguments
|
|
/// </summary>
|
|
private class HCVaultSettings : ICommandLineArgumentMapper
|
|
{
|
|
/// <summary>
|
|
/// The token to use for authentication
|
|
/// </summary>
|
|
public string? Token { get; set; }
|
|
/// <summary>
|
|
/// The connection type to use
|
|
/// </summary>
|
|
public ConnectionType ConnectionType { get; set; } = ConnectionType.Https;
|
|
/// <summary>
|
|
/// The secrets to probe for values
|
|
/// </summary>
|
|
public string? Secrets { get; set; }
|
|
/// <summary>
|
|
/// The mount point for the secrets
|
|
/// </summary>
|
|
public string? MountPoint { get; set; } = "secret";
|
|
/// <summary>
|
|
/// The client ID to use for authentication
|
|
/// </summary>
|
|
public string? ClientId { get; set; }
|
|
/// <summary>
|
|
/// The client secret to use for authentication
|
|
/// </summary>
|
|
public string? ClientSecret { get; set; }
|
|
/// <summary>
|
|
/// Whether the secrets are case sensitive
|
|
/// </summary>
|
|
public bool CaseSensitive { get; set; }
|
|
|
|
/// <summary>
|
|
/// Gets the description for a command line argument
|
|
/// </summary>
|
|
/// <param name="name">The name of the argument</param>
|
|
/// <returns>The description for the argument</returns>
|
|
public static CommandLineArgumentDescriptionAttribute? GetCommandLineArgumentDescription(string name)
|
|
=> name switch
|
|
{
|
|
nameof(Token) => new CommandLineArgumentDescriptionAttribute() { Name = "token", Type = CommandLineArgument.ArgumentType.Password, ShortDescription = Strings.HCVaultSecretProvider.TokenDescriptionShort, LongDescription = Strings.HCVaultSecretProvider.TokenDescriptionLong },
|
|
nameof(ConnectionType) => new CommandLineArgumentDescriptionAttribute() { Name = "connection-type", Type = CommandLineArgument.ArgumentType.String, ShortDescription = Strings.HCVaultSecretProvider.ProtocolDescriptionShort, LongDescription = Strings.HCVaultSecretProvider.ProtocolDescriptionLong },
|
|
nameof(Secrets) => new CommandLineArgumentDescriptionAttribute() { Name = "secrets", Type = CommandLineArgument.ArgumentType.String, ShortDescription = Strings.HCVaultSecretProvider.SecretsDescriptionShort, LongDescription = Strings.HCVaultSecretProvider.SecretsDescriptionLong },
|
|
nameof(ClientId) => new CommandLineArgumentDescriptionAttribute() { Name = "client-id", Type = CommandLineArgument.ArgumentType.String, ShortDescription = Strings.HCVaultSecretProvider.ClientIdDescriptionShort, LongDescription = Strings.HCVaultSecretProvider.ClientIdDescriptionLong(EnvConstants.HCP_CLIENT_ID) },
|
|
nameof(ClientSecret) => new CommandLineArgumentDescriptionAttribute() { Name = "client-secret", Type = CommandLineArgument.ArgumentType.Password, ShortDescription = Strings.HCVaultSecretProvider.ClientSecretDescriptionShort, LongDescription = Strings.HCVaultSecretProvider.ClientSecretDescriptionLong(EnvConstants.HCP_CLIENT_SECRET) },
|
|
nameof(MountPoint) => new CommandLineArgumentDescriptionAttribute() { Name = "mount", Type = CommandLineArgument.ArgumentType.String, ShortDescription = Strings.HCVaultSecretProvider.MountPointDescriptionShort, LongDescription = Strings.HCVaultSecretProvider.MountPointDescriptionLong },
|
|
nameof(CaseSensitive) => new CommandLineArgumentDescriptionAttribute() { Name = "case-sensitive", Type = CommandLineArgument.ArgumentType.Boolean, ShortDescription = Strings.HCVaultSecretProvider.CaseSensitiveDescriptionShort, LongDescription = Strings.HCVaultSecretProvider.CaseSensitiveDescriptionLong },
|
|
_ => null
|
|
};
|
|
|
|
/// <inheritdoc/>
|
|
CommandLineArgumentDescriptionAttribute? ICommandLineArgumentMapper.GetCommandLineArgumentDescription(MemberInfo mi)
|
|
=> GetCommandLineArgumentDescription(mi.Name);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public IList<ICommandLineArgument> SupportedCommands
|
|
=> CommandLineArgumentMapper.MapArguments(new HCVaultSettings())
|
|
.Concat(CommandLineArgumentMapper.MapArguments(typeof(VaultClientSettings)))
|
|
.ToList();
|
|
|
|
/// <summary>
|
|
/// Gets the name of the argument
|
|
/// </summary>
|
|
/// <param name="name">The name of the argument</param>
|
|
/// <returns>The name of the argument</returns>
|
|
private string ArgName(string name) => HCVaultSettings.GetCommandLineArgumentDescription(name)?.Name ?? name;
|
|
|
|
/// <inheritdoc />
|
|
public async Task InitializeAsync(System.Uri config, CancellationToken cancellationToken)
|
|
{
|
|
var args = HttpUtility.ParseQueryString(config.Query);
|
|
var cfg = CommandLineArgumentMapper.ApplyArguments(new HCVaultSettings(), args);
|
|
|
|
if (string.IsNullOrWhiteSpace(cfg.ClientId))
|
|
cfg.ClientId = Environment.GetEnvironmentVariable(EnvConstants.HCP_CLIENT_ID);
|
|
if (string.IsNullOrWhiteSpace(cfg.ClientSecret))
|
|
cfg.ClientSecret = Environment.GetEnvironmentVariable(EnvConstants.HCP_CLIENT_SECRET);
|
|
|
|
if (string.IsNullOrWhiteSpace(cfg.ClientSecret) && !string.IsNullOrWhiteSpace(cfg.ClientId))
|
|
throw new UserInformationException($"{ArgName(nameof(HCVaultSettings.ClientSecret))} is required when {ArgName(nameof(HCVaultSettings.ClientId))} is specified", "MissingClientSecret");
|
|
if (string.IsNullOrWhiteSpace(cfg.Token) && string.IsNullOrWhiteSpace(cfg.ClientId))
|
|
throw new UserInformationException($"Either {ArgName(nameof(HCVaultSettings.Token))} or {ArgName(nameof(HCVaultSettings.ClientId))} is required", "MissingTokenOrClient");
|
|
if (string.IsNullOrWhiteSpace(cfg.Secrets))
|
|
throw new UserInformationException($"{ArgName(nameof(HCVaultSettings.Secrets))} is required", "MissingSecrets");
|
|
|
|
var secrets = cfg.Secrets?.Split(new char[] { ';', ',' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? Array.Empty<string>();
|
|
|
|
var builder = new UriBuilder(config)
|
|
{
|
|
Scheme = cfg.ConnectionType == ConnectionType.Http ? "http" : "https",
|
|
Query = null
|
|
};
|
|
|
|
IAuthMethodInfo authMethod = string.IsNullOrWhiteSpace(cfg.Token)
|
|
? new AppRoleAuthMethodInfo(cfg.ClientId, cfg.ClientSecret)
|
|
: new TokenAuthMethodInfo(cfg.Token);
|
|
|
|
var vaultConfig = CommandLineArgumentMapper.ApplyArguments(
|
|
new VaultClientSettings(builder.Uri.ToString(), authMethod),
|
|
args
|
|
);
|
|
|
|
var client = new VaultClient(vaultConfig);
|
|
// Check if the connection works
|
|
await client.V1.Secrets.KeyValue.V2.ReadSecretAsync(path: secrets.First(), mountPoint: cfg.MountPoint).ConfigureAwait(false); //missing cancellationToken
|
|
|
|
_secrets = secrets;
|
|
_mountPoint = cfg.MountPoint;
|
|
_caseSensitive = cfg.CaseSensitive;
|
|
_client = client;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<Dictionary<string, string>> ResolveSecretsAsync(IEnumerable<string> keys, CancellationToken cancellationToken)
|
|
{
|
|
if (_client is null || _secrets is null)
|
|
throw new InvalidOperationException("The secret provider has not been initialized");
|
|
|
|
using var client = HttpClientHelper.CreateClient();
|
|
// We will not set the timeout here, keeping the 100s default one
|
|
var result = new Dictionary<string, string>();
|
|
var missing = new HashSet<string>(keys);
|
|
|
|
// Keep trying to get the secrets from each URL until all keys are found
|
|
foreach (var secret in _secrets)
|
|
{
|
|
var data = await _client.V1.Secrets.KeyValue.V2.ReadSecretAsync(path: secret, mountPoint: _mountPoint).ConfigureAwait(false); //mssing cancellationToken
|
|
if (data is null || data.Data is null)
|
|
continue;
|
|
|
|
var lookupDict = data.Data.Data;
|
|
|
|
if (!_caseSensitive)
|
|
lookupDict = lookupDict
|
|
.GroupBy(x => x.Key, x => x.Value, StringComparer.OrdinalIgnoreCase)
|
|
.ToDictionary(x => x.Key, x => x.First(), StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (var key in missing)
|
|
{
|
|
if (lookupDict.TryGetValue(key, out var value) && value is string stringValue)
|
|
{
|
|
result[key] = stringValue;
|
|
missing.Remove(key);
|
|
}
|
|
}
|
|
|
|
if (missing.Count == 0)
|
|
return result;
|
|
}
|
|
|
|
throw new KeyNotFoundException("The following keys were not found: " + string.Join(", ", missing));
|
|
}
|
|
}
|