From 52acd028044abae7a90514fd6de3198b3dfc6e24 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 19 Oct 2024 20:31:59 -0700 Subject: [PATCH] Add support for dynamic proxies URLs --- custom_components/hass_proxy/__init__.py | 16 +- custom_components/hass_proxy/config_flow.py | 16 +- custom_components/hass_proxy/const.py | 20 +- custom_components/hass_proxy/data.py | 12 ++ custom_components/hass_proxy/proxy.py | 188 ++++++++++++++++-- custom_components/hass_proxy/proxy_lib.py | 14 +- custom_components/hass_proxy/services.yaml | 71 +++++++ .../hass_proxy/translations/en.json | 9 +- 8 files changed, 294 insertions(+), 52 deletions(-) create mode 100644 custom_components/hass_proxy/services.yaml diff --git a/custom_components/hass_proxy/__init__.py b/custom_components/hass_proxy/__init__.py index 50f2dc2..f55ed86 100644 --- a/custom_components/hass_proxy/__init__.py +++ b/custom_components/hass_proxy/__init__.py @@ -15,9 +15,10 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant - from .data import HASSProxyData + from .data import HASSProxyConfigEntry -from .proxy import async_setup as async_proxy_setup +from .proxy import async_setup_entry as async_proxy_setup_entry +from .proxy import async_unload_entry as async_proxy_unload_entry PLATFORMS: list[Platform] = [] @@ -25,7 +26,7 @@ # https://developers.home-assistant.io/docs/config_entries_index/#setting-up-an-entry async def async_setup_entry( hass: HomeAssistant, - entry: HASSProxyData, + entry: HASSProxyConfigEntry, ) -> bool: """Set up this integration.""" LOGGER.info("HASSPROXY Setting up entry %s", entry.entry_id) @@ -33,23 +34,26 @@ async def async_setup_entry( await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(async_reload_entry)) - await async_proxy_setup(hass) + await async_proxy_setup_entry(hass, entry) return True async def async_unload_entry( hass: HomeAssistant, - entry: HASSProxyData, + entry: HASSProxyConfigEntry, ) -> bool: """Handle removal of an entry.""" LOGGER.info("HASSPROXY Unloading entry %s", entry.entry_id) + + await async_proxy_unload_entry(hass, entry) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def async_reload_entry( hass: HomeAssistant, - entry: HASSProxyData, + entry: HASSProxyConfigEntry, ) -> None: """Reload config entry.""" LOGGER.info("HASSPROXY Reloading entry %s", entry.entry_id) diff --git a/custom_components/hass_proxy/config_flow.py b/custom_components/hass_proxy/config_flow.py index 6f4898f..76f8e40 100644 --- a/custom_components/hass_proxy/config_flow.py +++ b/custom_components/hass_proxy/config_flow.py @@ -9,11 +9,11 @@ from .const import ( CONF_DYNAMIC_URLS, - CONF_SSL_CIPHER_INSECURE, - CONF_SSL_CIPHER_INTERMEDIATE, - CONF_SSL_CIPHER_MODERN, - CONF_SSL_CIPHER_PYTHON_DEFAULT, CONF_SSL_CIPHERS, + CONF_SSL_CIPHERS_DEFAULT, + CONF_SSL_CIPHERS_INSECURE, + CONF_SSL_CIPHERS_INTERMEDIATE, + CONF_SSL_CIPHERS_MODERN, CONF_SSL_VERIFICATION, CONF_URL_PATTERNS, DEFAULT_OPTIONS, @@ -38,10 +38,10 @@ ): selector.SelectSelector( selector.SelectSelectorConfig( options=[ - CONF_SSL_CIPHER_PYTHON_DEFAULT, - CONF_SSL_CIPHER_MODERN, - CONF_SSL_CIPHER_INTERMEDIATE, - CONF_SSL_CIPHER_INSECURE, + CONF_SSL_CIPHERS_DEFAULT, + CONF_SSL_CIPHERS_MODERN, + CONF_SSL_CIPHERS_INTERMEDIATE, + CONF_SSL_CIPHERS_INSECURE, ], mode=selector.SelectSelectorMode.DROPDOWN, translation_key=CONF_SSL_CIPHERS, diff --git a/custom_components/hass_proxy/const.py b/custom_components/hass_proxy/const.py index 42b6437..6b44efa 100644 --- a/custom_components/hass_proxy/const.py +++ b/custom_components/hass_proxy/const.py @@ -1,9 +1,7 @@ """Constants for hass_proxy.""" from logging import Logger, getLogger -from typing import Final - -from homeassistant.util.ssl import SSLCipherList +from typing import Final, Literal DOMAIN: Final = "hass_proxy" @@ -11,16 +9,22 @@ CONF_SSL_VERIFICATION: Final = "ssl_verification" CONF_SSL_CIPHERS: Final = "ssl_ciphers" -CONF_SSL_CIPHER_INSECURE: Final = SSLCipherList.INSECURE -CONF_SSL_CIPHER_MODERN: Final = SSLCipherList.MODERN -CONF_SSL_CIPHER_INTERMEDIATE: Final = SSLCipherList.INTERMEDIATE -CONF_SSL_CIPHER_PYTHON_DEFAULT: Final = SSLCipherList.PYTHON_DEFAULT + +CONF_SSL_CIPHERS_INSECURE: Final = "insecure" +CONF_SSL_CIPHERS_MODERN: Final = "modern" +CONF_SSL_CIPHERS_INTERMEDIATE: Final = "intermediate" +CONF_SSL_CIPHERS_DEFAULT: Final = "default" + +type HASSProxySSLCiphers = Literal["insecure", "modern", "intermediate", "default"] CONF_DYNAMIC_URLS: Final = "dynamic_urls" CONF_URL_PATTERNS: Final = "url_patterns" +SERVICE_CREATE_PROXIED_URL: Final = "create_proxied_url" +SERVICE_DELETE_PROXIED_URL: Final = "delete_proxied_url" + DEFAULT_OPTIONS: dict[str, str | bool | list[str]] = { CONF_SSL_VERIFICATION: True, CONF_DYNAMIC_URLS: True, - CONF_SSL_CIPHERS: CONF_SSL_CIPHER_PYTHON_DEFAULT, + CONF_SSL_CIPHERS: CONF_SSL_CIPHERS_DEFAULT, } diff --git a/custom_components/hass_proxy/data.py b/custom_components/hass_proxy/data.py index ddadcfe..2efa89b 100644 --- a/custom_components/hass_proxy/data.py +++ b/custom_components/hass_proxy/data.py @@ -10,6 +10,17 @@ from homeassistant.loader import Integration +@dataclass +class DynamicProxiedURL: + """A proxied URL.""" + + url_pattern: str + ssl_verification: bool + ssl_ciphers: str + open_limit: int + time_to_live: int + + type HASSProxyConfigEntry = ConfigEntry[HASSProxyData] @@ -18,3 +29,4 @@ class HASSProxyData: """Data for the HASS Proxy integration.""" integration: Integration + dynamic_proxied_urls: dict[str, DynamicProxiedURL] diff --git a/custom_components/hass_proxy/proxy.py b/custom_components/hass_proxy/proxy.py index 5cb49ce..3fe5ff4 100644 --- a/custom_components/hass_proxy/proxy.py +++ b/custom_components/hass_proxy/proxy.py @@ -2,24 +2,47 @@ from __future__ import annotations +import time import urllib +import uuid from typing import TYPE_CHECKING, Any import urlmatch +import voluptuous as vol +from homeassistant.core import callback +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.loader import async_get_loaded_integration from homeassistant.util.ssl import ( + SSLCipherList, client_context, client_context_no_verify, ) from custom_components.hass_proxy.const import DOMAIN +from custom_components.hass_proxy.data import ( + DynamicProxiedURL, + HASSProxyConfigEntry, + HASSProxyData, +) from custom_components.hass_proxy.proxy_lib import ( - HASSProxyNotFoundRequestError, + HASSProxyLibNotFoundRequestError, ProxiedURL, ProxyView, ) -from .const import CONF_SSL_CIPHERS, CONF_SSL_VERIFICATION +from .const import ( + CONF_DYNAMIC_URLS, + CONF_SSL_CIPHERS, + CONF_SSL_CIPHERS_DEFAULT, + CONF_SSL_CIPHERS_INSECURE, + CONF_SSL_CIPHERS_INTERMEDIATE, + CONF_SSL_CIPHERS_MODERN, + CONF_SSL_VERIFICATION, + SERVICE_CREATE_PROXIED_URL, + SERVICE_DELETE_PROXIED_URL, +) if TYPE_CHECKING: import ssl @@ -27,14 +50,102 @@ import aiohttp from aiohttp import web - from homeassistant.core import HomeAssistant + from homeassistant.core import HomeAssistant, ServiceCall + + from .const import HASSProxySSLCiphers + +CREATE_PROXIED_URL_SCHEMA = vol.Schema( + { + vol.Required("url_pattern"): cv.string, + vol.Optional("url_id"): cv.string, + vol.Optional("ssl_verification", default=True): cv.boolean, + vol.Optional("ssl_ciphers", default=CONF_SSL_CIPHERS_DEFAULT): vol.Any( + None, + CONF_SSL_CIPHERS_INSECURE, + CONF_SSL_CIPHERS_MODERN, + CONF_SSL_CIPHERS_INTERMEDIATE, + CONF_SSL_CIPHERS_DEFAULT, + ), + vol.Optional("open_limit", default=1): cv.positive_int, + vol.Optional("time_to_live", default=60): cv.positive_int, + }, + required=True, +) + +DELETE_PROXIED_URL_SCHEMA = vol.Schema( + { + vol.Required("url_id"): cv.string, + }, + required=True, +) -async def async_setup(hass: HomeAssistant) -> None: - """Set up the views.""" +class HASSProxyError(Exception): + """Exception to indicate a general Proxy error.""" + + +class HASSProxyURLIDNotFoundError(HASSProxyError): + """Exception to indicate that a URL ID was not found.""" + + +@callback +async def async_setup_entry(hass: HomeAssistant, entry: HASSProxyConfigEntry) -> None: + """Set up the proxy entry.""" session = async_get_clientsession(hass) hass.http.register_view(V0ProxyView(hass, session)) + entry.runtime_data = HASSProxyData( + integration=async_get_loaded_integration(hass, entry.domain), + dynamic_proxied_urls={}, + ) + + def create_proxied_url(call: ServiceCall) -> None: + """Create a proxied URL.""" + url_id = call.data.get("url_id") or str(uuid.uuid4()) + ttl = call.data["time_to_live"] + + entry.runtime_data.dynamic_proxied_urls[url_id] = DynamicProxiedURL( + url_pattern=call.data["url_pattern"], + ssl_verification=call.data["ssl_verification"], + ssl_ciphers=call.data["ssl_ciphers"], + open_limit=call.data["open_limit"], + time_to_live=time.time() + ttl if ttl else 0, + ) + + def delete_proxied_url(call: ServiceCall) -> None: + """Delete a proxied URL.""" + url_id = call.data["url_id"] + dynamic_proxied_urls = entry.runtime_data.dynamic_proxied_urls + + if url_id not in dynamic_proxied_urls: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="url_id_not_found", + translation_placeholders={"url_id": url_id}, + ) + del entry.runtime_data.dynamic_proxied_urls[url_id] + + if entry.options.get(CONF_DYNAMIC_URLS): + hass.services.async_register( + DOMAIN, + SERVICE_CREATE_PROXIED_URL, + create_proxied_url, + CREATE_PROXIED_URL_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_DELETE_PROXIED_URL, + delete_proxied_url, + DELETE_PROXIED_URL_SCHEMA, + ) + + +@callback +async def async_unload_entry(hass: HomeAssistant, _entry: HASSProxyConfigEntry) -> None: + """Unload the proxy entry.""" + hass.services.async_remove(DOMAIN, SERVICE_CREATE_PROXIED_URL) + hass.services.async_remove(DOMAIN, SERVICE_DELETE_PROXIED_URL) + class HAProxyView(ProxyView): """A proxy view for HomeAssistant.""" @@ -44,31 +155,70 @@ def __init__(self, hass: HomeAssistant, websession: aiohttp.ClientSession) -> No self._hass = hass super().__init__(websession) + def _get_config_entry(self) -> HASSProxyConfigEntry: + """Get the config entry.""" + return self._hass.config_entries.async_entries(DOMAIN)[0] + + def get_dynamic_proxied_urls(self) -> dict[str, DynamicProxiedURL]: + """Get the dynamic proxied URLs.""" + return self._get_config_entry().runtime_data.dynamic_proxied_urls + def _get_options(self) -> MappingProxyType[str, Any]: """Get a ConfigEntry options for a given request.""" - return self._hass.config_entries.async_entries(DOMAIN)[0].options + return self._get_config_entry().options - def _get_url_to_proxy(self, request: web.Request) -> str: + def _get_proxied_url(self, request: web.Request) -> ProxiedURL: """Get the URL to proxy.""" if "url" not in request.query: - raise HASSProxyNotFoundRequestError + raise HASSProxyLibNotFoundRequestError + options = self._get_options() url_to_proxy = urllib.parse.unquote(request.query["url"]) + + for proxied_url in self.get_dynamic_proxied_urls().values(): + if urlmatch.urlmatch( + proxied_url.url_pattern, + url_to_proxy, + path_required=False, + ): + return ProxiedURL( + url=url_to_proxy, + ssl_context=self._get_ssl_context(proxied_url.ssl_ciphers) + if proxied_url.ssl_verification + else self._get_ssl_context_no_verify(proxied_url.ssl_ciphers), + ) + for url_pattern in self._get_options().get("url_patterns", []): if urlmatch.urlmatch(url_pattern, url_to_proxy, path_required=False): - break - else: - raise HASSProxyNotFoundRequestError + ssl_cipher = options.get(CONF_SSL_CIPHERS) + ssl_verification = options.get(CONF_SSL_VERIFICATION, True) - return url_to_proxy + return ProxiedURL( + url=url_to_proxy, + ssl_context=self._get_ssl_context(ssl_cipher) + if ssl_verification + else self._get_ssl_context_no_verify(ssl_cipher), + ) - def _get_ssl_context(self) -> ssl.SSLContext: + raise HASSProxyLibNotFoundRequestError + + def _get_ssl_context_no_verify( + self, ssl_cipher: HASSProxySSLCiphers + ) -> ssl.SSLContext: """Get an SSL context.""" - options = self._get_options() + return client_context_no_verify( + self._proxy_ssl_cipher_to_ha_ssl_cipher(ssl_cipher) + ) - if not options.get(CONF_SSL_VERIFICATION, True): - return client_context_no_verify(options.get(CONF_SSL_CIPHERS)) - return client_context(options.get(CONF_SSL_CIPHERS)) + def _get_ssl_context(self, ssl_ciphers: HASSProxySSLCiphers) -> ssl.SSLContext: + """Get an SSL context.""" + return client_context(self._proxy_ssl_cipher_to_ha_ssl_cipher(ssl_ciphers)) + + def _proxy_ssl_cipher_to_ha_ssl_cipher(self, ssl_ciphers: str) -> SSLCipherList: + """Convert a proxy SSL cipher to a HA SSL cipher.""" + if ssl_ciphers == CONF_SSL_CIPHERS_DEFAULT: + return SSLCipherList.PYTHON_DEFAULT + return ssl_ciphers class V0ProxyView(HAProxyView): @@ -76,7 +226,3 @@ class V0ProxyView(HAProxyView): url = "/api/hass_proxy/v0/" name = "api:hass_proxy:v0" - - def _get_proxied_url(self, request: web.Request, **_kwargs: Any) -> str: - """Create path.""" - return ProxiedURL(self._get_url_to_proxy(request), self._get_ssl_context()) diff --git a/custom_components/hass_proxy/proxy_lib.py b/custom_components/hass_proxy/proxy_lib.py index 0ea8e32..09f0ec7 100644 --- a/custom_components/hass_proxy/proxy_lib.py +++ b/custom_components/hass_proxy/proxy_lib.py @@ -23,19 +23,19 @@ from multidict import CIMultiDict -class HASSProxyError(Exception): +class HASSProxyLibError(Exception): """Exception to indicate a general Proxy error.""" -class HASSProxyBadRequestError(HASSProxyError): +class HASSProxyLibBadRequestError(HASSProxyLibError): """Exception to indicate a bad request.""" -class HASSProxyForbiddenBadRequestError(HASSProxyError): +class HASSProxyLibForbiddenBadRequestError(HASSProxyLibError): """Exception to indicate a bad request.""" -class HASSProxyNotFoundRequestError(HASSProxyError): +class HASSProxyLibNotFoundRequestError(HASSProxyLibError): """Exception to indicate something being not found.""" @@ -90,11 +90,11 @@ def _get_proxied_url_or_handle_error( """Get the proxied URL or handle error.""" try: url = self._get_proxied_url(request, **kwargs) - except HASSProxyForbiddenBadRequestError: + except HASSProxyLibForbiddenBadRequestError: return web.Response(status=HTTPStatus.FORBIDDEN) - except HASSProxyNotFoundRequestError: + except HASSProxyLibNotFoundRequestError: return web.Response(status=HTTPStatus.NOT_FOUND) - except HASSProxyBadRequestError: + except HASSProxyLibBadRequestError: return web.Response(status=HTTPStatus.BAD_REQUEST) if not url or not url.url: diff --git a/custom_components/hass_proxy/services.yaml b/custom_components/hass_proxy/services.yaml new file mode 100644 index 0000000..f28d89a --- /dev/null +++ b/custom_components/hass_proxy/services.yaml @@ -0,0 +1,71 @@ +--- +create_proxied_url: + name: Create a proxied URL + description: > + Dynamically creates a proxied URL or pattern of proxied URLs that will be + proxied through Home Assistant. + fields: + url_pattern: + name: URL Pattern + description: A URL or pattern of URLs to proxy through Home Assistant. + required: true + example: https://*.backends.behind.homeassistant + selector: + text: + url_id: + name: URL ID + description: An arbitrary ID for the proxied URL that can be used to reference it later. + example: 9064c544-1544-4fe5-817e-6974a120a391 + required: false + selector: + text: + ssl_verification: + name: SSL Verification + description: Whether SSL certification verification is enabled for the downstream connection. + required: false + selector: + boolean: + ssl_ciphers: + name: SSL Ciphers + description: Which SSL ciphers to use for the downstream connection. + required: false + selector: + select: + options: + - "default" + - "modern" + - "intermediate" + - "insecure" + translation_key: ssl_ciphers + mode: dropdown + open_limit: + name: Open Limit + description: The number of times this proxied URL can be accessed before it is automatically removed. + required: false + selector: + number: + min: 0 + max: 100 + unit_of_measurement: times + time_to_live: + name: Time to Live + description: The number of seconds this proxied URL will be available before it is automatically removed. + required: false + selector: + number: + min: 0 + max: 100000 + unit_of_measurement: seconds + +delete_proxied_url: + name: Delete a proxied URL + description: > + Delete a dynamically created proxied URL. + fields: + url_id: + name: URL ID + description: The ID for the proxied URL to delete. + example: 9064c544-1544-4fe5-817e-6974a120a391 + required: true + selector: + text: diff --git a/custom_components/hass_proxy/translations/en.json b/custom_components/hass_proxy/translations/en.json index 902f2f6..cda3918 100644 --- a/custom_components/hass_proxy/translations/en.json +++ b/custom_components/hass_proxy/translations/en.json @@ -11,7 +11,7 @@ "step": { "init": { "data": { - "dynamic_urls": "Enable dynamic link creation", + "dynamic_urls": "Enable dynamic proxied URL creation", "ssl_verification": "Enable SSL Verification", "ssl_ciphers": "SSL Ciphers", "url_patterns": "URL pattern to proxy" @@ -25,8 +25,13 @@ "insecure": "Insecure", "intermediate": "Intermediate", "modern": "Modern", - "python_default": "Default" + "default": "Default" } } + }, + "exceptions": { + "url_id_not_found": { + "message": "URL ID \"{url_id}\" not found." + } } }