diff --git a/API.md b/API.md index f22407a32..d1ac0ce72 100644 --- a/API.md +++ b/API.md @@ -512,7 +512,8 @@ Get all available addons. "ip_address": "ip address", "ingress": "bool", "ingress_entry": "null|/api/hassio_ingress/slug", - "ingress_url": "null|/api/hassio_ingress/slug/entry.html" + "ingress_url": "null|/api/hassio_ingress/slug/entry.html", + "ingress_port": "null|int" } ``` diff --git a/hassio/addons/addon.py b/hassio/addons/addon.py index 3e3022a2c..50fc05208 100644 --- a/hassio/addons/addon.py +++ b/hassio/addons/addon.py @@ -431,9 +431,15 @@ class Addon(CoreSysAttributes): return f"{proto}://[HOST]:{port}{s_suffix}" @property - def ingress_internal(self): - """Return Ingress host URL.""" - return f"http://{self.ip_address}:{self._mesh[ATTR_INGRESS_PORT]}" + def ingress_port(self): + """Return Ingress port.""" + if not self.is_installed or not self.with_ingress: + return None + + port = self._mesh[ATTR_INGRESS_PORT] + if port == 0: + return self.sys_ingress.get_dynamic_port(self.slug) + return port @property def host_network(self): diff --git a/hassio/addons/validate.py b/hassio/addons/validate.py index 91477c9e6..e0d0c849c 100644 --- a/hassio/addons/validate.py +++ b/hassio/addons/validate.py @@ -148,7 +148,7 @@ SCHEMA_ADDON_CONFIG = vol.Schema({ vol.Optional(ATTR_WEBUI): vol.Match(r"^(?:https?|\[PROTO:\w+\]):\/\/\[HOST\]:\[PORT:\d+\].*$"), vol.Optional(ATTR_INGRESS, default=False): vol.Boolean(), - vol.Optional(ATTR_INGRESS_PORT, default=8099): NETWORK_PORT, + vol.Optional(ATTR_INGRESS_PORT, default=8099): vol.Any(NETWORK_PORT, vol.Equal(0)), vol.Optional(ATTR_INGRESS_ENTRY): vol.Coerce(str), vol.Optional(ATTR_HOMEASSISTANT): vol.Maybe(vol.Coerce(str)), vol.Optional(ATTR_HOST_NETWORK, default=False): vol.Boolean(), diff --git a/hassio/api/addons.py b/hassio/api/addons.py index 8bf08d95f..c99e93d67 100644 --- a/hassio/api/addons.py +++ b/hassio/api/addons.py @@ -44,6 +44,7 @@ from ..const import ( ATTR_ICON, ATTR_INGRESS, ATTR_INGRESS_ENTRY, + ATTR_INGRESS_PORT, ATTR_INGRESS_URL, ATTR_INSTALLED, ATTR_IP_ADDRESS, @@ -223,6 +224,7 @@ class APIAddons(CoreSysAttributes): ATTR_INGRESS: addon.with_ingress, ATTR_INGRESS_ENTRY: addon.ingress_entry, ATTR_INGRESS_URL: addon.ingress_url, + ATTR_INGRESS_PORT: addon.ingress_port, } @api_process diff --git a/hassio/api/ingress.py b/hassio/api/ingress.py index c4fd6b087..0c9c6359a 100644 --- a/hassio/api/ingress.py +++ b/hassio/api/ingress.py @@ -43,7 +43,7 @@ class APIIngress(CoreSysAttributes): def _create_url(self, addon: Addon, path: str) -> str: """Create URL to container.""" - return f"{addon.ingress_internal}/{path}" + return f"http://{addon.ip_address}:{addon.ingress_port}/{path}" @api_process async def create_session(self, request: web.Request) -> Dict[str, Any]: diff --git a/hassio/ingress.py b/hassio/ingress.py index 384fefc17..28b7c12e4 100644 --- a/hassio/ingress.py +++ b/hassio/ingress.py @@ -1,14 +1,15 @@ """Fetch last versions from webserver.""" from datetime import timedelta import logging -from typing import Dict, Optional +import random import secrets +from typing import Dict, Optional from .addons.addon import Addon -from .const import ATTR_SESSION, FILE_HASSIO_INGRESS +from .const import ATTR_PORTS, ATTR_SESSION, FILE_HASSIO_INGRESS from .coresys import CoreSys, CoreSysAttributes +from .utils.dt import utc_from_timestamp, utcnow from .utils.json import JsonConfig -from .utils.dt import utcnow, utc_from_timestamp from .validate import SCHEMA_INGRESS_CONFIG _LOGGER = logging.getLogger(__name__) @@ -34,6 +35,11 @@ class Ingress(JsonConfig, CoreSysAttributes): """Return sessions.""" return self._data[ATTR_SESSION] + @property + def ports(self) -> Dict[str, int]: + """Return list of dynamic ports.""" + return self._data[ATTR_PORTS] + async def load(self) -> None: """Update internal data.""" self._update_token_list() @@ -101,3 +107,14 @@ class Ingress(JsonConfig, CoreSysAttributes): self.sessions[session] = valid_until.timestamp() return True + + def get_dynamic_port(self, addon_slug: str) -> int: + """Get/Create a dynamic port from range.""" + if addon_slug in self.ports: + return self.ports[addon_slug] + port = random.randint(62000, 65500) + + # Save port for next time + self.ports[addon_slug] = port + self.save_data() + return port diff --git a/hassio/validate.py b/hassio/validate.py index dc51de046..aa5504e5f 100644 --- a/hassio/validate.py +++ b/hassio/validate.py @@ -18,6 +18,7 @@ from .const import ( ATTR_LAST_VERSION, ATTR_PASSWORD, ATTR_PORT, + ATTR_PORTS, ATTR_REFRESH_TOKEN, ATTR_SESSION, ATTR_SSL, @@ -143,6 +144,13 @@ SCHEMA_AUTH_CONFIG = vol.Schema({SHA256: SHA256}) SCHEMA_INGRESS_CONFIG = vol.Schema( - {vol.Required(ATTR_SESSION, default=dict): vol.Schema({TOKEN: vol.Coerce(float)})}, + { + vol.Required(ATTR_SESSION, default=dict): vol.Schema( + {TOKEN: vol.Coerce(float)} + ), + vol.Required(ATTR_PORTS, default=dict): vol.Schema( + {vol.Coerce(str): NETWORK_PORT} + ), + }, extra=vol.REMOVE_EXTRA, ) diff --git a/tests/test_ingress.py b/tests/test_ingress.py index 5b8cf44af..50f1d5de4 100644 --- a/tests/test_ingress.py +++ b/tests/test_ingress.py @@ -20,3 +20,22 @@ def test_session_handling(coresys): coresys.ingress.sessions[session] = not_valid.timestamp() assert not coresys.ingress.validate_session(session) assert not coresys.ingress.validate_session("invalid session") + + +def test_dynamic_ports(coresys): + """Test dyanmic port handling.""" + port_test1 = coresys.ingress.get_dynamic_port("test1") + + assert port_test1 + assert coresys.ingress.save_data.called + assert port_test1 == coresys.ingress.get_dynamic_port("test1") + + port_test2 = coresys.ingress.get_dynamic_port("test2") + + assert port_test2 + assert port_test2 != port_test1 + + assert port_test2 > 62000 + assert port_test2 < 65500 + assert port_test1 > 62000 + assert port_test1 < 65500