Merge pull request #3154 from epol/master

Openstack keystone v3 support
This commit is contained in:
Kenneth Skovhede
2018-04-08 13:15:22 +02:00
committed by GitHub
5 changed files with 295 additions and 31 deletions
@@ -19,10 +19,13 @@ using System; using Duplicati.Library.Interface; using System.Collections.Generi
using System.Collections.Generic;
using System.Linq;
namespace Duplicati.Library.Backend.OpenStack
namespace Duplicati.Library.Backend.OpenStack
{
public class SwiftConfig : IWebModule
{
{
private const ConfigType DEFAULT_CONFIG_TYPE = ConfigType.Providers;
private static readonly string DEFAULT_CONFIG_TYPE_STR = Enum.GetName(typeof(ConfigType), DEFAULT_CONFIG_TYPE);
private const string KEY_CONFIGTYPE = "openstack-config";
public enum ConfigType
{
@@ -18,8 +18,10 @@ using System;
using System.Collections.Generic;
using Duplicati.Library.Interface;
using System.Linq;
using System.IO;
using Duplicati.Library.Utility;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Duplicati.Library.Strings;
using System.Net;
using System.Text;
@@ -28,10 +30,12 @@ namespace Duplicati.Library.Backend.OpenStack
{
public class OpenStackStorage : IBackend, IStreamingBackend
{
private const string DOMAINNAME_OPTION = "openstack-domain-name";
private const string USERNAME_OPTION = "auth-username";
private const string PASSWORD_OPTION = "auth-password";
private const string TENANTNAME_OPTION = "openstack-tenant-name";
private const string AUTHURI_OPTION = "openstack-authuri";
private const string VERSION_OPTION = "openstack-version";
private const string APIKEY_OPTION = "openstack-apikey";
private const string REGION_OPTION = "openstack-region";
@@ -41,9 +45,11 @@ namespace Duplicati.Library.Backend.OpenStack
private string m_container;
private string m_prefix;
private readonly string m_domainName;
private string m_username;
private string m_password;
private string m_authUri;
private readonly string m_version;
private string m_tenantName;
private string m_apikey;
private string m_region;
@@ -53,13 +59,110 @@ namespace Duplicati.Library.Backend.OpenStack
private WebHelper m_helper = null;
private OpenStackAuthResponse.TokenClass m_accessToken;
public static readonly KeyValuePair<string, string>[] KNOWN_OPENSTACK_PROVIDERS = new KeyValuePair<string,string>[] {
public static readonly KeyValuePair<string, string>[] KNOWN_OPENSTACK_PROVIDERS = new KeyValuePair<string, string>[] {
new KeyValuePair<string, string>("Rackspace US", "https://identity.api.rackspacecloud.com/v2.0"),
new KeyValuePair<string, string>("Rackspace UK", "https://lon.identity.api.rackspacecloud.com/v2.0"),
new KeyValuePair<string, string>("OVH Cloud Storage", "https://auth.cloud.ovh.net/v2.0"),
new KeyValuePair<string, string>("Selectel Cloud Storage", "https://auth.selcdn.ru"),
};
public static readonly KeyValuePair<string, string>[] OPENSTACK_VERSIONS = new KeyValuePair<string, string>[] {
new KeyValuePair<string, string>("v2.0", "v2"),
new KeyValuePair<string, string>("v3", "v3"),
};
private class Keystone3AuthRequest
{
public class AuthContainer
{
public Identity identity { get; set; }
public Scope scope { get; set; }
}
public class Identity
{
[JsonProperty(ItemConverterType=typeof(StringEnumConverter))]
public IdentityMethods[] methods { get; set; }
[JsonProperty("password", NullValueHandling = NullValueHandling.Ignore)]
public PasswordBasedRequest PasswordCredentials { get; set; }
public Identity()
{
this.methods = new [] { IdentityMethods.password };
}
}
public class Scope
{
public Project project;
}
public enum IdentityMethods
{
password,
}
public class PasswordBasedRequest
{
public UserCredentials user { get; set; }
}
public class UserCredentials
{
public Domain domain { get; set; }
public string name { get; set; }
public string password { get; set; }
public UserCredentials()
{
}
public UserCredentials(Domain domain, string name, string password)
{
this.domain = domain;
this.name = name;
this.password = password;
}
}
public class Domain
{
public string name { get; set; }
public Domain(string name)
{
this.name = name;
}
}
public class Project {
public Domain domain { get; set; }
public string name { get; set; }
public Project(Domain domain, string name)
{
this.domain = domain;
this.name = name;
}
}
public AuthContainer auth { get; set; }
public Keystone3AuthRequest(string domain_name, string username, string password, string project_name)
{
Domain domain = new Domain(domain_name);
this.auth = new AuthContainer();
this.auth.identity = new Identity();
this.auth.identity.PasswordCredentials = new PasswordBasedRequest();
this.auth.identity.PasswordCredentials.user = new UserCredentials(domain,username,password);
this.auth.scope = new Scope();
this.auth.scope.project = new Project(domain, project_name);
}
}
private class OpenStackAuthRequest
{
public class AuthContainer
@@ -106,14 +209,16 @@ namespace Duplicati.Library.Backend.OpenStack
if (string.IsNullOrEmpty(apikey))
{
this.auth.PasswordCredentials = new PasswordBasedRequest() {
this.auth.PasswordCredentials = new PasswordBasedRequest()
{
username = username,
password = password,
};
}
else
{
this.auth.ApiCredentials = new ApiKeyBasedRequest() {
this.auth.ApiCredentials = new ApiKeyBasedRequest()
{
username = username,
apiKey = apikey
};
@@ -122,6 +227,32 @@ namespace Duplicati.Library.Backend.OpenStack
}
}
private class Keystone3AuthResponse
{
public TokenClass token { get; set; }
public class EndpointItem
{
// 'interface' is a reserved keyword, so we need this decorator to map it
[JsonProperty(PropertyName = "interface")]
public string interface_name { get; set; }
public string region { get; set; }
public string url { get; set; }
}
public class CatalogItem
{
public EndpointItem[] endpoints { get; set; }
public string name { get; set; }
public string type { get; set; }
}
public class TokenClass
{
public CatalogItem[] catalog { get; set; }
public DateTime? expires_at { get; set; }
}
}
private class OpenStackAuthResponse
{
public AccessClass access { get; set; }
@@ -195,10 +326,12 @@ namespace Duplicati.Library.Backend.OpenStack
if (m_prefix.StartsWith("/", StringComparison.Ordinal))
m_prefix = m_prefix.Substring(1);
options.TryGetValue(DOMAINNAME_OPTION, out m_domainName);
options.TryGetValue(USERNAME_OPTION, out m_username);
options.TryGetValue(PASSWORD_OPTION, out m_password);
options.TryGetValue(TENANTNAME_OPTION, out m_tenantName);
options.TryGetValue(AUTHURI_OPTION, out m_authUri);
options.TryGetValue(VERSION_OPTION, out m_version);
options.TryGetValue(APIKEY_OPTION, out m_apikey);
options.TryGetValue(REGION_OPTION, out m_region);
@@ -207,12 +340,26 @@ namespace Duplicati.Library.Backend.OpenStack
if (string.IsNullOrWhiteSpace(m_authUri))
throw new UserInformationException(Strings.OpenStack.MissingOptionError(AUTHURI_OPTION), "OpenStackMissingAuthUri");
if (string.IsNullOrWhiteSpace(m_apikey))
switch (m_version)
{
if (string.IsNullOrWhiteSpace(m_password))
throw new UserInformationException(Strings.OpenStack.MissingOptionError(PASSWORD_OPTION), "OpenStackMissingPassword");
if (string.IsNullOrWhiteSpace(m_tenantName))
throw new UserInformationException(Strings.OpenStack.MissingOptionError(TENANTNAME_OPTION), "OpenStackMissingTenantName");
case "v3":
if (string.IsNullOrWhiteSpace(m_password))
throw new UserInformationException(Strings.OpenStack.MissingOptionError(PASSWORD_OPTION), "OpenStackMissingPassword");
if (string.IsNullOrWhiteSpace(m_domainName))
throw new UserInformationException(Strings.OpenStack.MissingOptionError(DOMAINNAME_OPTION), "OpenStackMissingDomainName");
if (string.IsNullOrWhiteSpace(m_tenantName))
throw new UserInformationException(Strings.OpenStack.MissingOptionError(TENANTNAME_OPTION), "OpenStackMissingTenantName");
break;
case "v2":
default:
if (string.IsNullOrWhiteSpace(m_apikey))
{
if (string.IsNullOrWhiteSpace(m_password))
throw new UserInformationException(Strings.OpenStack.MissingOptionError(PASSWORD_OPTION), "OpenStackMissingPassword");
if (string.IsNullOrWhiteSpace(m_tenantName))
throw new UserInformationException(Strings.OpenStack.MissingOptionError(TENANTNAME_OPTION), "OpenStackMissingTenantName");
}
break;
}
m_helper = new WebHelper(this);
@@ -239,7 +386,65 @@ namespace Duplicati.Library.Backend.OpenStack
return JoinUrls(JoinUrls(uri, fragment1), fragment2);
}
private OpenStackAuthResponse GetAuthResponse()
private void GetAuthResponse()
{
switch (this.m_version)
{
case "v3":
GetKeystone3AuthResponse();
break;
case "v2":
default:
GetOpenstackAuthResponse();
break;
}
}
private Keystone3AuthResponse GetKeystone3AuthResponse()
{
var helper = new JSONWebHelper();
var req = helper.CreateRequest(JoinUrls(m_authUri, "auth/tokens"));
req.Accept = "application/json";
req.Method = "POST";
var data = Encoding.UTF8.GetBytes(
JsonConvert.SerializeObject(
new Keystone3AuthRequest(m_domainName, m_username, m_password, m_tenantName)
));
req.ContentLength = data.Length;
req.ContentType = "application/json; charset=UTF-8";
using (var rs = req.GetRequestStream())
rs.Write(data, 0, data.Length);
WebResponse http_response = req.GetResponse();
Keystone3AuthResponse resp;
using (var reader = new StreamReader(http_response.GetResponseStream()))
{
resp = Newtonsoft.Json.JsonConvert.DeserializeObject<Keystone3AuthResponse>(
reader.ReadToEnd());
}
string token = http_response.Headers["X-Subject-Token"];
this.m_accessToken = new OpenStackAuthResponse.TokenClass();
this.m_accessToken.id = token;
this.m_accessToken.expires = resp.token.expires_at;
// Grab the endpoint now that we have received it anyway
var fileservice = resp.token.catalog.FirstOrDefault(x => string.Equals(x.type, "object-store", StringComparison.OrdinalIgnoreCase));
if (fileservice == null)
throw new Exception("No object-store service found, is this service supported by the provider?");
var endpoint = fileservice.endpoints.FirstOrDefault(x => (string.Equals(m_region, x.region) && string.Equals(x.interface_name, "public", StringComparison.OrdinalIgnoreCase))) ?? fileservice.endpoints.First();
m_simplestorageendpoint = endpoint.url;
return resp;
}
private OpenStackAuthResponse GetOpenstackAuthResponse()
{
var helper = new JSONWebHelper();
@@ -406,11 +611,13 @@ namespace Duplicati.Library.Backend.OpenStack
authuris.AppendLine(string.Format("{0}: {1}", s.Key, s.Value));
return new List<ICommandLineArgument>(new CommandLineArgument[] {
new CommandLineArgument(DOMAINNAME_OPTION, CommandLineArgument.ArgumentType.String, Strings.OpenStack.DomainnameOptionShort, Strings.OpenStack.UsernameOptionLong),
new CommandLineArgument(USERNAME_OPTION, CommandLineArgument.ArgumentType.String, Strings.OpenStack.UsernameOptionShort, Strings.OpenStack.UsernameOptionLong),
new CommandLineArgument(PASSWORD_OPTION, CommandLineArgument.ArgumentType.Password, Strings.OpenStack.PasswordOptionShort, Strings.OpenStack.PasswordOptionLong(TENANTNAME_OPTION)),
new CommandLineArgument(TENANTNAME_OPTION, CommandLineArgument.ArgumentType.String, Strings.OpenStack.TenantnameOptionShort, Strings.OpenStack.TenantnameOptionLong),
new CommandLineArgument(APIKEY_OPTION, CommandLineArgument.ArgumentType.Password, Strings.OpenStack.ApikeyOptionShort, Strings.OpenStack.ApikeyOptionLong),
new CommandLineArgument(AUTHURI_OPTION, CommandLineArgument.ArgumentType.String, Strings.OpenStack.AuthuriOptionShort, Strings.OpenStack.AuthuriOptionLong(authuris.ToString())),
new CommandLineArgument(VERSION_OPTION, CommandLineArgument.ArgumentType.String, Strings.OpenStack.VersionOptionShort, Strings.OpenStack.AuthuriOptionLong(authuris.ToString())),
new CommandLineArgument(REGION_OPTION, CommandLineArgument.ArgumentType.String, Strings.OpenStack.RegionOptionShort, Strings.OpenStack.RegionOptionLong),
});
}
@@ -17,5 +17,11 @@
using Duplicati.Library.Localization.Short;
namespace Duplicati.Library.Backend.Strings
namespace Duplicati.Library.Backend.Strings
{
internal static class OpenStack {
public static string Description { get { return LC.L(@"This backend can read and write data to Swift (OpenStack Object Storage). Supported format is ""openstack://container/folder""."); } }
public static string DisplayName { get { return LC.L(@"OpenStack Simple Storage"); } }
public static string MissingOptionError(string optionname) { return LC.L(@"Missing required option: {0}", optionname); }
public static string PasswordOptionLong(string tenantnameoption) { return LC.L(@"The password used to connect to the server. This may also be supplied as the environment variable ""AUTH_PASSWORD"". If the password is supplied, --{0} must also be set", tenantnameoption); }
public static string PasswordOptionShort { get { return LC.L(@"Supplies the password used to connect to the server"); } }
@@ -215,6 +215,18 @@ backupApp.service('EditUriBuiltins', function(AppService, AppUtils, SystemInfo,
if (scope.openstack_server == undefined && scope.openstack_server_custom == undefined)
scope.openstack_server = 'https://identity.api.rackspacecloud.com/';
}
if (scope.openstack_versions == null) {
AppService.post('/webmodule/openstack-getconfig', {'openstack-config': 'Versions'}).then(function(data) {
scope.openstack_versions = data.data.Result;
if (scope.openstack_version == undefined )
scope.openstack_version = 'v2.0';
}, AppUtils.connectionError);
} else {
if (scope.openstack_version == undefined )
scope.openstack_version = 'v2.0';
}
};
@@ -312,12 +324,14 @@ backupApp.service('EditUriBuiltins', function(AppService, AppUtils, SystemInfo,
EditUriBackendConfig.parsers['dropbox'] = function() { return this['oauth-base'].apply(this, arguments); };
EditUriBackendConfig.parsers['openstack'] = function(scope, module, server, port, path, options) {
scope.openstack_domainname = options['--openstack-domain-name'];
scope.openstack_server = scope.openstack_server_custom = options['--openstack-authuri'];
scope.openstack_version = options['--openstack-version'];
scope.openstack_tenantname = options['--openstack-tenant-name'];
scope.openstack_apikey = options['--openstack-apikey'];
scope.openstack_region = options['--openstack-region'];
var nukeopts = ['--openstack-authuri', '--openstack-tenant-name', '--openstack-apikey', '--openstack-region'];
var nukeopts = ['--openstack-domain-name', '--openstack-authuri', '--openstack-tenant-name', '--openstack-apikey', '--openstack-region', '--openstack-version'];
for(var x in nukeopts)
delete options[nukeopts[x]];
@@ -464,18 +478,24 @@ backupApp.service('EditUriBuiltins', function(AppService, AppUtils, SystemInfo,
EditUriBackendConfig.builders['openstack'] = function(scope) {
var opts = {
'openstack-domain-name': scope.openstack_domainname,
'openstack-authuri': AppUtils.contains_value(scope.openstack_providers, scope.openstack_server) ? scope.openstack_server : scope.openstack_server_custom,
'openstack-version': scope.openstack_version,
'openstack-tenant-name': scope.openstack_tenantname,
'openstack-apikey': scope.openstack_apikey,
'openstack-region': scope.openstack_region
};
if ((opts['openstack-domain-name'] || '') == '')
delete opts['openstack-domain-name'];
if ((opts['openstack-tenant-name'] || '') == '')
delete opts['openstack-tenant-name'];
if ((opts['openstack-apikey'] || '') == '')
delete opts['openstack-apikey'];
if ((opts['openstack-region'] || '') == '')
delete opts['openstack-region'];
if ((opts['openstack-version'] || '') == '')
delete opts['openstack-version'];
EditUriBackendConfig.merge_in_advanced_options(scope, opts);
@@ -763,29 +783,45 @@ backupApp.service('EditUriBuiltins', function(AppService, AppUtils, SystemInfo,
EditUriBackendConfig.validaters['openstack'] = function(scope, continuation) {
var res =
EditUriBackendConfig.require_field(scope, 'Username', gettextCatalog.getString('Username')) &&
EditUriBackendConfig.require_field(scope, 'Path', gettextCatalog.getString('Bucket Name'));
EditUriBackendConfig.require_field(scope, 'Username', gettextCatalog.getString('Username')) &&
EditUriBackendConfig.require_field(scope, 'Path', gettextCatalog.getString('Bucket Name'));
if (res && (scope['openstack_server'] || '').trim().length == 0 && (scope['openstack_server_custom'] || '').trim().length == 0)
res = EditUriBackendConfig.show_error_dialog(gettextCatalog.getString('You must select or fill in the AuthURI'));
if (((scope.openstack_version) || '').trim() == 'v3') {
if (res && (scope.Password || '').trim().length == 0)
res = EditUriBackendConfig.show_error_dialog(gettextCatalog.getString('You must enter a password to use v3 API'));
if (res && ((scope.openstack_domainname) || '').trim().length == 0)
res = EditUriBackendConfig.show_error_dialog(gettextCatalog.getString('You must enter a domain name to use v3 API'));
if (res && ((scope.openstack_tenantname) || '').trim().length == 0)
res = EditUriBackendCOnfig.show_error_dialog(gettextCatalog.getString('You must enter a tenant (aka project) name to use v3 API'));
if (((scope.openstack_apikey) || '').trim().length == 0) {
if (res && (scope.Password || '').trim().length == 0)
res = EditUriBackendConfig.show_error_dialog(gettextCatalog.getString('You must enter either a password or an API Key'));
if (res && ((scope.openstack_tenantname) || '').trim().length == 0)
res = EditUriBackendConfig.show_error_dialog(gettextCatalog.getString('You must enter a tenant name if you do not provide an API Key'));
} else {
if (res && (scope.Password || '').trim().length != 0)
res = EditUriBackendConfig.show_error_dialog(gettextCatalog.getString('You must enter either a password or an API Key, not both'));
}
if (res && (scope.openstack_apikey || '').trim().length != 0)
res = EditUriBackendConfig.show_error_dialog(gettextCatalog.getString('Openstack API Key are not supported in v3 keystone API.'));
} else {
if (((scope.openstack_apikey) || '').trim().length == 0) {
if (res && (scope.Password || '').trim().length == 0)
res = EditUriBackendConfig.show_error_dialog(gettextCatalog.getString('You must enter either a password or an API Key'));
if (res && ((scope.openstack_tenantname) || '').trim().length == 0)
res = EditUriBackendConfig.show_error_dialog(gettextCatalog.getString('You must enter a tenant name if you do not provide an API Key'));
} else {
if (res && (scope.Password || '').trim().length != 0)
res = EditUriBackendConfig.show_error_dialog(gettextCatalog.getString('You must enter either a password or an API Key, not both'));
}
}
if (res)
continuation();
};
EditUriBackendConfig.validaters['s3'] = function(scope, continuation) {
var res =
EditUriBackendConfig.require_field(scope, 'Server', gettextCatalog.getString('Bucket Name')) &&
@@ -14,6 +14,18 @@
</div>
<div class="input text">
<label for="openstack_version" translate>Keystone API version</label>
<select name="openstack_version" id="openstack_version" ng-model="$parent.openstack_version" ng-options="v as k + ' (' + v + ')' for (k, v) in openstack_versions | orderBy: k">
</select>
</div>
<div class="input text">
<label for="openstack_domainname" translate>Domain Name</label>
<input type="text" name="openstack_domainname" id="openstack_domainname" ng-model="$parent.openstack_domainname" placeholder="{{'User domain name' | translate}}" />
</div>
<div class="input text">
<label for="openstack_username" translate>Username</label>
<input type="text" name="openstack_username" id="openstack_username" ng-model="$parent.Username" placeholder="{{'Authentication username' | translate}}" />