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

localstack / localstack / 20565403496

29 Dec 2025 05:11AM UTC coverage: 84.103% (-2.8%) from 86.921%
20565403496

Pull #13567

github

web-flow
Merge 4816837a5 into 2417384aa
Pull Request #13567: Update ASF APIs

67166 of 79862 relevant lines covered (84.1%)

0.84 hits per line

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

82.79
/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✔
419
        except Exception as e:
×
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
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

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 _find_matching_aliases(self, question: DNSQuestion) -> list[AliasTarget] | None:
1✔
449
        """
450
        Find aliases matching the question, supporting wildcards.
451
        """
452
        qlabel = DNSLabel(to_bytes(question.qname))
1✔
453
        qtype = RecordType[QTYPE[question.qtype]]
1✔
454
        for (label, rtype), targets in self.aliases.items():
1✔
455
            if rtype == qtype and qlabel.matchWildcard(label):
1✔
456
                return targets
1✔
457
        return None
1✔
458

459
    def _resolve_alias(
1✔
460
        self, request: DNSRecord, reply: DNSRecord, client_address: ClientAddress
461
    ) -> bool:
462
        if request.q.qtype in (QTYPE.A, QTYPE.AAAA, QTYPE.CNAME):
1✔
463
            if aliases := self._find_matching_aliases(request.q):
1✔
464
                for alias in aliases:
1✔
465
                    # if there is no health check, or the healthcheck is successful, we will consider this alias
466
                    # take the first alias passing this check
467
                    if not alias.health_check or alias.health_check():
1✔
468
                        request_copy: DNSRecord = copy.deepcopy(request)
1✔
469
                        request_copy.q.qname = alias.target
1✔
470
                        # check if we can resolve the alias
471
                        found = self._resolve_name_from_zones(request_copy, reply, client_address)
1✔
472
                        if found:
1✔
473
                            LOG.debug(
1✔
474
                                "Found entry for AliasTarget '%s' ('%s')", request.q.qname, alias
475
                            )
476
                            # change the replaced rr-DNS names back to the original request
477
                            for rr in reply.rr:
1✔
478
                                rr.set_rname(request.q.qname)
1✔
479
                        else:
480
                            reply.header.set_rcode(RCODE.REFUSED)
×
481
                        return True
1✔
482
        return False
1✔
483

484
    def _resolve_name(
1✔
485
        self, request: DNSRecord, reply: DNSRecord, client_address: ClientAddress
486
    ) -> bool:
487
        if alias_found := self._resolve_alias(request, reply, client_address):
1✔
488
            LOG.debug("Alias found: %s", request.q.qname)
1✔
489
            return alias_found
1✔
490
        return self._resolve_name_from_zones(request, reply, client_address)
1✔
491

492
    def _resolve_name_from_zones(
1✔
493
        self, request: DNSRecord, reply: DNSRecord, client_address: ClientAddress
494
    ) -> bool:
495
        found = False
1✔
496

497
        converter = RecordConverter(request, client_address)
1✔
498

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

530
    def _parse_section(self, section: str) -> list[RR]:
1✔
531
        result = []
1✔
532
        for line in section.split("\n"):
1✔
533
            line = line.strip()
1✔
534
            if line:
1✔
535
                if line.startswith(";"):
1✔
536
                    # section ended, stop parsing
537
                    break
1✔
538
                else:
539
                    result += RR.fromZone(line)
1✔
540
        return result
1✔
541

542
    def _map_response_dnspython_to_dnslib(self, response):
1✔
543
        """Map response object from dnspython to dnslib (looks like we cannot
544
        simply export/import the raw messages from the wire)"""
545
        flags = dns.flags.to_text(response.flags)
1✔
546

547
        def flag(f):
1✔
548
            return 1 if f.upper() in flags else 0
1✔
549

550
        questions = []
1✔
551
        for q in response.question:
1✔
552
            questions.append(DNSQuestion(qname=str(q.name), qtype=q.rdtype, qclass=q.rdclass))
1✔
553

554
        result = DNSRecord(
1✔
555
            DNSHeader(
556
                qr=flag("qr"), aa=flag("aa"), ra=flag("ra"), id=response.id, rcode=response.rcode()
557
            ),
558
            q=questions[0],
559
        )
560

561
        # extract answers
562
        answer_parts = str(response).partition(";ANSWER")
1✔
563
        result.add_answer(*self._parse_section(answer_parts[2]))
1✔
564
        # extract authority information
565
        authority_parts = str(response).partition(";AUTHORITY")
1✔
566
        result.add_auth(*self._parse_section(authority_parts[2]))
1✔
567
        return result
1✔
568

569
    def add_host(self, name: str, record: NameRecord):
1✔
570
        LOG.debug("Adding host %s with record %s", name, record)
1✔
571
        name = normalise_dns_name(name)
1✔
572
        with self.lock:
1✔
573
            self.zones.setdefault(name, [])
1✔
574
            self.zones[name].append(record)
1✔
575

576
    def delete_host(self, name: str, record: NameRecord):
1✔
577
        LOG.debug("Deleting host %s with record %s", name, record)
1✔
578
        name = normalise_dns_name(name)
1✔
579
        with self.lock:
1✔
580
            if not self.zones.get(name):
1✔
581
                raise ValueError("Could not find entry %s for name %s in zones", record, name)
1✔
582
            self.zones.setdefault(name, [])
1✔
583
            current_zones = self.zones[name]
1✔
584
            self.zones[name] = [
1✔
585
                zone for zone in self.zones[name] if not _should_delete_zone(record, zone)
586
            ]
587
            if self.zones[name] == current_zones:
1✔
588
                raise ValueError("Could not find entry %s for name %s in zones", record, name)
×
589
            # if we deleted the last entry, clean up
590
            if not self.zones[name]:
1✔
591
                del self.zones[name]
1✔
592

593
    def add_alias(self, source_name: str, record_type: RecordType, target: AliasTarget):
1✔
594
        LOG.debug("Adding alias %s with record type %s target %s", source_name, record_type, target)
1✔
595
        label = (DNSLabel(to_bytes(source_name)), record_type)
1✔
596
        with self.lock:
1✔
597
            self.aliases.setdefault(label, [])
1✔
598
            self.aliases[label].append(target)
1✔
599

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

631
    def add_host_pointing_to_localstack(self, name: str):
1✔
632
        LOG.debug("Adding host %s pointing to LocalStack", name)
1✔
633
        self.add_host(name, DynamicRecord(record_type=RecordType.A))
1✔
634
        if config.DNS_RESOLVE_IP == config.LOCALHOST_IP:
1✔
635
            self.add_host(name, DynamicRecord(record_type=RecordType.AAAA))
1✔
636

637
    def delete_host_pointing_to_localstack(self, name: str):
1✔
638
        LOG.debug("Deleting host %s pointing to LocalStack", name)
1✔
639
        self.delete_host(name, DynamicRecord(record_type=RecordType.A))
1✔
640
        if config.DNS_RESOLVE_IP == config.LOCALHOST_IP:
1✔
641
            self.delete_host(name, DynamicRecord(record_type=RecordType.AAAA))
1✔
642

643
    def add_skip(self, skip_pattern: str):
1✔
644
        LOG.debug("Adding skip pattern %s", skip_pattern)
1✔
645
        self.skip_patterns.append(skip_pattern)
1✔
646

647
    def delete_skip(self, skip_pattern: str):
1✔
648
        LOG.debug("Deleting skip pattern %s", skip_pattern)
1✔
649
        self.skip_patterns.remove(skip_pattern)
1✔
650

651
    def clear(self):
1✔
652
        LOG.debug("Clearing DNS zones")
1✔
653
        self.skip_patterns.clear()
1✔
654
        self.zones.clear()
1✔
655
        self.aliases.clear()
1✔
656

657

658
class DnsServer(Server, DnsServerProtocol):
1✔
659
    servers: list[DNSServer]
1✔
660
    resolver: Resolver | None
1✔
661

662
    def __init__(
1✔
663
        self,
664
        port: int,
665
        protocols: list[Literal["udp", "tcp"]],
666
        upstream_dns: str,
667
        host: str = "0.0.0.0",
668
    ) -> None:
669
        super().__init__(port, host)
1✔
670
        self.resolver = Resolver(upstream_dns=upstream_dns)
1✔
671
        self.protocols = protocols
1✔
672
        self.servers = []
1✔
673
        self.handler_class = NonLoggingHandler
1✔
674

675
    def _get_servers(self) -> list[DNSServer]:
1✔
676
        servers = []
1✔
677
        for protocol in self.protocols:
1✔
678
            # TODO add option to use normal logger instead of NoopLogger for verbose debug mode
679
            servers.append(
1✔
680
                DNSServer(
681
                    self.resolver,
682
                    handler=self.handler_class,
683
                    logger=NoopLogger(),
684
                    port=self.port,
685
                    address=self.host,
686
                    tcp=protocol == "tcp",
687
                )
688
            )
689
        return servers
1✔
690

691
    @property
1✔
692
    def protocol(self):
1✔
693
        return "udp"
×
694

695
    def health(self):
1✔
696
        """
697
        Runs a health check on the server. The default implementation performs is_port_open on the server URL.
698
        """
699
        try:
1✔
700
            request = dns.message.make_query("localhost.localstack.cloud", "A")
1✔
701
            answers = dns.query.udp(request, "127.0.0.1", port=self.port, timeout=0.5).answer
1✔
702
            return len(answers) > 0
1✔
703
        except Exception:
1✔
704
            return False
1✔
705

706
    def do_run(self):
1✔
707
        self.servers = self._get_servers()
1✔
708
        for server in self.servers:
1✔
709
            server.start_thread()
1✔
710
        LOG.debug("DNS Server started")
1✔
711
        for server in self.servers:
1✔
712
            server.thread.join()
1✔
713

714
    def do_shutdown(self):
1✔
715
        for server in self.servers:
1✔
716
            server.stop()
1✔
717

718
    def add_host(self, name: str, record: NameRecord):
1✔
719
        self.resolver.add_host(name, record)
1✔
720

721
    def delete_host(self, name: str, record: NameRecord):
1✔
722
        self.resolver.delete_host(name, record)
1✔
723

724
    def add_alias(self, source_name: str, record_type: RecordType, target: AliasTarget):
1✔
725
        self.resolver.add_alias(source_name, record_type, target)
1✔
726

727
    def delete_alias(self, source_name: str, record_type: RecordType, target: AliasTarget):
1✔
728
        self.resolver.delete_alias(source_name, record_type, target)
1✔
729

730
    def add_host_pointing_to_localstack(self, name: str):
1✔
731
        self.resolver.add_host_pointing_to_localstack(name)
1✔
732

733
    def delete_host_pointing_to_localstack(self, name: str):
1✔
734
        self.resolver.delete_host_pointing_to_localstack(name)
1✔
735

736
    def add_skip(self, skip_pattern: str):
1✔
737
        self.resolver.add_skip(skip_pattern)
1✔
738

739
    def delete_skip(self, skip_pattern: str):
1✔
740
        self.resolver.delete_skip(skip_pattern)
1✔
741

742
    def clear(self):
1✔
743
        self.resolver.clear()
1✔
744

745

746
class SeparateProcessDNSServer(Server, DnsServerProtocol):
1✔
747
    def __init__(
1✔
748
        self,
749
        port: int = 53,
750
        host: str = "0.0.0.0",
751
    ) -> None:
752
        super().__init__(port, host)
×
753

754
    @property
1✔
755
    def protocol(self):
1✔
756
        return "udp"
×
757

758
    def health(self):
1✔
759
        """
760
        Runs a health check on the server. The default implementation performs is_port_open on the server URL.
761
        """
762
        try:
×
763
            request = dns.message.make_query("localhost.localstack.cloud", "A")
×
764
            answers = dns.query.udp(request, "127.0.0.1", port=self.port, timeout=0.5).answer
×
765
            return len(answers) > 0
×
766
        except Exception:
×
767
            return False
×
768

769
    def do_start_thread(self):
1✔
770
        # For host mode
771
        env_vars = {}
×
772
        for env_var in config.CONFIG_ENV_VARS:
×
773
            if env_var.startswith("DNS_"):
×
774
                value = os.environ.get(env_var, None)
×
775
                if value is not None:
×
776
                    env_vars[env_var] = value
×
777

778
        # note: running in a separate process breaks integration with Route53 (to be fixed for local dev mode!)
779
        thread = run_module_as_sudo(
×
780
            "localstack.dns.server",
781
            asynchronous=True,
782
            env_vars=env_vars,
783
            arguments=["-p", str(self.port)],
784
        )
785
        return thread
×
786

787

788
def get_fallback_dns_server():
1✔
789
    return config.DNS_SERVER or get_available_dns_server()
1✔
790

791

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

818
        if result:
1✔
819
            LOG.debug("Determined fallback dns: %s", result)
1✔
820
        else:
821
            LOG.info(
×
822
                "Unable to determine fallback DNS. Please check if '%s' is reachable by your configured DNS servers"
823
                "DNS fallback will be disabled.",
824
                VERIFICATION_DOMAIN,
825
            )
826
        return result
1✔
827

828

829
# ###### LEGACY METHODS ######
830
def add_resolv_entry(file_path: Path | str = Path("/etc/resolv.conf")):
1✔
831
    global PREVIOUS_RESOLV_CONF_FILE
832
    # never overwrite the host configuration without the user's permission
833
    if not in_docker():
1✔
834
        LOG.warning("Incorrectly attempted to alter host networking config")
1✔
835
        return
1✔
836

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

862

863
def revert_resolv_entry(file_path: Path | str = Path("/etc/resolv.conf")):
1✔
864
    # never overwrite the host configuration without the user's permission
865
    if not in_docker():
1✔
866
        LOG.warning("Incorrectly attempted to alter host networking config")
×
867
        return
×
868

869
    if not PREVIOUS_RESOLV_CONF_FILE:
1✔
870
        LOG.warning("resolv.conf file to restore not found.")
×
871
        return
×
872

873
    LOG.debug("Reverting container DNS config")
1✔
874
    file_path = Path(file_path)
1✔
875
    try:
1✔
876
        with file_path.open("w") as outfile:
1✔
877
            outfile.write(PREVIOUS_RESOLV_CONF_FILE)
1✔
878
    except Exception:
×
879
        LOG.warning(
×
880
            "Could not revert container DNS settings", exc_info=LOG.isEnabledFor(logging.DEBUG)
881
        )
882

883

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

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

893

894
def revert_network_configuration():
1✔
895
    # check if DNS is disabled
896
    if not config.use_custom_dns():
1✔
897
        return
×
898

899
    # add entry to /etc/resolv.conf
900
    if in_docker():
1✔
901
        revert_resolv_entry()
1✔
902

903

904
def start_server(upstream_dns: str, host: str, port: int = config.DNS_PORT):
1✔
905
    global DNS_SERVER
906

907
    if DNS_SERVER:
1✔
908
        # already started - bail
909
        LOG.debug("DNS servers are already started. Avoid starting again.")
×
910
        return
×
911

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

915
    for name in NAME_PATTERNS_POINTING_TO_LOCALSTACK:
1✔
916
        dns_server.add_host_pointing_to_localstack(name)
1✔
917
    if config.LOCALSTACK_HOST.host != LOCALHOST_HOSTNAME:
1✔
918
        dns_server.add_host_pointing_to_localstack(f".*{config.LOCALSTACK_HOST.host}")
×
919

920
    # support both DNS_NAME_PATTERNS_TO_RESOLVE_UPSTREAM and DNS_LOCAL_NAME_PATTERNS
921
    # until the next major version change
922
    # TODO(srw): remove the usage of DNS_LOCAL_NAME_PATTERNS
923
    skip_local_resolution = " ".join(
1✔
924
        [
925
            config.DNS_NAME_PATTERNS_TO_RESOLVE_UPSTREAM,
926
            config.DNS_LOCAL_NAME_PATTERNS,
927
        ]
928
    ).strip()
929
    if skip_local_resolution:
1✔
930
        for skip_pattern in re.split(r"[,;\s]+", skip_local_resolution):
×
931
            dns_server.add_skip(skip_pattern.strip(" \"'"))
×
932

933
    dns_server.start()
1✔
934
    if not dns_server.wait_is_up(timeout=5):
1✔
935
        LOG.warning("DNS server did not come up within 5 seconds.")
×
936
        dns_server.shutdown()
×
937
        return
×
938
    DNS_SERVER = dns_server
1✔
939
    LOG.debug("DNS server startup finished.")
1✔
940

941

942
def stop_servers():
1✔
943
    if DNS_SERVER:
1✔
944
        DNS_SERVER.shutdown()
1✔
945

946

947
def start_dns_server_as_sudo(port: int):
1✔
948
    global DNS_SERVER
949
    LOG.debug(
×
950
        "Starting the DNS on its privileged port (%s) needs root permissions. Trying to start DNS with sudo.",
951
        config.DNS_PORT,
952
    )
953

954
    dns_server = SeparateProcessDNSServer(port)
×
955
    dns_server.start()
×
956

957
    if not dns_server.wait_is_up(timeout=5):
×
958
        LOG.warning("DNS server did not come up within 5 seconds.")
×
959
        dns_server.shutdown()
×
960
        return
×
961

962
    DNS_SERVER = dns_server
×
963
    LOG.debug("DNS server startup finished (as sudo).")
×
964

965

966
def start_dns_server(port: int, asynchronous: bool = False, standalone: bool = False):
1✔
967
    if DNS_SERVER:
1✔
968
        # already started - bail
969
        LOG.error("DNS servers are already started. Avoid starting again.")
×
970
        return
×
971

972
    # check if DNS server is disabled
973
    if not config.use_custom_dns():
1✔
974
        LOG.debug("Not starting DNS. DNS_ADDRESS=%s", config.DNS_ADDRESS)
×
975
        return
×
976

977
    upstream_dns = get_fallback_dns_server()
1✔
978
    if not upstream_dns:
1✔
979
        LOG.warning("Error starting the DNS server: No upstream dns server found.")
×
980
        return
×
981

982
    # host to bind the DNS server to. In docker we always want to bind to "0.0.0.0"
983
    host = config.DNS_ADDRESS
1✔
984
    if in_docker():
1✔
985
        host = "0.0.0.0"
1✔
986

987
    if port_can_be_bound(Port(port, "udp"), address=host):
1✔
988
        start_server(port=port, host=host, upstream_dns=upstream_dns)
1✔
989
        if not asynchronous:
1✔
990
            sleep_forever()
×
991
        return
1✔
992

993
    if standalone:
×
994
        LOG.debug("Already in standalone mode and port binding still fails.")
×
995
        return
×
996

997
    start_dns_server_as_sudo(port)
×
998

999

1000
def get_dns_server() -> DnsServerProtocol:
1✔
1001
    return DNS_SERVER
×
1002

1003

1004
def is_server_running() -> bool:
1✔
1005
    return DNS_SERVER is not None
1✔
1006

1007

1008
if __name__ == "__main__":
1009
    parser = argparse.ArgumentParser()
1010
    parser.add_argument("-p", "--port", required=False, default=53, type=int)
1011
    args = parser.parse_args()
1012

1013
    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

© 2025 Coveralls, Inc