From 7da3a68be686c9a787c44b0022c391e857c92aa9 Mon Sep 17 00:00:00 2001 From: John H Date: Sun, 11 Oct 2026 14:52:17 -0500 Subject: [PATCH 1/7] feat(tls): add TLS/mTLS helper, constants and translations Add tls.py, which builds an ssl.SSLContext from PEM text: optional private CA, optional client certificate/key (mutual TLS) and key passphrase. Server verification is always on; TLS 1.2 is the minimum. VERIFY_X509_STRICT is relaxed so private CAs whose certificates lack an Authority Key Identifier still work on Python 3.13+. Failures carry precise translation keys. Add the config keys and the English/German strings, including a new translations/en.json, which custom integrations need. --- .../docker_socket_proxy/const.py | 13 ++ .../docker_socket_proxy/strings.json | 48 ++++- custom_components/docker_socket_proxy/tls.py | 197 ++++++++++++++++++ .../docker_socket_proxy/translations/de.json | 48 ++++- .../docker_socket_proxy/translations/en.json | 73 +++++++ 5 files changed, 369 insertions(+), 10 deletions(-) create mode 100644 custom_components/docker_socket_proxy/tls.py create mode 100644 custom_components/docker_socket_proxy/translations/en.json diff --git a/custom_components/docker_socket_proxy/const.py b/custom_components/docker_socket_proxy/const.py index d23f98d..ccc7733 100644 --- a/custom_components/docker_socket_proxy/const.py +++ b/custom_components/docker_socket_proxy/const.py @@ -26,6 +26,19 @@ CONF_GRACE_PERIOD_ENABLED: Final = "grace_period_enabled" CONF_GRACE_PERIOD_SECONDS: Final = "grace_period_seconds" +# TLS Configuration Keys (stored in the config entry data, all optional). +# Values are PEM text. Leaving all of them out keeps the previous behaviour: +# plain http, or https verified against the system trust store. +CONF_CA_CERT: Final = "ca_cert" +CONF_CLIENT_CERT: Final = "client_cert" +CONF_CLIENT_KEY: Final = "client_key" +CONF_CLIENT_KEY_PASSWORD: Final = "client_key_password" +# Reconfigure-only form switch: remove all stored TLS material +CONF_CLEAR_TLS: Final = "clear_tls" + +# Config entry data keys holding secret material; never log or expose these. +TLS_SECRET_KEYS: Final = (CONF_CLIENT_KEY, CONF_CLIENT_KEY_PASSWORD) + # Host Status states (The logic we defined for the host_status entity) HOST_STATUS_VERSION_UNAVAILABLE: Final = "unavailable version" HOST_STATUS_CONTAINERS_UNAVAILABLE: Final = "unavailable containers" diff --git a/custom_components/docker_socket_proxy/strings.json b/custom_components/docker_socket_proxy/strings.json index 5abea35..24563ca 100644 --- a/custom_components/docker_socket_proxy/strings.json +++ b/custom_components/docker_socket_proxy/strings.json @@ -4,19 +4,57 @@ "step": { "user": { "title": "Docker Socket Proxy", - "description": "Enter the connection details for your Docker Proxy", + "description": "Enter the connection details for your Docker Proxy. For an HTTPS proxy that uses a private CA or requires client certificates (mutual TLS), upload the files below; leave them empty otherwise.", "data": { "name": "Instance Name (e.g., NAS)", - "url": "Proxy URL (e.g., http://192.168.1.100:2375)" + "url": "Proxy URL (e.g., http://192.168.1.100:2375 or https://docker.example.com:2376)", + "ca_cert": "CA certificate (optional)", + "client_cert": "Client certificate (optional)", + "client_key": "Client private key (optional)", + "client_key_password": "Client key passphrase (optional)" + }, + "data_description": { + "ca_cert": "PEM file used to verify the proxy's certificate. Only needed if the proxy uses a private or self-signed CA. Defaults to the system trust store.", + "client_cert": "PEM client certificate, for proxies that require mutual TLS. Must be provided together with the private key.", + "client_key": "PEM private key matching the client certificate. Stored in the config entry like other credentials.", + "client_key_password": "Only needed if the private key is encrypted." + } + }, + "reconfigure": { + "title": "Reconfigure Docker Socket Proxy", + "description": "Change the proxy URL or TLS files. Existing entities and history are kept. Currently stored: {stored}. Leave a file field empty to keep the stored file.", + "data": { + "url": "Proxy URL (e.g., http://192.168.1.100:2375 or https://docker.example.com:2376)", + "ca_cert": "CA certificate (optional)", + "client_cert": "Client certificate (optional)", + "client_key": "Client private key (optional)", + "client_key_password": "Client key passphrase (optional)", + "clear_tls": "Remove stored TLS certificates" + }, + "data_description": { + "ca_cert": "PEM file used to verify the proxy's certificate. Only needed if the proxy uses a private or self-signed CA. Defaults to the system trust store.", + "client_cert": "PEM client certificate, for proxies that require mutual TLS. Must be provided together with the private key.", + "client_key": "PEM private key matching the client certificate. Stored in the config entry like other credentials.", + "client_key_password": "Only needed if the private key is encrypted.", + "clear_tls": "Deletes the CA certificate, client certificate and key from this entry. Upload new files above to replace them instead." } } }, "error": { "cannot_connect": "Failed to connect to Docker Proxy, check the URL", - "unknown": "Unexpected error occurred" + "unknown": "Unexpected error occurred", + "ssl_error": "TLS handshake failed. Check that the CA certificate trusts the proxy and that the URL host name matches the proxy certificate.", + "invalid_ca_cert": "The CA certificate is not a valid PEM certificate", + "invalid_client_cert": "The client certificate is not a valid PEM certificate", + "invalid_client_key": "The client private key is not a valid PEM key", + "client_cert_key_required": "Provide both the client certificate and the client private key, or neither", + "client_cert_key_mismatch": "The client certificate and private key do not belong together", + "client_key_password_required": "The private key is encrypted; enter its passphrase", + "client_key_password_incorrect": "The private key passphrase is incorrect" }, "abort": { - "already_configured": "This Docker host is already configured" + "already_configured": "This Docker host is already configured", + "reconfigure_successful": "Reconfiguration successful" } }, "options": { @@ -32,4 +70,4 @@ } } } -} \ No newline at end of file +} diff --git a/custom_components/docker_socket_proxy/tls.py b/custom_components/docker_socket_proxy/tls.py new file mode 100644 index 0000000..0385749 --- /dev/null +++ b/custom_components/docker_socket_proxy/tls.py @@ -0,0 +1,197 @@ +"""TLS helpers for Docker Socket Proxy. + +Builds an ``ssl.SSLContext`` from PEM material stored in a config entry so the +integration can talk to a proxy that is protected by TLS, optionally with a +private CA and/or mutual TLS (client certificate). + +Behaviour is deliberately conservative and not tied to any particular +deployment: + +* Server certificates are always verified (there is no "skip verification" + switch). Trust a private or self-signed server by supplying its CA (or the + server certificate itself) as the CA certificate. +* Without a CA certificate the system/Home Assistant trust store is used. +* A client certificate and key are optional but must be supplied together. +* TLS 1.2 is the minimum protocol version. +""" + +from __future__ import annotations + +import os +import ssl +import tempfile +from collections.abc import Mapping +from typing import Any + +from .const import ( + CONF_CA_CERT, + CONF_CLIENT_CERT, + CONF_CLIENT_KEY, + CONF_CLIENT_KEY_PASSWORD, +) + +_PEM_MARKER = "-----BEGIN " + +# Error keys; these match the entries under "config.error" in strings.json. +ERR_INVALID_CA_CERT = "invalid_ca_cert" +ERR_INVALID_CLIENT_CERT = "invalid_client_cert" +ERR_INVALID_CLIENT_KEY = "invalid_client_key" +ERR_CLIENT_CERT_KEY_REQUIRED = "client_cert_key_required" +ERR_KEY_PASSWORD_REQUIRED = "client_key_password_required" +ERR_KEY_PASSWORD_INCORRECT = "client_key_password_incorrect" +ERR_CERT_KEY_MISMATCH = "client_cert_key_mismatch" + + +class DockerProxyTLSError(Exception): + """Raised when the supplied TLS material is unusable. + + ``reason`` is a translation key suitable for a config flow error. + """ + + def __init__(self, reason: str, message: str | None = None) -> None: + """Initialize the error.""" + super().__init__(message or reason) + self.reason = reason + + +def _clean(value: Any) -> str | None: + """Return stripped text, or None when the value is empty or not text.""" + if not isinstance(value, str): + return None + value = value.strip() + return value or None + + +def has_custom_tls(data: Mapping[str, Any]) -> bool: + """Return True if the entry carries any custom TLS material.""" + return any(_clean(data.get(key)) for key in (CONF_CA_CERT, CONF_CLIENT_CERT)) + + +def validate_tls_input(data: Mapping[str, Any]) -> None: + """Cheap, non-blocking checks on the supplied values. + + Raises DockerProxyTLSError when the combination is invalid. Does not touch + the filesystem or OpenSSL, so it is safe to call on the event loop. + """ + ca = _clean(data.get(CONF_CA_CERT)) + cert = _clean(data.get(CONF_CLIENT_CERT)) + key = _clean(data.get(CONF_CLIENT_KEY)) + + if ca and _PEM_MARKER not in ca: + raise DockerProxyTLSError(ERR_INVALID_CA_CERT) + if (cert is None) != (key is None): + raise DockerProxyTLSError(ERR_CLIENT_CERT_KEY_REQUIRED) + if cert and _PEM_MARKER not in cert: + raise DockerProxyTLSError(ERR_INVALID_CLIENT_CERT) + if key and _PEM_MARKER not in key: + raise DockerProxyTLSError(ERR_INVALID_CLIENT_KEY) + + +def build_ssl_context(data: Mapping[str, Any]) -> ssl.SSLContext | None: + """Build an SSL context from config entry data. + + Returns None when no custom TLS material is configured, in which case the + caller should keep using Home Assistant's default session behaviour (plain + http, or https verified against the default trust store). + + This function does blocking work (OpenSSL parsing, temporary files). Run it + in an executor: ``await hass.async_add_executor_job(build_ssl_context, data)``. + """ + validate_tls_input(data) + + ca = _clean(data.get(CONF_CA_CERT)) + cert = _clean(data.get(CONF_CLIENT_CERT)) + key = _clean(data.get(CONF_CLIENT_KEY)) + password = data.get(CONF_CLIENT_KEY_PASSWORD) or None + + if not ca and not cert: + return None + + context = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH) + context.minimum_version = ssl.TLSVersion.TLSv1_2 + # create_default_context already enables CERT_REQUIRED and hostname checks. + # Python 3.13+ also sets VERIFY_X509_STRICT, which rejects private-CA + # certificates lacking optional extensions (e.g. Authority Key Identifier), + # as produced by many simple `openssl` recipes. Relax only that conformance + # check: chain validation, expiry and host name matching stay enforced. + context.verify_flags &= ~ssl.VERIFY_X509_STRICT + + if ca: + try: + context.load_verify_locations(cadata=ca) + except (ssl.SSLError, ValueError) as err: + raise DockerProxyTLSError(ERR_INVALID_CA_CERT, str(err)) from err + + if cert and key: + _load_client_chain(context, cert, key, password) + + return context + + +def _load_client_chain( + context: ssl.SSLContext, cert: str, key: str, password: str | None +) -> None: + """Load a client certificate chain from PEM text. + + ``ssl`` can only load a chain from files, so the PEM is written to a private + temporary directory (mode 0700, files 0600) that is removed immediately. + """ + with tempfile.TemporaryDirectory(prefix="dsp_tls_") as tmp: + cert_path = os.path.join(tmp, "client.crt") + key_path = os.path.join(tmp, "client.key") + for path, text in ((cert_path, cert), (key_path, key)): + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(text if text.endswith("\n") else text + "\n") + + # A callable password prevents OpenSSL from prompting on a terminal + # when the key is encrypted and no passphrase was supplied. + try: + context.load_cert_chain( + cert_path, key_path, password=lambda: password or "" + ) + except ssl.SSLError as err: + raise DockerProxyTLSError(_classify_chain_error(err, cert, key, password), str(err)) from err + except (OSError, ValueError) as err: + raise DockerProxyTLSError(ERR_INVALID_CLIENT_CERT, str(err)) from err + + +def _classify_chain_error( + err: ssl.SSLError, cert: str, key: str, password: str | None +) -> str: + """Map a failed load_cert_chain to a translation key. + + OpenSSL reports most PEM problems as an opaque "PEM lib" error whose text + differs between versions, so the inputs are re-inspected with + ``cryptography`` (bundled with Home Assistant) to give a precise reason. + """ + if "key values mismatch" in str(err).lower(): + return ERR_CERT_KEY_MISMATCH + + try: + from cryptography import x509 + from cryptography.hazmat.primitives.serialization import ( + load_pem_private_key, + ) + except ImportError: # pragma: no cover - cryptography ships with HA core + return ERR_INVALID_CLIENT_CERT + + try: + x509.load_pem_x509_certificate(cert.encode()) + except ValueError: + return ERR_INVALID_CLIENT_CERT + + try: + load_pem_private_key(key.encode(), password=password.encode() if password else None) + except TypeError: + # Key is encrypted but no passphrase was supplied (or vice versa). + return ERR_KEY_PASSWORD_INCORRECT if password else ERR_KEY_PASSWORD_REQUIRED + except ValueError as key_err: + text = str(key_err).lower() + if password and ("decrypt" in text or "password" in text): + return ERR_KEY_PASSWORD_INCORRECT + return ERR_INVALID_CLIENT_KEY + except Exception: # noqa: BLE001 - unsupported key types etc. + return ERR_INVALID_CLIENT_KEY + + return ERR_INVALID_CLIENT_CERT diff --git a/custom_components/docker_socket_proxy/translations/de.json b/custom_components/docker_socket_proxy/translations/de.json index f1efad3..81f8667 100644 --- a/custom_components/docker_socket_proxy/translations/de.json +++ b/custom_components/docker_socket_proxy/translations/de.json @@ -4,19 +4,57 @@ "step": { "user": { "title": "Docker Socket Proxy", - "description": "Verbindungsdetails für den Docker-Proxy eingeben", + "description": "Verbindungsdetails für den Docker-Proxy eingeben. Für einen HTTPS-Proxy mit privater CA oder Client-Zertifikaten (Mutual TLS) die Dateien unten hochladen, sonst leer lassen.", "data": { "name": "Instanz-Name (z. B. NAS)", - "url": "Proxy-URL (z. B. http://192.168.1.100:2375)" + "url": "Proxy-URL (z. B. http://192.168.1.100:2375 oder https://docker.example.com:2376)", + "ca_cert": "CA-Zertifikat (optional)", + "client_cert": "Client-Zertifikat (optional)", + "client_key": "Privater Client-Schlüssel (optional)", + "client_key_password": "Passphrase des Client-Schlüssels (optional)" + }, + "data_description": { + "ca_cert": "PEM-Datei zur Prüfung des Proxy-Zertifikats. Nur nötig bei privater oder selbstsignierter CA. Standard ist der System-Truststore.", + "client_cert": "PEM-Client-Zertifikat für Proxys mit Mutual TLS. Nur zusammen mit dem privaten Schlüssel angeben.", + "client_key": "PEM-Schlüssel passend zum Client-Zertifikat. Wird wie andere Zugangsdaten im Konfigurationseintrag gespeichert.", + "client_key_password": "Nur nötig, wenn der private Schlüssel verschlüsselt ist." + } + }, + "reconfigure": { + "title": "Docker Socket Proxy neu konfigurieren", + "description": "Proxy-URL oder TLS-Dateien ändern. Bestehende Entitäten und Verläufe bleiben erhalten. Gespeichert: {stored}. Ein leeres Dateifeld behält die gespeicherte Datei.", + "data": { + "url": "Proxy-URL (z. B. http://192.168.1.100:2375 oder https://docker.example.com:2376)", + "ca_cert": "CA-Zertifikat (optional)", + "client_cert": "Client-Zertifikat (optional)", + "client_key": "Privater Client-Schlüssel (optional)", + "client_key_password": "Passphrase des Client-Schlüssels (optional)", + "clear_tls": "TLS-Zertifikate entfernen" + }, + "data_description": { + "ca_cert": "PEM-Datei zur Prüfung des Proxy-Zertifikats. Nur nötig bei privater oder selbstsignierter CA. Standard ist der System-Truststore.", + "client_cert": "PEM-Client-Zertifikat für Proxys mit Mutual TLS. Nur zusammen mit dem privaten Schlüssel angeben.", + "client_key": "PEM-Schlüssel passend zum Client-Zertifikat. Wird wie andere Zugangsdaten im Konfigurationseintrag gespeichert.", + "client_key_password": "Nur nötig, wenn der private Schlüssel verschlüsselt ist.", + "clear_tls": "Löscht CA-Zertifikat, Client-Zertifikat und Schlüssel aus diesem Eintrag. Neue Dateien oben hochladen, um sie stattdessen zu ersetzen." } } }, "error": { "cannot_connect": "Verbindung zum Docker-Proxy fehlgeschlagen, URL prüfen", - "unknown": "Unerwarteter Fehler aufgetreten" + "unknown": "Unerwarteter Fehler aufgetreten", + "ssl_error": "TLS-Handshake fehlgeschlagen. Prüfen, ob das CA-Zertifikat dem Proxy vertraut und der Hostname der URL zum Proxy-Zertifikat passt.", + "invalid_ca_cert": "Das CA-Zertifikat ist kein gültiges PEM-Zertifikat", + "invalid_client_cert": "Das Client-Zertifikat ist kein gültiges PEM-Zertifikat", + "invalid_client_key": "Der private Client-Schlüssel ist kein gültiger PEM-Schlüssel", + "client_cert_key_required": "Client-Zertifikat und privaten Schlüssel zusammen angeben oder beide weglassen", + "client_cert_key_mismatch": "Client-Zertifikat und privater Schlüssel gehören nicht zusammen", + "client_key_password_required": "Der private Schlüssel ist verschlüsselt; Passphrase eingeben", + "client_key_password_incorrect": "Die Passphrase des privaten Schlüssels ist falsch" }, "abort": { - "already_configured": "Dieser Docker-Host ist bereits konfiguriert" + "already_configured": "Dieser Docker-Host ist bereits konfiguriert", + "reconfigure_successful": "Neukonfiguration erfolgreich" } }, "options": { @@ -32,4 +70,4 @@ } } } -} \ No newline at end of file +} diff --git a/custom_components/docker_socket_proxy/translations/en.json b/custom_components/docker_socket_proxy/translations/en.json new file mode 100644 index 0000000..24563ca --- /dev/null +++ b/custom_components/docker_socket_proxy/translations/en.json @@ -0,0 +1,73 @@ +{ + "title": "Docker Socket Proxy", + "config": { + "step": { + "user": { + "title": "Docker Socket Proxy", + "description": "Enter the connection details for your Docker Proxy. For an HTTPS proxy that uses a private CA or requires client certificates (mutual TLS), upload the files below; leave them empty otherwise.", + "data": { + "name": "Instance Name (e.g., NAS)", + "url": "Proxy URL (e.g., http://192.168.1.100:2375 or https://docker.example.com:2376)", + "ca_cert": "CA certificate (optional)", + "client_cert": "Client certificate (optional)", + "client_key": "Client private key (optional)", + "client_key_password": "Client key passphrase (optional)" + }, + "data_description": { + "ca_cert": "PEM file used to verify the proxy's certificate. Only needed if the proxy uses a private or self-signed CA. Defaults to the system trust store.", + "client_cert": "PEM client certificate, for proxies that require mutual TLS. Must be provided together with the private key.", + "client_key": "PEM private key matching the client certificate. Stored in the config entry like other credentials.", + "client_key_password": "Only needed if the private key is encrypted." + } + }, + "reconfigure": { + "title": "Reconfigure Docker Socket Proxy", + "description": "Change the proxy URL or TLS files. Existing entities and history are kept. Currently stored: {stored}. Leave a file field empty to keep the stored file.", + "data": { + "url": "Proxy URL (e.g., http://192.168.1.100:2375 or https://docker.example.com:2376)", + "ca_cert": "CA certificate (optional)", + "client_cert": "Client certificate (optional)", + "client_key": "Client private key (optional)", + "client_key_password": "Client key passphrase (optional)", + "clear_tls": "Remove stored TLS certificates" + }, + "data_description": { + "ca_cert": "PEM file used to verify the proxy's certificate. Only needed if the proxy uses a private or self-signed CA. Defaults to the system trust store.", + "client_cert": "PEM client certificate, for proxies that require mutual TLS. Must be provided together with the private key.", + "client_key": "PEM private key matching the client certificate. Stored in the config entry like other credentials.", + "client_key_password": "Only needed if the private key is encrypted.", + "clear_tls": "Deletes the CA certificate, client certificate and key from this entry. Upload new files above to replace them instead." + } + } + }, + "error": { + "cannot_connect": "Failed to connect to Docker Proxy, check the URL", + "unknown": "Unexpected error occurred", + "ssl_error": "TLS handshake failed. Check that the CA certificate trusts the proxy and that the URL host name matches the proxy certificate.", + "invalid_ca_cert": "The CA certificate is not a valid PEM certificate", + "invalid_client_cert": "The client certificate is not a valid PEM certificate", + "invalid_client_key": "The client private key is not a valid PEM key", + "client_cert_key_required": "Provide both the client certificate and the client private key, or neither", + "client_cert_key_mismatch": "The client certificate and private key do not belong together", + "client_key_password_required": "The private key is encrypted; enter its passphrase", + "client_key_password_incorrect": "The private key passphrase is incorrect" + }, + "abort": { + "already_configured": "This Docker host is already configured", + "reconfigure_successful": "Reconfiguration successful" + } + }, + "options": { + "step": { + "init": { + "title": "Docker Options", + "description": "Configure scan interval and entity cleanup with retention", + "data": { + "scan_interval": "Scan Interval (seconds) for updating container states", + "grace_period_enabled": "Enable Grace Period for orphaned containers", + "grace_period_seconds": "Grace Period (seconds) before removal" + } + } + } + } +} From 8ac7be47f04bfcf331d693ed96d1f30aac84d372 Mon Sep 17 00:00:00 2001 From: John H Date: Sun, 11 Oct 2026 14:53:00 -0500 Subject: [PATCH 2/7] feat(config-flow): TLS file uploads and in-place reconfigure Add optional CA, client certificate, client key and passphrase fields to setup, validated against /version with the resulting SSL context. Add a reconfigure step that edits an entry in place (entities and history are kept), updates the unique ID when the URL changes, keeps stored files when a field is left empty and can remove all stored TLS material. Requires the file_upload dependency and Home Assistant 2024.11.0 for reconfigure support. --- .../docker_socket_proxy/config_flow.py | 267 +++++++++++++++--- .../docker_socket_proxy/manifest.json | 11 +- hacs.json | 4 +- 3 files changed, 240 insertions(+), 42 deletions(-) diff --git a/custom_components/docker_socket_proxy/config_flow.py b/custom_components/docker_socket_proxy/config_flow.py index a5d6f8e..8ae3d88 100644 --- a/custom_components/docker_socket_proxy/config_flow.py +++ b/custom_components/docker_socket_proxy/config_flow.py @@ -16,11 +16,25 @@ import voluptuous as vol from homeassistant import config_entries +from homeassistant.components.file_upload import process_uploaded_file from homeassistant.config_entries import ConfigEntry, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_NAME, CONF_URL -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + FileSelector, + FileSelectorConfig, + TextSelector, + TextSelectorConfig, + TextSelectorType, +) from .const import ( + CONF_CA_CERT, + CONF_CLEAR_TLS, + CONF_CLIENT_CERT, + CONF_CLIENT_KEY, + CONF_CLIENT_KEY_PASSWORD, CONF_GRACE_PERIOD_ENABLED, CONF_GRACE_PERIOD_SECONDS, CONF_SCAN_INTERVAL, @@ -31,16 +45,134 @@ DEFAULT_URL, DOMAIN, ) +from .tls import ( + ERR_CLIENT_CERT_KEY_REQUIRED, + ERR_INVALID_CA_CERT, + ERR_INVALID_CLIENT_CERT, + ERR_INVALID_CLIENT_KEY, + DockerProxyTLSError, + build_ssl_context, +) _LOGGER = logging.getLogger(__name__) -# Schema for the initial setup dialog (Name and URL) -STEP_USER_DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_NAME, default=DEFAULT_NAME): str, - vol.Required(CONF_URL, default=DEFAULT_URL): str, +# The three uploadable PEM fields; the passphrase is a plain text field. +_UPLOAD_FIELDS = (CONF_CA_CERT, CONF_CLIENT_CERT, CONF_CLIENT_KEY) +_PEM_SELECTOR = FileSelector(FileSelectorConfig(accept=".pem,.crt,.cer,.cert,.key")) +_INVALID_UPLOAD_REASON = { + CONF_CA_CERT: ERR_INVALID_CA_CERT, + CONF_CLIENT_CERT: ERR_INVALID_CLIENT_CERT, + CONF_CLIENT_KEY: ERR_INVALID_CLIENT_KEY, +} + + +def _tls_fields() -> dict[Any, Any]: + """Optional TLS fields shared by the setup and reconfigure forms.""" + return { + vol.Optional(CONF_CA_CERT): _PEM_SELECTOR, + vol.Optional(CONF_CLIENT_CERT): _PEM_SELECTOR, + vol.Optional(CONF_CLIENT_KEY): _PEM_SELECTOR, + vol.Optional(CONF_CLIENT_KEY_PASSWORD): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), } -) + + +def _user_schema(name: str, url: str) -> vol.Schema: + """Schema for the initial setup dialog (Name, URL and optional TLS).""" + return vol.Schema( + { + vol.Required(CONF_NAME, default=name): str, + vol.Required(CONF_URL, default=url): str, + **_tls_fields(), + } + ) + + +def _reconfigure_schema(url: str) -> vol.Schema: + """Schema for reconfiguring an existing entry.""" + return vol.Schema( + { + vol.Required(CONF_URL, default=url): str, + **_tls_fields(), + vol.Optional(CONF_CLEAR_TLS, default=False): bool, + } + ) + + +def _read_uploaded_pem(hass: HomeAssistant, file_id: str) -> str: + """Read (and consume) an uploaded file. Blocking; run in the executor.""" + with process_uploaded_file(hass, file_id) as path: + return path.read_text(encoding="utf-8") + + +async def _async_collect_tls( + hass: HomeAssistant, + user_input: dict[str, Any], + existing: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Turn form input into TLS entry data. + + An uploaded file replaces the stored value; an empty field keeps the + existing one (reconfigure only). Raises DockerProxyTLSError if an upload + cannot be read as text. + """ + existing = existing or {} + tls_data: dict[str, Any] = {} + + for key in _UPLOAD_FIELDS: + if file_id := user_input.get(key): + try: + tls_data[key] = await hass.async_add_executor_job( + _read_uploaded_pem, hass, file_id + ) + except (OSError, ValueError) as err: + raise DockerProxyTLSError(_INVALID_UPLOAD_REASON[key], str(err)) from err + elif existing.get(key): + tls_data[key] = existing[key] + + if password := user_input.get(CONF_CLIENT_KEY_PASSWORD): + tls_data[CONF_CLIENT_KEY_PASSWORD] = password + elif existing.get(CONF_CLIENT_KEY_PASSWORD) and not user_input.get(CONF_CLIENT_KEY): + # Keep the stored passphrase only while the stored key is kept too. + tls_data[CONF_CLIENT_KEY_PASSWORD] = existing[CONF_CLIENT_KEY_PASSWORD] + + return tls_data + + +async def _async_validate( + hass: HomeAssistant, url: str, tls_data: dict[str, Any] +) -> str | None: + """Check the TLS material and reach /version. Returns an error key or None.""" + kwargs: dict[str, Any] = {} + try: + ssl_context = await hass.async_add_executor_job(build_ssl_context, tls_data) + except DockerProxyTLSError as err: + return err.reason + if ssl_context is not None: + kwargs["ssl"] = ssl_context + elif tls_data: + # Only a passphrase was supplied: there is no key to apply it to. + return ERR_CLIENT_CERT_KEY_REQUIRED + + try: + session = async_get_clientsession(hass) + async with session.get( + f"{url}/version", timeout=ClientTimeout(total=5), **kwargs + ) as response: + return None if response.status == 200 else "cannot_connect" + except aiohttp.ClientSSLError: + # Includes certificate verification and handshake failures. + return "ssl_error" + except aiohttp.ServerDisconnectedError: + # A TLS server that requires a client certificate typically drops the + # connection after the handshake when none (or a wrong one) was sent. + return "ssl_error" if url.lower().startswith("https://") else "cannot_connect" + except (aiohttp.ClientError, TimeoutError): + return "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception during Docker Socket Proxy validation") + return "unknown" class DockerProxyConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -63,38 +195,99 @@ async def async_step_user( self._abort_if_unique_id_configured() try: - # Validate the connection to the Docker Socket Proxy using the version endpoint - timeout = ClientTimeout(total=5) - async with ( - aiohttp.ClientSession() as session, - session.get(f"{url}/version", timeout=timeout) as response, - ): - if response.status != 200: - errors["base"] = "cannot_connect" - else: - # Success: Create entry and initialize options with defaults - return self.async_create_entry( - title=user_input[CONF_NAME], - data={ - CONF_NAME: user_input[CONF_NAME], - CONF_URL: url, - }, - options={ - CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, - CONF_GRACE_PERIOD_ENABLED: DEFAULT_GRACE_PERIOD_ENABLED, - CONF_GRACE_PERIOD_SECONDS: DEFAULT_GRACE_PERIOD_SECONDS, - }, - ) - except (aiohttp.ClientError, TimeoutError): - errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except - _LOGGER.exception( - "Unexpected exception during Docker Socket Proxy validation" - ) - errors["base"] = "unknown" + tls_data = await _async_collect_tls(self.hass, user_input) + except DockerProxyTLSError as err: + errors["base"] = err.reason + else: + if error := await _async_validate(self.hass, url, tls_data): + errors["base"] = error + else: + # Success: Create entry and initialize options with defaults + return self.async_create_entry( + title=user_input[CONF_NAME], + data={ + CONF_NAME: user_input[CONF_NAME], + CONF_URL: url, + **tls_data, + }, + options={ + CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, + CONF_GRACE_PERIOD_ENABLED: DEFAULT_GRACE_PERIOD_ENABLED, + CONF_GRACE_PERIOD_SECONDS: DEFAULT_GRACE_PERIOD_SECONDS, + }, + ) + user_input = user_input or {} return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + step_id="user", + data_schema=_user_schema( + user_input.get(CONF_NAME, DEFAULT_NAME), + user_input.get(CONF_URL, DEFAULT_URL), + ), + errors=errors, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Change the URL and/or TLS material of an existing entry in place. + + The config entry is kept, so every entity (unique IDs are based on the + entry ID) and its history survive. Only the entry data and unique ID + change. + """ + entry = self._get_reconfigure_entry() + errors: dict[str, str] = {} + + if user_input is not None: + url = user_input[CONF_URL].rstrip("/") + + # The unique ID is the URL, so moving to a URL that another entry + # already uses would create a duplicate host. + if any( + other.entry_id != entry.entry_id and other.unique_id == url + for other in self._async_current_entries(include_ignore=False) + ): + return self.async_abort(reason="already_configured") + + existing = {} if user_input.get(CONF_CLEAR_TLS) else dict(entry.data) + try: + tls_data = await _async_collect_tls(self.hass, user_input, existing) + except DockerProxyTLSError as err: + errors["base"] = err.reason + else: + if error := await _async_validate(self.hass, url, tls_data): + errors["base"] = error + else: + # Replace the data wholesale so cleared TLS keys really go + # away. The entry's update listener reloads the integration. + self.hass.config_entries.async_update_entry( + entry, + unique_id=url, + data={ + CONF_NAME: entry.data.get(CONF_NAME, entry.title), + CONF_URL: url, + **tls_data, + }, + ) + return self.async_abort(reason="reconfigure_successful") + + stored = [ + label + for key, label in ( + (CONF_CA_CERT, "CA certificate"), + (CONF_CLIENT_CERT, "client certificate"), + (CONF_CLIENT_KEY, "client key"), + ) + if entry.data.get(key) + ] + return self.async_show_form( + step_id="reconfigure", + data_schema=_reconfigure_schema( + (user_input or {}).get(CONF_URL, entry.data[CONF_URL]) + ), + description_placeholders={"stored": ", ".join(stored) or "none"}, + errors=errors, ) @staticmethod diff --git a/custom_components/docker_socket_proxy/manifest.json b/custom_components/docker_socket_proxy/manifest.json index 2050b02..fc883ff 100644 --- a/custom_components/docker_socket_proxy/manifest.json +++ b/custom_components/docker_socket_proxy/manifest.json @@ -1,12 +1,17 @@ { "domain": "docker_socket_proxy", "name": "Docker Socket Proxy", - "codeowners": ["@s-t-e-f-a-n"], + "codeowners": [ + "@s-t-e-f-a-n" + ], "config_flow": true, + "dependencies": [ + "file_upload" + ], "documentation": "https://github.com/s-t-e-f-a-n/ha_docker_socket_proxy", "integration_type": "hub", "iot_class": "local_polling", "issue_tracker": "https://github.com/s-t-e-f-a-n/ha_docker_socket_proxy/issues", "requirements": [], - "version": "1.0.2" -} \ No newline at end of file + "version": "1.1.0" +} diff --git a/hacs.json b/hacs.json index 04edc02..51ff5f0 100644 --- a/hacs.json +++ b/hacs.json @@ -1,5 +1,5 @@ { "name": "Docker Socket Proxy", "render_readme": true, - "homeassistant": "2024.1.0" -} \ No newline at end of file + "homeassistant": "2024.11.0" +} From c17284a0f179a3c6928a3a04c7e73a3e0fbebc6c Mon Sep 17 00:00:00 2001 From: John H Date: Sun, 11 Oct 2026 14:53:23 -0500 Subject: [PATCH 3/7] feat(coordinator): use the SSL context and report failure reasons Build the SSL context at setup in the executor and pass it on both API calls; invalid stored TLS material is a ConfigEntryError. Entries without TLS data behave exactly as before. Failures are logged once per distinct reason (certificate verification, handshake, missing client certificate, refused, timeout, HTTP 401/403) with a recovery message. --- .../docker_socket_proxy/__init__.py | 15 +- .../docker_socket_proxy/coordinator.py | 134 ++++++++++++++---- 2 files changed, 122 insertions(+), 27 deletions(-) diff --git a/custom_components/docker_socket_proxy/__init__.py b/custom_components/docker_socket_proxy/__init__.py index f2eecf7..7942bae 100644 --- a/custom_components/docker_socket_proxy/__init__.py +++ b/custom_components/docker_socket_proxy/__init__.py @@ -14,10 +14,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError # Import constants and the coordinator class from .const import BLUEPRINT_FILENAME, DOMAIN, PLATFORMS from .coordinator import DockerProxyCoordinator +from .tls import DockerProxyTLSError, build_ssl_context _LOGGER = logging.getLogger(__name__) @@ -28,9 +30,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Deploy or update the blueprint before setting up platforms to ensure it's available await async_install_blueprint(hass) + # Build the TLS context (custom CA / client certificate) off the event loop. + # It is None for plain http or default-trust https, which keeps the previous + # behaviour. Stored material that no longer loads is a configuration error + # the user must fix via Reconfigure, so do not retry it endlessly. + try: + ssl_context = await hass.async_add_executor_job(build_ssl_context, entry.data) + except DockerProxyTLSError as err: + raise ConfigEntryError( + f"Invalid TLS configuration ({err.reason}); use Reconfigure to fix it" + ) from err + # Initialize the coordinator with the full entry object # This allows the coordinator to access entry.options and entry.data["url"] - coordinator = DockerProxyCoordinator(hass, entry) + coordinator = DockerProxyCoordinator(hass, entry, ssl_context) # Trigger the first refresh to ensure we have data before creating entities # This will set the initial host_status and populate the container list diff --git a/custom_components/docker_socket_proxy/coordinator.py b/custom_components/docker_socket_proxy/coordinator.py index 96378f2..3895398 100644 --- a/custom_components/docker_socket_proxy/coordinator.py +++ b/custom_components/docker_socket_proxy/coordinator.py @@ -6,6 +6,7 @@ from datetime import datetime, timedelta import logging import re +import ssl from typing import Any from urllib.parse import urlparse @@ -42,8 +43,19 @@ class DockerProxyCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching Docker data with smart parsing and update indicators.""" - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: - """Initialize the coordinator.""" + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + ssl_context: ssl.SSLContext | None = None, + ) -> None: + """Initialize the coordinator. + + ``ssl_context`` carries the custom CA / client certificate, if any. When + it is None the shared Home Assistant session defaults apply. + """ + self.ssl_context = ssl_context + self._last_error: str | None = None self.url = entry.data[CONF_URL].rstrip("/") self.host_name = entry.title self.entry = entry @@ -62,6 +74,72 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: update_interval=timedelta(seconds=int(scan_interval)), ) + @property + def last_error(self) -> str | None: + """Most recent failure reason, or None while the host is reachable.""" + return self._last_error + + def _report_error(self, stage: str, reason: str | None) -> None: + """Log a failure once per distinct reason; log recovery once. + + Polling happens every scan interval, so repeating the same warning + would flood the log while a host is down. + """ + if reason == self._last_error: + return + if reason is None: + _LOGGER.info("%s: %s is reachable again", self.host_name, self.url) + else: + _LOGGER.warning("%s: %s failed for %s: %s", self.host_name, stage, self.url, reason) + self._last_error = reason + + @staticmethod + def _describe_error(err: BaseException) -> str: + """Turn a request exception into a short, actionable reason.""" + if isinstance(err, aiohttp.ClientConnectorCertificateError): + return ( + "TLS certificate verification failed " + "(untrusted CA or host name does not match the certificate)" + ) + if isinstance(err, aiohttp.ClientSSLError): + return ( + "TLS handshake failed (the proxy may require a client " + "certificate, or the CA/client certificate is wrong)" + ) + if isinstance(err, aiohttp.ServerDisconnectedError): + return ( + "connection closed by the server (with TLS this usually means the " + "proxy requires a client certificate; otherwise check the scheme and port)" + ) + if isinstance(err, aiohttp.ClientConnectorError): + return "connection refused or host unreachable" + if isinstance(err, TimeoutError): + return "request timed out" + return f"{type(err).__name__}: {err}" + + async def _get_json( + self, session: aiohttp.ClientSession, path: str + ) -> tuple[int, Any]: + """GET ``path`` from the proxy and return (status, parsed JSON or None).""" + kwargs: dict[str, Any] = {} + if self.ssl_context is not None: + kwargs["ssl"] = self.ssl_context + async with asyncio.timeout(5): + res = await session.get(f"{self.url}{path}", **kwargs) + if res.status != 200: + return res.status, None + return res.status, await res.json() + + @staticmethod + def _http_reason(status: int) -> str: + """Describe a non-200 response from the proxy.""" + if status in (401, 403): + return ( + f"HTTP {status}: the proxy denied this request " + "(check its access settings for this endpoint)" + ) + return f"unexpected HTTP status {status}" + async def _async_update_data(self) -> dict[str, Any]: """Fetch data sequentially and process into safe UI strings.""" session = async_get_clientsession(self.hass) @@ -70,39 +148,43 @@ async def _async_update_data(self) -> dict[str, Any]: # 1. Version Check version_info = {} try: - async with asyncio.timeout(5): - res = await session.get(f"{self.url}/version") - if res.status != 200: - self.host_status = HOST_STATUS_VERSION_UNAVAILABLE - return self.data or {} - v_raw = await res.json() - version_info = { - "Platform": str( - v_raw.get("Platform", {}).get("Name", ATTR_DEFAULT_NA) - ), - "Version": str(v_raw.get("Version", ATTR_DEFAULT_NA)), - "ApiVersion": str(v_raw.get("ApiVersion", ATTR_DEFAULT_NA)), - "Os": str(v_raw.get("Os", ATTR_DEFAULT_NA)), - "Arch": str(v_raw.get("Arch", ATTR_DEFAULT_NA)), - "KernelVersion": str(v_raw.get("KernelVersion", ATTR_DEFAULT_NA)), - } - except (aiohttp.ClientError, TimeoutError): + status, v_raw = await self._get_json(session, "/version") + if status != 200: + self.host_status = HOST_STATUS_VERSION_UNAVAILABLE + self._report_error("version check", self._http_reason(status)) + return self.data or {} + version_info = { + "Platform": str( + v_raw.get("Platform", {}).get("Name", ATTR_DEFAULT_NA) + ), + "Version": str(v_raw.get("Version", ATTR_DEFAULT_NA)), + "ApiVersion": str(v_raw.get("ApiVersion", ATTR_DEFAULT_NA)), + "Os": str(v_raw.get("Os", ATTR_DEFAULT_NA)), + "Arch": str(v_raw.get("Arch", ATTR_DEFAULT_NA)), + "KernelVersion": str(v_raw.get("KernelVersion", ATTR_DEFAULT_NA)), + } + except (aiohttp.ClientError, TimeoutError) as err: self.host_status = HOST_STATUS_VERSION_UNAVAILABLE + self._report_error("version check", self._describe_error(err)) return self.data or {} # 2. Containers Check raw_containers = [] try: - async with asyncio.timeout(5): - res = await session.get(f"{self.url}/containers/json?all=1") - if res.status != 200: - self.host_status = HOST_STATUS_CONTAINERS_UNAVAILABLE - return self.data or {} - raw_containers = await res.json() - except (aiohttp.ClientError, TimeoutError): + status, raw_containers = await self._get_json( + session, "/containers/json?all=1" + ) + if status != 200: + self.host_status = HOST_STATUS_CONTAINERS_UNAVAILABLE + self._report_error("container list", self._http_reason(status)) + return self.data or {} + except (aiohttp.ClientError, TimeoutError) as err: self.host_status = HOST_STATUS_CONTAINERS_UNAVAILABLE + self._report_error("container list", self._describe_error(err)) return self.data or {} + self._report_error("update", None) + # 3. Processing processed_containers: dict[str, Any] = {} running_count = 0 From 786ae6e5fe24afcc880e9de2a51ef6aaad695ea5 Mon Sep 17 00:00:00 2001 From: John H Date: Sun, 11 Oct 2026 14:53:40 -0500 Subject: [PATCH 4/7] feat(diagnostics): add redacted diagnostics Redact the client key, passphrase, certificate PEMs and proxy host. Include certificate subject/expiry summaries and live coordinator state. --- .../docker_socket_proxy/diagnostics.py | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 custom_components/docker_socket_proxy/diagnostics.py diff --git a/custom_components/docker_socket_proxy/diagnostics.py b/custom_components/docker_socket_proxy/diagnostics.py new file mode 100644 index 0000000..f4dda22 --- /dev/null +++ b/custom_components/docker_socket_proxy/diagnostics.py @@ -0,0 +1,88 @@ +"""Diagnostics support for Docker Socket Proxy. + +Secrets are never included: the client private key and its passphrase are +redacted, and certificates are summarised (subject / expiry) instead of dumped. +""" + +from __future__ import annotations + +from typing import Any +from urllib.parse import urlparse + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant + +from .const import ( + ATTR_CONTAINERS, + CONF_CA_CERT, + CONF_CLIENT_CERT, + DOMAIN, + TLS_SECRET_KEYS, +) + +# Redacted everywhere they appear in the entry data. The URL host identifies the +# user's network, and certificate PEMs are replaced by a summary below. +TO_REDACT = {CONF_URL, CONF_CA_CERT, CONF_CLIENT_CERT, *TLS_SECRET_KEYS} + + +def _describe_cert(pem: str | None) -> dict[str, Any] | None: + """Summarise the first certificate in a PEM string (no key material).""" + if not pem: + return None + try: + from cryptography import x509 # bundled with Home Assistant core + + cert = x509.load_pem_x509_certificate(pem.encode()) + return { + "subject": cert.subject.rfc4514_string(), + "not_valid_after": cert.not_valid_after_utc.isoformat(), + } + except Exception: # noqa: BLE001 - diagnostics must never raise + return {"error": "unreadable"} + + +def _redacted_url(url: str) -> str: + """Keep scheme and port (useful for debugging); drop the host.""" + parsed = urlparse(url) + port = f":{parsed.port}" if parsed.port else "" + return f"{parsed.scheme}://{port}" + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = hass.data.get(DOMAIN, {}).get(entry.entry_id) + data = entry.data + + diagnostics: dict[str, Any] = { + "entry": { + "data": async_redact_data(dict(data), TO_REDACT), + "options": dict(entry.options), + }, + "connection": { + "url": _redacted_url(data.get(CONF_URL, "")), + "tls": { + "custom_ca": bool(data.get(CONF_CA_CERT)), + "client_certificate": bool(data.get(CONF_CLIENT_CERT)), + "ca_summary": _describe_cert(data.get(CONF_CA_CERT)), + "client_certificate_summary": _describe_cert( + data.get(CONF_CLIENT_CERT) + ), + }, + }, + } + + if coordinator is not None: + containers = (coordinator.data or {}).get(ATTR_CONTAINERS, {}) + diagnostics["coordinator"] = { + "host_status": coordinator.host_status, + "last_error": coordinator.last_error, + "last_update_success": coordinator.last_update_success, + "container_count": len(containers), + "ssl_context_in_use": coordinator.ssl_context is not None, + } + + return diagnostics From 6a2d99807f48a3b783c4559c9f12e8764aa6a85c Mon Sep 17 00:00:00 2001 From: John H Date: Sun, 11 Oct 2026 14:55:24 -0500 Subject: [PATCH 5/7] test,docs: add test suite, CI, README and release 1.1.0 Add pytest tests (TLS helper, config flow and reconfigure, end-to-end mTLS against a real local server, diagnostics redaction), a tests workflow, README documentation for TLS/mTLS and troubleshooting, updated contributor notes, and bump the version to 1.1.0. --- .github/copilot-instructions.md | 9 +- .github/workflows/tests.yml | 34 ++++ README.md | 73 ++++++- pytest.ini | 4 + requirements_test.txt | 1 + tests/__init__.py | 0 tests/conftest.py | 38 ++++ tests/pki.py | 125 ++++++++++++ tests/test_config_flow.py | 343 ++++++++++++++++++++++++++++++++ tests/test_diagnostics.py | 80 ++++++++ tests/test_integration.py | 220 ++++++++++++++++++++ tests/test_tls.py | 104 ++++++++++ 12 files changed, 1027 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 pytest.ini create mode 100644 requirements_test.txt create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/pki.py create mode 100644 tests/test_config_flow.py create mode 100644 tests/test_diagnostics.py create mode 100644 tests/test_integration.py create mode 100644 tests/test_tls.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ea8e945..f2c0d76 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -4,9 +4,11 @@ This is a Home Assistant custom integration that monitors Docker containers via a Docker socket proxy API. It uses the coordinator pattern for data fetching and dynamic sensor entities. **Key Components:** -- `coordinator.py`: Fetches data from `/containers/json` and `/version` endpoints in parallel +- `coordinator.py`: Fetches data from `/version` and `/containers/json` sequentially; passes the optional SSL context via `ssl=` and logs each distinct failure reason once +- `tls.py`: Builds an `ssl.SSLContext` from PEM text in the config entry (optional private CA, mTLS client cert/key/passphrase). Server verification is always on; there is intentionally no skip-verify option +- `diagnostics.py`: Redacts keys, passphrases, certificate PEMs and the proxy host - `sensor.py`: Creates host sensor (running/total ratio) and per-container sensors with detailed attributes -- `config_flow.py`: Handles setup with URL validation via `/version` endpoint +- `config_flow.py`: Setup and in-place Reconfigure (URL + TLS file uploads) with validation via `/version`; reconfigure keeps the config entry so entities and history survive, and updates the entry's unique ID (the URL) - Dynamic entity management with grace period cleanup for orphaned containers ## Data Flow @@ -25,6 +27,7 @@ This is a Home Assistant custom integration that monitors Docker containers via - Supports multiple Docker hosts via separate config entries - Options flow for grace period settings (enabled/disabled, seconds) - URL validation in config flow ensures proxy connectivity +- TLS fields are optional; entries saved before TLS support (no TLS keys) must keep working unchanged ## Development Notes - No external dependencies; uses aiohttp for API calls @@ -33,4 +36,4 @@ This is a Home Assistant custom integration that monitors Docker containers via - Container names stripped of leading `/` for consistency ## Testing -Run Home Assistant with the integration loaded; verify sensors appear for containers and update on state changes. \ No newline at end of file +`pip install -r requirements_test.txt && pytest` (Python 3.13+). Tests live in `tests/` and use `pytest-homeassistant-custom-component`; `tests/pki.py` generates throwaway certificates in memory and `test_integration.py` talks to a real local mTLS server. Also verify manually that sensors appear for containers and update on state changes. \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..3a3b4fc --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,34 @@ +name: Tests + +on: + workflow_dispatch: + push: + branches: + - main + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + pytest: + name: pytest + runs-on: ubuntu-latest + steps: + - name: Checkout the repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: pip + cache-dependency-path: requirements_test.txt + + - name: Install test dependencies + run: pip install -r requirements_test.txt + + - name: Run tests + run: pytest diff --git a/README.md b/README.md index c655d1a..51135c2 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,8 @@ A Home Assistant integration that monitors Docker containers through a Docker so 2. Search for "Docker Socket Proxy" and select it 3. Enter: - **Instance Name**: A friendly name for this Docker host (e.g., "NAS", "Server") - - **Proxy URL**: The URL of your Docker socket proxy (e.g., `http://192.168.1.100:2375`) + - **Proxy URL**: The URL of your Docker socket proxy (e.g., `http://192.168.1.100:2375`, or `https://docker.example.com:2376` for a TLS-protected proxy) + - *(Optional)* **CA certificate**, **Client certificate**, **Client private key** and **Client key passphrase**: only needed for an HTTPS proxy that uses a private CA and/or requires client certificates. See [Securing the connection (TLS / mutual TLS)](#securing-the-connection-tls--mutual-tls). ### Configuration & Options @@ -94,6 +95,58 @@ services: start_period: "21s" ``` +### Securing the connection (TLS / mutual TLS) + +The integration can talk to a proxy that is protected with HTTPS, optionally with +**mutual TLS (mTLS)**, where the proxy only accepts clients that present a +certificate from a CA you trust. This is the recommended way to expose a proxy +beyond `localhost`, especially if it allows more than read-only access. + +[Tecnativa's docker-socket-proxy](https://github.com/Tecnativa/docker-socket-proxy) +does not speak TLS itself, so put a TLS terminator in front of it (nginx, Caddy, +HAProxy, Traefik, ...). Minimal nginx example, run next to the proxy container +(`dockerproxy` here is the proxy's container name): + +```nginx +events {} +stream { + server { + listen 2376 ssl; + proxy_pass dockerproxy:2375; + + ssl_certificate /certs/server.pem; + ssl_certificate_key /certs/server.key; + ssl_protocols TLSv1.2 TLSv1.3; + + # Remove the next two lines for server-side TLS only (no client certificate) + ssl_client_certificate /certs/ca.pem; + ssl_verify_client on; + } +} +``` + +Then, in Home Assistant, use an `https://` URL and upload the files: + +| Field | What to upload | When | +| :--- | :--- | :--- | +| **CA certificate** | PEM of the CA that signed the proxy's certificate (or the proxy's own certificate if self-signed) | Only if the proxy certificate is not signed by a publicly trusted CA | +| **Client certificate** | PEM client certificate | Only if the proxy requires mTLS | +| **Client private key** | PEM key matching the client certificate | Required together with the client certificate | +| **Client key passphrase** | Passphrase | Only if the private key is encrypted | + +Notes: + +- The proxy's certificate is **always verified**, and there is deliberately no "skip verification" switch. The host name in the URL must match a name or IP address in the certificate's *Subject Alternative Name* list. +- Leave all TLS fields empty for plain `http://` or for HTTPS with a publicly trusted certificate; behaviour is then unchanged. +- The uploaded certificates and key are stored in the integration's config entry, like other credentials in Home Assistant. They are redacted in diagnostics downloads and never logged. +- TLS 1.2 or newer is required. + +To add TLS to an **existing** entry without losing any entities or history, open +**Settings → Devices & Services → Docker Socket Proxy**, use the three-dot menu +of the entry and choose **Reconfigure**. You can change the URL and upload, +replace or remove (**Remove stored TLS certificates**) the certificate files. +Leaving a file field empty keeps the stored file. + ### Container Labels Add labels to your containers for enhanced functionality: @@ -409,6 +462,16 @@ Automatically receive push notifications on your phone if: - Check that the docker-socket-proxy container is running - Ensure firewall allows connections to the proxy port +#### TLS errors + +Check the Home Assistant log for `custom_components.docker_socket_proxy`; it states the reason once per outage. + +- **"TLS certificate verification failed"**: the CA certificate does not match the CA that signed the proxy's certificate, or the host name in the URL is not listed in the certificate's Subject Alternative Names. Use the name or IP the certificate was issued for. +- **"connection closed by the server ... requires a client certificate"**: the proxy wants mutual TLS but no (or an unaccepted) client certificate was sent. Upload a client certificate signed by the CA the proxy trusts. +- **"The client certificate and private key do not belong together"**: re-upload the matching pair. +- **"HTTP 403"**: TLS works, but the proxy denies that API endpoint. Enable it in the proxy (for Tecnativa: `CONTAINERS=1`, `VERSION=1`). +- Expired certificates also fail verification; the certificate's expiry date is included in the diagnostics download. + #### Missing containers - Containers must be running for auto-discovery @@ -433,6 +496,14 @@ logger: ## Contributing +Tests run with `pytest` (Python 3.13 or newer): + +```bash +pip install -r requirements_test.txt +pytest +``` + + Contributions are welcome! Please: 1. Fork the repository diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..1bdd40d --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +testpaths = tests +asyncio_mode = auto +addopts = -p no:cacheprovider diff --git a/requirements_test.txt b/requirements_test.txt new file mode 100644 index 0000000..71f5999 --- /dev/null +++ b/requirements_test.txt @@ -0,0 +1 @@ +pytest-homeassistant-custom-component diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..483a937 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,38 @@ +"""Shared fixtures for the Docker Socket Proxy tests.""" + +from __future__ import annotations + +import pytest + +from .pki import Authority + +pytest_plugins = "pytest_homeassistant_custom_component" + + +@pytest.fixture(autouse=True) +def auto_enable_custom_integrations(enable_custom_integrations): + """Allow Home Assistant to load the integration from custom_components/.""" + + +@pytest.fixture(scope="session") +def ca() -> Authority: + """The private CA that the test proxy's certificates chain to.""" + return Authority.create("test-ca") + + +@pytest.fixture(scope="session") +def other_ca() -> Authority: + """An unrelated CA, used to provoke verification failures.""" + return Authority.create("other-ca") + + +@pytest.fixture(scope="session") +def server_pair(ca: Authority) -> tuple[str, str]: + """Server certificate valid for localhost and 127.0.0.1.""" + return ca.issue("proxy", hosts=("localhost", "127.0.0.1")) + + +@pytest.fixture(scope="session") +def client_pair(ca: Authority) -> tuple[str, str]: + """Client certificate (cert_pem, key_pem) signed by the test CA.""" + return ca.issue("ha-client") diff --git a/tests/pki.py b/tests/pki.py new file mode 100644 index 0000000..9434528 --- /dev/null +++ b/tests/pki.py @@ -0,0 +1,125 @@ +"""Throwaway PKI for the TLS tests (generated in memory, never written to the repo).""" + +from __future__ import annotations + +from dataclasses import dataclass +import datetime as dt +import ipaddress + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID + + +def _key() -> rsa.RSAPrivateKey: + return rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +def _name(cn: str) -> x509.Name: + return x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn)]) + + +def _cert( + cn: str, + key: rsa.RSAPrivateKey, + *, + issuer: x509.Name | None = None, + issuer_key: rsa.RSAPrivateKey | None = None, + ca: bool = False, + san: list[x509.GeneralName] | None = None, + key_ids: bool = False, + days: int = 2, + expired: bool = False, +) -> x509.Certificate: + now = dt.datetime.now(dt.UTC) + start = now - dt.timedelta(days=10 if expired else 1) + end = now - dt.timedelta(days=5) if expired else now + dt.timedelta(days=days) + builder = ( + x509.CertificateBuilder() + .subject_name(_name(cn)) + .issuer_name(issuer or _name(cn)) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(start) + .not_valid_after(end) + .add_extension(x509.BasicConstraints(ca=ca, path_length=None), critical=True) + ) + if san: + builder = builder.add_extension(x509.SubjectAlternativeName(san), critical=False) + if key_ids: + builder = builder.add_extension( + x509.SubjectKeyIdentifier.from_public_key(key.public_key()), critical=False + ) + signer = (issuer_key or key).public_key() + builder = builder.add_extension( + x509.AuthorityKeyIdentifier.from_issuer_public_key(signer), critical=False + ) + return builder.sign(issuer_key or key, hashes.SHA256()) + + +def cert_pem(cert: x509.Certificate) -> str: + """Return a certificate as PEM text.""" + return cert.public_bytes(serialization.Encoding.PEM).decode() + + +def key_pem(key: rsa.RSAPrivateKey, password: str | None = None) -> str: + """Return a private key as PKCS#8 PEM text, optionally encrypted.""" + enc = ( + serialization.BestAvailableEncryption(password.encode()) + if password + else serialization.NoEncryption() + ) + return key.private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, enc + ).decode() + + +@dataclass +class Authority: + """A CA plus the ability to issue leaf certificates.""" + + key: rsa.RSAPrivateKey + cert: x509.Certificate + + @classmethod + def create(cls, cn: str, *, key_ids: bool = True) -> Authority: + key = _key() + return cls(key, _cert(cn, key, ca=True, key_ids=key_ids)) + + @property + def pem(self) -> str: + return cert_pem(self.cert) + + def issue( + self, + cn: str, + *, + hosts: tuple[str, ...] = (), + key_ids: bool = True, + expired: bool = False, + ) -> tuple[str, str]: + """Issue a leaf certificate. Returns (cert_pem, key_pem).""" + key = _key() + san: list[x509.GeneralName] = [] + for host in hosts: + try: + san.append(x509.IPAddress(ipaddress.ip_address(host))) + except ValueError: + san.append(x509.DNSName(host)) + cert = _cert( + cn, + key, + issuer=self.cert.subject, + issuer_key=self.key, + san=san or None, + key_ids=key_ids, + expired=expired, + ) + return cert_pem(cert), key_pem(key) + + def issue_encrypted(self, cn: str, password: str) -> tuple[str, str]: + """Issue a leaf whose private key is passphrase-protected.""" + key = _key() + cert = _cert(cn, key, issuer=self.cert.subject, issuer_key=self.key, key_ids=True) + return cert_pem(cert), key_pem(key, password) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py new file mode 100644 index 0000000..87a7085 --- /dev/null +++ b/tests/test_config_flow.py @@ -0,0 +1,343 @@ +"""Tests for the config flow: setup, TLS validation and in-place reconfigure.""" + +from __future__ import annotations + +from contextlib import asynccontextmanager, contextmanager +from pathlib import Path +import uuid +from unittest.mock import MagicMock, patch + +import aiohttp +from aiohttp.client_reqrep import ConnectionKey +from homeassistant import config_entries +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.docker_socket_proxy.const import ( + CONF_CA_CERT, + CONF_CLEAR_TLS, + CONF_CLIENT_CERT, + CONF_CLIENT_KEY, + CONF_CLIENT_KEY_PASSWORD, + DOMAIN, +) + +from .pki import Authority + +FLOW = "custom_components.docker_socket_proxy.config_flow" + + +class FakeUploads: + """Stand-in for HA's file_upload store: file_id -> text.""" + + def __init__(self, tmp_path: Path) -> None: + self.tmp_path = tmp_path + self.files: dict[str, str] = {} + + def add(self, _label: str, text: str) -> str: + """Store text and return a UUID, which is what FileSelector accepts.""" + file_id = str(uuid.uuid4()) + self.files[file_id] = text + return file_id + + @contextmanager + def process(self, hass, file_id): + path = self.tmp_path / file_id + path.write_text(self.files[file_id], encoding="utf-8") + yield path + + +@pytest.fixture +def uploads(tmp_path: Path): + fake = FakeUploads(tmp_path) + with patch(f"{FLOW}.process_uploaded_file", fake.process): + yield fake + + +class FakeSession: + """Records get() calls; behaves like aiohttp's session for the flow.""" + + def __init__(self, status: int = 200, exc: Exception | None = None) -> None: + self.status, self.exc, self.calls = status, exc, [] + + @asynccontextmanager + async def get(self, url, **kwargs): + self.calls.append((url, kwargs)) + if self.exc: + raise self.exc + yield MagicMock(status=self.status) + + +@pytest.fixture +def session(): + fake = FakeSession() + with patch(f"{FLOW}.async_get_clientsession", return_value=fake): + yield fake + + +@pytest.fixture(autouse=True) +def no_setup(): + """Config flow tests don't need the integration to actually start.""" + with patch( + "custom_components.docker_socket_proxy.async_setup_entry", return_value=True + ) as mock: + yield mock + + +async def _start_user(hass: HomeAssistant, **overrides) -> dict: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + return await hass.config_entries.flow.async_configure( + result["flow_id"], {"name": "NAS", "url": "http://10.0.0.5:2375", **overrides} + ) + + +async def test_plain_http_setup_unchanged(hass: HomeAssistant, session) -> None: + result = await _start_user(hass) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {"name": "NAS", "url": "http://10.0.0.5:2375"} + assert "ssl" not in session.calls[0][1] # HA's default session behaviour + + +async def test_trailing_slash_normalised(hass: HomeAssistant, session) -> None: + result = await _start_user(hass, url="http://10.0.0.5:2375/") + assert result["data"]["url"] == "http://10.0.0.5:2375" + + +async def test_mtls_setup_stores_pem_and_uses_context( + hass: HomeAssistant, session, uploads: FakeUploads, ca: Authority, client_pair +) -> None: + cert, key = client_pair + result = await _start_user( + hass, + url="https://proxy.lan:2376", + **{ + CONF_CA_CERT: uploads.add("ca", ca.pem), + CONF_CLIENT_CERT: uploads.add("cert", cert), + CONF_CLIENT_KEY: uploads.add("key", key), + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_CA_CERT] == ca.pem + assert result["data"][CONF_CLIENT_CERT] == cert + assert result["data"][CONF_CLIENT_KEY] == key + assert session.calls[0][1]["ssl"] is not None + + +@pytest.mark.parametrize( + ("fields", "error"), + [ + ({"cert_only": True}, "client_cert_key_required"), + ({"bad_ca": True}, "invalid_ca_cert"), + ], +) +async def test_tls_input_errors( + hass: HomeAssistant, session, uploads, client_pair, fields, error +) -> None: + extra = {} + if fields.get("cert_only"): + extra[CONF_CLIENT_CERT] = uploads.add("cert", client_pair[0]) + if fields.get("bad_ca"): + extra[CONF_CA_CERT] = uploads.add("ca", "garbage") + result = await _start_user(hass, url="https://proxy.lan:2376", **extra) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + assert session.calls == [] # never contacted the proxy with bad material + + +async def test_passphrase_without_key_is_rejected(hass: HomeAssistant, session) -> None: + result = await _start_user( + hass, url="https://proxy.lan:2376", **{CONF_CLIENT_KEY_PASSWORD: "x"} + ) + assert result["errors"] == {"base": "client_cert_key_required"} + + +def _client_conn_error(cls, **kw): + key = ConnectionKey("h", 1, True, True, None, None, None) + return cls(key, OSError("boom"), **kw) + + +@pytest.mark.parametrize( + ("url", "exc", "error"), + [ + ("http://h:2375", aiohttp.ClientConnectionError("x"), "cannot_connect"), + ("http://h:2375", TimeoutError(), "cannot_connect"), + ("https://h:2376", aiohttp.ClientSSLError( + ConnectionKey("h", 1, True, True, None, None, None), OSError("x")), "ssl_error"), + ("https://h:2376", aiohttp.ServerDisconnectedError(), "ssl_error"), + ("http://h:2375", aiohttp.ServerDisconnectedError(), "cannot_connect"), + ("http://h:2375", RuntimeError("bug"), "unknown"), + ], +) +async def test_connection_error_mapping(hass: HomeAssistant, url, exc, error) -> None: + with patch(f"{FLOW}.async_get_clientsession", return_value=FakeSession(exc=exc)): + result = await _start_user(hass, url=url) + assert result["errors"] == {"base": error} + + +async def test_non_200_is_cannot_connect(hass: HomeAssistant) -> None: + with patch(f"{FLOW}.async_get_clientsession", return_value=FakeSession(status=403)): + result = await _start_user(hass) + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_duplicate_url_aborts(hass: HomeAssistant, session) -> None: + MockConfigEntry( + domain=DOMAIN, unique_id="http://10.0.0.5:2375", + data={"name": "A", "url": "http://10.0.0.5:2375"}, + ).add_to_hass(hass) + result = await _start_user(hass) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +# ---------------------------------------------------------------- reconfigure + + +def _legacy_entry(hass: HomeAssistant, url="http://10.0.0.5:2375", **extra): + """An entry as created by the original (pre-TLS) version of the integration.""" + entry = MockConfigEntry( + domain=DOMAIN, title="NAS", unique_id=url, + data={"name": "NAS", "url": url, **extra}, + options={"scan_interval": 30}, + ) + entry.add_to_hass(hass) + return entry + + +async def test_reconfigure_legacy_entry_to_mtls_in_place( + hass: HomeAssistant, session, uploads, ca, client_pair +) -> None: + entry = _legacy_entry(hass) + original_id = entry.entry_id + cert, key = client_pair + + result = await entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["description_placeholders"] == {"stored": "none"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "url": "https://proxy.lan:2376/", + CONF_CA_CERT: uploads.add("ca", ca.pem), + CONF_CLIENT_CERT: uploads.add("cert", cert), + CONF_CLIENT_KEY: uploads.add("key", key), + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + updated = hass.config_entries.async_get_entry(original_id) + assert updated is entry # same entry => same entity unique IDs and history + assert entry.unique_id == "https://proxy.lan:2376" + assert entry.data["url"] == "https://proxy.lan:2376" + assert entry.data["name"] == "NAS" + assert entry.data[CONF_CLIENT_KEY] == key + assert entry.options == {"scan_interval": 30} # options untouched + + +async def test_reconfigure_empty_fields_keep_stored_files( + hass: HomeAssistant, session, ca, client_pair +) -> None: + cert, key = client_pair + entry = _legacy_entry( + hass, url="https://proxy.lan:2376", + **{CONF_CA_CERT: ca.pem, CONF_CLIENT_CERT: cert, CONF_CLIENT_KEY: key}, + ) + result = await entry.start_reconfigure_flow(hass) + assert result["description_placeholders"] == { + "stored": "CA certificate, client certificate, client key" + } + # Change only the port; leave every file field empty. + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"url": "https://proxy.lan:2377"} + ) + assert result["reason"] == "reconfigure_successful" + assert entry.data[CONF_CA_CERT] == ca.pem + assert entry.data[CONF_CLIENT_KEY] == key + assert entry.unique_id == "https://proxy.lan:2377" + assert session.calls[-1][1]["ssl"] is not None + + +async def test_reconfigure_same_url_does_not_trip_duplicate_check( + hass: HomeAssistant, session +) -> None: + entry = _legacy_entry(hass) + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"url": "http://10.0.0.5:2375"} + ) + assert result["reason"] == "reconfigure_successful" + + +async def test_reconfigure_to_url_used_by_other_entry_aborts( + hass: HomeAssistant, session +) -> None: + _legacy_entry(hass, url="http://10.0.0.6:2375") + entry = _legacy_entry(hass) + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"url": "http://10.0.0.6:2375"} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert entry.unique_id == "http://10.0.0.5:2375" + + +async def test_reconfigure_clear_tls_removes_material( + hass: HomeAssistant, session, ca, client_pair +) -> None: + cert, key = client_pair + entry = _legacy_entry( + hass, url="http://10.0.0.5:2375", + **{CONF_CA_CERT: ca.pem, CONF_CLIENT_CERT: cert, CONF_CLIENT_KEY: key, + CONF_CLIENT_KEY_PASSWORD: "pw"}, + ) + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"url": "http://10.0.0.5:2375", CONF_CLEAR_TLS: True} + ) + assert result["reason"] == "reconfigure_successful" + assert entry.data == {"name": "NAS", "url": "http://10.0.0.5:2375"} + + +async def test_reconfigure_failure_keeps_entry_untouched( + hass: HomeAssistant, uploads +) -> None: + entry = _legacy_entry(hass) + before = dict(entry.data) + with patch(f"{FLOW}.async_get_clientsession", return_value=FakeSession(status=500)): + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"url": "https://nope:2376"} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + assert dict(entry.data) == before + assert entry.unique_id == "http://10.0.0.5:2375" + + +async def test_reconfigure_new_key_drops_old_passphrase( + hass: HomeAssistant, session, uploads, ca +) -> None: + cert, key = ca.issue("new-client") + entry = _legacy_entry( + hass, **{CONF_CLIENT_CERT: "old", CONF_CLIENT_KEY: "old", CONF_CLIENT_KEY_PASSWORD: "oldpw"} + ) + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "url": "http://10.0.0.5:2375", + CONF_CLIENT_CERT: uploads.add("cert", cert), + CONF_CLIENT_KEY: uploads.add("key", key), + }, + ) + assert result["reason"] == "reconfigure_successful" + assert CONF_CLIENT_KEY_PASSWORD not in entry.data diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py new file mode 100644 index 0000000..baac897 --- /dev/null +++ b/tests/test_diagnostics.py @@ -0,0 +1,80 @@ +"""Diagnostics must never leak key material, passphrases or the proxy host.""" + +from __future__ import annotations + +import json +from unittest.mock import patch + +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from pytest_homeassistant_custom_component.common import MockConfigEntry +from pytest_homeassistant_custom_component.components.diagnostics import ( + get_diagnostics_for_config_entry, +) + +from custom_components.docker_socket_proxy.const import ( + CONF_CA_CERT, + CONF_CLIENT_CERT, + CONF_CLIENT_KEY, + CONF_CLIENT_KEY_PASSWORD, + DOMAIN, +) + + +async def test_diagnostics_redacts_secrets(hass: HomeAssistant, hass_client, ca) -> None: + cert, key = ca.issue_encrypted("diag-client", "hunter2") + entry = MockConfigEntry( + domain=DOMAIN, title="NAS", unique_id="https://192.168.9.9:2376", + data={ + "name": "NAS", "url": "https://192.168.9.9:2376", + CONF_CA_CERT: ca.pem, CONF_CLIENT_CERT: cert, + CONF_CLIENT_KEY: key, CONF_CLIENT_KEY_PASSWORD: "hunter2", + }, + ) + entry.add_to_hass(hass) + assert await async_setup_component(hass, "diagnostics", {}) + # Load the integration (so its diagnostics platform registers) without + # starting a coordinator; this entry points at a host that does not exist. + with patch("custom_components.docker_socket_proxy.async_setup_entry", return_value=True): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await get_diagnostics_for_config_entry(hass, hass_client, entry) + text = json.dumps(result) + + for secret in ("BEGIN", "hunter2", "192.168.9.9", key.splitlines()[1]): + assert secret not in text, secret + + assert result["connection"]["url"] == "https://:2376" + tls = result["connection"]["tls"] + assert tls["custom_ca"] is True and tls["client_certificate"] is True + assert tls["ca_summary"]["subject"] == "CN=test-ca" + assert "not_valid_after" in tls["client_certificate_summary"] + assert "coordinator" not in result # entry was never set up + + +async def test_diagnostics_includes_live_coordinator_state( + hass: HomeAssistant, hass_client, aioclient_mock +) -> None: + aioclient_mock.get("http://h:2375/version", json={"Version": "27"}) + aioclient_mock.get("http://h:2375/containers/json?all=1", json=[]) + entry = MockConfigEntry( + domain=DOMAIN, title="NAS", unique_id="http://h:2375", + data={"name": "NAS", "url": "http://h:2375"}, + ) + entry.add_to_hass(hass) + assert await async_setup_component(hass, "diagnostics", {}) + with patch("custom_components.docker_socket_proxy.async_install_blueprint"): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await get_diagnostics_for_config_entry(hass, hass_client, entry) + assert result["connection"]["tls"]["custom_ca"] is False + assert result["connection"]["tls"]["ca_summary"] is None + assert result["connection"]["url"] == "http://:2375" + assert result["coordinator"]["ssl_context_in_use"] is False + assert result["coordinator"]["last_update_success"] is True + assert result["coordinator"]["last_error"] is None + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..3c1fc87 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,220 @@ +"""End-to-end tests: real Home Assistant setup against a real local TLS server.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Awaitable, Callable +import ssl +from unittest.mock import patch + +import aiohttp +from aiohttp import web +from aiohttp.test_utils import unused_port +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry +from pytest_homeassistant_custom_component.test_util.aiohttp import AiohttpClientMocker + +from custom_components.docker_socket_proxy.const import ( + CONF_CA_CERT, + CONF_CLIENT_CERT, + CONF_CLIENT_KEY, + DOMAIN, +) + +from .pki import Authority + +CONTAINERS = [ + { + "Id": "abc123", + "Names": ["/web"], + "Image": "nginx:latest", + "State": "running", + "Status": "Up 2 hours (healthy)", + "Created": 1700000000, + "Labels": {}, + "Ports": [], + "NetworkSettings": {"Networks": {}}, + } +] + + +@pytest.fixture(autouse=True) +def no_blueprint_copy(): + with patch("custom_components.docker_socket_proxy.async_install_blueprint"): + yield + + +@pytest.fixture +async def real_session(socket_enabled): + """Give the coordinator a real aiohttp session. + + The test harness replaces Home Assistant's resolver, so its shared session + cannot reach even localhost. Everything else (SSL context, request path, + error handling) is the production code. + """ + session = aiohttp.ClientSession() + with patch( + "custom_components.docker_socket_proxy.coordinator.async_get_clientsession", + return_value=session, + ): + yield session + await session.close() + + +async def _version(_request: web.Request) -> web.Response: + return web.json_response({"Version": "27.0", "ApiVersion": "1.46", "Os": "linux"}) + + +async def _containers(_request: web.Request) -> web.Response: + return web.json_response(CONTAINERS) + + +async def _forbidden(_request: web.Request) -> web.Response: + return web.Response(status=403) + + +@pytest.fixture +async def tls_proxy( + socket_enabled, ca: Authority, server_pair +) -> AsyncIterator[Callable[..., Awaitable[str]]]: + """Start an mTLS proxy on localhost; returns its base URL.""" + runners: list[web.AppRunner] = [] + tmp: list[str] = [] + + async def start(*, require_client: bool = True, containers_status: int = 200) -> str: + import tempfile, os + + d = tempfile.mkdtemp() + tmp.append(d) + cert, key = server_pair + for name, text in (("s.pem", cert), ("s.key", key), ("ca.pem", ca.pem)): + with open(os.path.join(d, name), "w", encoding="utf-8") as f: + f.write(text) + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ctx.load_cert_chain(os.path.join(d, "s.pem"), os.path.join(d, "s.key")) + if require_client: + ctx.load_verify_locations(os.path.join(d, "ca.pem")) + ctx.verify_mode = ssl.CERT_REQUIRED + app = web.Application() + app.router.add_get("/version", _version) + app.router.add_get( + "/containers/json", _containers if containers_status == 200 else _forbidden + ) + runner = web.AppRunner(app) + await runner.setup() + port = unused_port() + await web.TCPSite(runner, "127.0.0.1", port, ssl_context=ctx).start() + runners.append(runner) + return f"https://localhost:{port}" + + yield start + for runner in runners: + await runner.cleanup() + + +def _entry(url: str, **tls) -> MockConfigEntry: + return MockConfigEntry( + domain=DOMAIN, title="Proxy", unique_id=url, + data={"name": "Proxy", "url": url, **tls}, options={"scan_interval": 30}, + ) + + +async def _setup(hass: HomeAssistant, entry: MockConfigEntry): + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return hass.data.get(DOMAIN, {}).get(entry.entry_id) + + +async def _unload(hass: HomeAssistant, entry: MockConfigEntry) -> None: + if entry.state is ConfigEntryState.LOADED: + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_mtls_end_to_end(hass, real_session, tls_proxy, ca, client_pair) -> None: + url = await tls_proxy() + cert, key = client_pair + entry = _entry(url, **{CONF_CA_CERT: ca.pem, CONF_CLIENT_CERT: cert, CONF_CLIENT_KEY: key}) + coordinator = await _setup(hass, entry) + + assert entry.state is ConfigEntryState.LOADED + assert coordinator.host_status == "1/1 running" + assert coordinator.last_error is None + assert coordinator.ssl_context is not None + await _unload(hass, entry) + + +async def test_server_only_tls_with_private_ca(hass, real_session, tls_proxy, ca) -> None: + url = await tls_proxy(require_client=False) + coordinator = await _setup(hass, _entry(url, **{CONF_CA_CERT: ca.pem})) + assert coordinator.host_status == "1/1 running" + await _unload(hass, hass.config_entries.async_entries(DOMAIN)[0]) + + +async def test_missing_client_cert_is_reported_clearly(hass, real_session, tls_proxy, ca) -> None: + url = await tls_proxy() + entry = _entry(url, **{CONF_CA_CERT: ca.pem}) # proxy wants a client cert + coordinator = await _setup(hass, entry) + assert coordinator.host_status == "unavailable version" + assert "client certificate" in coordinator.last_error + await _unload(hass, entry) + + +async def test_untrusted_server_is_reported_clearly(hass, real_session, tls_proxy, other_ca, client_pair) -> None: + url = await tls_proxy() + cert, key = client_pair + entry = _entry( + url, **{CONF_CA_CERT: other_ca.pem, CONF_CLIENT_CERT: cert, CONF_CLIENT_KEY: key} + ) + coordinator = await _setup(hass, entry) + assert coordinator.host_status == "unavailable version" + assert "certificate verification failed" in coordinator.last_error + await _unload(hass, entry) + + +async def test_https_without_custom_ca_uses_default_trust(hass, real_session, tls_proxy) -> None: + """No custom CA: the private test CA is not in the default store, so it fails closed.""" + url = await tls_proxy(require_client=False) + entry = _entry(url) + coordinator = await _setup(hass, entry) + assert coordinator.ssl_context is None + assert coordinator.host_status == "unavailable version" + await _unload(hass, entry) + + +async def test_403_from_proxy_is_distinguished(hass, real_session, tls_proxy, ca) -> None: + url = await tls_proxy(require_client=False, containers_status=403) + entry = _entry(url, **{CONF_CA_CERT: ca.pem}) + coordinator = await _setup(hass, entry) + assert coordinator.host_status == "unavailable containers" + assert "HTTP 403" in coordinator.last_error + await _unload(hass, entry) + + +async def test_corrupt_stored_tls_is_a_setup_error(hass) -> None: + entry = _entry( + "https://proxy.lan:2376", + **{CONF_CLIENT_CERT: "-----BEGIN CERTIFICATE-----\nzz\n-----END CERTIFICATE-----", + CONF_CLIENT_KEY: "-----BEGIN PRIVATE KEY-----\nzz\n-----END PRIVATE KEY-----"}, + ) + await _setup(hass, entry) + assert entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_legacy_plain_http_entry_still_works( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """An entry saved by the original version (no TLS keys) must keep working.""" + aioclient_mock.get("http://10.0.0.5:2375/version", json={"Version": "27.0"}) + aioclient_mock.get("http://10.0.0.5:2375/containers/json?all=1", json=CONTAINERS) + entry = MockConfigEntry( + domain=DOMAIN, title="NAS", unique_id="http://10.0.0.5:2375", + data={"name": "NAS", "url": "http://10.0.0.5:2375"}, + ) + coordinator = await _setup(hass, entry) + assert entry.state is ConfigEntryState.LOADED + assert coordinator.ssl_context is None + assert coordinator.host_status == "1/1 running" + await _unload(hass, entry) diff --git a/tests/test_tls.py b/tests/test_tls.py new file mode 100644 index 0000000..f35b02c --- /dev/null +++ b/tests/test_tls.py @@ -0,0 +1,104 @@ +"""Tests for the TLS helper (no Home Assistant instance needed).""" + +from __future__ import annotations + +import ssl + +import pytest + +from custom_components.docker_socket_proxy.const import ( + CONF_CA_CERT, + CONF_CLIENT_CERT, + CONF_CLIENT_KEY, + CONF_CLIENT_KEY_PASSWORD, +) +from custom_components.docker_socket_proxy.tls import ( + DockerProxyTLSError, + build_ssl_context, + has_custom_tls, +) + +from .pki import Authority + + +def _reason(data: dict) -> str: + with pytest.raises(DockerProxyTLSError) as err: + build_ssl_context(data) + return err.value.reason + + +def test_no_tls_data_returns_none() -> None: + assert build_ssl_context({}) is None + assert build_ssl_context({"name": "x", "url": "http://h:2375"}) is None + assert not has_custom_tls({"url": "http://h:2375"}) + + +def test_ca_only_builds_verifying_context(ca: Authority) -> None: + ctx = build_ssl_context({CONF_CA_CERT: ca.pem}) + assert isinstance(ctx, ssl.SSLContext) + assert ctx.verify_mode == ssl.CERT_REQUIRED + assert ctx.check_hostname is True + assert ctx.minimum_version >= ssl.TLSVersion.TLSv1_2 + + +def test_strict_flag_relaxed_but_verification_kept(ca: Authority) -> None: + ctx = build_ssl_context({CONF_CA_CERT: ca.pem}) + assert not ctx.verify_flags & ssl.VERIFY_X509_STRICT + assert ctx.verify_mode == ssl.CERT_REQUIRED + + +def test_mutual_tls_context(ca: Authority, client_pair: tuple[str, str]) -> None: + cert, key = client_pair + ctx = build_ssl_context( + {CONF_CA_CERT: ca.pem, CONF_CLIENT_CERT: cert, CONF_CLIENT_KEY: key} + ) + assert isinstance(ctx, ssl.SSLContext) + + +def test_client_cert_without_ca_is_allowed(client_pair: tuple[str, str]) -> None: + cert, key = client_pair + assert build_ssl_context({CONF_CLIENT_CERT: cert, CONF_CLIENT_KEY: key}) is not None + + +def test_cert_and_key_must_come_together(client_pair: tuple[str, str]) -> None: + cert, key = client_pair + assert _reason({CONF_CLIENT_CERT: cert}) == "client_cert_key_required" + assert _reason({CONF_CLIENT_KEY: key}) == "client_cert_key_required" + + +def test_garbage_inputs_are_rejected(client_pair: tuple[str, str]) -> None: + cert, key = client_pair + assert _reason({CONF_CA_CERT: "not a pem"}) == "invalid_ca_cert" + assert ( + _reason({CONF_CA_CERT: "-----BEGIN CERTIFICATE-----\nzzzz\n-----END CERTIFICATE-----"}) + == "invalid_ca_cert" + ) + assert _reason({CONF_CLIENT_CERT: "nope", CONF_CLIENT_KEY: key}) == "invalid_client_cert" + assert _reason({CONF_CLIENT_CERT: cert, CONF_CLIENT_KEY: "nope"}) == "invalid_client_key" + broken = "-----BEGIN PRIVATE KEY-----\nzzzz\n-----END PRIVATE KEY-----" + assert _reason({CONF_CLIENT_CERT: cert, CONF_CLIENT_KEY: broken}) == "invalid_client_key" + + +def test_mismatched_cert_and_key(ca: Authority, client_pair: tuple[str, str]) -> None: + cert, _ = client_pair + _, other_key = ca.issue("someone-else") + data = {CONF_CLIENT_CERT: cert, CONF_CLIENT_KEY: other_key} + assert _reason(data) == "client_cert_key_mismatch" + + +def test_encrypted_key_passphrase_handling(ca: Authority) -> None: + cert, key = ca.issue_encrypted("enc-client", "s3cret") + base = {CONF_CLIENT_CERT: cert, CONF_CLIENT_KEY: key} + assert build_ssl_context({**base, CONF_CLIENT_KEY_PASSWORD: "s3cret"}) is not None + assert _reason(base) == "client_key_password_required" + assert _reason({**base, CONF_CLIENT_KEY_PASSWORD: "wrong"}) == "client_key_password_incorrect" + + +def test_no_temp_key_material_left_behind( + ca: Authority, client_pair: tuple[str, str], tmp_path, monkeypatch +) -> None: + monkeypatch.setenv("TMPDIR", str(tmp_path)) + monkeypatch.setattr("tempfile.tempdir", str(tmp_path)) + cert, key = client_pair + build_ssl_context({CONF_CLIENT_CERT: cert, CONF_CLIENT_KEY: key}) + assert list(tmp_path.iterdir()) == [] From b04ede02678b15965bf4870f9bb4d48168f4a8bc Mon Sep 17 00:00:00 2001 From: John H Date: Sun, 11 Oct 2026 16:41:41 -0500 Subject: [PATCH 6/7] ci: pin actions/setup-python by commit SHA Match the existing workflow, which pins its actions to full commit SHAs. --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3a3b4fc..9512613 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -21,7 +21,7 @@ jobs: uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.13" cache: pip From 7ca9ac68c9043204132661f89d71563dca548a9f Mon Sep 17 00:00:00 2001 From: John H Date: Sun, 11 Oct 2026 16:47:55 -0500 Subject: [PATCH 7/7] fix(translations): remove URLs from strings for hassfest hassfest rejects URLs inside translation strings. Use a plain 'Proxy URL' label and describe the expected address in data_description instead. --- custom_components/docker_socket_proxy/strings.json | 10 ++++++---- .../docker_socket_proxy/translations/de.json | 10 ++++++---- .../docker_socket_proxy/translations/en.json | 10 ++++++---- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/custom_components/docker_socket_proxy/strings.json b/custom_components/docker_socket_proxy/strings.json index 24563ca..73f8b53 100644 --- a/custom_components/docker_socket_proxy/strings.json +++ b/custom_components/docker_socket_proxy/strings.json @@ -7,7 +7,7 @@ "description": "Enter the connection details for your Docker Proxy. For an HTTPS proxy that uses a private CA or requires client certificates (mutual TLS), upload the files below; leave them empty otherwise.", "data": { "name": "Instance Name (e.g., NAS)", - "url": "Proxy URL (e.g., http://192.168.1.100:2375 or https://docker.example.com:2376)", + "url": "Proxy URL", "ca_cert": "CA certificate (optional)", "client_cert": "Client certificate (optional)", "client_key": "Client private key (optional)", @@ -17,14 +17,15 @@ "ca_cert": "PEM file used to verify the proxy's certificate. Only needed if the proxy uses a private or self-signed CA. Defaults to the system trust store.", "client_cert": "PEM client certificate, for proxies that require mutual TLS. Must be provided together with the private key.", "client_key": "PEM private key matching the client certificate. Stored in the config entry like other credentials.", - "client_key_password": "Only needed if the private key is encrypted." + "client_key_password": "Only needed if the private key is encrypted.", + "url": "Address of the Docker socket proxy: http or https, host name or IP address, and port (for example 192.168.1.100:2375). Use https for a TLS-protected proxy." } }, "reconfigure": { "title": "Reconfigure Docker Socket Proxy", "description": "Change the proxy URL or TLS files. Existing entities and history are kept. Currently stored: {stored}. Leave a file field empty to keep the stored file.", "data": { - "url": "Proxy URL (e.g., http://192.168.1.100:2375 or https://docker.example.com:2376)", + "url": "Proxy URL", "ca_cert": "CA certificate (optional)", "client_cert": "Client certificate (optional)", "client_key": "Client private key (optional)", @@ -36,7 +37,8 @@ "client_cert": "PEM client certificate, for proxies that require mutual TLS. Must be provided together with the private key.", "client_key": "PEM private key matching the client certificate. Stored in the config entry like other credentials.", "client_key_password": "Only needed if the private key is encrypted.", - "clear_tls": "Deletes the CA certificate, client certificate and key from this entry. Upload new files above to replace them instead." + "clear_tls": "Deletes the CA certificate, client certificate and key from this entry. Upload new files above to replace them instead.", + "url": "Address of the Docker socket proxy: http or https, host name or IP address, and port (for example 192.168.1.100:2375). Use https for a TLS-protected proxy." } } }, diff --git a/custom_components/docker_socket_proxy/translations/de.json b/custom_components/docker_socket_proxy/translations/de.json index 81f8667..d48ffac 100644 --- a/custom_components/docker_socket_proxy/translations/de.json +++ b/custom_components/docker_socket_proxy/translations/de.json @@ -7,7 +7,7 @@ "description": "Verbindungsdetails für den Docker-Proxy eingeben. Für einen HTTPS-Proxy mit privater CA oder Client-Zertifikaten (Mutual TLS) die Dateien unten hochladen, sonst leer lassen.", "data": { "name": "Instanz-Name (z. B. NAS)", - "url": "Proxy-URL (z. B. http://192.168.1.100:2375 oder https://docker.example.com:2376)", + "url": "Proxy-URL", "ca_cert": "CA-Zertifikat (optional)", "client_cert": "Client-Zertifikat (optional)", "client_key": "Privater Client-Schlüssel (optional)", @@ -17,14 +17,15 @@ "ca_cert": "PEM-Datei zur Prüfung des Proxy-Zertifikats. Nur nötig bei privater oder selbstsignierter CA. Standard ist der System-Truststore.", "client_cert": "PEM-Client-Zertifikat für Proxys mit Mutual TLS. Nur zusammen mit dem privaten Schlüssel angeben.", "client_key": "PEM-Schlüssel passend zum Client-Zertifikat. Wird wie andere Zugangsdaten im Konfigurationseintrag gespeichert.", - "client_key_password": "Nur nötig, wenn der private Schlüssel verschlüsselt ist." + "client_key_password": "Nur nötig, wenn der private Schlüssel verschlüsselt ist.", + "url": "Adresse des Docker-Socket-Proxys: http oder https, Hostname oder IP-Adresse und Port (z. B. 192.168.1.100:2375). Für einen TLS-geschützten Proxy https verwenden." } }, "reconfigure": { "title": "Docker Socket Proxy neu konfigurieren", "description": "Proxy-URL oder TLS-Dateien ändern. Bestehende Entitäten und Verläufe bleiben erhalten. Gespeichert: {stored}. Ein leeres Dateifeld behält die gespeicherte Datei.", "data": { - "url": "Proxy-URL (z. B. http://192.168.1.100:2375 oder https://docker.example.com:2376)", + "url": "Proxy-URL", "ca_cert": "CA-Zertifikat (optional)", "client_cert": "Client-Zertifikat (optional)", "client_key": "Privater Client-Schlüssel (optional)", @@ -36,7 +37,8 @@ "client_cert": "PEM-Client-Zertifikat für Proxys mit Mutual TLS. Nur zusammen mit dem privaten Schlüssel angeben.", "client_key": "PEM-Schlüssel passend zum Client-Zertifikat. Wird wie andere Zugangsdaten im Konfigurationseintrag gespeichert.", "client_key_password": "Nur nötig, wenn der private Schlüssel verschlüsselt ist.", - "clear_tls": "Löscht CA-Zertifikat, Client-Zertifikat und Schlüssel aus diesem Eintrag. Neue Dateien oben hochladen, um sie stattdessen zu ersetzen." + "clear_tls": "Löscht CA-Zertifikat, Client-Zertifikat und Schlüssel aus diesem Eintrag. Neue Dateien oben hochladen, um sie stattdessen zu ersetzen.", + "url": "Adresse des Docker-Socket-Proxys: http oder https, Hostname oder IP-Adresse und Port (z. B. 192.168.1.100:2375). Für einen TLS-geschützten Proxy https verwenden." } } }, diff --git a/custom_components/docker_socket_proxy/translations/en.json b/custom_components/docker_socket_proxy/translations/en.json index 24563ca..73f8b53 100644 --- a/custom_components/docker_socket_proxy/translations/en.json +++ b/custom_components/docker_socket_proxy/translations/en.json @@ -7,7 +7,7 @@ "description": "Enter the connection details for your Docker Proxy. For an HTTPS proxy that uses a private CA or requires client certificates (mutual TLS), upload the files below; leave them empty otherwise.", "data": { "name": "Instance Name (e.g., NAS)", - "url": "Proxy URL (e.g., http://192.168.1.100:2375 or https://docker.example.com:2376)", + "url": "Proxy URL", "ca_cert": "CA certificate (optional)", "client_cert": "Client certificate (optional)", "client_key": "Client private key (optional)", @@ -17,14 +17,15 @@ "ca_cert": "PEM file used to verify the proxy's certificate. Only needed if the proxy uses a private or self-signed CA. Defaults to the system trust store.", "client_cert": "PEM client certificate, for proxies that require mutual TLS. Must be provided together with the private key.", "client_key": "PEM private key matching the client certificate. Stored in the config entry like other credentials.", - "client_key_password": "Only needed if the private key is encrypted." + "client_key_password": "Only needed if the private key is encrypted.", + "url": "Address of the Docker socket proxy: http or https, host name or IP address, and port (for example 192.168.1.100:2375). Use https for a TLS-protected proxy." } }, "reconfigure": { "title": "Reconfigure Docker Socket Proxy", "description": "Change the proxy URL or TLS files. Existing entities and history are kept. Currently stored: {stored}. Leave a file field empty to keep the stored file.", "data": { - "url": "Proxy URL (e.g., http://192.168.1.100:2375 or https://docker.example.com:2376)", + "url": "Proxy URL", "ca_cert": "CA certificate (optional)", "client_cert": "Client certificate (optional)", "client_key": "Client private key (optional)", @@ -36,7 +37,8 @@ "client_cert": "PEM client certificate, for proxies that require mutual TLS. Must be provided together with the private key.", "client_key": "PEM private key matching the client certificate. Stored in the config entry like other credentials.", "client_key_password": "Only needed if the private key is encrypted.", - "clear_tls": "Deletes the CA certificate, client certificate and key from this entry. Upload new files above to replace them instead." + "clear_tls": "Deletes the CA certificate, client certificate and key from this entry. Upload new files above to replace them instead.", + "url": "Address of the Docker socket proxy: http or https, host name or IP address, and port (for example 192.168.1.100:2375). Use https for a TLS-protected proxy." } } },