• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

localstack / localstack / 19880423371

02 Dec 2025 08:25PM UTC coverage: 86.905% (-0.04%) from 86.945%
19880423371

push

github

web-flow
fix/external client CA bundle (#13451)

1 of 5 new or added lines in 1 file covered. (20.0%)

414 existing lines in 19 files now uncovered.

69738 of 80246 relevant lines covered (86.91%)

0.87 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

82.61
/localstack-core/localstack/aws/connect.py
1
"""
2
LocalStack client stack.
3

4
This module provides the interface to perform cross-service communication between
5
LocalStack providers.
6
"""
7

8
import json
1✔
9
import logging
1✔
10
import os
1✔
11
import re
1✔
12
import threading
1✔
13
from abc import ABC, abstractmethod
1✔
14
from collections.abc import Callable
1✔
15
from functools import lru_cache, partial
1✔
16
from random import choice
1✔
17
from socket import socket
1✔
18
from typing import Any, Generic, TypedDict, TypeVar
1✔
19

20
import dns.message
1✔
21
import dns.query
1✔
22
from boto3.session import Session
1✔
23
from botocore.awsrequest import (
1✔
24
    AWSHTTPConnection,
25
    AWSHTTPConnectionPool,
26
    AWSHTTPSConnection,
27
    AWSHTTPSConnectionPool,
28
)
29
from botocore.client import BaseClient
1✔
30
from botocore.config import Config
1✔
31
from botocore.httpsession import URLLib3Session
1✔
32
from botocore.waiter import Waiter
1✔
33

34
from localstack import config as localstack_config
1✔
35
from localstack.aws.spec import LOCALSTACK_BUILTIN_DATA_PATH
1✔
36
from localstack.constants import (
1✔
37
    AWS_REGION_US_EAST_1,
38
    INTERNAL_AWS_ACCESS_KEY_ID,
39
    INTERNAL_AWS_SECRET_ACCESS_KEY,
40
    MAX_POOL_CONNECTIONS,
41
)
42
from localstack.utils.aws.aws_stack import get_s3_hostname
1✔
43
from localstack.utils.aws.client_types import ServicePrincipal, TypedServiceClientFactory
1✔
44
from localstack.utils.patch import patch
1✔
45
from localstack.utils.strings import short_uid
1✔
46

47
LOG = logging.getLogger(__name__)
1✔
48

49

50
@patch(target=Waiter.wait, pass_target=True)
1✔
51
def my_patch(fn, self, **kwargs):
1✔
52
    """
53
    We're patching defaults in here that will override the defaults specified in the waiter spec since these are usually way too long
54

55
    Alternatively we could also try to find a solution where we patch the loader used in the generated clients
56
    so that we can dynamically fix the waiter config when it's loaded instead of when it's being used for wait execution
57
    """
58

59
    if localstack_config.DISABLE_CUSTOM_BOTO_WAITER_CONFIG:
1✔
60
        return fn(self, **kwargs)
×
61
    else:
62
        patched_kwargs = {
1✔
63
            **kwargs,
64
            "WaiterConfig": {
65
                "Delay": localstack_config.BOTO_WAITER_DELAY,
66
                "MaxAttempts": localstack_config.BOTO_WAITER_MAX_ATTEMPTS,
67
                **kwargs.get(
68
                    "WaiterConfig", {}
69
                ),  # we still allow client users to override these defaults
70
            },
71
        }
72
        return fn(self, **patched_kwargs)
1✔
73

74

75
# patch the botocore.Config object to be comparable and hashable.
76
# this solution does not validates the hashable (https://docs.python.org/3/glossary.html#term-hashable) definition on python
77
# It would do so only when someone accesses the internals of the Config option to change the dict directly.
78
# Since this is not a proper way to use the config object (but via config.merge), this should be fine
79
def make_hash(o):
1✔
80
    if isinstance(o, (set, tuple, list)):
1✔
81
        return tuple([make_hash(e) for e in o])
×
82

83
    elif not isinstance(o, dict):
1✔
84
        return hash(o)
1✔
85

86
    new_o = {}
1✔
87
    for k, v in o.items():
1✔
88
        new_o[k] = make_hash(v)
1✔
89

90
    return hash(frozenset(sorted(new_o.items())))
1✔
91

92

93
def config_equality_patch(self, other: object) -> bool:
1✔
94
    return type(self) is type(other) and self._user_provided_options == other._user_provided_options
1✔
95

96

97
def config_hash_patch(self):
1✔
98
    return make_hash(self._user_provided_options)
1✔
99

100

101
Config.__eq__ = config_equality_patch
1✔
102
Config.__hash__ = config_hash_patch
1✔
103

104

105
def attribute_name_to_service_name(attribute_name):
1✔
106
    """
107
    Converts a python-compatible attribute name to the boto service name
108
    :param attribute_name: Python compatible attribute name using the following replacements:
109
                            a) Add an underscore suffix `_` to any reserved Python keyword (PEP-8).
110
                            b) Replace any dash `-` with an underscore `_`
111
    :return:
112
    """
113
    if attribute_name.endswith("_"):
1✔
114
        # lambda_ -> lambda
115
        attribute_name = attribute_name[:-1]
1✔
116
    # replace all _ with -: cognito_idp -> cognito-idp
117
    return attribute_name.replace("_", "-")
1✔
118

119

120
def get_service_endpoint() -> str | None:
1✔
121
    """
122
    Returns the endpoint the client should target.
123

124
    :return: Endpoint url
125
    """
126
    if localstack_config.DISTRIBUTED_MODE:
1✔
127
        return None
×
128
    return localstack_config.internal_service_url()
1✔
129

130

131
#
132
# Data transfer object
133
#
134

135
INTERNAL_REQUEST_PARAMS_HEADER = "x-localstack-data"
1✔
136
"""Request header which contains the data transfer object."""
1✔
137

138

139
class InternalRequestParameters(TypedDict):
1✔
140
    """
141
    LocalStack Data Transfer Object.
142

143
    This is sent with every internal request and contains any additional information
144
    LocalStack might need for the purpose of policy enforcement. It is serialised
145
    into text and sent in the request header.
146

147
    Attributes can be added as needed. The keys should roughly correspond to:
148
    https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_condition-keys.html
149
    """
150

151
    source_arn: str | None
1✔
152
    """ARN of resource which is triggering the call"""
1✔
153

154
    service_principal: str | None
1✔
155
    """Service principal making this call"""
1✔
156

157

158
def dump_dto(data: InternalRequestParameters) -> str:
1✔
159
    # To produce a compact JSON representation of DTO, remove spaces from separators
160
    # If possible, we could use a custom encoder to further decrease header size in the future
161
    return json.dumps(data, separators=(",", ":"))
1✔
162

163

164
def load_dto(data: str) -> InternalRequestParameters:
1✔
165
    return json.loads(data)
1✔
166

167

168
T = TypeVar("T")
1✔
169

170

171
class MetadataRequestInjector(Generic[T]):
1✔
172
    def __init__(self, client: T, params: dict[str, str] | None = None):
1✔
173
        self._client = client
1✔
174
        self._params = params
1✔
175

176
    def __getattr__(self, item):
1✔
177
        target = getattr(self._client, item)
1✔
178
        if not isinstance(target, Callable):
1✔
179
            return target
1✔
180
        if self._params:
1✔
181
            return partial(target, **self._params)
1✔
182
        else:
183
            return target
1✔
184

185
    def request_metadata(
1✔
186
        self, source_arn: str | None = None, service_principal: str | None = None
187
    ) -> T:
188
        """
189
        Returns a new client instance preset with the given request metadata.
190
        Identical to providing _ServicePrincipal and _SourceArn directly as operation arguments but typing
191
        compatible.
192

193
        Raw example: lambda_client.invoke(FunctionName="fn", _SourceArn="...")
194
        Injector example: lambda_client.request_metadata(source_arn="...").invoke(FunctionName="fn")
195
        Cannot be called on objects where the parameters are already set.
196

197
        :param source_arn: Arn on which behalf the calls of this client shall be made
198
        :param service_principal: Service principal on which behalf the calls of this client shall be made
199
        :return: A new version of the MetadataRequestInjector
200
        """
201
        if self._params is not None:
1✔
202
            raise TypeError("Request_data cannot be called on it's own return value")
×
203
        params = {}
1✔
204
        if source_arn:
1✔
205
            params["_SourceArn"] = source_arn
1✔
206
        if service_principal:
1✔
207
            params["_ServicePrincipal"] = service_principal
1✔
208
        return MetadataRequestInjector(client=self._client, params=params)
1✔
209

210

211
#
212
# Factory
213
#
214
class ServiceLevelClientFactory(TypedServiceClientFactory):
1✔
215
    """
216
    A service level client factory, preseeded with parameters for the boto3 client creation.
217
    Will create any service client with parameters already provided by the ClientFactory.
218
    """
219

220
    def __init__(
1✔
221
        self,
222
        *,
223
        factory: "ClientFactory",
224
        client_creation_params: dict[str, str | Config | None],
225
        request_wrapper_clazz: type,
226
    ):
227
        self._factory = factory
1✔
228
        self._client_creation_params = client_creation_params
1✔
229
        self._request_wrapper_clazz = request_wrapper_clazz
1✔
230

231
    def get_client(self, service: str):
1✔
232
        return self._request_wrapper_clazz(
1✔
233
            client=self._factory.get_client(service_name=service, **self._client_creation_params)
234
        )
235

236
    def __getattr__(self, service: str):
1✔
237
        service = attribute_name_to_service_name(service)
1✔
238
        return self._request_wrapper_clazz(
1✔
239
            client=self._factory.get_client(service_name=service, **self._client_creation_params)
240
        )
241

242

243
class ClientFactory(ABC):
1✔
244
    """
245
    Factory to build the AWS client.
246

247
    Boto client creation is resource intensive. This class caches all Boto
248
    clients it creates and must be used instead of directly using boto lib.
249
    """
250

251
    def __init__(
1✔
252
        self,
253
        use_ssl: bool = False,
254
        verify: bool | str = False,
255
        session: Session = None,
256
        config: Config = None,
257
    ):
258
        """
259
        :param use_ssl: Whether to use SSL
260
        :param verify: Whether to verify SSL certificates
261
        :param session: Session to be used for client creation. Will create a new session if not provided.
262
            Please note that sessions are not generally thread safe.
263
            Either create a new session for each factory or make sure the session is not shared with another thread.
264
            The factory itself has a lock for the session, so as long as you only use the session in one factory,
265
            it should be fine using the factory in a multithreaded context.
266
        :param config: Config used as default for client creation.
267
        """
268
        self._use_ssl = use_ssl
1✔
269
        self._verify = verify
1✔
270
        self._config: Config = config or Config(max_pool_connections=MAX_POOL_CONNECTIONS)
1✔
271
        self._session: Session = session or Session()
1✔
272

273
        # make sure we consider our custom data paths for legacy specs (like SQS query protocol)
274
        if LOCALSTACK_BUILTIN_DATA_PATH not in self._session._loader.search_paths:
1✔
275
            self._session._loader.search_paths.insert(0, LOCALSTACK_BUILTIN_DATA_PATH)
1✔
276

277
        self._create_client_lock = threading.RLock()
1✔
278

279
    def __call__(
1✔
280
        self,
281
        *,
282
        region_name: str | None = None,
283
        aws_access_key_id: str | None = None,
284
        aws_secret_access_key: str | None = None,
285
        aws_session_token: str | None = None,
286
        endpoint_url: str = None,
287
        config: Config = None,
288
    ) -> ServiceLevelClientFactory:
289
        """
290
        Get back an object which lets you select the typed service you want to access with the given attributes
291

292
        :param region_name: Name of the AWS region to be associated with the client
293
            If set to None, loads from botocore session.
294
        :param aws_access_key_id: Access key to use for the client.
295
            If set to None, loads from botocore session.
296
        :param aws_secret_access_key: Secret key to use for the client.
297
            If set to None, loads from botocore session.
298
        :param aws_session_token: Session token to use for the client.
299
            Not being used if not set.
300
        :param endpoint_url: Full endpoint URL to be used by the client.
301
            Defaults to appropriate LocalStack endpoint.
302
        :param config: Boto config for advanced use.
303
        :return: Service Region Client Creator
304
        """
305
        params = {
1✔
306
            "region_name": region_name,
307
            "aws_access_key_id": aws_access_key_id,
308
            "aws_secret_access_key": aws_secret_access_key,
309
            "aws_session_token": aws_session_token,
310
            "endpoint_url": endpoint_url,
311
            "config": config,
312
        }
313
        return ServiceLevelClientFactory(
1✔
314
            factory=self,
315
            client_creation_params=params,
316
            request_wrapper_clazz=MetadataRequestInjector,
317
        )
318

319
    def with_assumed_role(
1✔
320
        self,
321
        *,
322
        role_arn: str,
323
        service_principal: ServicePrincipal | None = None,
324
        session_name: str | None = None,
325
        region_name: str | None = None,
326
        endpoint_url: str | None = None,
327
        config: Config | None = None,
328
    ) -> ServiceLevelClientFactory:
329
        """
330
        Create a service level client factory with credentials from assuming the given role ARN.
331
        The service_principal will only be used for the assume_role call, for all succeeding calls it has to be provided
332
        separately, either as call attribute or using request_metadata()
333

334
        :param role_arn: Role to assume
335
        :param service_principal: Service the role should be assumed as, must not be set for test clients
336
        :param session_name: Session name for the role session
337
        :param region_name: Region for the returned client
338
        :param endpoint_url: Endpoint for both the assume_role call and the returned client
339
        :param config: Config for both the assume_role call and the returned client
340
        :return: Service Level Client Factory
341
        """
342
        session_name = session_name or f"session-{short_uid()}"
1✔
343
        sts_client = self(endpoint_url=endpoint_url, config=config, region_name=region_name).sts
1✔
344

345
        metadata = {}
1✔
346
        if service_principal:
1✔
347
            metadata["service_principal"] = service_principal
1✔
348

349
        sts_client = sts_client.request_metadata(**metadata)
1✔
350
        credentials = sts_client.assume_role(RoleArn=role_arn, RoleSessionName=session_name)[
1✔
351
            "Credentials"
352
        ]
353

354
        return self(
1✔
355
            region_name=region_name,
356
            aws_access_key_id=credentials["AccessKeyId"],
357
            aws_secret_access_key=credentials["SecretAccessKey"],
358
            aws_session_token=credentials["SessionToken"],
359
            endpoint_url=endpoint_url,
360
            config=config,
361
        )
362

363
    @abstractmethod
1✔
364
    def get_client(
1✔
365
        self,
366
        service_name: str,
367
        region_name: str | None = None,
368
        aws_access_key_id: str | None = None,
369
        aws_secret_access_key: str | None = None,
370
        aws_session_token: str | None = None,
371
        endpoint_url: str | None = None,
372
        config: Config | None = None,
373
    ):
374
        raise NotImplementedError()
375

376
    def _get_client_post_hook(self, client: BaseClient) -> BaseClient:
1✔
377
        """
378
        This is called after the client is created by Boto.
379

380
        Any modifications to the client can be implemented here in subclasses
381
        without affecting the caching mechanism.
382
        """
383
        return client
1✔
384

385
    # TODO @lru_cache here might result in a memory leak, as it keeps a reference to `self`
386
    # We might need an alternative caching decorator with a weak ref to `self`
387
    # Otherwise factories might never be garbage collected
388
    @lru_cache(maxsize=256)
1✔
389
    def _get_client(
1✔
390
        self,
391
        service_name: str,
392
        region_name: str,
393
        use_ssl: bool,
394
        verify: bool | None,
395
        endpoint_url: str | None,
396
        aws_access_key_id: str | None,
397
        aws_secret_access_key: str | None,
398
        aws_session_token: str | None,
399
        config: Config,
400
    ) -> BaseClient:
401
        """
402
        Returns a boto3 client with the given configuration, and the hooks added by `_get_client_post_hook`.
403
        This is a cached call, so modifications to the used client will affect others.
404
        Please use another instance of the factory, should you want to modify clients.
405
        Client creation is behind a lock as it is not generally thread safe.
406

407
        :param service_name: Service to build the client for, eg. `s3`
408
        :param region_name: Name of the AWS region to be associated with the client
409
            If set to None, loads from botocore session.
410
        :param aws_access_key_id: Access key to use for the client.
411
            If set to None, loads from botocore session.
412
        :param aws_secret_access_key: Secret key to use for the client.
413
            If set to None, loads from botocore session.
414
        :param aws_session_token: Session token to use for the client.
415
            Not being used if not set.
416
        :param endpoint_url: Full endpoint URL to be used by the client.
417
            Defaults to appropriate LocalStack endpoint.
418
        :param config: Boto config for advanced use.
419
        :return: Boto3 client.
420
        """
421
        with self._create_client_lock:
1✔
422
            default_config = (
1✔
423
                Config(retries={"max_attempts": 0})
424
                if localstack_config.DISABLE_BOTO_RETRIES
425
                else Config()
426
            )
427

428
            client = self._session.client(
1✔
429
                service_name=service_name,
430
                region_name=region_name,
431
                use_ssl=use_ssl,
432
                verify=verify,
433
                endpoint_url=endpoint_url,
434
                aws_access_key_id=aws_access_key_id,
435
                aws_secret_access_key=aws_secret_access_key,
436
                aws_session_token=aws_session_token,
437
                config=config.merge(default_config),
438
            )
439

440
        return self._get_client_post_hook(client)
1✔
441

442
    #
443
    # Boto session utilities
444
    #
445
    def _get_session_region(self) -> str:
1✔
446
        """
447
        Return AWS region as set in the Boto session.
448
        """
449
        return self._session.region_name
1✔
450

451
    def _get_region(self) -> str:
1✔
452
        """
453
        Return the AWS region name from following sources, in order of availability.
454
        - LocalStack request context
455
        - Boto session
456
        - us-east-1
457
        """
458
        return self._get_session_region() or AWS_REGION_US_EAST_1
1✔
459

460

461
class InternalClientFactory(ClientFactory):
1✔
462
    def _get_client_post_hook(self, client: BaseClient) -> BaseClient:
1✔
463
        """
464
        Register handlers that enable internal data object transfer mechanism
465
        for internal clients.
466
        """
467
        client.meta.events.register(
1✔
468
            "provide-client-params.*.*", handler=_handler_create_request_parameters
469
        )
470

471
        client.meta.events.register("before-call.*.*", handler=_handler_inject_dto_header)
1✔
472

473
        if localstack_config.IN_MEMORY_CLIENT:
1✔
474
            # this make the client call the gateway directly
475
            from localstack.aws.client import GatewayShortCircuit
×
476
            from localstack.runtime import get_current_runtime
×
477

478
            GatewayShortCircuit.modify_client(client, get_current_runtime().components.gateway)
×
479

480
        return client
1✔
481

482
    def get_client(
1✔
483
        self,
484
        service_name: str,
485
        region_name: str | None = None,
486
        aws_access_key_id: str | None = None,
487
        aws_secret_access_key: str | None = None,
488
        aws_session_token: str | None = None,
489
        endpoint_url: str | None = None,
490
        config: Config | None = None,
491
    ) -> BaseClient:
492
        """
493
        Build and return client for connections originating within LocalStack.
494

495
        All API operation methods (such as `.list_buckets()` or `.run_instances()`
496
        take additional args that start with `_` prefix. These are used to pass
497
        additional information to LocalStack server during internal calls.
498

499
        :param service_name: Service to build the client for, eg. `s3`
500
        :param region_name: Region name. See note above.
501
            If set to None, loads from botocore session.
502
        :param aws_access_key_id: Access key to use for the client.
503
            Defaults to LocalStack internal credentials.
504
        :param aws_secret_access_key: Secret key to use for the client.
505
            Defaults to LocalStack internal credentials.
506
        :param aws_session_token: Session token to use for the client.
507
            Not being used if not set.
508
        :param endpoint_url: Full endpoint URL to be used by the client.
509
            Defaults to appropriate LocalStack endpoint.
510
        :param config: Boto config for advanced use.
511
        """
512

513
        if config is None:
1✔
514
            config = self._config
1✔
515
        else:
516
            config = self._config.merge(config)
1✔
517

518
        endpoint_url = endpoint_url or get_service_endpoint()
1✔
519
        if service_name == "s3" and endpoint_url:
1✔
520
            if re.match(r"https?://localhost(:[0-9]+)?", endpoint_url):
1✔
521
                endpoint_url = endpoint_url.replace("://localhost", f"://{get_s3_hostname()}")
1✔
522

523
        return self._get_client(
1✔
524
            service_name=service_name,
525
            region_name=region_name or self._get_region(),
526
            use_ssl=self._use_ssl,
527
            verify=self._verify,
528
            endpoint_url=endpoint_url,
529
            aws_access_key_id=aws_access_key_id or INTERNAL_AWS_ACCESS_KEY_ID,
530
            aws_secret_access_key=aws_secret_access_key or INTERNAL_AWS_SECRET_ACCESS_KEY,
531
            aws_session_token=aws_session_token,
532
            config=config,
533
        )
534

535

536
class ExternalClientFactory(ClientFactory):
1✔
537
    def get_client(
1✔
538
        self,
539
        service_name: str,
540
        region_name: str | None = None,
541
        aws_access_key_id: str | None = None,
542
        aws_secret_access_key: str | None = None,
543
        aws_session_token: str | None = None,
544
        endpoint_url: str | None = None,
545
        config: Config | None = None,
546
    ) -> BaseClient:
547
        """
548
        Build and return client for connections originating outside LocalStack and targeting Localstack.
549

550
        If the region is set to None, it is loaded from following
551
        locations:
552
        - AWS environment variables
553
        - Credentials file `~/.aws/credentials`
554
        - Config file `~/.aws/config`
555

556
        :param service_name: Service to build the client for, eg. `s3`
557
        :param region_name: Name of the AWS region to be associated with the client
558
            If set to None, loads from botocore session.
559
        :param aws_access_key_id: Access key to use for the client.
560
            If set to None, loads from botocore session.
561
        :param aws_secret_access_key: Secret key to use for the client.
562
            If set to None, uses a placeholder value
563
        :param aws_session_token: Session token to use for the client.
564
            Not being used if not set.
565
        :param endpoint_url: Full endpoint URL to be used by the client.
566
            Defaults to appropriate LocalStack endpoint.
567
        :param config: Boto config for advanced use.
568
        """
569
        if config is None:
1✔
570
            config = self._config
1✔
571
        else:
572
            config = self._config.merge(config)
1✔
573

574
        # Boto has an odd behaviour when using a non-default (any other region than us-east-1) in config
575
        # If the region in arg is non-default, it gives the arg the precedence
576
        # But if the region in arg is default (us-east-1), it gives precedence to one in config
577
        # Below: always give precedence to arg region
578
        if config and config.region_name != AWS_REGION_US_EAST_1:
1✔
579
            if region_name == AWS_REGION_US_EAST_1:
1✔
580
                config = config.merge(Config(region_name=region_name))
1✔
581

582
        endpoint_url = endpoint_url or get_service_endpoint()
1✔
583
        if service_name == "s3":
1✔
584
            if re.match(r"https?://localhost(:[0-9]+)?", endpoint_url):
1✔
585
                endpoint_url = endpoint_url.replace("://localhost", f"://{get_s3_hostname()}")
1✔
586

587
        # Prevent `PartialCredentialsError` when only access key ID is provided
588
        # The value of secret access key is insignificant and can be set to anything
589
        if aws_access_key_id:
1✔
590
            aws_secret_access_key = aws_secret_access_key or INTERNAL_AWS_SECRET_ACCESS_KEY
1✔
591

592
        return self._get_client(
1✔
593
            service_name=service_name,
594
            region_name=region_name or config.region_name or self._get_region(),
595
            use_ssl=self._use_ssl,
596
            verify=self._verify,
597
            endpoint_url=endpoint_url,
598
            aws_access_key_id=aws_access_key_id,
599
            aws_secret_access_key=aws_secret_access_key,
600
            aws_session_token=aws_session_token,
601
            config=config,
602
        )
603

604

605
class ExternalAwsClientFactory(ClientFactory):
1✔
606
    def get_client(
1✔
607
        self,
608
        service_name: str,
609
        region_name: str | None = None,
610
        aws_access_key_id: str | None = None,
611
        aws_secret_access_key: str | None = None,
612
        aws_session_token: str | None = None,
613
        endpoint_url: str | None = None,
614
        config: Config | None = None,
615
    ) -> BaseClient:
616
        """
617
        Build and return client for connections originating outside LocalStack and targeting AWS.
618

619
        If either of the access keys or region are set to None, they are loaded from following
620
        locations:
621
        - AWS environment variables
622
        - Credentials file `~/.aws/credentials`
623
        - Config file `~/.aws/config`
624

625
        :param service_name: Service to build the client for, eg. `s3`
626
        :param region_name: Name of the AWS region to be associated with the client
627
            If set to None, loads from botocore session.
628
        :param aws_access_key_id: Access key to use for the client.
629
            If set to None, loads from botocore session.
630
        :param aws_secret_access_key: Secret key to use for the client.
631
            If set to None, loads from botocore session.
632
        :param aws_session_token: Session token to use for the client.
633
            Not being used if not set.
634
        :param endpoint_url: Full endpoint URL to be used by the client.
635
            Defaults to appropriate AWS endpoint.
636
        :param config: Boto config for advanced use.
637
        """
638
        if config is None:
1✔
639
            config = self._config
1✔
640
        else:
641
            config = self._config.merge(config)
×
642

643
        return self._get_client(
1✔
644
            config=config,
645
            service_name=service_name,
646
            region_name=region_name or self._get_session_region(),
647
            endpoint_url=endpoint_url,
648
            use_ssl=True,
649
            verify=True,
650
            aws_access_key_id=aws_access_key_id,
651
            aws_secret_access_key=aws_secret_access_key,
652
            aws_session_token=aws_session_token,
653
        )
654

655

656
def resolve_dns_from_upstream(hostname: str) -> str:
1✔
657
    from localstack.dns.server import get_fallback_dns_server
×
658

659
    upstream_dns = get_fallback_dns_server()
×
660
    request = dns.message.make_query(hostname, "A")
×
661
    response = dns.query.udp(request, upstream_dns, port=53, timeout=5)
×
662
    if len(response.answer) == 0:
×
663
        raise ValueError(f"No DNS response found for hostname '{hostname}'")
×
664

665
    ip_addresses = []
×
666
    for answer in response.answer:
×
667
        if answer.match(dns.rdataclass.IN, dns.rdatatype.A, dns.rdatatype.NONE):
×
668
            ip_addresses.extend(answer.items.keys())
×
669

670
    if not ip_addresses:
×
671
        raise ValueError(f"No DNS records of type 'A' found for hostname '{hostname}'")
×
672

673
    return choice(ip_addresses).address
×
674

675

676
class ExternalBypassDnsClientFactory(ExternalAwsClientFactory):
1✔
677
    """
678
    Client factory that makes requests against AWS ensuring that DNS resolution is not affected by the LocalStack DNS
679
    server.
680
    """
681

682
    def __init__(
1✔
683
        self,
684
        session: Session = None,
685
        config: Config = None,
686
    ):
NEW
687
        if ca_cert := os.getenv("REQUESTS_CA_BUNDLE"):
×
NEW
688
            LOG.debug("Creating External AWS Client with REQUESTS_CA_BUNDLE=%s", ca_cert)
×
689

NEW
690
        super().__init__(
×
691
            use_ssl=localstack_config.is_env_not_false("USE_SSL"),
692
            verify=ca_cert or True,
693
            session=session,
694
            config=config,
695
        )
696

697
    def _get_client_post_hook(self, client: BaseClient) -> BaseClient:
1✔
698
        client = super()._get_client_post_hook(client)
×
NEW
699
        client._endpoint.http_session = ExternalBypassDnsSession(verify=self._verify)
×
700
        return client
×
701

702

703
class ExternalBypassDnsHTTPConnection(AWSHTTPConnection):
1✔
704
    """
705
    Connection class that bypasses the LocalStack DNS server for HTTP connections
706
    """
707

708
    def _new_conn(self) -> socket:
1✔
709
        orig_host = self._dns_host
×
710
        try:
×
711
            self._dns_host = resolve_dns_from_upstream(self._dns_host)
×
712
            return super()._new_conn()
×
713
        finally:
714
            self._dns_host = orig_host
×
715

716

717
class ExternalBypassDnsHTTPSConnection(AWSHTTPSConnection):
1✔
718
    """
719
    Connection class that bypasses the LocalStack DNS server for HTTPS connections
720
    """
721

722
    def _new_conn(self) -> socket:
1✔
723
        orig_host = self._dns_host
×
724
        try:
×
725
            self._dns_host = resolve_dns_from_upstream(self._dns_host)
×
726
            return super()._new_conn()
×
727
        finally:
728
            self._dns_host = orig_host
×
729

730

731
class ExternalBypassDnsHTTPConnectionPool(AWSHTTPConnectionPool):
1✔
732
    ConnectionCls = ExternalBypassDnsHTTPConnection
1✔
733

734

735
class ExternalBypassDnsHTTPSConnectionPool(AWSHTTPSConnectionPool):
1✔
736
    ConnectionCls = ExternalBypassDnsHTTPSConnection
1✔
737

738

739
class ExternalBypassDnsSession(URLLib3Session):
1✔
740
    """
741
    urllib3 session wrapper that uses our custom connection pool.
742
    """
743

744
    def __init__(self, *args, **kwargs):
1✔
745
        super().__init__(*args, **kwargs)
×
746

747
        self._pool_classes_by_scheme["https"] = ExternalBypassDnsHTTPSConnectionPool
×
748
        self._pool_classes_by_scheme["http"] = ExternalBypassDnsHTTPConnectionPool
×
749

750

751
connect_to = InternalClientFactory(use_ssl=localstack_config.DISTRIBUTED_MODE)
1✔
752
connect_externally_to = ExternalClientFactory()
1✔
753

754

755
#
756
# Handlers
757
#
758

759

760
def _handler_create_request_parameters(params: dict[str, Any], context: dict[str, Any], **kwargs):
1✔
761
    """
762
    Construct the data transfer object at the time of parsing the client
763
    parameters and proxy it via the Boto context dict.
764

765
    This handler enables the use of additional keyword parameters in Boto API
766
    operation functions.
767

768
    It uses the `InternalRequestParameters` type annotations to handle supported parameters.
769
    The keys supported by this type will be converted to method parameters by prefixing it with an underscore `_`
770
    and converting the snake case to camel case.
771
    Example:
772
        service_principal -> _ServicePrincipal
773
    """
774

775
    # Names of arguments that can be passed to Boto API operation functions.
776
    # These must correspond to entries on the data transfer object.
777
    dto = InternalRequestParameters()
1✔
778
    for member in InternalRequestParameters.__annotations__.keys():
1✔
779
        parameter = f"_{''.join([part.title() for part in member.split('_')])}"
1✔
780
        if parameter in params:
1✔
781
            dto[member] = params.pop(parameter)
1✔
782

783
    context["_localstack"] = dto
1✔
784

785

786
def _handler_inject_dto_header(params: dict[str, Any], context: dict[str, Any], **kwargs):
1✔
787
    """
788
    Retrieve the data transfer object from the Boto context dict and serialise
789
    it as part of the request headers.
790
    """
791
    if (dto := context.pop("_localstack", None)) is not None:
1✔
792
        params["headers"][INTERNAL_REQUEST_PARAMS_HEADER] = dump_dto(dto)
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc