From f370e263b597d273af2bb88430ba1f24058dbd96 Mon Sep 17 00:00:00 2001
From: Jonathan Moody <103143855+moodyjon@users.noreply.github.com>
Date: Wed, 28 Dec 2022 16:56:34 -0600
Subject: [PATCH 1/8] Add IPv6 support to StatusServer and related classes.

---
 hub/common.py         |  14 +++++
 hub/herald/session.py |   3 +-
 hub/herald/udp.py     | 138 +++++++++++++++++++++++++++++-------------
 3 files changed, 113 insertions(+), 42 deletions(-)

diff --git a/hub/common.py b/hub/common.py
index 9304689..fc147a2 100644
--- a/hub/common.py
+++ b/hub/common.py
@@ -590,6 +590,20 @@ def is_valid_public_ipv4(address, allow_localhost: bool = False, allow_lan: bool
     except (ipaddress.AddressValueError, ValueError):
         return False
 
+def is_valid_public_ipv6(address, allow_localhost: bool = False, allow_lan: bool = False):
+    try:
+        parsed_ip = ipaddress.ip_address(address)
+        if parsed_ip.is_loopback and allow_localhost:
+            return True
+        if allow_lan and parsed_ip.is_private:
+            return True
+        return not any((parsed_ip.version != 6, parsed_ip.is_unspecified, parsed_ip.is_link_local, parsed_ip.is_loopback,
+                        parsed_ip.is_multicast, parsed_ip.is_reserved, parsed_ip.is_private))
+    except (ipaddress.AddressValueError, ValueError):
+        return False
+
+def is_valid_public_ip(address, **kwargs):
+    return is_valid_public_ipv6(address, **kwargs) or is_valid_public_ipv4(address, **kwargs)
 
 def sha256(x):
     """Simple wrapper of hashlib sha256."""
diff --git a/hub/herald/session.py b/hub/herald/session.py
index 56b33d2..d981dfa 100644
--- a/hub/herald/session.py
+++ b/hub/herald/session.py
@@ -271,7 +271,8 @@ class SessionManager:
                                   f'{host}:{port:d} : {e!r}')
             raise
         else:
-            self.logger.info(f'{kind} server listening on {host}:{port:d}')
+            for s in self.servers[kind].sockets:
+                self.logger.info(f'{kind} server listening on {s.getsockname()[:2]}')
 
     async def _start_external_servers(self):
         """Start listening on TCP and SSL ports, but only if the respective
diff --git a/hub/herald/udp.py b/hub/herald/udp.py
index af83d0c..3101ea7 100644
--- a/hub/herald/udp.py
+++ b/hub/herald/udp.py
@@ -1,10 +1,17 @@
 import asyncio
+import ipaddress
+import socket
 import struct
 from time import perf_counter
 import logging
-from typing import Optional, Tuple, NamedTuple
+from typing import Optional, Tuple, NamedTuple, List, Union
 from hub.schema.attrs import country_str_to_int, country_int_to_str
-from hub.common import LRUCache, is_valid_public_ipv4
+from hub.common import (
+    LRUCache,
+    is_valid_public_ip,
+    is_valid_public_ipv4,
+    is_valid_public_ipv6,
+)
 
 
 log = logging.getLogger(__name__)
@@ -36,48 +43,75 @@ class SPVPing(NamedTuple):
         return decoded
 
 
-PONG_ENCODING = b'!BBL32s4sH'
-
+PONG_ENCODING_PRE = b'!BBL32s'
+PONG_ENCODING_POST = b'!H'
 
 class SPVPong(NamedTuple):
     protocol_version: int
     flags: int
     height: int
     tip: bytes
-    source_address_raw: bytes
+    ipaddr: Union[ipaddress.IPv4Address, ipaddress.IPv6Address]
     country: int
 
+    FLAG_AVAILABLE = 0b00000001
+    FLAG_IPV6 = 0b00000010
+
     def encode(self):
-        return struct.pack(PONG_ENCODING, *self)
+        return (struct.pack(PONG_ENCODING_PRE, self.protocol_version, self.flags, self.height, self.tip) +
+                self.encode_address(self.ipaddr) +
+                struct.pack(PONG_ENCODING_POST, self.country))
 
     @staticmethod
-    def encode_address(address: str):
-        return bytes(int(b) for b in address.split("."))
+    def encode_address(address: Union[str, ipaddress.IPv4Address, ipaddress.IPv6Address]):
+        if not isinstance(address, (ipaddress.IPv4Address, ipaddress.IPv6Address)):
+            address = ipaddress.ip_address(address)
+        return address.packed
 
     @classmethod
     def make(cls, flags: int, height: int, tip: bytes, source_address: str, country: str) -> bytes:
+        ipaddr = ipaddress.ip_address(source_address)
+        flags = (flags | cls.FLAG_IPV6) if ipaddr.version == 6 else (flags & ~cls.FLAG_IPV6)
         return SPVPong(
             PROTOCOL_VERSION, flags, height, tip,
-            cls.encode_address(source_address),
+            ipaddr,
             country_str_to_int(country)
-        ).encode()
+        )
 
     @classmethod
     def make_sans_source_address(cls, flags: int, height: int, tip: bytes, country: str) -> Tuple[bytes, bytes]:
         pong = cls.make(flags, height, tip, '0.0.0.0', country)
-        return pong[:38], pong[42:]
+        pong = pong.encode()
+        return pong[0:1], pong[2:38], pong[42:]
 
     @classmethod
     def decode(cls, packet: bytes):
-        return cls(*struct.unpack(PONG_ENCODING, packet[:44]))
+        offset = 0
+        protocol_version, flags, height, tip = struct.unpack(PONG_ENCODING_PRE, packet[offset:offset+38])
+        offset += 38
+        if flags & cls.FLAG_IPV6:
+            addr_len = ipaddress.IPV6LENGTH // 8
+            ipaddr = ipaddress.ip_address(packet[offset:offset+addr_len])
+            offset += addr_len
+        else:
+            addr_len = ipaddress.IPV4LENGTH // 8
+            ipaddr = ipaddress.ip_address(packet[offset:offset+addr_len])
+            offset += addr_len
+        country, = struct.unpack(PONG_ENCODING_POST, packet[offset:offset+2])
+        offset += 2
+        return cls(protocol_version, flags, height, tip, ipaddr, country)
 
     @property
     def available(self) -> bool:
-        return (self.flags & 0b00000001) > 0
+        return (self.flags & self.FLAG_AVAILABLE) > 0
+
+    @property
+    def ipv6(self) -> bool:
+        return (self.flags & self.FLAG_IPV6) > 0
 
     @property
     def ip_address(self) -> str:
-        return ".".join(map(str, self.source_address_raw))
+        return self.ipaddr.compressed
 
     @property
     def country_name(self):
@@ -94,7 +128,8 @@ class SPVServerStatusProtocol(asyncio.DatagramProtocol):
     def __init__(
         self, height: int, tip: bytes, country: str,
         throttle_cache_size: int = 1024, throttle_reqs_per_sec: int = 10,
-        allow_localhost: bool = False, allow_lan: bool = False
+        allow_localhost: bool = False, allow_lan: bool = False,
+        is_valid_ip = is_valid_public_ip,
     ):
         super().__init__()
         self.transport: Optional[asyncio.transports.DatagramTransport] = None
@@ -102,26 +137,27 @@ class SPVServerStatusProtocol(asyncio.DatagramProtocol):
         self._tip = tip
         self._flags = 0
         self._country = country
-        self._left_cache = self._right_cache = None
+        self._cache0 = self._cache1 = self.cache2 = None
         self.update_cached_response()
         self._throttle = LRUCache(throttle_cache_size)
         self._should_log = LRUCache(throttle_cache_size)
         self._min_delay = 1 / throttle_reqs_per_sec
         self._allow_localhost = allow_localhost
         self._allow_lan = allow_lan
+        self._is_valid_ip = is_valid_ip
         self.closed = asyncio.Event()
 
     def update_cached_response(self):
-        self._left_cache, self._right_cache = SPVPong.make_sans_source_address(
+        self._cache0, self._cache1, self._cache2 = SPVPong.make_sans_source_address(
             self._flags, max(0, self._height), self._tip, self._country
         )
 
     def set_unavailable(self):
-        self._flags &= 0b11111110
+        self._flags &= ~SPVPong.FLAG_AVAILABLE
         self.update_cached_response()
 
     def set_available(self):
-        self._flags |= 0b00000001
+        self._flags |= SPVPong.FLAG_AVAILABLE
         self.update_cached_response()
 
     def set_height(self, height: int, tip: bytes):
@@ -141,17 +177,25 @@ class SPVServerStatusProtocol(asyncio.DatagramProtocol):
         return False
 
     def make_pong(self, host):
-        return self._left_cache + SPVPong.encode_address(host) + self._right_cache
+        ipaddr = ipaddress.ip_address(host)
+        if ipaddr.version == 6:
+            flags = self._flags | SPVPong.FLAG_IPV6
+        else:
+            flags = self._flags & ~SPVPong.FLAG_IPV6
+        return (self._cache0 + flags.to_bytes(1, 'big') +
+                self._cache1 + SPVPong.encode_address(ipaddr) +
+                self._cache2)
 
-    def datagram_received(self, data: bytes, addr: Tuple[str, int]):
+    def datagram_received(self, data: bytes, addr: Union[Tuple[str, int], Tuple[str, int, int, int]]):
         if self.should_throttle(addr[0]):
+            # print(f"throttled: {addr}")
             return
         try:
             SPVPing.decode(data)
         except (ValueError, struct.error, AttributeError, TypeError):
             # log.exception("derp")
             return
-        if addr[1] >= 1024 and is_valid_public_ipv4(
+        if addr[1] >= 1024 and self._is_valid_ip(
                 addr[0], allow_localhost=self._allow_localhost, allow_lan=self._allow_lan):
             self.transport.sendto(self.make_pong(addr[0]), addr)
         else:
@@ -174,39 +218,51 @@ class SPVServerStatusProtocol(asyncio.DatagramProtocol):
 
 class StatusServer:
     def __init__(self):
-        self._protocol: Optional[SPVServerStatusProtocol] = None
+        self._protocols: List[SPVServerStatusProtocol] = []
 
     async def start(self, height: int, tip: bytes, country: str, interface: str, port: int, allow_lan: bool = False):
         if self.is_running:
             return
         loop = asyncio.get_event_loop()
-        interface = interface if interface.lower() != 'localhost' else '127.0.0.1'
-        self._protocol = SPVServerStatusProtocol(
-            height, tip, country, allow_localhost=interface == '127.0.0.1', allow_lan=allow_lan
+        addr = interface if interface.lower() != 'localhost' else '127.0.0.1'
+        proto = SPVServerStatusProtocol(
+            height, tip, country, allow_localhost=addr == '127.0.0.1', allow_lan=allow_lan,
+            is_valid_ip=is_valid_public_ipv4,
         )
-        await loop.create_datagram_endpoint(lambda: self._protocol, (interface, port))
-        log.info("started udp status server on %s:%i", interface, port)
+        await loop.create_datagram_endpoint(lambda: proto, (addr, port), family=socket.AF_INET)
+        log.warning("started udp4 status server on %s", proto.transport.get_extra_info('sockname')[:2])
+        self._protocols.append(proto)
+        if not socket.has_ipv6:
+            return
+        addr = interface if interface.lower() != 'localhost' else '::1'
+        proto = SPVServerStatusProtocol(
+            height, tip, country, allow_localhost=addr == '::1', allow_lan=allow_lan,
+            is_valid_ip=is_valid_public_ipv6,
+        )
+        await loop.create_datagram_endpoint(lambda: proto, (addr, port), family=socket.AF_INET6)
+        log.warning("started udp6 status server on %s", proto.transport.get_extra_info('sockname')[:2])
+        self._protocols.append(proto)
 
     async def stop(self):
-        if self.is_running:
-            await self._protocol.close()
-            self._protocol = None
+        for p in self._protocols:
+            await p.close()
+        self._protocols.clear()
 
     @property
     def is_running(self):
-        return self._protocol is not None
+        return self._protocols
 
     def set_unavailable(self):
-        if self.is_running:
-            self._protocol.set_unavailable()
+        for p in self._protocols:
+            p.set_unavailable()
 
     def set_available(self):
-        if self.is_running:
-            self._protocol.set_available()
+        for p in self._protocols:
+            p.set_available()
 
     def set_height(self, height: int, tip: bytes):
-        if self.is_running:
-            self._protocol.set_height(height, tip)
+        for p in self._protocols:
+            p.set_height(height, tip)
 
 
 class SPVStatusClientProtocol(asyncio.DatagramProtocol):
@@ -217,9 +273,9 @@ class SPVStatusClientProtocol(asyncio.DatagramProtocol):
         self.responses = responses
         self._ping_packet = SPVPing.make()
 
-    def datagram_received(self, data: bytes, addr: Tuple[str, int]):
+    def datagram_received(self, data: bytes, addr: Union[Tuple[str, int], Tuple[str, int, int, int]]):
         try:
-            self.responses.put_nowait(((addr, perf_counter()), SPVPong.decode(data)))
+            self.responses.put_nowait(((addr[:2], perf_counter()), SPVPong.decode(data)))
         except (ValueError, struct.error, AttributeError, TypeError, RuntimeError):
             return
 
@@ -230,7 +286,7 @@ class SPVStatusClientProtocol(asyncio.DatagramProtocol):
         self.transport = None
         log.info("closed udp spv server selection client")
 
-    def ping(self, server: Tuple[str, int]):
+    def ping(self, server: Union[Tuple[str, int], Tuple[str, int, int, int]]):
         self.transport.sendto(self._ping_packet, server)
 
     def close(self):

From 252a1aa165886b94c7e9039e8909a22656409b54 Mon Sep 17 00:00:00 2001
From: Jonathan Moody <103143855+moodyjon@users.noreply.github.com>
Date: Thu, 29 Dec 2022 13:16:47 -0600
Subject: [PATCH 2/8] Remove override for 'localhost' allowing Hub to start
 server on IPv6.

---
 hub/env.py | 4 ----
 1 file changed, 4 deletions(-)

diff --git a/hub/env.py b/hub/env.py
index ce18748..49cb783 100644
--- a/hub/env.py
+++ b/hub/env.py
@@ -117,10 +117,6 @@ class Env:
         result = [part.strip() for part in host.split(',')]
         if len(result) == 1:
             result = result[0]
-        if result == 'localhost':
-            # 'localhost' resolves to ::1 (ipv6) on many systems, which fails on default setup of
-            # docker, using 127.0.0.1 instead forces ipv4
-            result = '127.0.0.1'
         return result
 
     def sane_max_sessions(self):

From 6c037b29b5571c9fc1ebf581b37c1e03b8c45dd9 Mon Sep 17 00:00:00 2001
From: Jonathan Moody <103143855+moodyjon@users.noreply.github.com>
Date: Sat, 31 Dec 2022 12:46:32 -0600
Subject: [PATCH 3/8] Rename "p" -> "proto" for consistency with lbry-sdk. Lint
 does not like variable named "p".

---
 hub/herald/udp.py | 16 ++++++++--------
 1 file changed, 8 insertions(+), 8 deletions(-)

diff --git a/hub/herald/udp.py b/hub/herald/udp.py
index 3101ea7..7929530 100644
--- a/hub/herald/udp.py
+++ b/hub/herald/udp.py
@@ -244,8 +244,8 @@ class StatusServer:
         self._protocols.append(proto)
 
     async def stop(self):
-        for p in self._protocols:
-            await p.close()
+        for proto in self._protocols:
+            await proto.close()
         self._protocols.clear()
 
     @property
@@ -253,16 +253,16 @@ class StatusServer:
         return self._protocols
 
     def set_unavailable(self):
-        for p in self._protocols:
-            p.set_unavailable()
+        for proto in self._protocols:
+            proto.set_unavailable()
 
     def set_available(self):
-        for p in self._protocols:
-            p.set_available()
+        for proto in self._protocols:
+            proto.set_available()
 
     def set_height(self, height: int, tip: bytes):
-        for p in self._protocols:
-            p.set_height(height, tip)
+        for proto in self._protocols:
+            proto.set_height(height, tip)
 
 
 class SPVStatusClientProtocol(asyncio.DatagramProtocol):

From 9c43c811a1bc5c12be688908f6a34dd8246ffd72 Mon Sep 17 00:00:00 2001
From: Jonathan Moody <103143855+moodyjon@users.noreply.github.com>
Date: Wed, 11 Jan 2023 11:17:43 -0600
Subject: [PATCH 4/8] Handle mapped IPv4 address more neatly. (for consistency
 with client code)

---
 hub/herald/udp.py | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/hub/herald/udp.py b/hub/herald/udp.py
index 7929530..5b380f7 100644
--- a/hub/herald/udp.py
+++ b/hub/herald/udp.py
@@ -275,6 +275,11 @@ class SPVStatusClientProtocol(asyncio.DatagramProtocol):
 
     def datagram_received(self, data: bytes, addr: Union[Tuple[str, int], Tuple[str, int, int, int]]):
         try:
+            if len(addr) > 2: # IPv6 with possible mapped IPv4
+                ipaddr = ipaddress.ip_address(addr[0])
+                if ipaddr.ipv4_mapped:
+                    # mapped IPv4 address identified
+                    addr = (ipaddr.ipv4_mapped.compressed, addr[1])
             self.responses.put_nowait(((addr[:2], perf_counter()), SPVPong.decode(data)))
         except (ValueError, struct.error, AttributeError, TypeError, RuntimeError):
             return

From 14f2f3b55b7fedc0d81903257f97a8f0fe5941bb Mon Sep 17 00:00:00 2001
From: Jonathan Moody <103143855+moodyjon@users.noreply.github.com>
Date: Mon, 16 Jan 2023 14:04:14 -0600
Subject: [PATCH 5/8] Exclude mapped IPv4 addresses. Add resolve_host() code
 from client.

---
 hub/common.py | 55 +++++++++++++++++++++++++++++++++++++++++----------
 1 file changed, 45 insertions(+), 10 deletions(-)

diff --git a/hub/common.py b/hub/common.py
index fc147a2..31c9c1a 100644
--- a/hub/common.py
+++ b/hub/common.py
@@ -5,6 +5,7 @@ import hmac
 import ipaddress
 import logging
 import logging.handlers
+import socket
 import typing
 import collections
 from bisect import insort_right
@@ -153,6 +154,38 @@ def protocol_version(client_req, min_tuple, max_tuple):
     return result, client_min
 
 
+async def resolve_host(url: str, port: int, proto: str,
+                       family: int = socket.AF_INET, all_results: bool = False) \
+        -> typing.Union[str, typing.List[str]]:
+    if proto not in ['udp', 'tcp']:
+        raise Exception("invalid protocol")
+    try:
+        if ipaddress.ip_address(url):
+            return [url] if all_results else url
+    except ValueError:
+        pass
+    loop = asyncio.get_running_loop()
+    records = await loop.getaddrinfo(
+        url, port,
+        proto=socket.IPPROTO_TCP if proto == 'tcp' else socket.IPPROTO_UDP,
+        type=socket.SOCK_STREAM if proto == 'tcp' else socket.SOCK_DGRAM,
+        family=family,
+    )
+    def addr_not_ipv4_mapped(rec):
+        _, _, _, _, sockaddr = rec
+        ipaddr = ipaddress.ip_address(sockaddr[0])
+        return ipaddr.version != 6 or not ipaddr.ipv4_mapped
+    records = filter(addr_not_ipv4_mapped, records)
+    results = [sockaddr[0] for fam, type, prot, canonname, sockaddr in records]
+    if not results and not all_results:
+        raise socket.gaierror(
+            socket.EAI_ADDRFAMILY,
+            'The specified network host does not have any network '
+            'addresses in the requested address family'
+        )
+    return results if all_results else results[0]
+
+
 class LRUCacheWithMetrics:
     __slots__ = [
         'capacity',
@@ -577,10 +610,10 @@ IPV4_TO_6_RELAY_SUBNET = ipaddress.ip_network('192.88.99.0/24')
 def is_valid_public_ipv4(address, allow_localhost: bool = False, allow_lan: bool = False):
     try:
         parsed_ip = ipaddress.ip_address(address)
-        if parsed_ip.is_loopback and allow_localhost:
-            return True
-        if allow_lan and parsed_ip.is_private:
-            return True
+        if parsed_ip.is_loopback:
+            return allow_localhost
+        if parsed_ip.is_private:
+            return allow_lan
         if any((parsed_ip.version != 4, parsed_ip.is_unspecified, parsed_ip.is_link_local, parsed_ip.is_loopback,
                 parsed_ip.is_multicast, parsed_ip.is_reserved, parsed_ip.is_private)):
             return False
@@ -593,12 +626,14 @@ def is_valid_public_ipv4(address, allow_localhost: bool = False, allow_lan: bool
 def is_valid_public_ipv6(address, allow_localhost: bool = False, allow_lan: bool = False):
     try:
         parsed_ip = ipaddress.ip_address(address)
-        if parsed_ip.is_loopback and allow_localhost:
-            return True
-        if allow_lan and parsed_ip.is_private:
-            return True
-        return not any((parsed_ip.version != 6, parsed_ip.is_unspecified, parsed_ip.is_link_local, parsed_ip.is_loopback,
-                        parsed_ip.is_multicast, parsed_ip.is_reserved, parsed_ip.is_private))
+        if parsed_ip.is_loopback:
+            return allow_localhost
+        if parsed_ip.is_private:
+            return allow_lan
+        return not any((parsed_ip.version != 6, parsed_ip.is_unspecified,
+                        parsed_ip.is_link_local, parsed_ip.is_loopback,
+                        parsed_ip.is_multicast, parsed_ip.is_reserved,
+                        parsed_ip.is_private, parsed_ip.ipv4_mapped))
     except (ipaddress.AddressValueError, ValueError):
         return False
 

From fa0d03fe95c2860718387784fbba980166109b48 Mon Sep 17 00:00:00 2001
From: Jonathan Moody <103143855+moodyjon@users.noreply.github.com>
Date: Mon, 16 Jan 2023 14:05:26 -0600
Subject: [PATCH 6/8] Rework StatusServer start() to handle lists of addresses,
 hostnames. Handle and retry EADDRINUSE errors.

---
 hub/herald/service.py | 19 +++++++++---
 hub/herald/udp.py     | 70 ++++++++++++++++++++++++++++++-------------
 2 files changed, 64 insertions(+), 25 deletions(-)

diff --git a/hub/herald/service.py b/hub/herald/service.py
index 9aa53e8..844c60a 100644
--- a/hub/herald/service.py
+++ b/hub/herald/service.py
@@ -1,3 +1,4 @@
+import errno
 import time
 import typing
 import asyncio
@@ -170,10 +171,20 @@ class HubServerService(BlockchainReaderService):
 
     async def start_status_server(self):
         if self.env.udp_port and int(self.env.udp_port):
-            await self.status_server.start(
-                0, bytes.fromhex(self.env.coin.GENESIS_HASH)[::-1], self.env.country,
-                self.env.host, self.env.udp_port, self.env.allow_lan_udp
-            )
+            hosts = self.env.cs_host()
+            started = False
+            while not started:
+                try:
+                    await self.status_server.start(
+                        0, bytes.fromhex(self.env.coin.GENESIS_HASH)[::-1], self.env.country,
+                        hosts, self.env.udp_port, self.env.allow_lan_udp
+                    )
+                    started = True
+                except OSError as e:
+                    if e.errno is errno.EADDRINUSE:
+                        await asyncio.sleep(3)
+                        continue
+                    raise
 
     def _iter_start_tasks(self):
         yield self.start_status_server()
diff --git a/hub/herald/udp.py b/hub/herald/udp.py
index 5b380f7..6fd388c 100644
--- a/hub/herald/udp.py
+++ b/hub/herald/udp.py
@@ -8,6 +8,7 @@ from typing import Optional, Tuple, NamedTuple, List, Union
 from hub.schema.attrs import country_str_to_int, country_int_to_str
 from hub.common import (
     LRUCache,
+    resolve_host,
     is_valid_public_ip,
     is_valid_public_ipv4,
     is_valid_public_ipv6,
@@ -220,29 +221,56 @@ class StatusServer:
     def __init__(self):
         self._protocols: List[SPVServerStatusProtocol] = []
 
-    async def start(self, height: int, tip: bytes, country: str, interface: str, port: int, allow_lan: bool = False):
-        if self.is_running:
-            return
-        loop = asyncio.get_event_loop()
-        addr = interface if interface.lower() != 'localhost' else '127.0.0.1'
-        proto = SPVServerStatusProtocol(
-            height, tip, country, allow_localhost=addr == '127.0.0.1', allow_lan=allow_lan,
-            is_valid_ip=is_valid_public_ipv4,
-        )
-        await loop.create_datagram_endpoint(lambda: proto, (addr, port), family=socket.AF_INET)
-        log.warning("started udp4 status server on %s", proto.transport.get_extra_info('sockname')[:2])
-        self._protocols.append(proto)
-        if not socket.has_ipv6:
-            return
-        addr = interface if interface.lower() != 'localhost' else '::1'
-        proto = SPVServerStatusProtocol(
-            height, tip, country, allow_localhost=addr == '::1', allow_lan=allow_lan,
-            is_valid_ip=is_valid_public_ipv6,
-        )
-        await loop.create_datagram_endpoint(lambda: proto, (addr, port), family=socket.AF_INET6)
-        log.warning("started udp6 status server on %s", proto.transport.get_extra_info('sockname')[:2])
+    async def _start(self, height: int, tip: bytes, country: str, addr: str, port: int, allow_lan: bool = False):
+        ipaddr = ipaddress.ip_address(addr)
+        if ipaddr.version == 4:
+            proto = SPVServerStatusProtocol(
+                height, tip, country,
+                allow_localhost=ipaddr.is_loopback or ipaddr.is_unspecified,
+                allow_lan=allow_lan,
+                is_valid_ip=is_valid_public_ipv4,
+            )
+            loop = asyncio.get_event_loop()
+            await loop.create_datagram_endpoint(lambda: proto, (ipaddr.compressed, port), family=socket.AF_INET)
+        elif ipaddr.version == 6:
+            proto = SPVServerStatusProtocol(
+                height, tip, country,
+                allow_localhost=ipaddr.is_loopback or ipaddr.is_unspecified,
+                allow_lan=allow_lan,
+                is_valid_ip=is_valid_public_ipv6,
+            )
+            # Because dualstack / IPv4 mapped address behavior on an IPv6 socket
+            # differs based on system config, create the socket with IPV6_V6ONLY.
+            # This disables the IPv4 mapped feature, so we don't need to consider
+            # when an IPv6 socket may interfere with IPv4 binding / traffic.
+            sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
+            sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
+            sock.bind((ipaddr.compressed, port))
+            loop = asyncio.get_event_loop()
+            await loop.create_datagram_endpoint(lambda: proto, sock=sock)
+        else:
+            raise ValueError(f'unexpected IP address version {ipaddr.version}')
+        log.info("started udp%i status server on %s", ipaddr.version, proto.transport.get_extra_info('sockname')[:2])
         self._protocols.append(proto)
 
+    async def start(self, height: int, tip: bytes, country: str, hosts: List[str], port: int, allow_lan: bool = False):
+        if not isinstance(hosts, list):
+            hosts = [hosts]
+        try:
+            for host in hosts:
+                addr = None
+                if not host:
+                    resolved = ['::', '0.0.0.0'] # unspecified address
+                else:
+                    resolved = await resolve_host(host, port, 'udp', family=socket.AF_UNSPEC, all_results=True)
+                for addr in resolved:
+                    await self._start(height, tip, country, addr, port, allow_lan)
+        except Exception as e:
+            if not isinstance(e, asyncio.CancelledError):
+                log.error("UDP status server failed to listen on (%s:%i) : %s", addr or host, port, e)
+            await self.stop()
+            raise
+
     async def stop(self):
         for proto in self._protocols:
             await proto.close()

From 8794ff48e0801d74283b50dc276273540afc3eae Mon Sep 17 00:00:00 2001
From: Jonathan Moody <103143855+moodyjon@users.noreply.github.com>
Date: Mon, 16 Jan 2023 15:35:55 -0600
Subject: [PATCH 7/8] Revert "Bump protobuf from 3.17.2 to 3.18.3"

This reverts commit 75d64f9dc6d3b2c913b8b10053bd3589e7a2e8eb.
---
 setup.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/setup.py b/setup.py
index a61059f..c022ed0 100644
--- a/setup.py
+++ b/setup.py
@@ -33,7 +33,7 @@ setup(
         'certifi>=2021.10.08',
         'colorama==0.3.7',
         'cffi==1.13.2',
-        'protobuf==3.18.3',
+        'protobuf==3.17.2',
         'msgpack==0.6.1',
         'prometheus_client==0.7.1',
         'coincurve==15.0.0',

From d495ce9f0ad54a4749ae28d127491e5a0cc986ee Mon Sep 17 00:00:00 2001
From: Jonathan Moody <103143855+moodyjon@users.noreply.github.com>
Date: Tue, 17 Jan 2023 16:42:43 -0600
Subject: [PATCH 8/8] Move IP version check earlier. Property ipv4_mapped is
 only defined on IPv6 addrs.

---
 hub/common.py | 13 ++++++++-----
 1 file changed, 8 insertions(+), 5 deletions(-)

diff --git a/hub/common.py b/hub/common.py
index 31c9c1a..d0ec230 100644
--- a/hub/common.py
+++ b/hub/common.py
@@ -610,11 +610,13 @@ IPV4_TO_6_RELAY_SUBNET = ipaddress.ip_network('192.88.99.0/24')
 def is_valid_public_ipv4(address, allow_localhost: bool = False, allow_lan: bool = False):
     try:
         parsed_ip = ipaddress.ip_address(address)
+        if parsed_ip.version != 4:
+            return False
         if parsed_ip.is_loopback:
             return allow_localhost
         if parsed_ip.is_private:
             return allow_lan
-        if any((parsed_ip.version != 4, parsed_ip.is_unspecified, parsed_ip.is_link_local, parsed_ip.is_loopback,
+        if any((parsed_ip.is_unspecified, parsed_ip.is_link_local, parsed_ip.is_loopback,
                 parsed_ip.is_multicast, parsed_ip.is_reserved, parsed_ip.is_private)):
             return False
         else:
@@ -626,14 +628,15 @@ def is_valid_public_ipv4(address, allow_localhost: bool = False, allow_lan: bool
 def is_valid_public_ipv6(address, allow_localhost: bool = False, allow_lan: bool = False):
     try:
         parsed_ip = ipaddress.ip_address(address)
+        if parsed_ip.version != 6:
+            return False
         if parsed_ip.is_loopback:
             return allow_localhost
         if parsed_ip.is_private:
             return allow_lan
-        return not any((parsed_ip.version != 6, parsed_ip.is_unspecified,
-                        parsed_ip.is_link_local, parsed_ip.is_loopback,
-                        parsed_ip.is_multicast, parsed_ip.is_reserved,
-                        parsed_ip.is_private, parsed_ip.ipv4_mapped))
+        return not any((parsed_ip.is_unspecified, parsed_ip.is_link_local, parsed_ip.is_loopback,
+                        parsed_ip.is_multicast, parsed_ip.is_reserved, parsed_ip.is_private,
+                        parsed_ip.ipv4_mapped))
     except (ipaddress.AddressValueError, ValueError):
         return False