Просмотр исходного кода

fix(smart-plugs): Home Assistant's connection test names a certificate problem (issue #3300)

- httpx raises an untrusted certificate as ConnectError, so after HA
  moved to https the test said "Could not connect to Home Assistant"
  for an HA that was up and answering.
- It now names the TLS failure and what to do: a mismatch (enter the
  hostname the certificate was issued for), an expired certificate,
  a certificate from an authority Bambuddy does not trust (add it to
  the trust store), or https on a port that serves plain http.
- Tests run against real local TLS servers and clear SSL_CERT_FILE /
  SSL_CERT_DIR, which httpx reads before certifi.
maziggy 1 день назад
Родитель
Сommit
9e450dfd68

+ 1 - 0
CHANGELOG.md

@@ -176,6 +176,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Home Assistant's connection test names a certificate problem (#3300)** — After Home Assistant moved to https, **Test Connection** said "Could not connect to Home Assistant" when Home Assistant was up and answering, because a certificate Bambuddy does not trust fails like an address nothing answers on. It now says the certificate is not trusted, why (self-signed or from your own authority, issued for a hostname while the URL uses the IP address, or expired) and what to do, and says when https was used on a port that serves plain http. The Smart Plugs page of the wiki explains how to add your own certificate authority, also on a native install.
 - **A bug report is no longer sent when debug logging stops (#3363, reported by @choumarin)** — "Stop & Submit" turned debug logging off and filed the report as a public GitHub issue in the same click, and the 5-minute auto-stop did the same with nobody watching, so a report meant only to capture a log was sent before it was ready. The button is now "Stop Logging": it collects the logs and ends on a review that says a public issue will be created and how many log lines were collected, with Submit and Discard. Closing the panel keeps the review, and a failed send goes back to it with the logs kept.
 - **A camera turned sideways fits the camera window (#3362, reported by @BurgerKerman)** — With a camera's rotation set to 90 or 270 degrees, the camera window that floats over the page laid the picture out as if it were still landscape and then turned it, so it was cut off in a wide window and surrounded by black bars in a tall one. It now fits whatever size the window is resized to, as the camera page opened in its own browser window already did. The same happened to Cam Wall tiles, where a portrait camera was cut off at the top and bottom, and to the classic stream overlay without a fixed layout; both are fixed too. The camera page in its own window sized the picture to the screen rather than the space below its header, which cut a little off the top and bottom in a wide window; it fits the space now.
 - **A SpoolBuddy kiosk no longer gets stuck on the login page during a database problem (#3360, reported by @sargonas)** — A kiosk signs in with its API key from the launch URL. When it loaded while Bambuddy's database was unavailable (out of connections, or a read-only replica after a failover), it gave up after three quick tries and showed the login page, which a touch-only screen can't use, until the next reboot. Pages now wait on a "Can't reach Bambuddy. Retrying…" screen and keep trying in the background, then show the page as soon as Bambuddy answers. A key or session Bambuddy reports as invalid, expired or belonging to a deactivated account still goes to the login page. This also covers browser tabs with a saved sign-in, which used to show the login page, or the app as if sign-in were off, when they loaded during an outage. Writing an API key's last-used time no longer refuses a key that has already been checked: if the write fails, the key is accepted and the failure logged. That time is now written at most once a minute instead of on every request, which takes one write and commit off almost every request made with a key. A write that fails is not tried again for a minute either. Camera tokens for the Cam Wall, stream overlays and camera streams get the same treatment: a failed last-used write no longer refuses a valid token.

+ 37 - 2
backend/app/services/homeassistant.py

@@ -2,6 +2,7 @@
 
 import asyncio
 import logging
+import ssl
 from typing import TYPE_CHECKING
 from urllib.parse import urlparse
 
@@ -15,6 +16,40 @@ if TYPE_CHECKING:
 logger = logging.getLogger(__name__)
 
 
+def tls_failure(exc: BaseException) -> str | None:
+    """Why a TLS connection to Home Assistant failed, or None if it was not TLS (#3300).
+
+    httpx raises an untrusted certificate as ``ConnectError``, the same as an
+    address nothing answers on, so the connection test said "Could not connect"
+    for an HA that was up and answering over https.
+    """
+    seen: set[int] = set()
+    cause: BaseException | None = exc
+    while cause is not None and id(cause) not in seen:
+        seen.add(id(cause))
+        if isinstance(cause, ssl.SSLCertVerificationError):
+            reason = str(cause.verify_message or cause.reason or cause).rstrip(".")
+            if "mismatch" in reason:
+                hint = "Enter the hostname the certificate was issued for, not an IP address or another name."
+            elif "expired" in reason:
+                hint = "Renew the certificate Home Assistant serves."
+            else:
+                # Self-signed, or issued by a CA of the user's own: Bambuddy
+                # trusts certifi's bundle unless pointed at a trust store.
+                hint = (
+                    "Bambuddy does not trust the authority that issued it. To use your own certificate "
+                    "authority, add it to Bambuddy's trust store (see Smart Plugs > Home Assistant in the wiki)."
+                )
+            return f"Home Assistant's certificate is not trusted ({reason}). {hint}"
+        if isinstance(cause, ssl.SSLError):
+            return (
+                f"The secure connection to Home Assistant failed ({cause.reason or cause}). "
+                "Check that Home Assistant serves https at this address and port."
+            )
+        cause = cause.__cause__ or cause.__context__
+    return None
+
+
 class HomeAssistantService:
     """Service for controlling Home Assistant entities via REST API."""
 
@@ -255,8 +290,8 @@ class HomeAssistantService:
             return {"success": False, "message": None, "error": f"HTTP {e.response.status_code}"}
         except httpx.TimeoutException:
             return {"success": False, "message": None, "error": "Connection timeout"}
-        except httpx.ConnectError:
-            return {"success": False, "message": None, "error": "Could not connect to Home Assistant"}
+        except httpx.ConnectError as e:
+            return {"success": False, "message": None, "error": tls_failure(e) or "Could not connect to Home Assistant"}
         except Exception as e:
             # May be the saved token's test: a refused header's value stays out.
             return {"success": False, "message": None, "error": redact_refused_headers(str(e))}

+ 178 - 0
backend/tests/unit/test_homeassistant_tls_3300.py

@@ -0,0 +1,178 @@
+"""The Home Assistant connection test names a certificate problem (#3300).
+
+httpx raises an untrusted certificate as ``ConnectError``, so after moving HA
+to https the test said "Could not connect to Home Assistant" for an HA that was
+up and answering. These run against real local servers on 127.0.0.1.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import ipaddress
+import socket
+import ssl
+from datetime import datetime, timedelta, timezone
+from unittest.mock import patch
+
+import pytest
+from cryptography import x509
+from cryptography.hazmat.primitives import hashes, serialization
+from cryptography.hazmat.primitives.asymmetric import ec
+from cryptography.x509.oid import NameOID
+
+from backend.app.services.homeassistant import HomeAssistantService
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.unit]
+
+
+@pytest.fixture(autouse=True)
+def _certifi_trust_store(monkeypatch):
+    """httpx reads SSL_CERT_FILE / SSL_CERT_DIR before certifi (USE_SYSTEM_TRUST_STORE sets one)."""
+    monkeypatch.delenv("SSL_CERT_FILE", raising=False)
+    monkeypatch.delenv("SSL_CERT_DIR", raising=False)
+
+
+def _cert(subject_cn: str, key, issuer_cn: str, issuer_key, san: x509.GeneralName, ca: bool, expired: bool = False):
+    now = datetime.now(timezone.utc)
+    return (
+        x509.CertificateBuilder()
+        .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, subject_cn)]))
+        .issuer_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, issuer_cn)]))
+        .public_key(key.public_key())
+        .serial_number(x509.random_serial_number())
+        .not_valid_before(now - timedelta(days=60 if expired else 1))
+        .not_valid_after(now - timedelta(days=1) if expired else now + timedelta(days=30))
+        .add_extension(x509.BasicConstraints(ca=ca, path_length=None), critical=True)
+        .add_extension(x509.SubjectAlternativeName([san]), critical=False)
+        # Python 3.13 verifies strictly, which wants key usage and both key identifiers.
+        .add_extension(
+            x509.KeyUsage(
+                digital_signature=True,
+                content_commitment=False,
+                key_encipherment=False,
+                data_encipherment=False,
+                key_agreement=False,
+                key_cert_sign=ca,
+                crl_sign=ca,
+                encipher_only=False,
+                decipher_only=False,
+            ),
+            critical=True,
+        )
+        .add_extension(x509.SubjectKeyIdentifier.from_public_key(key.public_key()), critical=False)
+        .add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(issuer_key.public_key()), critical=False)
+        .sign(issuer_key, hashes.SHA256())
+    )
+
+
+def _pem(path, cert, key=None):
+    data = cert.public_bytes(serialization.Encoding.PEM)
+    if key is not None:
+        data += key.private_bytes(
+            serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()
+        )
+    path.write_bytes(data)
+    return str(path)
+
+
+async def _https_server(certfile: str):
+    """An HA stand-in that answers /api/ over TLS with the given certificate."""
+    ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
+    ctx.load_cert_chain(certfile)
+
+    async def handle(reader, writer):
+        try:
+            await reader.readuntil(b"\r\n\r\n")
+            body = b'{"message": "API running."}'
+            writer.write(
+                b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: %d\r\n\r\n%s" % (len(body), body)
+            )
+            await writer.drain()
+        except (ConnectionError, asyncio.IncompleteReadError, ssl.SSLError):
+            pass
+        finally:
+            writer.close()
+
+    return await asyncio.start_server(handle, "127.0.0.1", 0, ssl=ctx)
+
+
+def _port(server) -> int:
+    return server.sockets[0].getsockname()[1]
+
+
+async def test_a_self_signed_certificate_is_named(tmp_path):
+    key = ec.generate_private_key(ec.SECP256R1())
+    cert = _cert("homeassistant", key, "homeassistant", key, x509.IPAddress(ipaddress.ip_address("127.0.0.1")), True)
+    server = await _https_server(_pem(tmp_path / "ha.pem", cert, key))
+    async with server:
+        result = await HomeAssistantService().test_connection(f"https://127.0.0.1:{_port(server)}", "token")
+
+    assert result["success"] is False
+    assert result["error"].startswith("Home Assistant's certificate is not trusted (self-signed certificate).")
+    assert "add it to Bambuddy's trust store" in result["error"]
+
+
+async def test_a_certificate_for_another_hostname_is_named(tmp_path):
+    """A trusted certificate reached by IP address, as with Let's Encrypt on a LAN."""
+    ca_key = ec.generate_private_key(ec.SECP256R1())
+    ca = _cert("Test CA", ca_key, "Test CA", ca_key, x509.DNSName("ca.test"), True)
+    leaf_key = ec.generate_private_key(ec.SECP256R1())
+    leaf = _cert("ha.example.org", leaf_key, "Test CA", ca_key, x509.DNSName("ha.example.org"), False)
+    server = await _https_server(_pem(tmp_path / "leaf.pem", leaf, leaf_key))
+    ca_file = _pem(tmp_path / "ca.pem", ca)
+    async with server:
+        # httpx builds its trust store from certifi.where().
+        with patch("certifi.where", return_value=ca_file):
+            trusted = await HomeAssistantService().test_connection(f"https://127.0.0.1:{_port(server)}", "token")
+
+    assert trusted["success"] is False
+    assert trusted["error"].startswith(
+        "Home Assistant's certificate is not trusted (IP address mismatch, certificate is not valid for '127.0.0.1')."
+    )
+    assert trusted["error"].endswith(
+        "Enter the hostname the certificate was issued for, not an IP address or another name."
+    )
+
+
+async def test_an_expired_certificate_is_named(tmp_path):
+    ca_key = ec.generate_private_key(ec.SECP256R1())
+    ca = _cert("Test CA", ca_key, "Test CA", ca_key, x509.DNSName("ca.test"), True)
+    leaf_key = ec.generate_private_key(ec.SECP256R1())
+    ip = x509.IPAddress(ipaddress.ip_address("127.0.0.1"))
+    leaf = _cert("homeassistant", leaf_key, "Test CA", ca_key, ip, False, expired=True)
+    server = await _https_server(_pem(tmp_path / "leaf.pem", leaf, leaf_key))
+    ca_file = _pem(tmp_path / "ca.pem", ca)
+    async with server:
+        with patch("certifi.where", return_value=ca_file):
+            result = await HomeAssistantService().test_connection(f"https://127.0.0.1:{_port(server)}", "token")
+
+    assert result["error"] == (
+        "Home Assistant's certificate is not trusted (certificate has expired). "
+        "Renew the certificate Home Assistant serves."
+    )
+
+
+async def test_https_to_a_plain_http_port_is_named():
+    async def handle(reader, writer):
+        await reader.read(1024)
+        writer.write(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n")
+        await writer.drain()
+        writer.close()
+
+    server = await asyncio.start_server(handle, "127.0.0.1", 0)
+    async with server:
+        result = await HomeAssistantService().test_connection(f"https://127.0.0.1:{_port(server)}", "token")
+
+    assert result["success"] is False
+    assert result["error"].startswith("The secure connection to Home Assistant failed (")
+    assert result["error"].endswith("Check that Home Assistant serves https at this address and port.")
+
+
+async def test_nothing_listening_still_says_could_not_connect():
+    with socket.socket() as s:
+        s.bind(("127.0.0.1", 0))
+        port = s.getsockname()[1]
+
+    result = await HomeAssistantService().test_connection(f"https://127.0.0.1:{port}", "token")
+
+    assert result == {"success": False, "message": None, "error": "Could not connect to Home Assistant"}