8000 add IPv6 support to the localstack runtime gateway · localstack/localstack@ec254b4 · GitHub
[go: up one dir, main page]

Skip to content

Commit ec254b4

Browse files
committed
add IPv6 support to the localstack runtime gateway
Adds support for configuring the gateway listener to listen on IPv6 addresses. For example, you can now set: ``` GATEWAY_LISTEN="[::]" ``` NOTE: `GATEWAY_LISTEN` now expects IPv6 address to be enclosed in square brackets, since the existing interface expected for `GATEWAY_LISTEN` is a subset of a URL (host and port), and square brackets are required for IPv6 addresses in URLs (to distinguish between the colons in an IPv6 address and the delimiter with the port number). This format is defined in https://www.rfc-editor.org/rfc/rfc6874. This required modifications to: * The HostAndPort.parse() meth 8000 od, which can now interpret an IPv6 address correctly. * The UniqueHostAndPortList class, which now distinguishes between IPv6 and IPv4 addresses when deduping. * The Twisted gateway, which will now use an IPv6 endpoint for an IPv6 host. I didn't make any changes to the two other gateway implementations (hypercorn or werkzeug) as I'm not sure if they're still considered supported. I also noticed a bug in the existing twisted implementation, in which it ignores any IPv4 host passed in via GATEWAY_LISTEN, and simply binds to all interfaces ("", which is interpreted as 0.0.0.0). I didn't fix this (given users might be depending on the current behavior), but the new IPv6 implementation respects the specified host. Addresses #11600
1 parent 56d7fd5 commit ec254b4

File tree

3 files changed

+162
-34
lines changed

3 files changed

+162
-34
lines changed

localstack-core/localstack/config.py

Lines changed: 97 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1+
import ipaddress
12
import logging
23
import os
34
import platform
5+
import re
46
import socket
57
import subprocess
68
import tempfile
79
import time
810
import warnings
11+
from collections import defaultdict
912
from typing import Any, Dict, List, Mapping, Optional, Tuple, TypeVar, Union
1013

1114
from localstack import constants
@@ -497,6 +500,21 @@ def is_trace_logging_enabled():
497500
)
498501

499502

503+
def is_ipv6_address(host: str) -> bool:
504+
"""
505+
Returns True if the given host is an IPv6 address.
506+
"""
507+
508+
if not host:
509+
return False
510+
511+
try:
512+
ipaddress.IPv6Address(host)
513+
return True
514+
except ipaddress.AddressValueError:
515+
return False
516+
517+
500518
class HostAndPort:
501519
"""
502520
Definition of an address for a server to listen to.
@@ -525,16 +543,36 @@ def parse(
525543
- 0.0.0.0:4566 -> host=0.0.0.0, port=4566
526544
- 0.0.0.0 -> host=0.0.0.0, port=`default_port`
527545
- :4566 -> host=`default_host`, port=4566
546+
- [::]:4566 -> host=[::], port=4566
547+
- [::1] -> host=[::1], port=`default_port`
528548
"""
529549
host, port = default_host, default_port
530-
if ":" in input:
550+
551+
# recognize IPv6 addresses (+ port)
552+
if input.startswith("["):
553+
ipv6_pattern = re.compile(r"^\[(?P<host>[^]]+)\](:(?P<port>\d+))?$")
554+
match = ipv6_pattern.match(input)
555+
556+
if match:
557+
host = match.group("host")
558+
if not is_ipv6_address(host):
559+
raise ValueError(
560+
f"input looks like an IPv6 address (is enclosed in square brackets), but is not valid: {host}"
561+
)
562+
port_s = match.group("port")
563+
if port_s:
564+
port = cls._validate_port(port_s)
565+
else:
566+
raise ValueError(
567+
f'input looks like an IPv6 address, but is invalid. Should be formatted "[ip]:port": {input}'
568+
)
569+
570+
# recognize IPv4 address + port
571+
elif ":" in input:
531572
hostname, port_s = input.split(":", 1)
532573
if hostname.strip():
533574
host = hostname.strip()
534-
try:
535-
port = int(port_s)
536-
except ValueError as e:
537-
raise ValueError(f"specified port {port_s} not a number") from e
575+
port = cls._validate_port(port_s)
538576
else:
539577
if input.strip():
540578
host = input.strip()
@@ -545,6 +583,15 @@ def parse(
545583

546584
return cls(host=host, port=port)
547585

586< 9E88 span class="diff-text-marker">+
@classmethod
587+
def _validate_port(cls, port_s: str) -> int:
588+
try:
589+
port = int(port_s)
590+
except ValueError as e:
591+
raise ValueError(f"specified port {port_s} not a number") from e
592+
593+
return port
594+
548595
def _get_unprivileged_port_range_start(self) -> int:
549596
try:
550597
with open(
@@ -559,7 +606,8 @@ def is_unprivileged(self) -> bool:
559606
return self.port >= self._get_unprivileged_port_range_start()
560607

561608
def host_and_port(self):
562-
return f"{self.host}:{self.port}" if self.port is not None else self.host
609+
formatted_host = f"[{self.host}]" if is_ipv6_address(self.host) else self.host
610+
return f"{formatted_host}:{self.port}" if self.port is not None else formatted_host
563611

564612
def __hash__(self) -> int:
565613
return hash((self.host, self.port))
@@ -584,40 +632,57 @@ class UniqueHostAndPortList(List[HostAndPort]):
584632
"""
585633
Container type that ensures that ports added to the list are unique based
586634
on these rules:
587-
- 0.0.0.0 "trumps" any other binding, i.e. adding 127.0.0.1:4566 to
588-
[0.0.0.0:4566] is a no-op
589-
- adding identical hosts and ports is a no-op
590-
- adding `0.0.0.0:4566` to [`127.0.0.1:4566`] "upgrades" the binding to
591-
create [`0.0.0.0:4566`]
635+
- :: "trumps" any other binding on the same port, including both IPv6 and IPv4
636+
addresses. All other bindings for this port are removed, since :: already
637+
covers all interfaces. For example, adding 127.0.0.1:4566, [::1]:4566,
638+
and [::]:4566 would result in only [::]:4566 being preserved.
639+
- 0.0.0.0 "trumps" any other binding on IPv4 addresses only. IPv6 addresses
640+
are not removed.
641+
- Identical identical hosts and ports are de-duped
592642
"""
593643

594-
def __init__(self, iterable=None):
595-
super().__init__()
596-
for item in iterable or []:
597-
self.append(item)
644+
def __init__(self, iterable: List[HostAndPort] | None = None):
645+
super().__init__(iterable or [])
646+
self._ensure_unique()
598647

599-
def append(self, value: HostAndPort):
600-
# no exact duplicates
601-
if value in self:
648+
def _ensure_unique(self):
649+
"""
650+
Ensure that all bindings on the same port are de-duped.
651+
"""
652+
if len(self) <= 1:
602653
return
603654

604-
# if 0.0.0.0:<port> already exists in the list, then do not add the new
605-
# item
655+
unique: List[HostAndPort] = list()
656+
657+
# Build a dictionary of hosts by port
658+
hosts_by_port: Dict[int, List[str]] = defaultdict(list)
606659
for item in self:
607-
if item.host == "0.0.0.0" and item.port == value.port:
608-
return
609-
610-
# if we add 0.0.0.0:<port> and already contain *:<port> then bind on
611-
# 0.0.0.0
612-
contained_ports = {every.port for every in self}
613-
if value.host == "0.0.0.0" and value.port in contained_ports:
614-
for item in self:
615-
if item.port == value.port:
616-
item.host = value.host
617-
return
660+
hosts_by_port[item.port].append(item.host)
661+
662+
# For any given port, dedupe the hosts
663+
for port, hosts in hosts_by_port.items():
664+
deduped_hosts = set(hosts)
665+
666+
# IPv6 all interfaces: this is the most general binding.
667+
# Any others should be removed.
668+
if "::" in deduped_hosts:
669+
unique.append(HostAndPort(host="::", port=port))
670+
continue
671+
# IPv4 all interfaces: this is the next most general binding.
672+
# Any others should be removed.
673+
if "0.0.0.0" in deduped_hosts:
674+
unique.append(HostAndPort(host="0.0.0.0", port=port))
675+
continue
618676

619-
# append the item
677+
# All other bindings just need to be unique
678+
unique.extend([HostAndPort(host=host, port=port) for host in deduped_hosts])
679+
680+
self.clear()
681+
self.extend(unique)
682+
683+
def append(self, value: HostAndPort):
620684
super().append(value)
685+
self._ensure_unique()
621686

622687

623688
def populate_edge_configuration(

localstack-core/localstack/runtime/server/twisted.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,13 @@ def register(
3333

3434
# add endpoint for each host/port combination
3535
for host_and_port in listen:
36-
# TODO: interface = host?
37-
endpoint = endpoints.TCP4ServerEndpoint(reactor, host_and_port.port)
36+
if config.is_ipv6_address(host_and_port.host):
37+
endpoint = endpoints.TCP6ServerEndpoint(
38+
reactor, host_and_port.port, interface=host_and_port.host
39+
)
40+
else:
41+
# TODO: interface = host?
42+
endpoint = endpoints.TCP4ServerEndpoint(reactor, host_and_port.port)
3843
endpoint.listen(protocol_factory)
3944

4045
def run(self):

tests/unit/test_config.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,26 @@ def test_add_all_interfaces_value(self):
203203
HostAndPort("0.0.0.0", 42),
204204
]
205205

206+
def test_add_all_interfaces_value_ipv6(self):
207+
ports = config.UniqueHostAndPortList()
208+
ports.append(HostAndPort("::", 42))
209+
ports.append(HostAndPort("::1", 42))
210+
211+
assert ports == [
212+
HostAndPort("::", 42),
213+
]
214+
215+
def test_add_all_interfaces_value_mixed_ipv6_wins(self):
216+
ports = config.UniqueHostAndPortList()
217+
ports.append(HostAndPort("0.0.0.0", 42))
218+
ports.append(HostAndPort("::", 42))
219+
ports.append(HostAndPort("127.0.0.1", 42))
220+
ports.append(HostAndPort("::1", 42))
221+
222+
assert ports == [
223+
HostAndPort("::", 42),
224+
]
225+
206226
def test_add_all_interfaces_value_after(self):
207227
ports = config.UniqueHostAndPortList()
208228
ports.append(HostAndPort("127.0.0.1", 42))
@@ -212,6 +232,24 @@ def test_add_all_interfaces_value_after(self):
212232
HostAndPort("0.0.0.0", 42),
213233
]
214234

235+
def test_add_all_interfaces_value_after_ipv6(self):
236+
ports = config.UniqueHostAndPortList()
237+
ports.append(HostAndPort("::1", 42))
238+
ports.append(HostAndPort("::", 42))
239+
240+
assert ports == [
241+
HostAndPort("::", 42),
242+
]
243+
244+
def test_add_all_interfaces_value_after_mixed_ipv6_wins(self):
245+
ports = config.UniqueHostAndPortList()
246+
ports.append(HostAndPort("::1", 42))
247+
ports.append(HostAndPort("127.0.0.1", 42))
248+
ports.append(HostAndPort("::", 42))
249+
ports.append(HostAndPort("0.0.0.0", 42))
250+
251+
assert ports == [HostAndPort("::", 42)]
252+
215253
def test_index_access(self):
216254
ports = config.UniqueHostAndPortList(
217255
[
@@ -260,6 +298,26 @@ def test_invalid_port(self):
260298

261299
assert "specified port not-a-port not a number" in str(exc_info)
262300

301+
def test_parsing_ipv6_with_port(self):
302+
h = config.HostAndPort.parse(
303+
"[5601:f95d:0:10:4978::2]:1000", default_host="", default_port=9876
304+
)
305+
assert h == HostAndPort(host="5601:f95d:0:10:4978::2", port=1000)
306+
307+
def test_parsing_ipv6_with_default_port(self):
308+
h = config.HostAndPort.parse("[5601:f95d:0:10:4978::2]", default_host="", default_port=9876)
309+
assert h == HostAndPort(host="5601:f95d:0:10:4978::2", port=9876)
310+
311+
def test_parsing_ipv6_all_interfaces_with_default_port(self):
312+
h = config.HostAndPort.parse("[::]", default_host="", default_port=9876)
313+
assert h == HostAndPort(host="::", port=9876)
314+
315+
def test_parsing_ipv6_with_invalid_address(self):
316+
with pytest.raises(ValueError) as exc_info:
317+
config.HostAndPort.parse("[i-am-invalid]", default_host="", default_port=9876)
318+
319+
assert "input looks like an IPv6 address" in str(exc_info)
320+
263321
@pytest.mark.parametrize("port", [-1000, -1, 2**16, 100_000])
264322
def test_port_out_of_range(self, port):
265323
with pytest.raises(ValueError) as exc_info:

0 commit comments

Comments
 (0)
0