supervisor/supervisor/ingress.py
Stefan Agner 5779b567f1
Optimize API connection handling by removing redundant port checks (#6212)
* Simplify ensure_access_token

Make the caller of ensure_access_token responsible for connection error
handling. This is especially useful for API connection checks, as it
avoids an extra call to the API (if we fail to connect when refreshing
the token there is no point in calling the API to check if it is up).
Document the change in the docstring.

Also avoid the overhead of creating a Job object. We can simply use an
asyncio.Lock() to ensure only one coroutine is refreshing the token at
a time. This also avoids Job interference in Exception handling.

* Remove check_port from API checks

Remove check_port usage from Home Assistant API connection checks.
Simply rely on errors raised from actual connection attempts. During a
Supervisor startup when Home Assistant Core is running (e.g. after a
Supervisor update) we make about 10 successful API checks. The old code
path did a port check and then a connection check, causing two socket
creation. The new code without the separate port check safes 10 socket
creations per startup (the aiohttp connections are reused, hence do not
cause only one socket creation).

* Log API exceptions on call site

Since make_request is no longer logging API exceptions on its own, we
need to log them where we call make_request. This approach gives the
user more context about what Supervisor was trying to do when the error
happened.

* Avoid unnecessary nesting

* Improve error when ingress panel update fails

* Add comment about fast path
2025-10-02 08:54:50 +02:00

209 lines
6.7 KiB
Python

"""Fetch last versions from webserver."""
from datetime import timedelta
import logging
import random
import secrets
from .addons.addon import Addon
from .const import (
ATTR_PORTS,
ATTR_SESSION,
ATTR_SESSION_DATA,
FILE_HASSIO_INGRESS,
IngressSessionData,
IngressSessionDataDict,
)
from .coresys import CoreSys, CoreSysAttributes
from .exceptions import HomeAssistantAPIError
from .utils import check_port
from .utils.common import FileConfiguration
from .utils.dt import utc_from_timestamp, utcnow
from .validate import SCHEMA_INGRESS_CONFIG
_LOGGER: logging.Logger = logging.getLogger(__name__)
class Ingress(FileConfiguration, CoreSysAttributes):
"""Fetch last versions from version.json."""
def __init__(self, coresys: CoreSys):
"""Initialize updater."""
super().__init__(FILE_HASSIO_INGRESS, SCHEMA_INGRESS_CONFIG)
self.coresys: CoreSys = coresys
self.tokens: dict[str, str] = {}
def get(self, token: str) -> Addon | None:
"""Return addon they have this ingress token."""
if token not in self.tokens:
return None
return self.sys_addons.get_local_only(self.tokens[token])
def get_session_data(self, session_id: str) -> IngressSessionData | None:
"""Return complementary data of current session or None."""
if data := self.sessions_data.get(session_id):
return IngressSessionData.from_dict(data)
return None
@property
def sessions(self) -> dict[str, float]:
"""Return sessions."""
return self._data[ATTR_SESSION]
@property
def sessions_data(self) -> dict[str, IngressSessionDataDict]:
"""Return sessions_data."""
return self._data[ATTR_SESSION_DATA]
@property
def ports(self) -> dict[str, int]:
"""Return list of dynamic ports."""
return self._data[ATTR_PORTS]
@property
def addons(self) -> list[Addon]:
"""Return list of ingress Add-ons."""
addons = []
for addon in self.sys_addons.installed:
if not addon.with_ingress:
continue
addons.append(addon)
return addons
async def load(self) -> None:
"""Update internal data."""
self._update_token_list()
self._cleanup_sessions()
_LOGGER.info("Loaded %d ingress sessions", len(self.sessions))
async def reload(self) -> None:
"""Reload/Validate sessions."""
self._cleanup_sessions()
self._update_token_list()
async def unload(self) -> None:
"""Shutdown sessions."""
await self.save_data()
def _cleanup_sessions(self) -> None:
"""Remove not used sessions."""
now = utcnow()
sessions = {}
sessions_data: dict[str, IngressSessionDataDict] = {}
for session, valid in self.sessions.items():
# check if timestamp valid, to avoid crash on malformed timestamp
try:
valid_dt = utc_from_timestamp(valid)
except OverflowError:
_LOGGER.warning("Session timestamp %f is invalid!", valid)
continue
if valid_dt < now:
continue
# Is valid
sessions[session] = valid
if session_data := self.sessions_data.get(session):
sessions_data[session] = session_data
# Write back
self.sessions.clear()
self.sessions.update(sessions)
self.sessions_data.clear()
self.sessions_data.update(sessions_data)
def _update_token_list(self) -> None:
"""Regenerate token <-> Add-on map."""
self.tokens.clear()
# Read all ingress token and build a map
for addon in self.addons:
if addon.ingress_token:
self.tokens[addon.ingress_token] = addon.slug
def create_session(self, data: IngressSessionData | None = None) -> str:
"""Create new session."""
session = secrets.token_hex(64)
valid = utcnow() + timedelta(minutes=15)
self.sessions[session] = valid.timestamp()
if data is not None:
self.sessions_data[session] = data.to_dict()
return session
def validate_session(self, session: str) -> bool:
"""Return True if session valid and make it longer valid."""
if session not in self.sessions:
_LOGGER.debug("Session %s is not known", session)
return False
# check if timestamp valid, to avoid crash on malformed timestamp
try:
valid_until = utc_from_timestamp(self.sessions[session])
except OverflowError:
self.sessions[session] = (utcnow() + timedelta(minutes=15)).timestamp()
return True
# Is still valid?
if valid_until < utcnow():
_LOGGER.debug("Session is no longer valid (%f/%f)", valid_until, utcnow())
return False
# Update time
valid_until = valid_until + timedelta(minutes=15)
self.sessions[session] = valid_until.timestamp()
return True
async 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 = None
while (
port is None
or port in self.ports.values()
or await check_port(self.sys_docker.network.gateway, port)
):
port = random.randint(62000, 65500)
# Save port for next time
self.ports[addon_slug] = port
await self.save_data()
return port
async def del_dynamic_port(self, addon_slug: str) -> None:
"""Remove a previously assigned dynamic port."""
if addon_slug not in self.ports:
return
del self.ports[addon_slug]
await self.save_data()
async def update_hass_panel(self, addon: Addon):
"""Return True if Home Assistant up and running."""
if not await self.sys_homeassistant.core.is_running():
_LOGGER.debug("Ignoring panel update on Core")
return
# Update UI
method = "post" if addon.ingress_panel else "delete"
try:
async with self.sys_homeassistant.api.make_request(
method, f"api/hassio_push/panel/{addon.slug}"
) as resp:
if resp.status in (200, 201):
_LOGGER.info("Update Ingress as panel for %s", addon.slug)
else:
_LOGGER.warning(
"Failed to update the Ingress panel for %s with %i",
addon.slug,
resp.status,
)
except HomeAssistantAPIError as err:
_LOGGER.error("Panel update request failed for %s: %s", addon.slug, err)