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

localstack / localstack / 17752573338

15 Sep 2025 10:56PM UTC coverage: 86.879% (+0.03%) from 86.851%
17752573338

push

github

web-flow
CFn: validate during get template (#13139)

6 of 6 new or added lines in 1 file covered. (100.0%)

138 existing lines in 10 files now uncovered.

67201 of 77350 relevant lines covered (86.88%)

0.87 hits per line

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

86.63
/localstack-core/localstack/dns/server.py
1
import argparse
1✔
2
import copy
1✔
3
import logging
1✔
4
import os
1✔
5
import re
1✔
6
import textwrap
1✔
7
import threading
1✔
8
from collections.abc import Iterable
1✔
9
from datetime import datetime
1✔
10
from functools import cache
1✔
11
from ipaddress import IPv4Address, IPv4Interface
1✔
12
from pathlib import Path
1✔
13
from socket import AddressFamily
1✔
14
from typing import Literal
1✔
15

16
import psutil
1✔
17
from cachetools import TTLCache, cached
1✔
18
from dnslib import (
1✔
19
    AAAA,
20
    CNAME,
21
    MX,
22
    NS,
23
    QTYPE,
24
    RCODE,
25
    RD,
26
    RDMAP,
27
    RR,
28
    SOA,
29
    TXT,
30
    A,
31
    DNSHeader,
32
    DNSLabel,
33
    DNSQuestion,
34
    DNSRecord,
35
)
36
from dnslib.server import DNSHandler, DNSServer
1✔
37
from psutil._common import snicaddr
1✔
38

39
import dns.flags
1✔
40
import dns.message
1✔
41
import dns.query
1✔
42
from dns.exception import Timeout
1✔
43

44
# Note: avoid adding additional imports here, to avoid import issues when running the CLI
45
from localstack import config
1✔
46
from localstack.constants import LOCALHOST_HOSTNAME, LOCALHOST_IP
1✔
47
from localstack.dns.models import (
1✔
48
    AliasTarget,
49
    DnsServerProtocol,
50
    DynamicRecord,
51
    NameRecord,
52
    RecordType,
53
    SOARecord,
54
    TargetRecord,
55
)
56
from localstack.services.edge import run_module_as_sudo
1✔
57
from localstack.utils import iputils
1✔
58
from localstack.utils.net import Port, port_can_be_bound
1✔
59
from localstack.utils.platform import in_docker
1✔
60
from localstack.utils.serving import Server
1✔
61
from localstack.utils.strings import to_bytes, to_str
1✔
62
from localstack.utils.sync import sleep_forever
1✔
63

64
EPOCH = datetime(1970, 1, 1)
1✔
65
SERIAL = int((datetime.utcnow() - EPOCH).total_seconds())
1✔
66

67
DEFAULT_FALLBACK_DNS_SERVER = "8.8.8.8"
1✔
68
FALLBACK_DNS_LOCK = threading.RLock()
1✔
69
VERIFICATION_DOMAIN = config.DNS_VERIFICATION_DOMAIN
1✔
70

71
RCODE_REFUSED = 5
1✔
72

73
DNS_SERVER: "DnsServerProtocol" = None
1✔
74
PREVIOUS_RESOLV_CONF_FILE: str | None = None
1✔
75

76
REQUEST_TIMEOUT_SECS = 7
1✔
77

78
TYPE_LOOKUP = {
1✔
79
    A: QTYPE.A,
80
    AAAA: QTYPE.AAAA,
81
    CNAME: QTYPE.CNAME,
82
    MX: QTYPE.MX,
83
    NS: QTYPE.NS,
84
    SOA: QTYPE.SOA,
85
    TXT: QTYPE.TXT,
86
}
87

88
LOG = logging.getLogger(__name__)
1✔
89

90
THREAD_LOCAL = threading.local()
1✔
91

92
# Type of the value given by DNSHandler.client_address
93
# in the form (ip, port) e.g. ("127.0.0.1", 58291)
94
ClientAddress = tuple[str, int]
1✔
95

96
psutil_cache = TTLCache(maxsize=100, ttl=10)
1✔
97

98

99
# TODO: update route53 provider to use this util
100
def normalise_dns_name(name: DNSLabel | str) -> str:
1✔
101
    name = str(name)
1✔
102
    if not name.endswith("."):
1✔
103
        return f"{name}."
1✔
104

105
    return name
1✔
106

107

108
@cached(cache=psutil_cache)
1✔
109
def list_network_interface_details() -> dict[str, list[snicaddr]]:
1✔
110
    return psutil.net_if_addrs()
1✔
111

112

113
class Record:
1✔
114
    def __init__(self, rdata_type, *args, **kwargs):
1✔
115
        rtype = kwargs.get("rtype")
1✔
116
        rname = kwargs.get("rname")
1✔
117
        ttl = kwargs.get("ttl")
1✔
118

119
        if isinstance(rdata_type, RD):
1✔
120
            # actually an instance, not a type
121
            self._rtype = TYPE_LOOKUP[rdata_type.__class__]
×
122
            rdata = rdata_type
×
123
        else:
124
            self._rtype = TYPE_LOOKUP[rdata_type]
1✔
125
            if rdata_type == SOA and len(args) == 2:
1✔
126
                # add sensible times to SOA
127
                args += (
1✔
128
                    (
129
                        SERIAL,  # serial number
130
                        60 * 60 * 1,  # refresh
131
                        60 * 60 * 3,  # retry
132
                        60 * 60 * 24,  # expire
133
                        60 * 60 * 1,  # minimum
134
                    ),
135
                )
136
            rdata = rdata_type(*args)
1✔
137

138
        if rtype:
1✔
139
            self._rtype = rtype
×
140
        self._rname = rname
1✔
141
        self.kwargs = dict(rdata=rdata, ttl=self.sensible_ttl() if ttl is None else ttl, **kwargs)
1✔
142

143
    def try_rr(self, q):
1✔
144
        if q.qtype == QTYPE.ANY or q.qtype == self._rtype:
1✔
145
            return self.as_rr(q.qname)
1✔
146

147
    def as_rr(self, alt_rname):
1✔
148
        return RR(rname=self._rname or alt_rname, rtype=self._rtype, **self.kwargs)
1✔
149

150
    def sensible_ttl(self):
1✔
151
        if self._rtype in (QTYPE.NS, QTYPE.SOA):
1✔
152
            return 60 * 60 * 24
1✔
153
        else:
154
            return 300
1✔
155

156
    @property
1✔
157
    def is_soa(self):
1✔
158
        return self._rtype == QTYPE.SOA
1✔
159

160
    def __str__(self):
1✔
161
        return f"{QTYPE[self._rtype]}({self.kwargs})"
×
162

163
    def __repr__(self):
164
        return self.__str__()
165

166

167
class RecordConverter:
1✔
168
    """
169
    Handles returning the correct DNS record for the stored name_record.
170

171
    Particularly, if the record is a DynamicRecord, then perform dynamic IP address lookup.
172
    """
173

174
    def __init__(self, request: DNSRecord, client_address: ClientAddress):
1✔
175
        self.request = request
1✔
176
        self.client_address = client_address
1✔
177

178
    def to_record(self, name_record: NameRecord) -> Record:
1✔
179
        """
180
        :param name_record: Internal representation of the name entry
181
        :return: Record type for the associated name record
182
        """
183
        match name_record:
1✔
184
            case TargetRecord(target=target, record_type=record_type):
1✔
185
                return Record(RDMAP.get(record_type.name), target)
1✔
186
            case SOARecord(m_name=m_name, r_name=r_name, record_type=_):
1✔
187
                return Record(SOA, m_name, r_name)
1✔
188
            case DynamicRecord(record_type=record_type):
1✔
189
                # Marker indicating that the target of the domain name lookup should be resolved
190
                # dynamically at query time to the most suitable LocalStack container IP address
191
                ip = self._determine_best_ip()
1✔
192
                # TODO: be more dynamic with IPv6
193
                if record_type == RecordType.AAAA:
1✔
194
                    ip = "::1"
1✔
195
                return Record(RDMAP.get(record_type.name), ip)
1✔
196
            case _:
×
197
                raise NotImplementedError(f"Record type '{type(name_record)}' not implemented")
198

199
    def _determine_best_ip(self) -> str:
1✔
200
        client_ip, _ = self.client_address
1✔
201
        # allow for overriding if required
202
        if config.DNS_RESOLVE_IP != LOCALHOST_IP:
1✔
203
            return config.DNS_RESOLVE_IP
1✔
204

205
        # Look up best matching ip address for the client
206
        interfaces = self._fetch_interfaces()
1✔
207
        for interface in interfaces:
1✔
208
            subnet = interface.network
1✔
209
            ip_address = IPv4Address(client_ip)
1✔
210
            if ip_address in subnet:
1✔
211
                # check if the request has come from the gateway or not. If so
212
                # assume the request has come from the host, and return
213
                # 127.0.0.1
214
                if config.is_in_docker and self._is_gateway(ip_address):
1✔
215
                    return LOCALHOST_IP
×
216

217
                return str(interface.ip)
1✔
218

219
        # no best solution found
220
        LOG.warning(
×
221
            "could not determine subnet-matched IP address for %s, falling back to %s",
222
            self.request.q.qname,
223
            LOCALHOST_IP,
224
        )
225
        return LOCALHOST_IP
×
226

227
    @staticmethod
1✔
228
    def _is_gateway(ip: IPv4Address) -> bool:
1✔
229
        """
230
        Look up the gateways that this contianer has, and return True if the
231
        supplied ip address is in that list.
232
        """
233
        return ip == iputils.get_default_gateway()
1✔
234

235
    @staticmethod
1✔
236
    def _fetch_interfaces() -> Iterable[IPv4Interface]:
1✔
237
        interfaces = list_network_interface_details()
1✔
238
        for _, addresses in interfaces.items():
1✔
239
            for address in addresses:
1✔
240
                if address.family != AddressFamily.AF_INET:
1✔
241
                    # TODO: IPv6
242
                    continue
1✔
243

244
                # argument is of the form e.g. 127.0.0.1/255.0.0.0
245
                net = IPv4Interface(f"{address.address}/{address.netmask}")
1✔
246
                yield net
1✔
247

248

249
class NonLoggingHandler(DNSHandler):
1✔
250
    """Subclass of DNSHandler that avoids logging to stdout on error"""
251

252
    def handle(self, *args, **kwargs):
1✔
253
        try:
1✔
254
            THREAD_LOCAL.client_address = self.client_address
1✔
255
            THREAD_LOCAL.server = self.server
1✔
256
            THREAD_LOCAL.request = self.request
1✔
257
            return super().handle(*args, **kwargs)
1✔
258
        except Exception:
×
259
            pass
×
260

261

262
# List of unique non-subdomain prefixes (e.g., data-) from endpoint.hostPrefix in the botocore specs.
263
# Subdomain-prefixes (e.g., api.) work properly unless DNS rebind protection blocks DNS resolution, but
264
# these `-` dash-prefixes require special consideration.
265
# IMPORTANT: Adding a new host prefix here requires deploying a public DNS entry to ensure proper DNS resolution for
266
# such non-dot prefixed domains (e.g., data-localhost.localstack.cloud)
267
# LIMITATION: As of 2025-05-26, only used prefixes are deployed to our public DNS, including `sync-` and `data-`
268
HOST_PREFIXES_NO_SUBDOMAIN = [
1✔
269
    "analytics-",
270
    "control-storage-",
271
    "data-",
272
    "query-",
273
    "runtime-",
274
    "storage-",
275
    "streaming-",
276
    "sync-",
277
    "tags-",
278
    "workflows-",
279
]
280
HOST_PREFIX_NAME_PATTERNS = [
1✔
281
    f"{host_prefix}{LOCALHOST_HOSTNAME}" for host_prefix in HOST_PREFIXES_NO_SUBDOMAIN
282
]
283

284
NAME_PATTERNS_POINTING_TO_LOCALSTACK = [
1✔
285
    f".*{LOCALHOST_HOSTNAME}",
286
    *HOST_PREFIX_NAME_PATTERNS,
287
]
288

289

290
def exclude_from_resolution(domain_regex: str):
1✔
291
    """
292
    Excludes the given domain pattern from being resolved to LocalStack.
293
    Currently only works in docker, since in host mode dns is started as separate process
294
    :param domain_regex: Domain regex string
295
    """
296
    if DNS_SERVER:
×
297
        DNS_SERVER.add_skip(domain_regex)
×
298

299

300
def revert_exclude_from_resolution(domain_regex: str):
1✔
301
    """
302
    Reverts the exclusion of the given domain pattern
303
    :param domain_regex: Domain regex string
304
    """
305
    try:
×
306
        if DNS_SERVER:
×
307
            DNS_SERVER.delete_skip(domain_regex)
×
308
    except ValueError:
×
309
        pass
×
310

311

312
def _should_delete_zone(record_to_delete: NameRecord, record_to_check: NameRecord):
1✔
313
    """
314
    Helper function to check if we should delete the record_to_check from the list we are iterating over
315
    :param record_to_delete: Record which we got from the delete request
316
    :param record_to_check: Record to be checked if it should be included in the records after delete
317
    :return:
318
    """
319
    if record_to_delete == record_to_check:
1✔
320
        return True
1✔
321
    return (
1✔
322
        record_to_delete.record_type == record_to_check.record_type
323
        and record_to_delete.record_id == record_to_check.record_id
324
    )
325

326

327
def _should_delete_alias(alias_to_delete: AliasTarget, alias_to_check: AliasTarget):
1✔
328
    """
329
    Helper function to check if we should delete the alias_to_check from the list we are iterating over
330
    :param alias_to_delete: Alias which we got from the delete request
331
    :param alias_to_check: Alias to be checked if it should be included in the records after delete
332
    :return:
333
    """
334
    return alias_to_delete.alias_id == alias_to_check.alias_id
1✔
335

336

337
class NoopLogger:
1✔
338
    """
339
    Necessary helper class to avoid logging of any dns records by dnslib
340
    """
341

342
    def __init__(self, *args, **kwargs):
1✔
343
        pass
1✔
344

345
    def log_pass(self, *args, **kwargs):
1✔
346
        pass
×
347

348
    def log_prefix(self, *args, **kwargs):
1✔
349
        pass
×
350

351
    def log_recv(self, *args, **kwargs):
1✔
352
        pass
1✔
353

354
    def log_send(self, *args, **kwargs):
1✔
355
        pass
1✔
356

357
    def log_request(self, *args, **kwargs):
1✔
358
        pass
1✔
359

360
    def log_reply(self, *args, **kwargs):
1✔
361
        pass
1✔
362

363
    def log_truncated(self, *args, **kwargs):
1✔
364
        pass
×
365

366
    def log_error(self, *args, **kwargs):
1✔
367
        pass
×
368

369
    def log_data(self, *args, **kwargs):
1✔
370
        pass
×
371

372

373
class Resolver(DnsServerProtocol):
1✔
374
    # Upstream DNS server
375
    upstream_dns: str
1✔
376
    # List of patterns which will be skipped for local resolution and always forwarded to upstream
377
    skip_patterns: list[str]
1✔
378
    # Dict of zones: (domain name or pattern) -> list[dns records]
379
    zones: dict[str, list[NameRecord]]
1✔
380
    # Alias map (source_name, record_type) => target_name (target name then still has to be resolved!)
381
    aliases: dict[tuple[DNSLabel, RecordType], list[AliasTarget]]
1✔
382
    # Lock to prevent issues due to concurrent modifications
383
    lock: threading.RLock
1✔
384

385
    def __init__(self, upstream_dns: str):
1✔
386
        self.upstream_dns = upstream_dns
1✔
387
        self.skip_patterns = []
1✔
388
        self.zones = {}
1✔
389
        self.aliases = {}
1✔
390
        self.lock = threading.RLock()
1✔
391

392
    def resolve(self, request: DNSRecord, handler: DNSHandler) -> DNSRecord | None:
1✔
393
        """
394
        Resolve a given request, by either checking locally registered records, or forwarding to the defined
395
        upstream DNS server.
396

397
        :param request: DNS Request
398
        :param handler: Unused.
399
        :return: DNS Reply
400
        """
401
        reply = request.reply()
1✔
402
        found = False
1✔
403

404
        try:
1✔
405
            if not self._skip_local_resolution(request):
1✔
406
                found = self._resolve_name(request, reply, handler.client_address)
1✔
407
        except Exception as e:
×
408
            LOG.info("Unable to get DNS result: %s", e)
×
409

410
        if found:
1✔
411
            return reply
1✔
412

413
        # If we did not find a matching record in our local zones, we forward to our upstream dns
414
        try:
1✔
415
            req_parsed = dns.message.from_wire(bytes(request.pack()))
1✔
416
            r = dns.query.udp(req_parsed, self.upstream_dns, timeout=REQUEST_TIMEOUT_SECS)
1✔
417
            result = self._map_response_dnspython_to_dnslib(r)
1✔
418
            return result
1✔
UNCOV
419
        except Exception as e:
×
UNCOV
420
            LOG.info(
×
421
                "Unable to get DNS result from upstream server %s for domain %s: %s",
422
                self.upstream_dns,
423
                str(request.q.qname),
424
                e,
425
            )
426

427
        # if we cannot reach upstream dns, return SERVFAIL
UNCOV
428
        if not reply.rr and reply.header.get_rcode == RCODE.NOERROR:
×
429
            # setting this return code will cause commands like 'host' to try the next nameserver
430
            reply.header.set_rcode(RCODE.SERVFAIL)
×
431
            return None
×
432

UNCOV
433
        return reply
×
434

435
    def _skip_local_resolution(self, request) -> bool:
1✔
436
        """
437
        Check whether we should skip local resolution for the given request, and directly contact upstream
438

439
        :param request: DNS Request
440
        :return: Whether the request local resolution should be skipped
441
        """
442
        request_name = to_str(str(request.q.qname))
1✔
443
        for p in self.skip_patterns:
1✔
444
            if re.match(p, request_name):
1✔
445
                return True
1✔
446
        return False
1✔
447

448
    def _resolve_alias(
1✔
449
        self, request: DNSRecord, reply: DNSRecord, client_address: ClientAddress
450
    ) -> bool:
451
        if request.q.qtype in (QTYPE.A, QTYPE.AAAA, QTYPE.CNAME):
1✔
452
            key = (DNSLabel(to_bytes(request.q.qname)), RecordType[QTYPE[request.q.qtype]])
1✔
453
            # check if we have aliases defined for our given qname/qtype pair
454
            if aliases := self.aliases.get(key):
1✔
455
                for alias in aliases:
1✔
456
                    # if there is no health check, or the healthcheck is successful, we will consider this alias
457
                    # take the first alias passing this check
458
                    if not alias.health_check or alias.health_check():
1✔
459
                        request_copy: DNSRecord = copy.deepcopy(request)
1✔
460
                        request_copy.q.qname = alias.target
1✔
461
                        # check if we can resolve the alias
462
                        found = self._resolve_name_from_zones(request_copy, reply, client_address)
1✔
463
                        if found:
1✔
464
                            LOG.debug(
1✔
465
                                "Found entry for AliasTarget '%s' ('%s')", request.q.qname, alias
466
                            )
467
                            # change the replaced rr-DNS names back to the original request
468
                            for rr in reply.rr:
1✔
469
                                rr.set_rname(request.q.qname)
1✔
470
                        else:
471
                            reply.header.set_rcode(RCODE.REFUSED)
×
472
                        return True
1✔
473
        return False
1✔
474

475
    def _resolve_name(
1✔
476
        self, request: DNSRecord, reply: DNSRecord, client_address: ClientAddress
477
    ) -> bool:
478
        if alias_found := self._resolve_alias(request, reply, client_address):
1✔
479
            LOG.debug("Alias found: %s", request.q.qname)
1✔
480
            return alias_found
1✔
481
        return self._resolve_name_from_zones(request, reply, client_address)
1✔
482

483
    def _resolve_name_from_zones(
1✔
484
        self, request: DNSRecord, reply: DNSRecord, client_address: ClientAddress
485
    ) -> bool:
486
        found = False
1✔
487

488
        converter = RecordConverter(request, client_address)
1✔
489

490
        # check for direct (not regex based) response
491
        zone = self.zones.get(normalise_dns_name(request.q.qname))
1✔
492
        if zone is not None:
1✔
493
            for zone_records in zone:
1✔
494
                rr = converter.to_record(zone_records).try_rr(request.q)
1✔
495
                if rr:
1✔
496
                    found = True
1✔
497
                    reply.add_answer(rr)
1✔
498
        else:
499
            # no direct zone so look for an SOA record for a higher level zone
500
            for zone_label, zone_records in self.zones.items():
1✔
501
                # try regex match
502
                pattern = re.sub(r"(^|[^.])\*", ".*", str(zone_label))
1✔
503
                if re.match(pattern, str(request.q.qname)):
1✔
504
                    for record in zone_records:
1✔
505
                        rr = converter.to_record(record).try_rr(request.q)
1✔
506
                        if rr:
1✔
507
                            found = True
1✔
508
                            reply.add_answer(rr)
1✔
509
                # try suffix match
510
                elif request.q.qname.matchSuffix(to_bytes(zone_label)):
1✔
511
                    try:
1✔
512
                        soa_record = next(r for r in zone_records if converter.to_record(r).is_soa)
1✔
513
                    except StopIteration:
1✔
514
                        continue
1✔
515
                    else:
516
                        found = True
1✔
517
                        reply.add_answer(converter.to_record(soa_record).as_rr(zone_label))
1✔
518
                        break
1✔
519
        return found
1✔
520

521
    def _parse_section(self, section: str) -> list[RR]:
1✔
522
        result = []
1✔
523
        for line in section.split("\n"):
1✔
524
            line = line.strip()
1✔
525
            if line:
1✔
526
                if line.startswith(";"):
1✔
527
                    # section ended, stop parsing
528
                    break
1✔
529
                else:
530
                    result += RR.fromZone(line)
1✔
531
        return result
1✔
532

533
    def _map_response_dnspython_to_dnslib(self, response):
1✔
534
        """Map response object from dnspython to dnslib (looks like we cannot
535
        simply export/import the raw messages from the wire)"""
536
        flags = dns.flags.to_text(response.flags)
1✔
537

538
        def flag(f):
1✔
539
            return 1 if f.upper() in flags else 0
1✔
540

541
        questions = []
1✔
542
        for q in response.question:
1✔
543
            questions.append(DNSQuestion(qname=str(q.name), qtype=q.rdtype, qclass=q.rdclass))
1✔
544

545
        result = DNSRecord(
1✔
546
            DNSHeader(
547
                qr=flag("qr"), aa=flag("aa"), ra=flag("ra"), id=response.id, rcode=response.rcode()
548
            ),
549
            q=questions[0],
550
        )
551

552
        # extract answers
553
        answer_parts = str(response).partition(";ANSWER")
1✔
554
        result.add_answer(*self._parse_section(answer_parts[2]))
1✔
555
        # extract authority information
556
        authority_parts = str(response).partition(";AUTHORITY")
1✔
557
        result.add_auth(*self._parse_section(authority_parts[2]))
1✔
558
        return result
1✔
559

560
    def add_host(self, name: str, record: NameRecord):
1✔
561
        LOG.debug("Adding host %s with record %s", name, record)
1✔
562
        name = normalise_dns_name(name)
1✔
563
        with self.lock:
1✔
564
            self.zones.setdefault(name, [])
1✔
565
            self.zones[name].append(record)
1✔
566

567
    def delete_host(self, name: str, record: NameRecord):
1✔
568
        LOG.debug("Deleting host %s with record %s", name, record)
1✔
569
        name = normalise_dns_name(name)
1✔
570
        with self.lock:
1✔
571
            if not self.zones.get(name):
1✔
572
                raise ValueError("Could not find entry %s for name %s in zones", record, name)
1✔
573
            self.zones.setdefault(name, [])
1✔
574
            current_zones = self.zones[name]
1✔
575
            self.zones[name] = [
1✔
576
                zone for zone in self.zones[name] if not _should_delete_zone(record, zone)
577
            ]
578
            if self.zones[name] == current_zones:
1✔
579
                raise ValueError("Could not find entry %s for name %s in zones", record, name)
×
580
            # if we deleted the last entry, clean up
581
            if not self.zones[name]:
1✔
582
                del self.zones[name]
1✔
583

584
    def add_alias(self, source_name: str, record_type: RecordType, target: AliasTarget):
1✔
585
        LOG.debug("Adding alias %s with record type %s target %s", source_name, record_type, target)
1✔
586
        label = (DNSLabel(to_bytes(source_name)), record_type)
1✔
587
        with self.lock:
1✔
588
            self.aliases.setdefault(label, [])
1✔
589
            self.aliases[label].append(target)
1✔
590

591
    def delete_alias(self, source_name: str, record_type: RecordType, target: AliasTarget):
1✔
592
        LOG.debug(
1✔
593
            "Deleting alias %s with record type %s",
594
            source_name,
595
            record_type,
596
        )
597
        label = (DNSLabel(to_bytes(source_name)), record_type)
1✔
598
        with self.lock:
1✔
599
            if not self.aliases.get(label):
1✔
600
                raise ValueError(
1✔
601
                    "Could not find entry %s for name %s, record type %s in aliases",
602
                    target,
603
                    source_name,
604
                    record_type,
605
                )
606
            self.aliases.setdefault(label, [])
1✔
607
            current_aliases = self.aliases[label]
1✔
608
            self.aliases[label] = [
1✔
609
                alias for alias in self.aliases[label] if not _should_delete_alias(target, alias)
610
            ]
611
            if self.aliases[label] == current_aliases:
1✔
612
                raise ValueError(
×
613
                    "Could not find entry %s for name %s, record_type %s in aliases",
614
                    target,
615
                    source_name,
616
                    record_type,
617
                )
618
            # if we deleted the last entry, clean up
619
            if not self.aliases[label]:
1✔
620
                del self.aliases[label]
1✔
621

622
    def add_host_pointing_to_localstack(self, name: str):
1✔
623
        LOG.debug("Adding host %s pointing to LocalStack", name)
1✔
624
        self.add_host(name, DynamicRecord(record_type=RecordType.A))
1✔
625
        if config.DNS_RESOLVE_IP == config.LOCALHOST_IP:
1✔
626
            self.add_host(name, DynamicRecord(record_type=RecordType.AAAA))
1✔
627

628
    def delete_host_pointing_to_localstack(self, name: str):
1✔
629
        LOG.debug("Deleting host %s pointing to LocalStack", name)
1✔
630
        self.delete_host(name, DynamicRecord(record_type=RecordType.A))
1✔
631
        if config.DNS_RESOLVE_IP == config.LOCALHOST_IP:
1✔
632
            self.delete_host(name, DynamicRecord(record_type=RecordType.AAAA))
1✔
633

634
    def add_skip(self, skip_pattern: str):
1✔
635
        LOG.debug("Adding skip pattern %s", skip_pattern)
1✔
636
        self.skip_patterns.append(skip_pattern)
1✔
637

638
    def delete_skip(self, skip_pattern: str):
1✔
639
        LOG.debug("Deleting skip pattern %s", skip_pattern)
1✔
640
        self.skip_patterns.remove(skip_pattern)
1✔
641

642
    def clear(self):
1✔
643
        LOG.debug("Clearing DNS zones")
1✔
644
        self.skip_patterns.clear()
1✔
645
        self.zones.clear()
1✔
646
        self.aliases.clear()
1✔
647

648

649
class DnsServer(Server, DnsServerProtocol):
1✔
650
    servers: list[DNSServer]
1✔
651
    resolver: Resolver | None
1✔
652

653
    def __init__(
1✔
654
        self,
655
        port: int,
656
        protocols: list[Literal["udp", "tcp"]],
657
        upstream_dns: str,
658
        host: str = "0.0.0.0",
659
    ) -> None:
660
        super().__init__(port, host)
1✔
661
        self.resolver = Resolver(upstream_dns=upstream_dns)
1✔
662
        self.protocols = protocols
1✔
663
        self.servers = []
1✔
664
        self.handler_class = NonLoggingHandler
1✔
665

666
    def _get_servers(self) -> list[DNSServer]:
1✔
667
        servers = []
1✔
668
        for protocol in self.protocols:
1✔
669
            # TODO add option to use normal logger instead of NoopLogger for verbose debug mode
670
            servers.append(
1✔
671
                DNSServer(
672
                    self.resolver,
673
                    handler=self.handler_class,
674
                    logger=NoopLogger(),
675
                    port=self.port,
676
                    address=self.host,
677
                    tcp=protocol == "tcp",
678
                )
679
            )
680
        return servers
1✔
681

682
    @property
1✔
683
    def protocol(self):
1✔
684
        return "udp"
×
685

686
    def health(self):
1✔
687
        """
688
        Runs a health check on the server. The default implementation performs is_port_open on the server URL.
689
        """
690
        try:
1✔
691
            request = dns.message.make_query("localhost.localstack.cloud", "A")
1✔
692
            answers = dns.query.udp(request, "127.0.0.1", port=self.port, timeout=0.5).answer
1✔
693
            return len(answers) > 0
1✔
694
        except Exception:
1✔
695
            return False
1✔
696

697
    def do_run(self):
1✔
698
        self.servers = self._get_servers()
1✔
699
        for server in self.servers:
1✔
700
            server.start_thread()
1✔
701
        LOG.debug("DNS Server started")
1✔
702
        for server in self.servers:
1✔
703
            server.thread.join()
1✔
704

705
    def do_shutdown(self):
1✔
706
        for server in self.servers:
1✔
707
            server.stop()
1✔
708

709
    def add_host(self, name: str, record: NameRecord):
1✔
710
        self.resolver.add_host(name, record)
1✔
711

712
    def delete_host(self, name: str, record: NameRecord):
1✔
713
        self.resolver.delete_host(name, record)
1✔
714

715
    def add_alias(self, source_name: str, record_type: RecordType, target: AliasTarget):
1✔
716
        self.resolver.add_alias(source_name, record_type, target)
1✔
717

718
    def delete_alias(self, source_name: str, record_type: RecordType, target: AliasTarget):
1✔
719
        self.resolver.delete_alias(source_name, record_type, target)
1✔
720

721
    def add_host_pointing_to_localstack(self, name: str):
1✔
722
        self.resolver.add_host_pointing_to_localstack(name)
1✔
723

724
    def delete_host_pointing_to_localstack(self, name: str):
1✔
725
        self.resolver.delete_host_pointing_to_localstack(name)
1✔
726

727
    def add_skip(self, skip_pattern: str):
1✔
728
        self.resolver.add_skip(skip_pattern)
1✔
729

730
    def delete_skip(self, skip_pattern: str):
1✔
731
        self.resolver.delete_skip(skip_pattern)
1✔
732

733
    def clear(self):
1✔
734
        self.resolver.clear()
1✔
735

736

737
class SeparateProcessDNSServer(Server, DnsServerProtocol):
1✔
738
    def __init__(
1✔
739
        self,
740
        port: int = 53,
741
        host: str = "0.0.0.0",
742
    ) -> None:
743
        super().__init__(port, host)
1✔
744

745
    @property
1✔
746
    def protocol(self):
1✔
747
        return "udp"
×
748

749
    def health(self):
1✔
750
        """
751
        Runs a health check on the server. The default implementation performs is_port_open on the server URL.
752
        """
753
        try:
1✔
754
            request = dns.message.make_query("localhost.localstack.cloud", "A")
1✔
755
            answers = dns.query.udp(request, "127.0.0.1", port=self.port, timeout=0.5).answer
1✔
756
            return len(answers) > 0
×
757
        except Exception:
1✔
758
            return False
1✔
759

760
    def do_start_thread(self):
1✔
761
        # For host mode
762
        env_vars = {}
1✔
763
        for env_var in config.CONFIG_ENV_VARS:
1✔
764
            if env_var.startswith("DNS_"):
1✔
765
                value = os.environ.get(env_var, None)
1✔
766
                if value is not None:
1✔
767
                    env_vars[env_var] = value
×
768

769
        # note: running in a separate process breaks integration with Route53 (to be fixed for local dev mode!)
770
        thread = run_module_as_sudo(
1✔
771
            "localstack.dns.server",
772
            asynchronous=True,
773
            env_vars=env_vars,
774
            arguments=["-p", str(self.port)],
775
        )
776
        return thread
1✔
777

778

779
def get_fallback_dns_server():
1✔
780
    return config.DNS_SERVER or get_available_dns_server()
1✔
781

782

783
@cache
1✔
784
def get_available_dns_server():
1✔
785
    #  TODO check if more loop-checks are necessary than just not using our own DNS server
786
    with FALLBACK_DNS_LOCK:
1✔
787
        resolver = dns.resolver.Resolver()
1✔
788
        # we do not want to include localhost here, or a loop might happen
789
        candidates = [r for r in resolver.nameservers if r != "127.0.0.1"]
1✔
790
        result = None
1✔
791
        candidates.append(DEFAULT_FALLBACK_DNS_SERVER)
1✔
792
        for ns in candidates:
1✔
793
            resolver.nameservers = [ns]
1✔
794
            try:
1✔
795
                try:
1✔
796
                    answer = resolver.resolve(VERIFICATION_DOMAIN, "a", lifetime=3)
1✔
797
                    answer = [
1✔
798
                        res.to_text() for answers in answer.response.answer for res in answers.items
799
                    ]
800
                except Timeout:
×
801
                    answer = None
×
802
                if not answer:
1✔
803
                    continue
×
804
                result = ns
1✔
805
                break
1✔
806
            except Exception:
×
807
                pass
×
808

809
        if result:
1✔
810
            LOG.debug("Determined fallback dns: %s", result)
1✔
811
        else:
812
            LOG.info(
×
813
                "Unable to determine fallback DNS. Please check if '%s' is reachable by your configured DNS servers"
814
                "DNS fallback will be disabled.",
815
                VERIFICATION_DOMAIN,
816
            )
817
        return result
1✔
818

819

820
# ###### LEGACY METHODS ######
821
def add_resolv_entry(file_path: Path | str = Path("/etc/resolv.conf")):
1✔
822
    global PREVIOUS_RESOLV_CONF_FILE
823
    # never overwrite the host configuration without the user's permission
824
    if not in_docker():
1✔
825
        LOG.warning("Incorrectly attempted to alter host networking config")
1✔
826
        return
1✔
827

828
    LOG.debug("Overwriting container DNS server to point to localhost")
1✔
829
    content = textwrap.dedent(
1✔
830
        """
831
    # The following line is required by LocalStack
832
    nameserver 127.0.0.1
833
    """
834
    )
835
    file_path = Path(file_path)
1✔
836
    try:
1✔
837
        with file_path.open("r+") as outfile:
1✔
838
            PREVIOUS_RESOLV_CONF_FILE = outfile.read()
1✔
839
            previous_resolv_conf_without_nameservers = [
1✔
840
                line
841
                for line in PREVIOUS_RESOLV_CONF_FILE.splitlines()
842
                if not line.startswith("nameserver")
843
            ]
844
            outfile.seek(0)
1✔
845
            outfile.write(content)
1✔
846
            outfile.write("\n".join(previous_resolv_conf_without_nameservers))
1✔
847
            outfile.truncate()
1✔
848
    except Exception:
×
849
        LOG.warning(
×
850
            "Could not update container DNS settings", exc_info=LOG.isEnabledFor(logging.DEBUG)
851
        )
852

853

854
def revert_resolv_entry(file_path: Path | str = Path("/etc/resolv.conf")):
1✔
855
    # never overwrite the host configuration without the user's permission
856
    if not in_docker():
1✔
857
        LOG.warning("Incorrectly attempted to alter host networking config")
×
858
        return
×
859

860
    if not PREVIOUS_RESOLV_CONF_FILE:
1✔
861
        LOG.warning("resolv.conf file to restore not found.")
×
862
        return
×
863

864
    LOG.debug("Reverting container DNS config")
1✔
865
    file_path = Path(file_path)
1✔
866
    try:
1✔
867
        with file_path.open("w") as outfile:
1✔
868
            outfile.write(PREVIOUS_RESOLV_CONF_FILE)
1✔
869
    except Exception:
×
870
        LOG.warning(
×
871
            "Could not revert container DNS settings", exc_info=LOG.isEnabledFor(logging.DEBUG)
872
        )
873

874

875
def setup_network_configuration():
1✔
876
    # check if DNS is disabled
877
    if not config.use_custom_dns():
1✔
878
        return
×
879

880
    # add entry to /etc/resolv.conf
881
    if in_docker():
1✔
882
        add_resolv_entry()
1✔
883

884

885
def revert_network_configuration():
1✔
886
    # check if DNS is disabled
887
    if not config.use_custom_dns():
1✔
888
        return
×
889

890
    # add entry to /etc/resolv.conf
891
    if in_docker():
1✔
892
        revert_resolv_entry()
1✔
893

894

895
def start_server(upstream_dns: str, host: str, port: int = config.DNS_PORT):
1✔
896
    global DNS_SERVER
897

898
    if DNS_SERVER:
1✔
899
        # already started - bail
900
        LOG.debug("DNS servers are already started. Avoid starting again.")
×
901
        return
×
902

903
    LOG.debug("Starting DNS servers (tcp/udp port %s on %s)...", port, host)
1✔
904
    dns_server = DnsServer(port, protocols=["tcp", "udp"], host=host, upstream_dns=upstream_dns)
1✔
905

906
    for name in NAME_PATTERNS_POINTING_TO_LOCALSTACK:
1✔
907
        dns_server.add_host_pointing_to_localstack(name)
1✔
908
    if config.LOCALSTACK_HOST.host != LOCALHOST_HOSTNAME:
1✔
909
        dns_server.add_host_pointing_to_localstack(f".*{config.LOCALSTACK_HOST.host}")
×
910

911
    # support both DNS_NAME_PATTERNS_TO_RESOLVE_UPSTREAM and DNS_LOCAL_NAME_PATTERNS
912
    # until the next major version change
913
    # TODO(srw): remove the usage of DNS_LOCAL_NAME_PATTERNS
914
    skip_local_resolution = " ".join(
1✔
915
        [
916
            config.DNS_NAME_PATTERNS_TO_RESOLVE_UPSTREAM,
917
            config.DNS_LOCAL_NAME_PATTERNS,
918
        ]
919
    ).strip()
920
    if skip_local_resolution:
1✔
921
        for skip_pattern in re.split(r"[,;\s]+", skip_local_resolution):
×
922
            dns_server.add_skip(skip_pattern.strip(" \"'"))
×
923

924
    dns_server.start()
1✔
925
    if not dns_server.wait_is_up(timeout=5):
1✔
926
        LOG.warning("DNS server did not come up within 5 seconds.")
×
927
        dns_server.shutdown()
×
928
        return
×
929
    DNS_SERVER = dns_server
1✔
930
    LOG.debug("DNS server startup finished.")
1✔
931

932

933
def stop_servers():
1✔
934
    if DNS_SERVER:
1✔
935
        DNS_SERVER.shutdown()
1✔
936

937

938
def start_dns_server_as_sudo(port: int):
1✔
939
    global DNS_SERVER
940
    LOG.debug(
1✔
941
        "Starting the DNS on its privileged port (%s) needs root permissions. Trying to start DNS with sudo.",
942
        config.DNS_PORT,
943
    )
944

945
    dns_server = SeparateProcessDNSServer(port)
1✔
946
    dns_server.start()
1✔
947

948
    if not dns_server.wait_is_up(timeout=5):
1✔
949
        LOG.warning("DNS server did not come up within 5 seconds.")
1✔
950
        dns_server.shutdown()
1✔
951
        return
1✔
952

953
    DNS_SERVER = dns_server
×
954
    LOG.debug("DNS server startup finished (as sudo).")
×
955

956

957
def start_dns_server(port: int, asynchronous: bool = False, standalone: bool = False):
1✔
958
    if DNS_SERVER:
1✔
959
        # already started - bail
960
        LOG.error("DNS servers are already started. Avoid starting again.")
×
961
        return
×
962

963
    # check if DNS server is disabled
964
    if not config.use_custom_dns():
1✔
965
        LOG.debug("Not starting DNS. DNS_ADDRESS=%s", config.DNS_ADDRESS)
×
966
        return
×
967

968
    upstream_dns = get_fallback_dns_server()
1✔
969
    if not upstream_dns:
1✔
970
        LOG.warning("Error starting the DNS server: No upstream dns server found.")
×
971
        return
×
972

973
    # host to bind the DNS server to. In docker we always want to bind to "0.0.0.0"
974
    host = config.DNS_ADDRESS
1✔
975
    if in_docker():
1✔
976
        host = "0.0.0.0"
1✔
977

978
    if port_can_be_bound(Port(port, "udp"), address=host):
1✔
979
        start_server(port=port, host=host, upstream_dns=upstream_dns)
1✔
980
        if not asynchronous:
1✔
981
            sleep_forever()
×
982
        return
1✔
983

984
    if standalone:
1✔
985
        LOG.debug("Already in standalone mode and port binding still fails.")
×
986
        return
×
987

988
    start_dns_server_as_sudo(port)
1✔
989

990

991
def get_dns_server() -> DnsServerProtocol:
1✔
992
    return DNS_SERVER
×
993

994

995
def is_server_running() -> bool:
1✔
996
    return DNS_SERVER is not None
1✔
997

998

999
if __name__ == "__main__":
1000
    parser = argparse.ArgumentParser()
1001
    parser.add_argument("-p", "--port", required=False, default=53, type=int)
1002
    args = parser.parse_args()
1003

1004
    start_dns_server(asynchronous=False, port=args.port, standalone=True)
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