# Copyright 2014 # The Cloudscaling Group, Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import hashlib import hmac import posixpath import urlparse import httplib2 from keystoneclient.v2_0 import client as keystone_client from oslo.config import cfg import six import webob from ec2api import context as ec2context from ec2api import exception from ec2api.metadata import api from ec2api.openstack.common import gettextutils as textutils from ec2api.openstack.common.gettextutils import _ from ec2api.openstack.common import log as logging from ec2api import utils from ec2api import wsgi LOG = logging.getLogger(__name__) CONF = cfg.CONF CONF.import_opt('use_forwarded_for', 'ec2api.api.auth') metadata_opts = [ cfg.StrOpt('nova_metadata_ip', default='127.0.0.1', help=_("IP address used by Nova metadata server.")), cfg.IntOpt('nova_metadata_port', default=8775, help=_("TCP Port used by Nova metadata server.")), cfg.StrOpt('nova_metadata_protocol', default='http', choices=['http', 'https'], help=_("Protocol to access nova metadata, http or https")), cfg.BoolOpt('nova_metadata_insecure', default=False, help=_("Allow to perform insecure SSL (https) requests to " "nova metadata")), cfg.StrOpt('auth_ca_cert', help=_("Certificate Authority public key (CA cert) " "file for ssl")), cfg.StrOpt('nova_client_cert', default='', help=_("Client certificate for nova metadata api server.")), cfg.StrOpt('nova_client_priv_key', default='', help=_("Private key of client certificate.")), cfg.StrOpt('admin_user', help=_("Admin user")), cfg.StrOpt('admin_password', help=_("Admin password"), secret=True), cfg.StrOpt('admin_tenant_name', help=_("Admin tenant name")), cfg.StrOpt('metadata_proxy_shared_secret', default='', help=_('Shared secret to sign instance-id request'), secret=True), ] CONF.register_opts(metadata_opts, group='metadata') class MetadataRequestHandler(wsgi.Application): """Serve metadata.""" @webob.dec.wsgify(RequestClass=wsgi.Request) def __call__(self, req): LOG.debug('Request: %s', req) path = req.path_info if path == '' or path[0] != '/': path = '/' + path path = posixpath.normpath(path) path_tokens = path.split('/')[1:] if path_tokens in ([''], ['ec2']): resp = api.get_version_list() return self._add_response_data(req.response, resp) try: if path_tokens[0] == 'openstack': return self._proxy_request(req) elif path_tokens[0] == 'ec2': path_tokens = path_tokens[1:] resp = self._get_metadata(req, path_tokens) return self._add_response_data(req.response, resp) except exception.EC2MetadataNotFound: return webob.exc.HTTPNotFound() except Exception: LOG.exception(textutils._LE("Unexpected error.")) msg = _('An unknown error has occurred. ' 'Please try your request again.') return webob.exc.HTTPInternalServerError(explanation=unicode(msg)) def _proxy_request(self, req): headers = self._build_proxy_request_headers(req) nova_ip_port = '%s:%s' % (CONF.metadata.nova_metadata_ip, CONF.metadata.nova_metadata_port) url = urlparse.urlunsplit(( CONF.metadata.nova_metadata_protocol, nova_ip_port, req.path_info, req.query_string, '')) h = httplib2.Http( ca_certs=CONF.metadata.auth_ca_cert, disable_ssl_certificate_validation=( CONF.metadata.nova_metadata_insecure) ) if (CONF.metadata.nova_client_cert and CONF.metadata.nova_client_priv_key): h.add_certificate(CONF.metadata.nova_client_priv_key, CONF.metadata.nova_client_cert, nova_ip_port) resp, content = h.request(url, method=req.method, headers=headers, body=req.body) if resp.status == 200: LOG.debug(str(resp)) req.response.content_type = resp['content-type'] req.response.body = content return req.response elif resp.status == 403: LOG.warn(textutils._LW( 'The remote metadata server responded with Forbidden. This ' 'response usually occurs when shared secrets do not match.' )) return webob.exc.HTTPForbidden() elif resp.status == 400: return webob.exc.HTTPBadRequest() elif resp.status == 404: return webob.exc.HTTPNotFound() elif resp.status == 409: return webob.exc.HTTPConflict() elif resp.status == 500: msg = _( 'Remote metadata server experienced an internal server error.' ) LOG.warn(msg) return webob.exc.HTTPInternalServerError(explanation=unicode(msg)) else: raise Exception(_('Unexpected response code: %s') % resp.status) def _build_proxy_request_headers(self, req): if req.headers.get('X-Instance-ID'): return req.headers remote_ip = self._get_remote_ip(req) context = self._get_context() instance_id, project_id = ( api.get_os_instance_and_project_id(context, remote_ip)) return { 'X-Forwarded-For': remote_ip, 'X-Instance-ID': instance_id, 'X-Tenant-ID': project_id, 'X-Instance-ID-Signature': self._sign_instance_id(instance_id), } def _get_remote_ip(self, req): remote_ip = req.remote_addr if CONF.use_forwarded_for: remote_ip = req.headers.get('X-Forwarded-For', remote_ip) if not remote_ip: raise exception.EC2MetadataInvalidAddress() return remote_ip def _get_context(self): # TODO(ft): make authentification token reusable keystone = keystone_client.Client( username=CONF.metadata.admin_user, password=CONF.metadata.admin_password, tenant_name=CONF.metadata.admin_tenant_name, auth_url=CONF.keystone_url, ) service_catalog = keystone.service_catalog.get_data() return ec2context.RequestContext( keystone.auth_user_id, keystone.auth_tenant_id, auth_token=keystone.auth_token, service_catalog=service_catalog, is_admin=True, cross_tenants=True) def _sign_instance_id(self, instance_id): return hmac.new(CONF.metadata.metadata_proxy_shared_secret, instance_id, hashlib.sha256).hexdigest() def _get_metadata(self, req, path_tokens): context = self._get_context() if req.headers.get('X-Instance-ID'): os_instance_id, project_id, remote_ip = ( self._unpack_request_attributes(req)) else: remote_ip = self._get_remote_ip(req) os_instance_id, project_id = ( api.get_os_instance_and_project_id(context, remote_ip)) # NOTE(ft): substitute project_id for context to instance's one. # It's needed for correct describe and auto update DB operations. # It doesn't affect operations via OpenStack's clients because # these clients use auth_token field only context.project_id = project_id return api.get_metadata_item(context, path_tokens, os_instance_id, remote_ip) def _unpack_request_attributes(self, req): os_instance_id = req.headers.get('X-Instance-ID') project_id = req.headers.get('X-Tenant-ID') signature = req.headers.get('X-Instance-ID-Signature') remote_ip = req.headers.get('X-Forwarded-For') if not remote_ip: raise exception.EC2MetadataInvalidAddress() if os_instance_id is None: msg = _('X-Instance-ID header is missing from request.') elif project_id is None: msg = _('X-Tenant-ID header is missing from request.') elif not isinstance(os_instance_id, six.string_types): msg = _('Multiple X-Instance-ID headers found within request.') elif not isinstance(project_id, six.string_types): msg = _('Multiple X-Tenant-ID headers found within request.') else: msg = None if msg: raise webob.exc.HTTPBadRequest(explanation=msg) expected_signature = hmac.new( CONF.metadata.metadata_proxy_shared_secret, os_instance_id, hashlib.sha256).hexdigest() if not utils.constant_time_compare(expected_signature, signature): LOG.warning(textutils._LW( 'X-Instance-ID-Signature: %(signature)s does ' 'not match the expected value: ' '%(expected_signature)s for id: ' '%(instance_id)s. Request From: ' '%(remote_ip)s'), {'signature': signature, 'expected_signature': expected_signature, 'instance_id': os_instance_id, 'remote_ip': remote_ip}) msg = _('Invalid proxy request signature.') raise webob.exc.HTTPForbidden(explanation=msg) return os_instance_id, project_id, remote_ip def _add_response_data(self, response, data): if isinstance(data, six.text_type): response.text = data else: response.body = data response.content_type = 'text/plain' return response