Skip to content

Commit cc72f94

Browse files
author
headdirt
committed
Fix Pi-hole v6 app password authentication
1 parent 967c374 commit cc72f94

4 files changed

Lines changed: 159 additions & 51 deletions

File tree

homeassistant/components/pi_hole/__init__.py

Lines changed: 53 additions & 28 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,7 +20,10 @@
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
@@ -110,7 +116,10 @@ def api_by_version(
110116

111117
if password is None:
112118
password = entry.get(CONF_API_KEY, "")
113-
session = async_get_clientsession(hass, entry[CONF_VERIFY_SSL])
119+
if version == 6:
120+
session = _async_create_v6_session(hass, entry[CONF_VERIFY_SSL])
121+
else:
122+
session = async_get_clientsession(hass, entry[CONF_VERIFY_SSL])
114123
hole_kwargs = {
115124
"host": entry[CONF_HOST],
116125
"session": session,
@@ -128,45 +137,61 @@ def api_by_version(
128137
return Hole(**hole_kwargs)
129138

130139

140+
@callback
141+
def _async_create_v6_session(
142+
hass: HomeAssistant, verify_ssl: bool
143+
) -> aiohttp.ClientSession:
144+
"""Create a session with an isolated cookie jar for the Pi-hole v6 API."""
145+
return async_create_clientsession(hass, verify_ssl, cookie_jar=DummyCookieJar())
146+
147+
148+
async def _async_is_v6_api(hass: HomeAssistant, entry: dict[str, Any]) -> bool:
149+
"""Check if the Pi-hole instance exposes the v6 API."""
150+
protocol = "https" if entry.get(CONF_SSL) else "http"
151+
session = _async_create_v6_session(hass, entry[CONF_VERIFY_SSL])
152+
url = f"{protocol}://{entry[CONF_HOST]}/api/info/version"
153+
154+
async with asyncio.timeout(5):
155+
async with session.get(url, ssl=entry[CONF_VERIFY_SSL]) as response:
156+
try:
157+
data = await response.json()
158+
except aiohttp.ContentTypeError, ValueError:
159+
return False
160+
161+
if response.status == 200:
162+
return isinstance(data.get("version"), dict)
163+
164+
if response.status == 401:
165+
return data.get("error", {}).get("key") == "unauthorized"
166+
167+
return False
168+
169+
131170
async def determine_api_version(
132171
hass: HomeAssistant, entry: dict[str, Any]
133172
) -> Literal[5, 6]:
134173
"""Determine the API version of the Pi-hole instance without requiring authentication.
135174
136175
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.
176+
Version 6 returns a distinct unauthorized error from its API endpoints, so we can use those to determine the version.
177+
Version 5 returns an empty list in response to unauthenticated requests.
139178
Because we are using endpoints that are not designed for this purpose, we should log liberally to help with debugging.
140179
"""
141180

142-
holeV6 = api_by_version(hass, entry, 6, password="wrong_password")
143181
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":
182+
if await _async_is_v6_api(hass, entry):
154183
_LOGGER.debug(
155-
"Success connecting to Pi-hole at %s without auth, API version is : %s",
156-
holeV6.base_url,
157-
6,
184+
"Response 'unauthorized' from API without auth, Pi-hole API version 6 probably detected at %s",
185+
entry[CONF_HOST],
158186
)
159187
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,
188+
except (TimeoutError, aiohttp.ClientError) as err:
189+
_LOGGER.error(
190+
"Unexpected error connecting to Pi-hole v6 API at %s: %s. Trying version 5 API",
191+
entry[CONF_HOST],
192+
err,
168193
)
169-
return 6
194+
170195
holeV5 = api_by_version(hass, entry, 5, password="wrong_token")
171196
try:
172197
await holeV5.get_data()
@@ -190,6 +215,6 @@ async def determine_api_version(
190215
)
191216
_LOGGER.debug(
192217
"Could not determine pi-hole API version at: %s",
193-
holeV6.base_url,
218+
entry[CONF_HOST],
194219
)
195220
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: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,33 @@
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
3865

3966

4067
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)