Coverage for nova/api/metadata/handler.py: 89%
184 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-04-24 11:16 +0000
« prev ^ index » next coverage.py v7.6.12, created at 2025-04-24 11:16 +0000
1# Copyright 2010 United States Government as represented by the
2# Administrator of the National Aeronautics and Space Administration.
3# All Rights Reserved.
4#
5# Licensed under the Apache License, Version 2.0 (the "License"); you may
6# not use this file except in compliance with the License. You may obtain
7# a copy of the License at
8#
9# http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
13# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
14# License for the specific language governing permissions and limitations
15# under the License.
17"""Metadata request handler."""
18import hashlib
19import hmac
20import os
22from oslo_log import log as logging
23from oslo_utils import encodeutils
24from oslo_utils import strutils
25import webob.dec
26import webob.exc
28from nova.api.metadata import base
29from nova.api import wsgi
30from nova import cache_utils
31import nova.conf
32from nova import context as nova_context
33from nova import exception
34from nova.i18n import _
35from nova.network import neutron as neutronapi
37CONF = nova.conf.CONF
38LOG = logging.getLogger(__name__)
40# 160 networks is large enough to satisfy most cases.
41# Yet while reaching 182 networks Neutron server will break as URL length
42# exceeds the maximum. Left this at 160 to allow additional parameters when
43# they're needed.
44MAX_QUERY_NETWORKS = 160
47class MetadataRequestHandler(wsgi.Application):
48 """Serve metadata."""
50 def __init__(self):
51 self._cache = cache_utils.get_client(
52 expiration_time=CONF.api.metadata_cache_expiration)
53 if (CONF.neutron.service_metadata_proxy and
54 not CONF.neutron.metadata_proxy_shared_secret):
55 LOG.warning("metadata_proxy_shared_secret is not configured, "
56 "the metadata information returned by the proxy "
57 "cannot be trusted")
59 def get_metadata_by_remote_address(self, address):
60 if not address:
61 raise exception.FixedIpNotFoundForAddress(address=address)
63 cache_key = 'metadata-%s' % address
64 data = self._cache.get(cache_key)
65 if data:
66 LOG.debug("Using cached metadata for %s", address)
67 return data
69 try:
70 data = base.get_metadata_by_address(address)
71 except exception.NotFound:
72 LOG.exception('Failed to get metadata for IP %s', address)
73 return None
75 if CONF.api.metadata_cache_expiration > 0:
76 self._cache.set(cache_key, data)
78 return data
80 def get_metadata_by_instance_id(self, instance_id, address):
81 cache_key = 'metadata-%s' % instance_id
82 data = self._cache.get(cache_key)
83 if data:
84 LOG.debug("Using cached metadata for instance %s", instance_id)
85 return data
87 try:
88 data = base.get_metadata_by_instance_id(instance_id, address)
89 except exception.NotFound:
90 return None
92 if CONF.api.metadata_cache_expiration > 0:
93 self._cache.set(cache_key, data)
95 return data
97 @webob.dec.wsgify(RequestClass=wsgi.Request)
98 def __call__(self, req):
99 if os.path.normpath(req.path_info) == "/":
100 resp = base.ec2_md_print(base.VERSIONS + ["latest"])
101 req.response.body = encodeutils.to_utf8(resp)
102 req.response.content_type = base.MIME_TYPE_TEXT_PLAIN
103 return req.response
105 # Convert webob.headers.EnvironHeaders to a dict and mask any sensitive
106 # details from the logs.
107 if CONF.debug: 107 ↛ 108line 107 didn't jump to line 108 because the condition on line 107 was never true
108 headers = {k: req.headers[k] for k in req.headers}
109 LOG.debug('Metadata request headers: %s',
110 strutils.mask_dict_password(headers))
111 if CONF.neutron.service_metadata_proxy:
112 if req.headers.get('X-Metadata-Provider'):
113 meta_data = self._handle_instance_id_request_from_lb(req)
114 else:
115 meta_data = self._handle_instance_id_request(req)
116 else:
117 if req.headers.get('X-Instance-ID'):
118 LOG.warning(
119 "X-Instance-ID present in request headers. The "
120 "'service_metadata_proxy' option must be "
121 "enabled to process this header.")
122 meta_data = self._handle_remote_ip_request(req)
124 if meta_data is None:
125 raise webob.exc.HTTPNotFound()
127 try:
128 data = meta_data.lookup(req.path_info)
129 except base.InvalidMetadataPath:
130 raise webob.exc.HTTPNotFound()
132 if callable(data):
133 return data(req, meta_data)
135 resp = base.ec2_md_print(data)
136 req.response.body = encodeutils.to_utf8(resp)
138 req.response.content_type = meta_data.get_mimetype()
139 return req.response
141 def _handle_remote_ip_request(self, req):
142 remote_address = req.remote_addr
144 try:
145 meta_data = self.get_metadata_by_remote_address(remote_address)
146 except Exception:
147 LOG.exception('Failed to get metadata for IP %s',
148 remote_address)
149 msg = _('An unknown error has occurred. '
150 'Please try your request again.')
151 raise webob.exc.HTTPInternalServerError(explanation=str(msg))
153 if meta_data is None:
154 LOG.error('Failed to get metadata for IP %s: no metadata',
155 remote_address)
157 return meta_data
159 def _handle_instance_id_request(self, req):
160 instance_id = req.headers.get('X-Instance-ID')
161 tenant_id = req.headers.get('X-Tenant-ID')
162 signature = req.headers.get('X-Instance-ID-Signature')
163 remote_address = req.headers.get('X-Forwarded-For')
165 # Ensure that only one header was passed
167 if instance_id is None: 167 ↛ 168line 167 didn't jump to line 168 because the condition on line 167 was never true
168 msg = _('X-Instance-ID header is missing from request.')
169 elif signature is None: 169 ↛ 170line 169 didn't jump to line 170 because the condition on line 169 was never true
170 msg = _('X-Instance-ID-Signature header is missing from request.')
171 elif tenant_id is None:
172 msg = _('X-Tenant-ID header is missing from request.')
173 elif not isinstance(instance_id, str): 173 ↛ 174line 173 didn't jump to line 174 because the condition on line 173 was never true
174 msg = _('Multiple X-Instance-ID headers found within request.')
175 elif not isinstance(tenant_id, str): 175 ↛ 176line 175 didn't jump to line 176 because the condition on line 175 was never true
176 msg = _('Multiple X-Tenant-ID headers found within request.')
177 else:
178 msg = None
180 if msg:
181 raise webob.exc.HTTPBadRequest(explanation=msg)
183 self._validate_shared_secret(instance_id, signature,
184 remote_address)
186 return self._get_meta_by_instance_id(instance_id, tenant_id,
187 remote_address)
189 def _get_instance_id_from_lb(self, provider_id, instance_address):
190 # We use admin context, admin=True to lookup the
191 # inter-Edge network port
192 context = nova_context.get_admin_context()
193 neutron = neutronapi.get_client(context, admin=True)
195 # Tenant, instance ids are found in the following method:
196 # X-Metadata-Provider contains id of the metadata provider, and since
197 # overlapping networks cannot be connected to the same metadata
198 # provider, the combo of tenant's instance IP and the metadata
199 # provider has to be unique.
200 #
201 # The networks which are connected to the metadata provider are
202 # retrieved in the 1st call to neutron.list_subnets()
203 # In the 2nd call we read the ports which belong to any of the
204 # networks retrieved above, and have the X-Forwarded-For IP address.
205 # This combination has to be unique as explained above, and we can
206 # read the instance_id, tenant_id from that port entry.
208 # Retrieve networks which are connected to metadata provider
209 md_subnets = neutron.list_subnets(
210 context,
211 advanced_service_providers=[provider_id],
212 fields=['network_id'])
214 if not md_subnets or not md_subnets.get('subnets'):
215 msg = _('Could not find any subnets for provider %s') % provider_id
216 LOG.error(msg)
217 raise webob.exc.HTTPBadRequest(explanation=msg)
219 md_networks = [subnet['network_id']
220 for subnet in md_subnets['subnets']]
222 try:
223 # Retrieve the instance data from the instance's port
224 ports = []
225 while md_networks:
226 ports.extend(neutron.list_ports(
227 context,
228 fixed_ips='ip_address=' + instance_address,
229 network_id=md_networks[:MAX_QUERY_NETWORKS],
230 fields=['device_id', 'tenant_id'])['ports'])
231 md_networks = md_networks[MAX_QUERY_NETWORKS:]
232 except Exception as e:
233 LOG.error('Failed to get instance id for metadata '
234 'request, provider %(provider)s '
235 'networks %(networks)s '
236 'requester %(requester)s. Error: %(error)s',
237 {'provider': provider_id,
238 'networks': md_networks,
239 'requester': instance_address,
240 'error': e})
241 msg = _('An unknown error has occurred. '
242 'Please try your request again.')
243 raise webob.exc.HTTPBadRequest(explanation=msg)
245 if len(ports) != 1:
246 msg = _('Expected a single port matching provider %(pr)s '
247 'and IP %(ip)s. Found %(count)d.') % {
248 'pr': provider_id,
249 'ip': instance_address,
250 'count': len(ports)}
252 LOG.error(msg)
253 raise webob.exc.HTTPBadRequest(explanation=msg)
255 instance_data = ports[0]
256 instance_id = instance_data['device_id']
257 tenant_id = instance_data['tenant_id']
259 # instance_data is unicode-encoded, while cache_utils doesn't like
260 # that. Therefore we convert to str
261 if isinstance(instance_id, str): 261 ↛ 263line 261 didn't jump to line 263 because the condition on line 261 was always true
262 instance_id = instance_id.encode('utf-8')
263 return instance_id, tenant_id
265 def _handle_instance_id_request_from_lb(self, req):
266 remote_address = req.headers.get('X-Forwarded-For')
267 if remote_address is None: 267 ↛ 268line 267 didn't jump to line 268 because the condition on line 267 was never true
268 msg = _('X-Forwarded-For is missing from request.')
269 raise webob.exc.HTTPBadRequest(explanation=msg)
270 provider_id = req.headers.get('X-Metadata-Provider')
271 if provider_id is None: 271 ↛ 272line 271 didn't jump to line 272 because the condition on line 271 was never true
272 msg = _('X-Metadata-Provider is missing from request.')
273 raise webob.exc.HTTPBadRequest(explanation=msg)
274 instance_address = remote_address.split(',')[0]
276 # If authentication token is set, authenticate
277 if CONF.neutron.metadata_proxy_shared_secret:
278 signature = req.headers.get('X-Metadata-Provider-Signature')
279 self._validate_shared_secret(provider_id, signature,
280 instance_address)
282 cache_key = 'provider-%s-%s' % (provider_id, instance_address)
283 data = self._cache.get(cache_key)
284 if data:
285 LOG.debug("Using cached metadata for %s for %s",
286 provider_id, instance_address)
287 instance_id, tenant_id = data
288 else:
289 instance_id, tenant_id = self._get_instance_id_from_lb(
290 provider_id, instance_address)
291 if CONF.api.metadata_cache_expiration > 0:
292 self._cache.set(cache_key, (instance_id, tenant_id))
293 LOG.debug('Instance %s with address %s matches provider %s',
294 instance_id, remote_address, provider_id)
295 return self._get_meta_by_instance_id(instance_id, tenant_id,
296 instance_address)
298 def _validate_shared_secret(self, requestor_id, signature,
299 requestor_address):
300 expected_signature = hmac.new(
301 encodeutils.to_utf8(CONF.neutron.metadata_proxy_shared_secret),
302 encodeutils.to_utf8(requestor_id),
303 hashlib.sha256).hexdigest()
304 if (not signature or
305 not hmac.compare_digest(expected_signature, signature)):
306 if requestor_id: 306 ↛ 316line 306 didn't jump to line 316 because the condition on line 306 was always true
307 LOG.warning('X-Instance-ID-Signature: %(signature)s does '
308 'not match the expected value: '
309 '%(expected_signature)s for id: '
310 '%(requestor_id)s. Request From: '
311 '%(requestor_address)s',
312 {'signature': signature,
313 'expected_signature': expected_signature,
314 'requestor_id': requestor_id,
315 'requestor_address': requestor_address})
316 msg = _('Invalid proxy request signature.')
317 raise webob.exc.HTTPForbidden(explanation=msg)
319 def _get_meta_by_instance_id(self, instance_id, tenant_id, remote_address):
320 try:
321 meta_data = self.get_metadata_by_instance_id(instance_id,
322 remote_address)
323 except Exception:
324 LOG.exception('Failed to get metadata for instance id: %s',
325 instance_id)
326 msg = _('An unknown error has occurred. '
327 'Please try your request again.')
328 raise webob.exc.HTTPInternalServerError(explanation=str(msg))
330 if meta_data is None: 330 ↛ 331line 330 didn't jump to line 331 because the condition on line 330 was never true
331 LOG.error('Failed to get metadata for instance id: %s',
332 instance_id)
333 elif meta_data.instance.project_id != tenant_id:
334 LOG.warning("Tenant_id %(tenant_id)s does not match tenant_id "
335 "of instance %(instance_id)s.",
336 {'tenant_id': tenant_id, 'instance_id': instance_id})
337 # causes a 404 to be raised
338 meta_data = None
340 return meta_data