* Fixed some minor whitespace issues * Updated all copyright to 2025
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 = new HttpClient();
|
|
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));
|
|
}
|
|
}
|