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

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. 

16 

17"""Metadata request handler.""" 

18import hashlib 

19import hmac 

20import os 

21 

22from oslo_log import log as logging 

23from oslo_utils import encodeutils 

24from oslo_utils import strutils 

25import webob.dec 

26import webob.exc 

27 

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 

36 

37CONF = nova.conf.CONF 

38LOG = logging.getLogger(__name__) 

39 

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 

45 

46 

47class MetadataRequestHandler(wsgi.Application): 

48 """Serve metadata.""" 

49 

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") 

58 

59 def get_metadata_by_remote_address(self, address): 

60 if not address: 

61 raise exception.FixedIpNotFoundForAddress(address=address) 

62 

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 

68 

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 

74 

75 if CONF.api.metadata_cache_expiration > 0: 

76 self._cache.set(cache_key, data) 

77 

78 return data 

79 

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 

86 

87 try: 

88 data = base.get_metadata_by_instance_id(instance_id, address) 

89 except exception.NotFound: 

90 return None 

91 

92 if CONF.api.metadata_cache_expiration > 0: 

93 self._cache.set(cache_key, data) 

94 

95 return data 

96 

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 

104 

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) 

123 

124 if meta_data is None: 

125 raise webob.exc.HTTPNotFound() 

126 

127 try: 

128 data = meta_data.lookup(req.path_info) 

129 except base.InvalidMetadataPath: 

130 raise webob.exc.HTTPNotFound() 

131 

132 if callable(data): 

133 return data(req, meta_data) 

134 

135 resp = base.ec2_md_print(data) 

136 req.response.body = encodeutils.to_utf8(resp) 

137 

138 req.response.content_type = meta_data.get_mimetype() 

139 return req.response 

140 

141 def _handle_remote_ip_request(self, req): 

142 remote_address = req.remote_addr 

143 

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)) 

152 

153 if meta_data is None: 

154 LOG.error('Failed to get metadata for IP %s: no metadata', 

155 remote_address) 

156 

157 return meta_data 

158 

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') 

164 

165 # Ensure that only one header was passed 

166 

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 

179 

180 if msg: 

181 raise webob.exc.HTTPBadRequest(explanation=msg) 

182 

183 self._validate_shared_secret(instance_id, signature, 

184 remote_address) 

185 

186 return self._get_meta_by_instance_id(instance_id, tenant_id, 

187 remote_address) 

188 

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) 

194 

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. 

207 

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']) 

213 

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) 

218 

219 md_networks = [subnet['network_id'] 

220 for subnet in md_subnets['subnets']] 

221 

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) 

244 

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)} 

251 

252 LOG.error(msg) 

253 raise webob.exc.HTTPBadRequest(explanation=msg) 

254 

255 instance_data = ports[0] 

256 instance_id = instance_data['device_id'] 

257 tenant_id = instance_data['tenant_id'] 

258 

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 

264 

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] 

275 

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) 

281 

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) 

297 

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) 

318 

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)) 

329 

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 

339 

340 return meta_data