Fix: improve selection of webserver IP
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Thu, 10 Apr 2025 07:45:20 +0000 (09:45 +0200)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Thu, 10 Apr 2025 07:45:20 +0000 (09:45 +0200)
Prevent selection of a loopback address

music_assistant/controllers/streams.py
music_assistant/controllers/webserver.py
music_assistant/helpers/util.py

index dd944d15c384ec4e6e141eb150926398e6385f2c..a439f9b18c84a3d12e834e283d2286ad72c25536 100644 (file)
@@ -70,8 +70,7 @@ from music_assistant.helpers.util import (
     get_folder_size,
     get_free_space,
     get_free_space_percentage,
-    get_ip,
-    get_ips,
+    get_ip_addresses,
     select_free_port,
     try_parse_bool,
 )
@@ -154,10 +153,19 @@ class StreamsController(CoreController):
         values: dict[str, ConfigValueType] | None = None,
     ) -> tuple[ConfigEntry, ...]:
         """Return all Config Entries for this core module (if any)."""
-        default_ip = await get_ip()
-        all_ips = await get_ips()
+        ip_addresses = await get_ip_addresses()
         default_port = await select_free_port(8097, 9200)
         return (
+            ConfigEntry(
+                key=CONF_PUBLISH_IP,
+                type=ConfigEntryType.STRING,
+                default_value=ip_addresses[0],
+                label="Published IP address",
+                description="This IP address is communicated to players where to find this server."
+                "\nMake sure that this IP can be reached by players on the local network, "
+                "otherwise audio streaming will not work.",
+                required=False,
+            ),
             ConfigEntry(
                 key=CONF_BIND_PORT,
                 type=ConfigEntryType.INTEGER,
@@ -205,25 +213,11 @@ class StreamsController(CoreController):
                 label="Fixed/fallback gain adjustment for tracks",
                 category="audio",
             ),
-            ConfigEntry(
-                key=CONF_PUBLISH_IP,
-                type=ConfigEntryType.STRING,
-                default_value=default_ip,
-                label="Published IP address",
-                description="This IP address is communicated to players where to find this server. "
-                "Override the default in advanced scenarios, such as multi NIC configurations. \n"
-                "Make sure that this server can be reached "
-                "on the given IP and TCP port by players on the local network. \n"
-                "This is an advanced setting that should normally "
-                "not be adjusted in regular setups.",
-                category="advanced",
-                required=False,
-            ),
             ConfigEntry(
                 key=CONF_BIND_IP,
                 type=ConfigEntryType.STRING,
                 default_value="0.0.0.0",
-                options=[ConfigValueOption(x, x) for x in {"0.0.0.0", *all_ips}],
+                options=[ConfigValueOption(x, x) for x in {"0.0.0.0", *ip_addresses}],
                 label="Bind to IP/interface",
                 description="Start the stream server on this specific interface. \n"
                 "Use 0.0.0.0 to bind to all interfaces, which is the default. \n"
index 108f2042780dbfcdd51ff675a1675f78bfc5f801..78724ee24dda9bb83ea9fc44a650b8e8e67fea7f 100644 (file)
@@ -32,7 +32,7 @@ from music_assistant.constants import CONF_BIND_IP, CONF_BIND_PORT, VERBOSE_LOG_
 from music_assistant.helpers.api import APICommandHandler, parse_arguments
 from music_assistant.helpers.audio import get_preview_stream
 from music_assistant.helpers.json import json_dumps
-from music_assistant.helpers.util import get_ip, get_ips
+from music_assistant.helpers.util import get_ip_addresses
 from music_assistant.helpers.webserver import Webserver
 from music_assistant.models.core_controller import CoreController
 
@@ -78,7 +78,8 @@ class WebserverController(CoreController):
         values: dict[str, ConfigValueType] | None = None,
     ) -> tuple[ConfigEntry, ...]:
         """Return all Config Entries for this core module (if any)."""
-        default_publish_ip = await get_ip()
+        ip_addresses = await get_ip_addresses()
+        default_publish_ip = ip_addresses[0]
         if self.mass.running_as_hass_addon:
             return (
                 ConfigEntry(
@@ -101,7 +102,6 @@ class WebserverController(CoreController):
 
         # HA supervisor not present: user is responsible for securing the webserver
         # we give the tools to do so by presenting config options
-        all_ips = await get_ips()
         default_base_url = f"http://{default_publish_ip}:{DEFAULT_SERVER_PORT}"
         return (
             ConfigEntry(
@@ -124,7 +124,7 @@ class WebserverController(CoreController):
                 key=CONF_BIND_IP,
                 type=ConfigEntryType.STRING,
                 default_value="0.0.0.0",
-                options=[ConfigValueOption(x, x) for x in {"0.0.0.0", *all_ips}],
+                options=[ConfigValueOption(x, x) for x in {"0.0.0.0", *ip_addresses}],
                 label="Bind to IP/interface",
                 description="Start the (web)server on this specific interface. \n"
                 "Use 0.0.0.0 to bind to all interfaces. \n"
@@ -165,7 +165,8 @@ class WebserverController(CoreController):
         # add jsonrpc api
         routes.append(("POST", "/api", self._handle_jsonrpc_api_command))
         # start the webserver
-        default_publish_ip = await get_ip()
+        all_ip_addresses = await get_ip_addresses()
+        default_publish_ip = all_ip_addresses[0]
         if self.mass.running_as_hass_addon:
             # if we're running on the HA supervisor the webserver is secured by HA ingress
             # we only start the webserver on the internal docker network and ingress connects
@@ -179,7 +180,7 @@ class WebserverController(CoreController):
             else:
                 # use internal ("172.30.32.) IP
                 self.publish_ip = bind_ip = next(
-                    (x for x in await get_ips() if x.startswith("172.30.32.")), default_publish_ip
+                    (x for x in all_ip_addresses if x.startswith("172.30.32.")), default_publish_ip
                 )
             base_url = f"http://{self.publish_ip}:{self.publish_port}"
         else:
index f51d6bb0e7f365add80d98a89d38f28ae9bb2de4..a50e4611ade72fb1f0a96fbadf6d91d34294b503 100644 (file)
@@ -221,23 +221,35 @@ def clean_stream_title(line: str) -> str:
     return line
 
 
-async def get_ip() -> str:
-    """Get primary IP-address for this host."""
+async def get_ip_addresses(include_ipv6: bool = False) -> tuple[str]:
+    """Return all IP-adresses of all network interfaces."""
 
-    def _get_ip() -> str:
-        """Get primary IP-address for this host."""
-        sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
-        try:
-            # doesn't even have to be reachable
-            sock.connect(("10.255.255.255", 1))
-            _ip = str(sock.getsockname()[0])
-        except Exception:
-            _ip = "127.0.0.1"
-        finally:
-            sock.close()
-        return _ip
+    def call() -> set[str]:
+        result: list[tuple[int, str]] = []
+        adapters = ifaddr.get_adapters()
+        for adapter in adapters:
+            for ip in adapter.ips:
+                if ip.is_IPv6 and not include_ipv6:
+                    continue
+                if ip.ip.startswith(("127", "169.254")):
+                    # filter out IPv4 loopback/APIPA address
+                    continue
+                if ip.ip.startswith(("::1", "::ffff:", "fe80")):
+                    # filter out IPv6 loopback/link-local address
+                    continue
+                if ip.ip.startswith(("192.", "10.")):
+                    score = 2
+                elif ip.ip.startswith("172."):
+                    # we rank the 172 range a bit lower as its most
+                    # often used as the private docker network
+                    score = 1
+                else:
+                    score = 0
+                result.append((score, ip.ip))
+        result.sort(key=lambda x: x[0], reverse=True)
+        return tuple(ip[1] for ip in result)
 
-    return await asyncio.to_thread(_get_ip)
+    return await asyncio.to_thread(call)
 
 
 async def is_port_in_use(port: int) -> bool:
@@ -276,10 +288,8 @@ async def get_ip_from_host(dns_name: str) -> str | None:
     return await asyncio.to_thread(_resolve)
 
 
-async def get_ip_pton(ip_string: str | None = None) -> bytes:
-    """Return socket pton for local ip."""
-    if ip_string is None:
-        ip_string = await get_ip()
+async def get_ip_pton(ip_string: str) -> bytes:
+    """Return socket pton for a local ip."""
     try:
         return await asyncio.to_thread(socket.inet_pton, socket.AF_INET, ip_string)
     except OSError:
@@ -391,24 +401,6 @@ async def get_package_version(pkg_name: str) -> str | None:
         return None
 
 
-async def get_ips(include_ipv6: bool = False, ignore_loopback: bool = True) -> set[str]:
-    """Return all IP-adresses of all network interfaces."""
-
-    def call() -> set[str]:
-        result: set[str] = set()
-        adapters = ifaddr.get_adapters()
-        for adapter in adapters:
-            for ip in adapter.ips:
-                if ip.is_IPv6 and not include_ipv6:
-                    continue
-                if ip.ip == "127.0.0.1" and ignore_loopback:
-                    continue
-                result.add(ip.ip)
-        return result
-
-    return await asyncio.to_thread(call)
-
-
 async def is_hass_supervisor() -> bool:
     """Return if we're running inside the HA Supervisor (e.g. HAOS)."""