Skip to content

Commit 39b2be9

Browse files
committed
Fix Pi-hole v6 app password authentication
1 parent 967c374 commit 39b2be9

4 files changed

Lines changed: 196 additions & 52 deletions

File tree

homeassistant/components/pi_hole/__init__.py

Lines changed: 67 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
"""The pi_hole component."""
22

3+
import asyncio
34
import logging
45
from typing import Any, Literal
56

7+
import aiohttp
8+
from aiohttp import DummyCookieJar
69
from hole import Hole, HoleV5, HoleV6
710
from hole.exceptions import HoleConnectionError, HoleError
811

@@ -17,13 +20,18 @@
1720
)
1821
from homeassistant.core import HomeAssistant, callback
1922
from homeassistant.helpers import entity_registry as er
20-
from homeassistant.helpers.aiohttp_client import async_get_clientsession
23+
from homeassistant.helpers.aiohttp_client import (
24+
async_create_clientsession,
25+
async_get_clientsession,
26+
)
2127

2228
from .const import CONF_STATISTICS_ONLY, DOMAIN
2329
from .coordinator import PiHoleConfigEntry, PiHoleData, PiHoleUpdateCoordinator
2430

2531
_LOGGER = logging.getLogger(__name__)
2632

33+
DATA_V6_CLIENTSESSIONS = f"{DOMAIN}_v6_clientsessions"
34+
2735

2836
PLATFORMS = [
2937
Platform.BINARY_SENSOR,
@@ -110,7 +118,10 @@ def api_by_version(
110118

111119
if password is None:
112120
password = entry.get(CONF_API_KEY, "")
113-
session = async_get_clientsession(hass, entry[CONF_VERIFY_SSL])
121+
if version == 6:
122+
session = _async_get_v6_session(hass, entry[CONF_VERIFY_SSL])
123+
else:
124+
session = async_get_clientsession(hass, entry[CONF_VERIFY_SSL])
114125
hole_kwargs = {
115126
"host": entry[CONF_HOST],
116127
"session": session,
@@ -128,45 +139,72 @@ def api_by_version(
128139
return Hole(**hole_kwargs)
129140

130141

142+
@callback
143+
def _async_get_v6_session(
144+
hass: HomeAssistant, verify_ssl: bool
145+
) -> aiohttp.ClientSession:
146+
"""Get a session with an isolated cookie jar for the Pi-hole v6 API."""
147+
sessions: dict[bool, aiohttp.ClientSession] = hass.data.setdefault(
148+
DATA_V6_CLIENTSESSIONS, {}
149+
)
150+
if verify_ssl not in sessions:
151+
sessions[verify_ssl] = async_create_clientsession(
152+
hass, verify_ssl, cookie_jar=DummyCookieJar()
153+
)
154+
return sessions[verify_ssl]
155+
156+
157+
async def _async_is_v6_api(hass: HomeAssistant, entry: dict[str, Any]) -> bool:
158+
"""Check if the Pi-hole instance exposes the v6 API."""
159+
protocol = "https" if entry.get(CONF_SSL) else "http"
160+
session = _async_get_v6_session(hass, entry[CONF_VERIFY_SSL])
161+
url = f"{protocol}://{entry[CONF_HOST]}/api/info/version"
162+
163+
async with asyncio.timeout(5):
164+
async with session.get(url) as response:
165+
try:
166+
data: Any = await response.json()
167+
except aiohttp.ContentTypeError, ValueError:
168+
return False
169+
170+
if not isinstance(data, dict):
171+
return False
172+
173+
if response.status == 200:
174+
return isinstance(data.get("version"), dict)
175+
176+
if response.status == 401:
177+
error = data.get("error")
178+
return isinstance(error, dict) and error.get("key") == "unauthorized"
179+
180+
return False
181+
182+
131183
async def determine_api_version(
132184
hass: HomeAssistant, entry: dict[str, Any]
133185
) -> Literal[5, 6]:
134186
"""Determine the API version of the Pi-hole instance without requiring authentication.
135187
136-
Neither API v5 or v6 provides an endpoint to check the version without authentication.
137-
Version 6 provides other enddpoints that do not require authentication, so we can use those to determine the version
138-
version 5 returns an empty list in response to unauthenticated requests.
188+
Version 6 returns either version data or a distinct unauthorized error from
189+
/api/info/version, so we can use that endpoint to determine the version.
190+
Version 5 returns an empty list in response to unauthenticated requests.
139191
Because we are using endpoints that are not designed for this purpose, we should log liberally to help with debugging.
140192
"""
141193

142-
holeV6 = api_by_version(hass, entry, 6, password="wrong_password")
143194
try:
144-
await holeV6.authenticate()
145-
except HoleConnectionError as err:
146-
_LOGGER.error(
147-
"Unexpected error connecting to Pi-hole v6 API at %s: %s. Trying version 5 API",
148-
holeV6.base_url,
149-
err,
150-
)
151-
# Ideally python-hole would raise a specific exception for authentication failures
152-
except HoleError as ex_v6:
153-
if str(ex_v6) == "Authentication failed: Invalid password":
195+
if await _async_is_v6_api(hass, entry):
154196
_LOGGER.debug(
155-
"Success connecting to Pi-hole at %s without auth, API version is : %s",
156-
holeV6.base_url,
157-
6,
197+
"Response from v6 API without auth, Pi-hole API version 6 probably detected at %s",
198+
entry[CONF_HOST],
158199
)
159200
return 6
160-
_LOGGER.debug(
161-
"Connection to %s failed: %s, trying API version 5", holeV6.base_url, ex_v6
162-
)
163-
else:
164-
# It seems that occasionally the auth can succeed unexpectedly when there is a valid session
165-
_LOGGER.warning(
166-
"Authenticated with %s through v6 API, but succeeded with an incorrect password. This is a known bug",
167-
holeV6.base_url,
201+
except (TimeoutError, aiohttp.ClientError) as err:
202+
_LOGGER.error(
203+
"Unexpected error connecting to Pi-hole v6 API at %s: %s. Trying version 5 API",
204+
entry[CONF_HOST],
205+
err,
168206
)
169-
return 6
207+
170208
holeV5 = api_by_version(hass, entry, 5, password="wrong_token")
171209
try:
172210
await holeV5.get_data()
@@ -190,6 +228,6 @@ async def determine_api_version(
190228
)
191229
_LOGGER.debug(
192230
"Could not determine pi-hole API version at: %s",
193-
holeV6.base_url,
231+
entry[CONF_HOST],
194232
)
195233
raise HoleError("Could not determine Pi-hole API version")

tests/components/pi_hole/__init__.py

Lines changed: 49 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
"""Tests for the pi_hole component."""
22

3+
from collections.abc import Generator
4+
from contextlib import ExitStack, contextmanager
35
from typing import Any
46
from unittest.mock import AsyncMock, MagicMock, patch
57

8+
from aiohttp import DummyCookieJar
69
from hole.exceptions import HoleConnectionError, HoleError
710

811
from homeassistant.components.pi_hole.const import (
@@ -140,6 +143,8 @@
140143
LOCATION = "location"
141144
NAME = "Pi hole"
142145
API_KEY = "apikey"
146+
APP_PASSWORD = "app_password"
147+
VALID_V6_PASSWORDS = ("newkey", "apikey", APP_PASSWORD)
143148
API_VERSION = 6
144149
SSL = False
145150
VERIFY_SSL = True
@@ -206,6 +211,7 @@ def _create_mocked_hole(
206211
incorrect_app_password: bool = False,
207212
wrong_host: bool = False,
208213
ftl_error: bool = False,
214+
require_cookie_free_app_password: bool = False,
209215
) -> MagicMock:
210216
"""Return a mocked Hole API object with side effects based on constructor args."""
211217

@@ -221,17 +227,22 @@ async def authenticate_side_effect(*_args, **_kwargs):
221227
if wrong_host:
222228
raise HoleConnectionError("Cannot authenticate with Pi-hole: err")
223229
password = getattr(mocked_hole, "password", None)
230+
cookie_jar = getattr(
231+
getattr(mocked_hole, "session", None), "cookie_jar", None
232+
)
224233

225234
if (
226-
raise_exception
227-
or incorrect_app_password
228-
or api_version == 5
229-
or (api_version == 6 and password not in ["newkey", "apikey"])
235+
require_cookie_free_app_password
236+
and password == APP_PASSWORD
237+
and not isinstance(cookie_jar, DummyCookieJar)
238+
):
239+
raise HoleError("Authentication failed: Invalid password")
240+
241+
if api_version == 6 and (
242+
incorrect_app_password or password not in VALID_V6_PASSWORDS
230243
):
231-
if api_version == 6 and (
232-
incorrect_app_password or password not in ["newkey", "apikey"]
233-
):
234-
raise HoleError("Authentication failed: Invalid password")
244+
raise HoleError("Authentication failed: Invalid password")
245+
if raise_exception or incorrect_app_password or api_version == 5:
235246
raise HoleConnectionError
236247

237248
async def get_data_side_effect(*_args, **_kwargs):
@@ -244,10 +255,10 @@ async def get_data_side_effect(*_args, **_kwargs):
244255
raise_exception
245256
or incorrect_app_password
246257
or (api_version == 5 and (not api_token or api_token == "wrong_token"))
247-
or (api_version == 6 and password not in ["newkey", "apikey"])
258+
or (api_version == 6 and password not in VALID_V6_PASSWORDS)
248259
):
249260
mocked_hole.data = [] if api_version == 5 else {}
250-
elif password in ["newkey", "apikey"] or api_token in ["newkey", "apikey"]:
261+
elif password in VALID_V6_PASSWORDS or api_token in ("newkey", "apikey"):
251262
mocked_hole.data = ZERO_DATA_V6 if api_version == 6 else ZERO_DATA
252263

253264
async def ftl_side_effect():
@@ -256,10 +267,8 @@ async def ftl_side_effect():
256267
mocked_hole.authenticate = AsyncMock(side_effect=authenticate_side_effect)
257268
mocked_hole.get_data = AsyncMock(side_effect=get_data_side_effect)
258269

259-
if ftl_error:
260-
# two unauthenticated instances are created in `determine_api_version` before aync_try_connect is called
261-
if len(instances) > 1:
262-
mocked_hole.get_data = AsyncMock(side_effect=ftl_side_effect)
270+
if ftl_error and instances:
271+
mocked_hole.get_data = AsyncMock(side_effect=ftl_side_effect)
263272
mocked_hole.get_versions = AsyncMock(return_value=None)
264273
mocked_hole.enable = AsyncMock()
265274
mocked_hole.disable = AsyncMock()
@@ -293,28 +302,45 @@ async def ftl_side_effect():
293302
return mocked_hole
294303

295304
# Return a factory function for patching
305+
make_mock.api_version = api_version
296306
make_mock.instances = instances
307+
make_mock.wrong_host = wrong_host
297308
return make_mock
298309

299310

300-
def _patch_init_hole(mocked_hole):
301-
"""Patch the Hole class in the main integration."""
311+
@contextmanager
312+
def _patch_hole(mocked_hole: MagicMock, patch_target: str) -> Generator[MagicMock]:
313+
"""Patch the Hole class and API version detection."""
302314

303315
def side_effect(*args, **kwargs):
304316
return mocked_hole(**kwargs)
305317

306-
return patch("homeassistant.components.pi_hole.Hole", side_effect=side_effect)
318+
async def is_v6_api_side_effect(*_args, **_kwargs) -> bool:
319+
if mocked_hole.wrong_host:
320+
raise HoleConnectionError("Cannot fetch data from Pi-hole: err")
321+
return mocked_hole.api_version == 6
322+
323+
with ExitStack() as stack:
324+
patched_hole = stack.enter_context(patch(patch_target, side_effect=side_effect))
325+
stack.enter_context(
326+
patch(
327+
"homeassistant.components.pi_hole._async_is_v6_api",
328+
side_effect=is_v6_api_side_effect,
329+
)
330+
)
331+
yield patched_hole
332+
333+
334+
def _patch_init_hole(mocked_hole):
335+
"""Patch the Hole class in the main integration."""
336+
337+
return _patch_hole(mocked_hole, "homeassistant.components.pi_hole.Hole")
307338

308339

309340
def _patch_config_flow_hole(mocked_hole):
310341
"""Patch the Hole class in the config flow."""
311342

312-
def side_effect(*args, **kwargs):
313-
return mocked_hole(**kwargs)
314-
315-
return patch(
316-
"homeassistant.components.pi_hole.config_flow.Hole", side_effect=side_effect
317-
)
343+
return _patch_hole(mocked_hole, "homeassistant.components.pi_hole.config_flow.Hole")
318344

319345

320346
def _patch_setup_hole():

tests/components/pi_hole/test_config_flow.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from homeassistant.data_entry_flow import FlowResultType
99

1010
from . import (
11+
APP_PASSWORD,
1112
CONFIG_DATA_DEFAULTS,
1213
CONFIG_ENTRY_WITH_API_KEY,
1314
CONFIG_FLOW_USER,
@@ -67,6 +68,35 @@ async def test_flow_user_with_api_key_v6(hass: HomeAssistant) -> None:
6768
assert result["reason"] == "already_configured"
6869

6970

71+
async def test_flow_user_with_app_password_v6(hass: HomeAssistant) -> None:
72+
"""Test user initialized flow with a v6 app password."""
73+
mocked_hole = _create_mocked_hole(
74+
has_data=False, api_version=6, require_cookie_free_app_password=True
75+
)
76+
app_password_input = {**CONFIG_FLOW_USER, CONF_API_KEY: APP_PASSWORD}
77+
with (
78+
_patch_init_hole(mocked_hole),
79+
_patch_config_flow_hole(mocked_hole),
80+
_patch_setup_hole() as mock_setup,
81+
):
82+
result = await hass.config_entries.flow.async_init(
83+
DOMAIN,
84+
context={"source": SOURCE_USER},
85+
)
86+
87+
result = await hass.config_entries.flow.async_configure(
88+
result["flow_id"],
89+
user_input=app_password_input,
90+
)
91+
92+
assert result["type"] is FlowResultType.CREATE_ENTRY
93+
assert result["data"] == {
94+
**CONFIG_ENTRY_WITH_API_KEY,
95+
CONF_API_KEY: APP_PASSWORD,
96+
}
97+
mock_setup.assert_called_once()
98+
99+
70100
async def test_flow_user_with_api_key_v5(hass: HomeAssistant) -> None:
71101
"""Test user initialized flow with api key needed."""
72102
mocked_hole = _create_mocked_hole(api_version=5)

tests/components/pi_hole/test_init.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,56 @@
3535
)
3636

3737
from tests.common import MockConfigEntry
38+
from tests.test_util.aiohttp import AiohttpClientMocker
39+
40+
41+
async def test_determine_api_version_v6(
42+
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
43+
) -> None:
44+
"""Test detecting a Pi-hole v6 API without authentication."""
45+
aioclient_mock.get(
46+
"http://1.2.3.4:80/api/info/version",
47+
status=401,
48+
json={"error": {"key": "unauthorized", "message": "Unauthorized"}},
49+
)
50+
51+
assert await pi_hole.determine_api_version(hass, CONFIG_DATA_DEFAULTS) == 6
52+
53+
54+
async def test_determine_api_version_v6_without_authentication(
55+
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
56+
) -> None:
57+
"""Test detecting a Pi-hole v6 API with authentication disabled."""
58+
aioclient_mock.get(
59+
"http://1.2.3.4:80/api/info/version",
60+
status=200,
61+
json={"version": {"core": {"local": {"version": "v6.0.0"}}}},
62+
)
63+
64+
assert await pi_hole.determine_api_version(hass, CONFIG_DATA_DEFAULTS) == 6
65+
66+
67+
@pytest.mark.parametrize(
68+
("status", "response_json"),
69+
[
70+
(200, []),
71+
(500, {"error": {"key": "internal_error"}}),
72+
],
73+
)
74+
async def test_is_v6_api_with_unexpected_response(
75+
hass: HomeAssistant,
76+
aioclient_mock: AiohttpClientMocker,
77+
status: int,
78+
response_json: object,
79+
) -> None:
80+
"""Test ignoring unexpected responses from the Pi-hole v6 version API."""
81+
aioclient_mock.get(
82+
"http://1.2.3.4:80/api/info/version",
83+
status=status,
84+
json=response_json,
85+
)
86+
87+
assert not await pi_hole._async_is_v6_api(hass, CONFIG_DATA_DEFAULTS)
3888

3989

4090
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)