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

Skip to content

Commit 3ac788a

Browse files
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() method, 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 fixed: IPv6 addresses were being deduped with IPv4 addresses
1 parent 56d7fd5 commit 3ac788a

File tree

3 files changed

+134
-14
lines changed

3 files changed

+134
-14
lines changed

localstack-core/localstack/config.py

Lines changed: 62 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import ipaddress
12
import logging
23
import os
34 8000
import platform
5+
import re
46
import socket
57
import subprocess
68
import tempfile
@@ -496,6 +498,20 @@ def is_trace_logging_enabled():
496498
"Initializing the configuration took %s ms", int((load_end_time - load_start_time) * 1000)
497499
)
498500

501+
def is_ipv6_address(host: str) -> bool:
502+
"""
503+
Returns True if the given host is an IPv6 address.
504+
"""
505+
506+
if not host:
507+
return False
508+
509+
try:
510+
ipaddress.IPv6Address(host)
511+
return True
512+
except ipaddress.AddressValueError:
513+
return False
514+
499515

500516
class HostAndPort:
501517
"""
@@ -525,16 +541,36 @@ def parse(
525541
- 0.0.0.0:4566 -> host=0.0.0.0, port=4566
526542
- 0.0.0.0 -> host=0.0.0.0, port=`default_port`
527543
- :4566 -> host=`default_host`, port=4566
544+
- [::]:4566 -> host=[::], port=4566
545+
- [::1] -> host=[::1], port=`default_port`
528546
"""
529547
host, port = default_host, default_port
530-
if ":" in input:
548+
549+
# recognize IPv6 addresses (+ port)
550+
if input.startswith("["):
551+
ipv6_pattern = re.compile(r"^\[(?P<host>[^]]+)\](:(?P<port>\d+))?$")
552+
match = ipv6_pattern.match(input)
553+
554+
if match:
555+
host = match.group("host")
556+
if not is_ipv6_address(host):
557+
raise ValueError(
558+
f"input looks like an IPv6 address (is enclosed in square brackets), but is not valid: {host}"
559+
)
560+
port_s = match.group("port")
561+
if port_s:
562+
port = cls._validate_port(port_s)
563+
else:
564+
raise ValueError(
565+
f'input looks like an IPv6 address, but is invalid. Should be formatted "[ip]:port": {input}'
566+
)
567+
568+
# recognize IPv4 address + port
569+
elif ":" in input:
531570
hostname, port_s = input.split(":", 1)
532571
if hostname.strip():
533572
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
573+
port = cls._validate_port(port_s)
538574
else:
539575
if input.strip():
540576
host = input.strip()
@@ -545,6 +581,15 @@ def parse(
545581

546582
return cls(host=host, port=port)
547583

584+
@classmethod
585+
def _validate_port(cls, port_s: str) -> int:
586+
try:
587+
port = int(port_s)
588+
except ValueError as e:
589+
raise ValueError(f"specified port {port_s} not a number") from e
590+
591+
return port
592+
548593
def _get_unprivileged_port_range_start(self) -> int:
549594
try:
550595
with open(
@@ -559,7 +604,8 @@ def is_unprivileged(self) -> bool:
559604
return self.port >= self._get_unprivileged_port_range_start()
560605

561606
def host_and_port(self):
562-
return f"{self.host}:{self.port}" if self.port is not None else self.host
607+
formatted_host = f"[{self.host}]" if is_ipv6_address(self.host) else self.host
608+
return f"{formatted_host}:{self.port}" if self.port is not None else formatted_host
563609

564610
def __hash__(self) -> int:
565611
return hash((self.host, self.port))
@@ -600,26 +646,30 @@ def append(self, value: HostAndPort):
600646
# no exact duplicates
601647
if value in self:
602648
return
649+
650+
value_is_ipv6 = is_ipv6_address(value.host)
651+
all_interfaces = "::" if value_is_ipv6 else "0.0.0.0"
603652

604-
# if 0.0.0.0:<port> already exists in the list, then do not add the new
653+
# if 0.0.0.0:<port> (or [::]:<port>) already exists in the list, then do not add the new
605654
# item
606655
for item in self:
607-
if item.host == "0.0.0.0" and item.port == value.port:
656+
if item.host == all_interfaces and item.port == value.port:
608657
return
609658

610659
# 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:
660+
# 0.0.0.0 (or "::" for IPv6)
661+
contained_ports = {every.port for every in self if is_ipv6_address(every.host) == value_is_ipv6}
662+
if value.host == all_interfaces and value.port in contained_ports:
614663
for item in self:
615-
if item.port == value.port:
664+
if item.port == value.port and is_ipv6_address(item.host) == value_is_ipv6:
616665
item.host = value.host
617666
return
618667

619668
# append the item
620669
super().append(value)
621670

622671

672+
623673
def populate_edge_configuration(
624674
environment: Mapping[str, str],
625675
) -> Tuple[HostAndPort, UniqueHostAndPortList]:

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from ipaddress import AddressValueError, IPv6Address
2+
13
from rolo.gateway import Gateway
24
from rolo.serving.twisted import TwistedGateway
35
from twisted.internet import endpoints, reactor, ssl
@@ -33,8 +35,13 @@ def register(
3335

3436
# add endpoint for each host/port combination
3537
for host_and_port in listen:
36-
# TODO: interface = host?
37-
endpoint = endpoints.TCP4ServerEndpoint(reactor, host_and_port.port)
38+
if config.is_ipv6_address(host_and_port.host):
39+
endpoint = endpoints.TCP6ServerEndpoint(
40+
reactor, host_and_port.port, interface=host_and_port.host
41+
)
42+
else:
43+
# TODO: interface = host?
44+
endpoint = endpoints.TCP4ServerEndpoint(reactor, host_and_port.port)
3845
endpoint.listen(protocol_factory)
3946

4047
def run(self):
@@ -50,3 +57,4 @@ def shutdown(self):
5057
if self.thread_pool:
5158
self.thread_pool.stop(timeout=10)
5259
reactor.stop()
60+

tests/unit/test_config.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,27 @@ 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_ip_versions(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("0.0.0.0", 42),
224+
HostAndPort("::", 42),
225+
]
226+
206227
def test_add_all_interfaces_value_after(self):
207228
ports = config.UniqueHostAndPortList()
208229
ports.append(HostAndPort("127.0.0.1", 42))
@@ -212,6 +233,27 @@ def test_add_all_interfaces_value_after(self):
212233
HostAndPort("0.0.0.0", 42),
213234
]
214235

236+
def test_add_all_interfaces_value_after_ipv6(self):
237+
ports = config.UniqueHostAndPortList()
238+
ports.append(HostAndPort("::1", 42))
239+
ports.append(HostAndPort("::", 42))
240+
241+
assert ports == [
242+
HostAndPort("::", 42),
243+
]
244+
245+
def test_add_all_interfaces_value_after_mixed_ip_versions(self):
246+
ports = config.UniqueHostAndPortList()
247+
ports.append(HostAndPort("::1", 42))
248+
ports.append(HostAndPort("127.0.0.1", 42))
249+
ports.append(HostAndPort("::", 42))
250+
ports.append(HostAndPort("0.0.0.0", 42))
251+
252+
assert ports == [
253+
HostAndPort("::", 42),
254+
HostAndPort("0.0.0.0", 42),
255+
]
256+
215257
def test_index_access(self):
216258
ports = config.UniqueHostAndPortList(
217259
[
@@ -260,6 +302,26 @@ def test_invalid_port(self):
260302

261303
assert "specified port not-a-port not a number" in str(exc_info)
262304

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

0 commit comments

Comments
 (0)
0