From c0387837516401c743349e434824a8b149bd0714 Mon Sep 17 00:00:00 2001 From: Snuffy2 Date: Sun, 24 Nov 2024 12:16:04 -0500 Subject: [PATCH] Add reconfigure --- custom_components/opnsense/config_flow.py | 414 +++++++++++------- custom_components/opnsense/const.py | 1 + .../opnsense/translations/en.json | 11 +- 3 files changed, 255 insertions(+), 171 deletions(-) diff --git a/custom_components/opnsense/config_flow.py b/custom_components/opnsense/config_flow.py index 6e8feb3..9e803ae 100644 --- a/custom_components/opnsense/config_flow.py +++ b/custom_components/opnsense/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow for OPNsense integration.""" +"""Config flow for OPNsense integration""" import ipaddress import logging @@ -22,7 +22,7 @@ CONF_USERNAME, CONF_VERIFY_SSL, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import selector from homeassistant.helpers.aiohttp_client import async_create_clientsession @@ -32,6 +32,7 @@ CONF_DEVICE_TRACKER_SCAN_INTERVAL, CONF_DEVICE_UNIQUE_ID, CONF_DEVICES, + CONF_FIRMWARE_VERSION, CONF_MANUAL_DEVICES, DEFAULT_DEVICE_TRACKER_CONSIDER_HOME, DEFAULT_DEVICE_TRACKER_ENABLED, @@ -67,179 +68,183 @@ def cleanse_sensitive_data(message, secrets=[]): return message +async def validate_input( + hass: HomeAssistant, user_input: Mapping[str, Any], errors: Mapping[str, Any] +): + try: + fix_url = user_input[CONF_URL].strip() + # ParseResult( + # scheme='', netloc='', path='f', params='', query='', fragment='' + # ) + url_parts: ParseResult = urlparse(fix_url) + if not url_parts.scheme and not url_parts.netloc: + fix_url: str = "https://" + fix_url + url_parts = urlparse(fix_url) + + if not url_parts.netloc: + raise InvalidURL() + + # remove any path etc details + user_input[CONF_URL] = f"{url_parts.scheme}://{url_parts.netloc}" + + client = OPNsenseClient( + url=user_input[CONF_URL], + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + session=async_create_clientsession(hass, raise_for_status=True), + opts={"verify_ssl": user_input[CONF_VERIFY_SSL]}, + initial=True, + ) + + user_input[CONF_FIRMWARE_VERSION] = await client.get_host_firmware_version() + try: + if awesomeversion.AwesomeVersion( + user_input[CONF_FIRMWARE_VERSION] + ) < awesomeversion.AwesomeVersion(OPNSENSE_MIN_FIRMWARE): + raise BelowMinFirmware() + except awesomeversion.exceptions.AwesomeVersionCompareException: + raise UnknownFirmware() + + if not await client.is_plugin_installed(): + raise PluginMissing() + + system_info: Mapping[str, Any] = await client.get_system_info() + if not user_input.get(CONF_NAME): + user_input[CONF_NAME] = system_info.get("name") or "OPNsense" + + user_input[CONF_DEVICE_UNIQUE_ID] = await client.get_device_unique_id() + if not user_input[CONF_DEVICE_UNIQUE_ID]: + raise MissingDeviceUniqueID() + + except BelowMinFirmware: + _LOGGER.error( + f"OPNsense Firmware of {user_input[CONF_FIRMWARE_VERSION]} is below the minimum supported version of {OPNSENSE_MIN_FIRMWARE}" + ) + errors["base"] = "below_min_firmware" + except UnknownFirmware: + _LOGGER.error("Unable to get OPNsense Firmware version") + errors["base"] = "unknown_firmware" + except MissingDeviceUniqueID as err: + errors["base"] = "missing_device_unique_id" + _LOGGER.error( + f"Missing Device Unique ID Error. {err.__class__.__qualname__}: {err}" + ) + except PluginMissing: + errors["base"] = "plugin_missing" + _LOGGER.error("OPNsense Plugin Missing") + except (aiohttp.InvalidURL, InvalidURL) as err: + errors["base"] = "invalid_url_format" + _LOGGER.error(f"InvalidURL Error. {err.__class__.__qualname__}: {err}") + except xmlrpc.client.Fault as err: + if "Invalid username or password" in str(err): + errors["base"] = "invalid_auth" + elif "Authentication failed: not enough privileges" in str(err): + errors["base"] = "privilege_missing" + elif "opnsense.exec_php does not exist" in str(err): + errors["base"] = "plugin_missing" + else: + errors["base"] = "cannot_connect" + _LOGGER.error( + cleanse_sensitive_data( + f"XMLRPC Error. {err.__class__.__qualname__}: {err}", + [user_input[CONF_USERNAME], user_input[CONF_PASSWORD]], + ) + ) + except aiohttp.ClientConnectorSSLError as err: + errors["base"] = "cannot_connect_ssl" + _LOGGER.error(f"Aiohttp Error. {err.__class__.__qualname__}: {err}") + except (aiohttp.ClientResponseError,) as err: + if err.status == 401 or err.status == 403: + errors["base"] = "invalid_auth" + else: + errors["base"] = "cannot_connect" + _LOGGER.error(f"Aiohttp Error. {err.__class__.__qualname__}: {err}") + except ( + aiohttp.ClientError, + aiohttp.ClientConnectorError, + socket.gaierror, + ) as err: + errors["base"] = "cannot_connect" + _LOGGER.error(f"Aiohttp Error. {err.__class__.__qualname__}: {err}") + except xmlrpc.client.ProtocolError as err: + if "307 Temporary Redirect" in str(err): + errors["base"] = "url_redirect" + elif "301 Moved Permanently" in str(err): + errors["base"] = "url_redirect" + else: + errors["base"] = "cannot_connect" + _LOGGER.error( + cleanse_sensitive_data( + f"XMLRPC Error. {err.__class__.__qualname__}: {err}", + [user_input[CONF_USERNAME], user_input[CONF_PASSWORD]], + ) + ) + except (aiohttp.TooManyRedirects, aiohttp.RedirectClientError) as err: + _LOGGER.error(f"Redirect Error. {err.__class__.__qualname__}: {err}") + errors["base"] = "url_redirect" + except (TimeoutError, aiohttp.ServerTimeoutError) as err: + _LOGGER.error(f"Timeout Error. {err.__class__.__qualname__}: {err}") + errors["base"] = "connect_timeout" + except OSError as err: + # bad response from OPNsense when creds are valid but authorization is + # not sufficient non-admin users must have 'System - HA node sync' + # privilege + if "unsupported XML-RPC protocol" in str(err): + errors["base"] = "privilege_missing" + elif "timed out" in str(err): + errors["base"] = "connect_timeout" + elif "SSL:" in str(err): + """OSError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1129)""" + errors["base"] = "cannot_connect_ssl" + else: + errors["base"] = "unknown" + _LOGGER.error( + cleanse_sensitive_data( + f"Error. {err.__class__.__qualname__}: {err}", + [user_input[CONF_USERNAME], user_input[CONF_PASSWORD]], + ) + ) + except Exception as err: + _LOGGER.error( + cleanse_sensitive_data( + f"Other Error. {err.__class__.__qualname__}: {err}", + [user_input[CONF_USERNAME], user_input[CONF_PASSWORD]], + ) + ) + errors["base"] = "unknown" + return errors + + class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow for OPNsense.""" + """Handle a config flow for OPNsense""" # bumping this is what triggers async_migrate_entry for the component VERSION = 4 # gets invoked without user input initially # when user submits has user_input - async def async_step_user(self, user_input=None): - """Handle the initial step.""" + async def async_step_user(self, user_input: Mapping[str, Any] | None = None): + """Handle the initial step""" errors: Mapping[str, Any] = {} + firmware = "Unknown" if user_input is not None: - try: - name = user_input.get(CONF_NAME, False) or None - - url = user_input[CONF_URL].strip() - # ParseResult( - # scheme='', netloc='', path='f', params='', query='', fragment='' - # ) - url_parts: ParseResult = urlparse(url) - if not url_parts.scheme and not url_parts.netloc: - # raise InvalidURL() - url: str = "https://" + url - url_parts = urlparse(url) - - if not url_parts.netloc: - raise InvalidURL() - - # remove any path etc details - url = f"{url_parts.scheme}://{url_parts.netloc}" - username: str = user_input[CONF_USERNAME] - password: str = user_input[CONF_PASSWORD] - verify_ssl: bool = user_input.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL) - - client = OPNsenseClient( - url=url, - username=username, - password=password, - session=async_create_clientsession( - self.hass, raise_for_status=True - ), - opts={"verify_ssl": verify_ssl}, - initial=True, - ) - - firmware: str = await client.get_host_firmware_version() - try: - if awesomeversion.AwesomeVersion( - firmware - ) < awesomeversion.AwesomeVersion(OPNSENSE_MIN_FIRMWARE): - raise BelowMinFirmware() - except awesomeversion.exceptions.AwesomeVersionCompareException: - raise UnknownFirmware() - - if not await client.is_plugin_installed(): - raise PluginMissing() - - system_info: Mapping[str, Any] = await client.get_system_info() - if name is None: - name: str = system_info.get("name") or "OPNsense" - - device_unique_id: str | None = await client.get_device_unique_id() - if not device_unique_id: - raise MissingDeviceUniqueID() + errors = await validate_input( + hass=self.hass, user_input=user_input, errors=errors + ) + firmware = user_input[CONF_FIRMWARE_VERSION] + if not errors: # https://developers.home-assistant.io/docs/config_entries_config_flow_handler#unique-ids - await self.async_set_unique_id(device_unique_id) + await self.async_set_unique_id(user_input[CONF_DEVICE_UNIQUE_ID]) self._abort_if_unique_id_configured() - except BelowMinFirmware: - _LOGGER.error( - f"OPNsense Firmware of {firmware} is below the minimum supported version of {OPNSENSE_MIN_FIRMWARE}" - ) - errors["base"] = "below_min_firmware" - except UnknownFirmware: - _LOGGER.error("Unable to get OPNsense Firmware version") - errors["base"] = "unknown_firmware" - except MissingDeviceUniqueID as err: - errors["base"] = "missing_device_unique_id" - _LOGGER.error( - f"Missing Device Unique ID Error. {err.__class__.__qualname__}: {err}" - ) - except PluginMissing: - errors["base"] = "plugin_missing" - _LOGGER.error("OPNsense Plugin Missing") - except (aiohttp.InvalidURL, InvalidURL) as err: - errors["base"] = "invalid_url_format" - _LOGGER.error(f"InvalidURL Error. {err.__class__.__qualname__}: {err}") - except xmlrpc.client.Fault as err: - if "Invalid username or password" in str(err): - errors["base"] = "invalid_auth" - elif "Authentication failed: not enough privileges" in str(err): - errors["base"] = "privilege_missing" - elif "opnsense.exec_php does not exist" in str(err): - errors["base"] = "plugin_missing" - else: - errors["base"] = "cannot_connect" - _LOGGER.error( - cleanse_sensitive_data( - f"XMLRPC Error. {err.__class__.__qualname__}: {err}", - [username, password], - ) - ) - except aiohttp.ClientConnectorSSLError as err: - errors["base"] = "cannot_connect_ssl" - _LOGGER.error(f"Aiohttp Error. {err.__class__.__qualname__}: {err}") - except (aiohttp.ClientResponseError,) as err: - if err.status == 401 or err.status == 403: - errors["base"] = "invalid_auth" - else: - errors["base"] = "cannot_connect" - _LOGGER.error(f"Aiohttp Error. {err.__class__.__qualname__}: {err}") - except ( - aiohttp.ClientError, - aiohttp.ClientConnectorError, - socket.gaierror, - ) as err: - errors["base"] = "cannot_connect" - _LOGGER.error(f"Aiohttp Error. {err.__class__.__qualname__}: {err}") - except xmlrpc.client.ProtocolError as err: - if "307 Temporary Redirect" in str(err): - errors["base"] = "url_redirect" - elif "301 Moved Permanently" in str(err): - errors["base"] = "url_redirect" - else: - errors["base"] = "cannot_connect" - _LOGGER.error( - cleanse_sensitive_data( - f"XMLRPC Error. {err.__class__.__qualname__}: {err}", - [username, password], - ) - ) - except (aiohttp.TooManyRedirects, aiohttp.RedirectClientError) as err: - _LOGGER.error(f"Redirect Error. {err.__class__.__qualname__}: {err}") - errors["base"] = "url_redirect" - except (TimeoutError, aiohttp.ServerTimeoutError) as err: - _LOGGER.error(f"Timeout Error. {err.__class__.__qualname__}: {err}") - errors["base"] = "connect_timeout" - except OSError as err: - # bad response from OPNsense when creds are valid but authorization is - # not sufficient non-admin users must have 'System - HA node sync' - # privilege - if "unsupported XML-RPC protocol" in str(err): - errors["base"] = "privilege_missing" - elif "timed out" in str(err): - errors["base"] = "connect_timeout" - elif "SSL:" in str(err): - """OSError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1129)""" - errors["base"] = "cannot_connect_ssl" - else: - errors["base"] = "unknown" - _LOGGER.error( - cleanse_sensitive_data( - f"Error. {err.__class__.__qualname__}: {err}", - [username, password], - ) - ) - except Exception as err: - _LOGGER.error( - cleanse_sensitive_data( - f"Other Error. {err.__class__.__qualname__}: {err}", - [username, password], - ) - ) - errors["base"] = "unknown" - else: return self.async_create_entry( - title=name, + title=user_input[CONF_NAME], data={ - CONF_URL: url, - CONF_PASSWORD: password, - CONF_USERNAME: username, - CONF_VERIFY_SSL: verify_ssl, - CONF_DEVICE_UNIQUE_ID: device_unique_id, + CONF_URL: user_input[CONF_URL], + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], + CONF_DEVICE_UNIQUE_ID: user_input[CONF_DEVICE_UNIQUE_ID], }, ) @@ -270,32 +275,101 @@ async def async_step_user(self, user_input=None): data_schema=schema, errors=errors, description_placeholders={ - "firmware": firmware if "firmware" in locals() else "Unknown", + "firmware": firmware, + "min_firmware": OPNSENSE_MIN_FIRMWARE, + }, + ) + + async def async_step_reconfigure(self, user_input: Mapping[str, Any] | None = None): + reconfigure_entry = self._get_reconfigure_entry() + prev_data = reconfigure_entry.data + errors: Mapping[str, Any] = {} + firmware = "Unknown" + if user_input is not None: + user_input[CONF_NAME] = prev_data.get(CONF_NAME, "") + errors = await validate_input( + hass=self.hass, user_input=user_input, errors=errors + ) + firmware = user_input[CONF_FIRMWARE_VERSION] + if not errors: + # https://developers.home-assistant.io/docs/config_entries_config_flow_handler#unique-ids + await self.async_set_unique_id(user_input[CONF_DEVICE_UNIQUE_ID]) + self._abort_if_unique_id_mismatch() + + return self.async_create_entry( + title=user_input[CONF_NAME], + data={ + CONF_URL: user_input[CONF_URL], + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], + CONF_DEVICE_UNIQUE_ID: user_input[CONF_DEVICE_UNIQUE_ID], + }, + ) + + if not user_input: + user_input = {} + schema = vol.Schema( + { + vol.Required( + CONF_URL, + default=user_input.get( + CONF_URL, prev_data.get(CONF_URL, "https://") + ), + ): str, + vol.Optional( + CONF_VERIFY_SSL, + default=user_input.get( + CONF_VERIFY_SSL, + prev_data.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL), + ), + ): bool, + vol.Required( + CONF_USERNAME, + default=user_input.get( + CONF_USERNAME, prev_data.get(CONF_USERNAME, "") + ), + ): str, + vol.Required( + CONF_PASSWORD, + default=user_input.get( + CONF_PASSWORD, prev_data.get(CONF_PASSWORD, "") + ), + ): str, + } + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=schema, + errors=errors, + description_placeholders={ + "firmware": firmware, "min_firmware": OPNSENSE_MIN_FIRMWARE, }, ) async def async_step_import(self, user_input): - """Handle import.""" + """Handle import""" return await self.async_step_user(user_input) @staticmethod @callback def async_get_options_flow(config_entry): - """Get the options flow for this handler.""" + """Get the options flow for this handler""" return OptionsFlowHandler(config_entry) class OptionsFlowHandler(config_entries.OptionsFlow): - """Handle option flow for OPNsense.""" + """Handle option flow for OPNsense""" def __init__(self, config_entry: config_entries.ConfigEntry) -> None: - """Initialize options flow.""" + """Initialize options flow""" self.new_options = None self.config_entry = config_entry async def async_step_init(self, user_input=None): - """Handle options flow.""" + """Handle options flow""" if user_input is not None: if user_input.get(CONF_DEVICE_TRACKER_ENABLED): self.new_options = user_input @@ -334,7 +408,7 @@ async def async_step_init(self, user_input=None): return self.async_show_form(step_id="init", data_schema=vol.Schema(base_schema)) async def async_step_device_tracker(self, user_input=None): - """Handle device tracker list step.""" + """Handle device tracker list step""" url = self.config_entry.data[CONF_URL].strip() username: str = self.config_entry.data[CONF_USERNAME] password: str = self.config_entry.data[CONF_PASSWORD] @@ -363,7 +437,7 @@ async def async_step_device_tracker(self, user_input=None): continue hostname: str = entry.get("hostname", "").strip("?").strip() ip: str = entry.get("ip", "").strip() - label: str = f"{ip} {'('+hostname+') ' if hostname else ''}[{mac}]" + label: str = f"{ip} {'(' + hostname + ') ' if hostname else ''}[{mac}]" entries[mac] = label sorted_entries: Mapping[str, Any] = { @@ -416,7 +490,7 @@ async def async_step_device_tracker(self, user_input=None): class InvalidURL(Exception): - """InavlidURL.""" + """InavlidURL""" class MissingDeviceUniqueID(Exception): diff --git a/custom_components/opnsense/const.py b/custom_components/opnsense/const.py index aabc2d4..c05b01b 100644 --- a/custom_components/opnsense/const.py +++ b/custom_components/opnsense/const.py @@ -39,6 +39,7 @@ DEFAULT_DEVICE_TRACKER_CONSIDER_HOME = 0 CONF_DEVICE_UNIQUE_ID = "device_unique_id" +CONF_FIRMWARE_VERSION = "firmware_version" CONF_DEVICES = "devices" CONF_MANUAL_DEVICES = "manual_devices" diff --git a/custom_components/opnsense/translations/en.json b/custom_components/opnsense/translations/en.json index dbdea7d..1404d9d 100644 --- a/custom_components/opnsense/translations/en.json +++ b/custom_components/opnsense/translations/en.json @@ -27,8 +27,17 @@ "verify_ssl": "Verify SSL certificate" }, "title": "Connect to the OPNsense firewall / router" + }, + "reconfigure": { + "data": { + "url": "URL", + "username": "API Key", + "password": "API Secret", + "verify_ssl": "Verify SSL certificate" + }, + "title": "Update connection to the OPNsense firewall / router" } - } + } }, "options": { "error": {