5
5
import subprocess
6
6
import sys
7
7
from functools import cache
8
+ from io import BytesIO
8
9
from typing import Dict , Optional , Tuple
9
10
from urllib .parse import urlparse , urlunparse
10
11
11
12
import boto3
12
13
import requests
13
- from botocore .awsrequest import AWSPreparedRequest
14
+ from botocore .awsrequest import AWSPreparedRequest , AWSResponse
15
+ from botocore .httpchecksum import resolve_checksum_context
14
16
from botocore .model import OperationModel
15
17
from localstack import config
16
18
from localstack import config as localstack_config
19
+ from localstack .aws .api import HttpRequest
20
+ from localstack .aws .chain import HandlerChain
21
+ from localstack .aws .chain import RequestContext as AwsRequestContext
22
+ from localstack .aws .gateway import Gateway
17
23
from localstack .aws .protocol .parser import create_parser
18
24
from localstack .aws .spec import load_service
19
25
from localstack .config import external_service_url
20
26
from localstack .constants import AWS_REGION_US_EAST_1 , DOCKER_IMAGE_NAME_PRO
21
27
from localstack .http import Request
28
+ from localstack .http import Response as HttpResponse
29
+ from localstack .http .hypercorn import GatewayServer
22
30
from localstack .utils .aws .aws_responses import requests_response
23
31
from localstack .utils .bootstrap import setup_logging
24
32
from localstack .utils .collections import select_attributes
57
65
DEFAULT_BIND_HOST = "127.0.0.1"
58
66
59
67
68
+ class AwsProxyHandler :
69
+ """
70
+ A handler for an AWS Handler chain that attempts to forward the request using a specific boto3 session.
71
+ This can be used to proxy incoming requests to real AWS.
72
+ """
73
+
74
+ def __init__ (self , session : boto3 .Session = None ):
75
+ self .session = session or boto3 .Session ()
76
+
77
+ def __call__ (self , chain : HandlerChain , context : AwsRequestContext , response : HttpResponse ):
78
+ # prepare the API invocation parameters
79
+ LOG .info (
80
+ "Received %s.%s = %s" ,
81
+ context .service .service_name ,
82
+ context .operation .name ,
83
+ context .service_request ,
84
+ )
85
+
86
+ # make the actual API call against upstream AWS (will also calculate a new auth signature)
87
+ try :
88
+ aws_response = self ._make_aws_api_call (context )
89
+ except Exception :
90
+ LOG .exception (
91
+ "Exception while proxying %s.%s to AWS" ,
92
+ context .service .service_name ,
93
+ context .operation .name ,
94
+ )
95
+ raise
96
+
97
+ # tell the handler chain to respond
98
+ LOG .info (
99
+ "AWS Response %s.%s: url=%s status_code=%s, headers=%s, content=%s" ,
100
+ context .service .service_name ,
101
+ context .operation .name ,
102
+ aws_response .url ,
103
+ aws_response .status_code ,
104
+ aws_response .headers ,
105
+ aws_response .content ,
106
+ )
107
+ chain .respond (aws_response .status_code , aws_response .content , dict (aws_response .headers ))
108
+
109
+ def _make_aws_api_call (self , context : AwsRequestContext ) -> AWSResponse :
110
+ # TODO: reconcile with AwsRequestProxy from localstack, and other forwarder tools
111
+ # create a real AWS client
112
+ client = self .session .client (context .service .service_name , region_name = context .region )
113
+ operation_model = context .operation
114
+
115
+ # prepare API request parameters as expected by boto
116
+ api_params = {k : v for k , v in context .service_request .items () if v is not None }
117
+
118
+ # this is a stripped down version of botocore's client._make_api_call to immediately get the HTTP
119
+ # response instead of a parsed response.
120
+ request_context = {
121
+ "client_region" : client .meta .region_name ,
122
+ "client_config" : client .meta .config ,
123
+ "has_streaming_input" : operation_model .has_streaming_input ,
124
+ "auth_type" : operation_model .auth_type ,
125
+ }
126
+
127
+ (
128
+ endpoint_url ,
129
+ additional_headers ,
130
+ properties ,
131
+ ) = client ._resolve_endpoint_ruleset (operation_model , api_params , request_context )
132
+ if properties :
133
+ # Pass arbitrary endpoint info with the Request
134
+ # for use during construction.
135
+ request_context ["endpoint_properties" ] = properties
136
+
137
+ request_dict = client ._convert_to_request_dict (
138
+ api_params = api_params ,
139
+ operation_model = operation_model ,
140
+ endpoint_url = endpoint_url ,
141
+ context = request_context ,
142
+ headers = additional_headers ,
143
+ )
144
+ resolve_checksum_context (request_dict , operation_model , api_params )
145
+
146
+ if operation_model .has_streaming_input :
147
+ request_dict ["body" ] = request_dict ["body" ].read ()
148
+
149
+ self ._adjust_request_dict (context .service .service_name , request_dict )
150
+
151
+ if operation_model .has_streaming_input :
152
+ request_dict ["body" ] = BytesIO (request_dict ["body" ])
153
+
154
+ LOG .info ("Making AWS request %s" , request_dict )
155
+ http , _ = client ._endpoint .make_request (operation_model , request_dict )
156
+
157
+ http : AWSResponse
158
+
159
+ # for some elusive reasons, these header modifications are needed (were part of http2_server)
160
+ http .headers .pop ("Date" , None )
161
+ http .headers .pop ("Server" , None )
162
+ if operation_model .has_streaming_output :
163
+ http .headers .pop ("Content-Length" , None )
164
+
165
+ return http
166
+
167
+ def _adjust_request_dict (self , service_name : str , request_dict : Dict ):
168
+ """Apply minor fixes to the request dict, which seem to be required in the current setup."""
169
+ # TODO: replacing localstack-specific URLs, IDs, etc, should ideally be done in a more generalized
170
+ # way.
171
+
172
+ req_body = request_dict .get ("body" )
173
+
174
+ # TODO: fix for switch between path/host addressing
175
+ # Note: the behavior seems to be different across botocore versions. Seems to be working
176
+ # with 1.29.97 (fix below not required) whereas newer versions like 1.29.151 require the fix.
177
+ if service_name == "s3" :
178
+ body_str = run_safe (lambda : to_str (req_body )) or ""
179
+
180
+ request_url = request_dict ["url" ]
181
+ url_parsed = list (urlparse (request_url ))
182
+ path_parts = url_parsed [2 ].strip ("/" ).split ("/" )
183
+ bucket_subdomain_prefix = f"://{ path_parts [0 ]} .s3."
184
+ if bucket_subdomain_prefix in request_url :
185
+ prefix = f"/{ path_parts [0 ]} "
186
+ url_parsed [2 ] = url_parsed [2 ].removeprefix (prefix )
187
+ request_dict ["url_path" ] = request_dict ["url_path" ].removeprefix (prefix )
188
+ # replace empty path with "/" (seems required for signature calculation)
189
+ request_dict ["url_path" ] = request_dict ["url_path" ] or "/"
190
+ url_parsed [2 ] = url_parsed [2 ] or "/"
191
+ # re-construct final URL
192
+ request_dict ["url" ] = urlunparse (url_parsed )
193
+
194
+ # TODO: this custom fix should not be required - investigate and remove!
195
+ if "<CreateBucketConfiguration" in body_str and "LocationConstraint" not in body_str :
196
+ region = request_dict ["context" ]["client_region" ]
197
+ if region == AWS_REGION_US_EAST_1 :
198
+ request_dict ["body" ] = ""
199
+ else :
200
+ request_dict ["body" ] = (
201
+ '<CreateBucketConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">'
202
+ f"<LocationConstraint>{ region } </LocationConstraint></CreateBucketConfiguration>"
203
+ )
204
+
205
+ if service_name == "sqs" and isinstance (req_body , dict ):
206
+ account_id = self ._query_account_id_from_aws ()
207
+ if "QueueUrl" in req_body :
208
+ queue_name = req_body ["QueueUrl" ].split ("/" )[- 1 ]
209
+ req_body ["QueueUrl" ] = f"https://queue.amazonaws.com/{ account_id } /{ queue_name } "
210
+ if "QueueOwnerAWSAccountId" in req_body :
211
+ req_body ["QueueOwnerAWSAccountId" ] = account_id
212
+ if service_name == "sqs" and request_dict .get ("url" ):
213
+ req_json = run_safe (lambda : json .loads (body_str )) or {}
214
+ account_id = self ._query_account_id_from_aws ()
215
+ queue_name = req_json .get ("QueueName" )
216
+ if account_id and queue_name :
217
+ request_dict ["url" ] = f"https://queue.amazonaws.com/{ account_id } /{ queue_name } "
218
+ req_json ["QueueOwnerAWSAccountId" ] = account_id
219
+ request_dict ["body" ] = to_bytes (json .dumps (req_json ))
220
+
221
+ def _fix_headers (self , request : HttpRequest , service_name : str ):
222
+ if service_name == "s3" :
223
+ # fix the Host header, to avoid bucket addressing issues
224
+ host = request .headers .get ("Host" ) or ""
225
+ regex = r"^(https?://)?([0-9.]+|localhost)(:[0-9]+)?"
226
+ if re .match (regex , host ):
227
+ request .headers ["Host" ] = re .sub (regex , r"\1s3.localhost.localstack.cloud" , host )
228
+ request .headers .pop ("Content-Length" , None )
229
+ request .headers .pop ("x-localstack-request-url" , None )
230
+ request .headers .pop ("X-Forwarded-For" , None )
231
+ request .headers .pop ("X-Localstack-Tgt-Api" , None )
232
+ request .headers .pop ("X-Moto-Account-Id" , None )
233
+ request .headers .pop ("Remote-Addr" , None )
234
+
235
+ @cache
236
+ def _query_account_id_from_aws (self ) -> str :
237
+ sts_client = self .session .client ("sts" )
238
+ result = sts_client .get_caller_identity ()
239
+ return result ["Account" ]
240
+
241
+
242
+ class AwsProxyGateway (Gateway ):
243
+ """
244
+ A handler chain that receives AWS requests, and proxies them transparently to upstream AWS using real
245
+ credentials. It de-constructs the incoming request, and creates a new request signed with the AWS
246
+ credentials configured in the environment.
247
+ """
248
+
249
+ def __init__ (self ) -> None :
250
+ from localstack .aws import handlers
251
+
252
+ super ().__init__ (
253
+ request_handlers = [
254
+ handlers .parse_service_name ,
255
+ handlers .content_decoder ,
256
+ handlers .add_region_from_header ,
257
+ handlers .add_account_id ,
258
+ handlers .parse_service_request ,
259
+ AwsProxyHandler (),
260
+ ],
261
+ exception_handlers = [
262
+ handlers .log_exception ,
263
+ handlers .handle_internal_failure ,
264
+ ],
265
+ context_class = AwsRequestContext ,
266
+ )
267
+
268
+
60
269
class AuthProxyAWS (Server ):
61
270
def __init__ (self , config : ProxyConfig , port : int = None ):
62
271
self .config = config
@@ -65,9 +274,13 @@ def __init__(self, config: ProxyConfig, port: int = None):
65
274
66
275
def do_run (self ):
67
276
self .register_in_instance ()
277
+
68
278
bind_host = self .config .get ("bind_host" ) or DEFAULT_BIND_HOST
69
- proxy = run_server (port = self .port , bind_addresses = [bind_host ], handler = self .proxy_request )
70
- proxy .join ()
279
+ srv = GatewayServer (AwsProxyGateway (), localstack_config .HostAndPort (bind_host , self .port ))
280
+ srv .start ()
281
+ srv .join ()
282
+ # proxy = run_server(port=self.port, bind_addresses=[bind_host], handler=self.proxy_request)
283
+ # proxy.join()
71
284
72
285
def proxy_request (self , request : Request , data : bytes ) -> Response :
73
286
parsed = self ._extract_region_and_service (request .headers )
@@ -214,20 +427,23 @@ def _parse_aws_request(
214
427
215
428
def _adjust_request_dict (self , service_name : str , request_dict : Dict ):
216
429
"""Apply minor fixes to the request dict, which seem to be required in the current setup."""
217
-
430
+ # TODO: replacing localstack-specific URLs, IDs, etc, should ideally be done in a more generalized
431
+ # way.
218
432
req_body = request_dict .get ("body" )
219
- body_str = run_safe (lambda : to_str (req_body )) or ""
220
-
221
- # TODO: this custom fix should not be required - investigate and remove!
222
- if "<CreateBucketConfiguration" in body_str and "LocationConstraint" not in body_str :
223
- region = request_dict ["context" ]["client_region" ]
224
- if region == AWS_REGION_US_EAST_1 :
225
- request_dict ["body" ] = ""
226
- else :
227
- request_dict ["body" ] = (
228
- '<CreateBucketConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">'
229
- f"<LocationConstraint>{ region } </LocationConstraint></CreateBucketConfiguration>"
230
- )
433
+
434
+ if service_name == "s3" :
435
+ body_str = run_safe (lambda : to_str (req_body )) or ""
436
+
437
+ # TODO: this custom fix should not be required - investigate and remove!
438
+ if "<CreateBucketConfiguration" in body_str and "LocationConstraint" not in body_str :
439
+ region = request_dict ["context" ]["client_region" ]
440
+ if region == AWS_REGION_US_EAST_1 :
441
+ request_dict ["body" ] = ""
442
+ else :
443
+ request_dict ["body" ] = (
444
+ '<CreateBucketConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">'
445
+ f"<LocationConstraint>{ region } </LocationConstraint></CreateBucketConfiguration>"
446
+ )
231
447
232
448
if service_name == "sqs" and isinstance (req_body , dict ):
233
449
account_id = self ._query_account_id_from_aws ()
0 commit comments