<reponame>scottwedge/OpenStack-Stein
# 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 httplib2
from oslo_cache import core as cache_core
from oslo_config import cfg
from oslo_log import log as logging
import six
import six.moves.urllib.parse as urlparse
import webob

from ec2api import context as ec2_context
from ec2api import exception
from ec2api.i18n import _
from ec2api.metadata import api
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('metadata_proxy_shared_secret',
               default='',
               help=_('Shared secret to sign instance-id request'),
               secret=True),
    cfg.IntOpt("cache_expiration",
        default=15,
        min=0,
        help=_('This option is the time (in seconds) to cache metadata. '
               'Increasing this setting should improve response times of the '
               'metadata API when under heavy load. Higher values may '
               'increase memory usage, and result in longer times for host '
               'metadata changes to take effect.'))
]

CONF.register_opts(metadata_opts, group='metadata')
cache_core.configure(CONF)


class MetadataRequestHandler(wsgi.Application):
    """Serve metadata."""

    def __init__(self):
        if not CONF.cache.enabled:
            LOG.warning("Metadata doesn't use cache. "
                        "Configure cache options to use cache.")
        self.cache_region = cache_core.create_region()
        cache_core.configure_cache_region(CONF, self.cache_region)

    @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[0] == 'ec2':
            path_tokens = path_tokens[1:]

        if path_tokens == ['']:
            resp = api.get_version_list()
            return self._add_response_data(req.response, resp)

        try:
            requester = self._get_requester(req)
            if path_tokens[0] == 'openstack':
                return self._proxy_request(req, requester)

            resp = self._get_metadata(path_tokens, requester)
            return self._add_response_data(req.response, resp)

        except exception.EC2MetadataNotFound:
            return webob.exc.HTTPNotFound()
        except Exception:
            LOG.exception("Unexpected error.")
            msg = _('An unknown error has occurred. '
                    'Please try your request again.')
            return webob.exc.HTTPInternalServerError(
                explanation=six.text_type(msg))

    def _proxy_request(self, req, requester):
        headers = self._build_proxy_request_headers(requester)
        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.warning(
                '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.warning(msg)
            return webob.exc.HTTPInternalServerError(
                explanation=six.text_type(msg))
        else:
            raise Exception(_('Unexpected response code: %s') % resp.status)

    def _build_proxy_request_headers(self, requester):
        signature = self._sign_instance_id(requester['os_instance_id'])
        return {
            'X-Forwarded-For': requester['private_ip'],
            'X-Instance-ID': requester['os_instance_id'],
            'X-Tenant-ID': requester['project_id'],
            'X-Instance-ID-Signature': signature,
        }

    def _sign_instance_id(self, instance_id):
        return hmac.new(
            CONF.metadata.metadata_proxy_shared_secret.encode("utf-8"),
            instance_id.encode(),
            hashlib.sha256).hexdigest()

    def _get_requester(self, req):
        if req.headers.get('X-Metadata-Provider'):
            provider_id, remote_ip = self._unpack_nsx_request(req)
            context = ec2_context.get_os_admin_context()
            os_instance_id, project_id = (
                api.get_os_instance_and_project_id_by_provider_id(
                    context, provider_id, remote_ip))
        else:
            os_instance_id, project_id, remote_ip = (
                self._unpack_neutron_request(req))
        return {'os_instance_id': os_instance_id,
                'project_id': project_id,
                'private_ip': remote_ip}

    def _unpack_neutron_request(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)

        self._validate_signature(signature, os_instance_id, remote_ip)
        return os_instance_id, project_id, remote_ip

    def _unpack_nsx_request(self, req):
        remote_address = req.headers.get('X-Forwarded-For')
        if remote_address is None:
            msg = _('X-Forwarded-For is missing from request.')
            raise webob.exc.HTTPBadRequest(explanation=msg)
        provider_id = req.headers.get('X-Metadata-Provider')
        if provider_id is None:
            msg = _('X-Metadata-Provider is missing from request.')
            raise webob.exc.HTTPBadRequest(explanation=msg)
        remote_ip = remote_address.split(',')[0]

        if CONF.metadata.metadata_proxy_shared_secret:
            signature = req.headers.get('X-Metadata-Provider-Signature')
            self._validate_signature(signature, provider_id, remote_ip)

        return provider_id, remote_ip

    def _validate_signature(self, signature, requester_id, requester_ip):
        expected_signature = hmac.new(
            CONF.metadata.metadata_proxy_shared_secret.encode("utf-8"),
            requester_id.encode(),
            hashlib.sha256).hexdigest()

        if not (signature and
                utils.constant_time_compare(expected_signature, signature)):
            LOG.warning('X-Instance-ID-Signature: %(signature)s does '
                        'not match the expected value: '
                        '%(expected_signature)s for id: '
                        '%(requester_id)s. Request From: '
                        '%(requester_ip)s',
                        {'signature': signature,
                         'expected_signature': expected_signature,
                         'requester_id': requester_id,
                         'requester_ip': requester_ip})

            msg = _('Invalid proxy request signature.')
            raise webob.exc.HTTPForbidden(explanation=msg)

    def _get_metadata(self, path_tokens, requester):
        context = ec2_context.get_os_admin_context()
        # 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 = requester['project_id']
        return api.get_metadata_item(context, path_tokens,
                                     requester['os_instance_id'],
                                     requester['private_ip'],
                                     self.cache_region)

    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

# 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 httplib2
from oslo_cache import core as cache_core
from oslo_config import cfg
from oslo_log import log as logging
import six
import six.moves.urllib.parse as urlparse
import webob

from ec2api import context as ec2_context
from ec2api import exception
from ec2api.i18n import _
from ec2api.metadata import api
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('metadata_proxy_shared_secret',
               default='',
               help=_('Shared secret to sign instance-id request'),
               secret=True),
    cfg.IntOpt("cache_expiration",
        default=15,
        min=0,
        help=_('This option is the time (in seconds) to cache metadata. '
               'Increasing this setting should improve response times of the '
               'metadata API when under heavy load. Higher values may '
               'increase memory usage, and result in longer times for host '
               'metadata changes to take effect.'))
]

CONF.register_opts(metadata_opts, group='metadata')
cache_core.configure(CONF)


class MetadataRequestHandler(wsgi.Application):
    """Serve metadata."""

    def __init__(self):
        if not CONF.cache.enabled:
            LOG.warning("Metadata doesn't use cache. "
                        "Configure cache options to use cache.")
        self.cache_region = cache_core.create_region()
        cache_core.configure_cache_region(CONF, self.cache_region)

    @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[0] == 'ec2':
            path_tokens = path_tokens[1:]

        if path_tokens == ['']:
            resp = api.get_version_list()
            return self._add_response_data(req.response, resp)

        try:
            requester = self._get_requester(req)
            if path_tokens[0] == 'openstack':
                return self._proxy_request(req, requester)

            resp = self._get_metadata(path_tokens, requester)
            return self._add_response_data(req.response, resp)

        except exception.EC2MetadataNotFound:
            return webob.exc.HTTPNotFound()
        except Exception:
            LOG.exception("Unexpected error.")
            msg = _('An unknown error has occurred. '
                    'Please try your request again.')
            return webob.exc.HTTPInternalServerError(
                explanation=six.text_type(msg))

    def _proxy_request(self, req, requester):
        headers = self._build_proxy_request_headers(requester)
        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.warning(
                '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.warning(msg)
            return webob.exc.HTTPInternalServerError(
                explanation=six.text_type(msg))
        else:
            raise Exception(_('Unexpected response code: %s') % resp.status)

    def _build_proxy_request_headers(self, requester):
        signature = self._sign_instance_id(requester['os_instance_id'])
        return {
            'X-Forwarded-For': requester['private_ip'],
            'X-Instance-ID': requester['os_instance_id'],
            'X-Tenant-ID': requester['project_id'],
            'X-Instance-ID-Signature': signature,
        }

    def _sign_instance_id(self, instance_id):
        return hmac.new(
            CONF.metadata.metadata_proxy_shared_secret.encode("utf-8"),
            instance_id.encode(),
            hashlib.sha256).hexdigest()

    def _get_requester(self, req):
        if req.headers.get('X-Metadata-Provider'):
            provider_id, remote_ip = self._unpack_nsx_request(req)
            context = ec2_context.get_os_admin_context()
            os_instance_id, project_id = (
                api.get_os_instance_and_project_id_by_provider_id(
                    context, provider_id, remote_ip))
        else:
            os_instance_id, project_id, remote_ip = (
                self._unpack_neutron_request(req))
        return {'os_instance_id': os_instance_id,
                'project_id': project_id,
                'private_ip': remote_ip}

    def _unpack_neutron_request(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)

        self._validate_signature(signature, os_instance_id, remote_ip)
        return os_instance_id, project_id, remote_ip

    def _unpack_nsx_request(self, req):
        remote_address = req.headers.get('X-Forwarded-For')
        if remote_address is None:
            msg = _('X-Forwarded-For is missing from request.')
            raise webob.exc.HTTPBadRequest(explanation=msg)
        provider_id = req.headers.get('X-Metadata-Provider')
        if provider_id is None:
            msg = _('X-Metadata-Provider is missing from request.')
            raise webob.exc.HTTPBadRequest(explanation=msg)
        remote_ip = remote_address.split(',')[0]

        if CONF.metadata.metadata_proxy_shared_secret:
            signature = req.headers.get('X-Metadata-Provider-Signature')
            self._validate_signature(signature, provider_id, remote_ip)

        return provider_id, remote_ip

    def _validate_signature(self, signature, requester_id, requester_ip):
        expected_signature = hmac.new(
            CONF.metadata.metadata_proxy_shared_secret.encode("utf-8"),
            requester_id.encode(),
            hashlib.sha256).hexdigest()

        if not (signature and
                utils.constant_time_compare(expected_signature, signature)):
            LOG.warning('X-Instance-ID-Signature: %(signature)s does '
                        'not match the expected value: '
                        '%(expected_signature)s for id: '
                        '%(requester_id)s. Request From: '
                        '%(requester_ip)s',
                        {'signature': signature,
                         'expected_signature': expected_signature,
                         'requester_id': requester_id,
                         'requester_ip': requester_ip})

            msg = _('Invalid proxy request signature.')
            raise webob.exc.HTTPForbidden(explanation=msg)

    def _get_metadata(self, path_tokens, requester):
        context = ec2_context.get_os_admin_context()
        # 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 = requester['project_id']
        return api.get_metadata_item(context, path_tokens,
                                     requester['os_instance_id'],
                                     requester['private_ip'],
                                     self.cache_region)

    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
# 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. Serve metadata. # 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