1
0
mirror of https://github.com/home-assistant/core.git synced 2025-12-24 21:06:19 +00:00

Use basic auth in go2rtc (#157008)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Robert Resch
2025-11-23 13:39:14 +01:00
committed by GitHub
parent 4488fdd2d6
commit 80151b205d
5 changed files with 355 additions and 41 deletions

View File

@@ -4,9 +4,10 @@ from __future__ import annotations
from dataclasses import dataclass
import logging
from secrets import token_hex
import shutil
from aiohttp import ClientSession, UnixConnector
from aiohttp import BasicAuth, ClientSession, UnixConnector
from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError
from awesomeversion import AwesomeVersion
from go2rtc_client import Go2RtcRestClient
@@ -36,7 +37,12 @@ from homeassistant.components.camera import (
from homeassistant.components.default_config import DOMAIN as DEFAULT_CONFIG_DOMAIN
from homeassistant.components.stream import Orientation
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP
from homeassistant.const import (
CONF_PASSWORD,
CONF_URL,
CONF_USERNAME,
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers import (
@@ -44,7 +50,10 @@ from homeassistant.helpers import (
discovery_flow,
issue_registry as ir,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.aiohttp_client import (
async_create_clientsession,
async_get_clientsession,
)
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.package import is_docker_env
@@ -62,14 +71,43 @@ from .server import Server
_LOGGER = logging.getLogger(__name__)
_FFMPEG = "ffmpeg"
_AUTH = "auth"
def _validate_auth(config: dict) -> dict:
"""Validate that username and password are only set when a URL is configured or when debug UI is enabled."""
auth_exists = CONF_USERNAME in config
debug_ui_enabled = config.get(CONF_DEBUG_UI, False)
if debug_ui_enabled and not auth_exists:
raise vol.Invalid("Username and password must be set when debug_ui is true")
if auth_exists and CONF_URL not in config and not debug_ui_enabled:
raise vol.Invalid(
"Username and password can only be set when a URL is configured or debug_ui is true"
)
return config
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
vol.Exclusive(CONF_URL, DOMAIN, DEBUG_UI_URL_MESSAGE): cv.url,
vol.Exclusive(CONF_DEBUG_UI, DOMAIN, DEBUG_UI_URL_MESSAGE): cv.boolean,
}
DOMAIN: vol.All(
vol.Schema(
{
vol.Exclusive(CONF_URL, DOMAIN, DEBUG_UI_URL_MESSAGE): cv.url,
vol.Exclusive(
CONF_DEBUG_UI, DOMAIN, DEBUG_UI_URL_MESSAGE
): cv.boolean,
vol.Inclusive(CONF_USERNAME, _AUTH): vol.All(
cv.string, vol.Length(min=1)
),
vol.Inclusive(CONF_PASSWORD, _AUTH): vol.All(
cv.string, vol.Length(min=1)
),
}
),
_validate_auth,
)
},
extra=vol.ALLOW_EXTRA,
@@ -83,12 +121,19 @@ type Go2RtcConfigEntry = ConfigEntry[WebRTCProvider]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up WebRTC."""
url: str | None = None
username: str | None = None
password: str | None = None
if DOMAIN not in config and DEFAULT_CONFIG_DOMAIN not in config:
await _remove_go2rtc_entries(hass)
return True
domain_config = config.get(DOMAIN, {})
username = domain_config.get(CONF_USERNAME)
password = domain_config.get(CONF_PASSWORD)
if not (configured_by_user := DOMAIN in config) or not (
url := config[DOMAIN].get(CONF_URL)
url := domain_config.get(CONF_URL)
):
if not is_docker_env():
if not configured_by_user:
@@ -101,13 +146,26 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
_LOGGER.error("Could not find go2rtc docker binary")
return False
# Generate random credentials when not provided to secure the server
if not username or not password:
username = token_hex()
password = token_hex()
_LOGGER.debug("Generated random credentials for go2rtc server")
auth = BasicAuth(username, password)
# HA will manage the binary
session = ClientSession(connector=UnixConnector(path=HA_MANAGED_UNIX_SOCKET))
# Manually created session (not using the helper) needs to be closed manually
# See on_stop listener below
session = ClientSession(
connector=UnixConnector(path=HA_MANAGED_UNIX_SOCKET), auth=auth
)
server = Server(
hass,
binary,
session,
enable_ui=config.get(DOMAIN, {}).get(CONF_DEBUG_UI, False),
enable_ui=domain_config.get(CONF_DEBUG_UI, False),
username=username,
password=password,
)
try:
await server.start()
@@ -122,6 +180,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, on_stop)
url = HA_MANAGED_URL
elif username and password:
# Create session with BasicAuth if credentials are provided
auth = BasicAuth(username, password)
session = async_create_clientsession(hass, auth=auth)
else:
session = async_get_clientsession(hass)

View File

@@ -24,6 +24,7 @@ _RESPAWN_COOLDOWN = 1
# Default configuration for HA
# - Unix socket for secure local communication
# - Basic auth enabled, including local connections
# - HTTP API only enabled when UI is enabled
# - Enable rtsp for localhost only as ffmpeg needs it
# - Clear default ice servers
@@ -37,6 +38,9 @@ api:
listen: "{listen_config}"
unix_listen: "{unix_socket}"
allow_paths: {api_allow_paths}
local_auth: true
username: {username}
password: {password}
# ffmpeg needs the exec module
# Restrict execution to only ffmpeg binary
@@ -118,7 +122,7 @@ def _format_list_for_yaml(items: tuple[str, ...]) -> str:
return f"[{formatted_items}]"
def _create_temp_file(enable_ui: bool) -> str:
def _create_temp_file(enable_ui: bool, username: str, password: str) -> str:
"""Create temporary config file."""
app_modules: tuple[str, ...] = _APP_MODULES
api_paths: tuple[str, ...] = _API_ALLOW_PATHS
@@ -142,6 +146,8 @@ def _create_temp_file(enable_ui: bool) -> str:
unix_socket=HA_MANAGED_UNIX_SOCKET,
app_modules=_format_list_for_yaml(app_modules),
api_allow_paths=_format_list_for_yaml(api_paths),
username=username,
password=password,
).encode()
)
return file.name
@@ -157,15 +163,19 @@ class Server:
session: ClientSession,
*,
enable_ui: bool = False,
username: str,
password: str,
) -> None:
"""Initialize the server."""
self._hass = hass
self._binary = binary
self._session = session
self._enable_ui = enable_ui
self._username = username
self._password = password
self._log_buffer: deque[str] = deque(maxlen=_LOG_BUFFER_SIZE)
self._process: asyncio.subprocess.Process | None = None
self._startup_complete = asyncio.Event()
self._enable_ui = enable_ui
self._watchdog_task: asyncio.Task | None = None
self._watchdog_tasks: list[asyncio.Task] = []
@@ -180,7 +190,7 @@ class Server:
"""Start the server."""
_LOGGER.debug("Starting go2rtc server")
config_file = await self._hass.async_add_executor_job(
_create_temp_file, self._enable_ui
_create_temp_file, self._enable_ui, self._username, self._password
)
self._startup_complete.clear()

View File

@@ -1,20 +1,20 @@
# serializer version: 1
# name: test_server_run_success[False]
# name: test_server_run_success[False-d2a0b844f4cdbe773702176c47c9a675eb0c56a0779b8f880cdb3b492ed3b1c1-bc495d266a32e66ba69b9c72546e00101e04fb573f1bd08863fe4ad1aac02949]
_CallList([
_Call(
tuple(
b'# This file is managed by Home Assistant\n# Do not edit it manually\n\napp:\n modules: ["api","exec","ffmpeg","http","mjpeg","onvif","rtmp","rtsp","srtp","webrtc","ws"]\n\napi:\n listen: ""\n unix_listen: "/run/go2rtc.sock"\n allow_paths: ["/","/api","/api/frame.jpeg","/api/schemes","/api/streams","/api/webrtc","/api/ws"]\n\n# ffmpeg needs the exec module\n# Restrict execution to only ffmpeg binary\nexec:\n allow_paths:\n - ffmpeg\n\nrtsp:\n listen: "127.0.0.1:18554"\n\nwebrtc:\n listen: ":18555/tcp"\n ice_servers: []\n',
b'# This file is managed by Home Assistant\n# Do not edit it manually\n\napp:\n modules: ["api","exec","ffmpeg","http","mjpeg","onvif","rtmp","rtsp","srtp","webrtc","ws"]\n\napi:\n listen: ""\n unix_listen: "/run/go2rtc.sock"\n allow_paths: ["/","/api","/api/frame.jpeg","/api/schemes","/api/streams","/api/webrtc","/api/ws"]\n local_auth: true\n username: d2a0b844f4cdbe773702176c47c9a675eb0c56a0779b8f880cdb3b492ed3b1c1\n password: bc495d266a32e66ba69b9c72546e00101e04fb573f1bd08863fe4ad1aac02949\n\n# ffmpeg needs the exec module\n# Restrict execution to only ffmpeg binary\nexec:\n allow_paths:\n - ffmpeg\n\nrtsp:\n listen: "127.0.0.1:18554"\n\nwebrtc:\n listen: ":18555/tcp"\n ice_servers: []\n',
),
dict({
}),
),
])
# ---
# name: test_server_run_success[True]
# name: test_server_run_success[True-user-pass]
_CallList([
_Call(
tuple(
b'# This file is managed by Home Assistant\n# Do not edit it manually\n\napp:\n modules: ["api","exec","ffmpeg","http","mjpeg","onvif","rtmp","rtsp","srtp","webrtc","ws","debug"]\n\napi:\n listen: ":11984"\n unix_listen: "/run/go2rtc.sock"\n allow_paths: ["/","/api","/api/frame.jpeg","/api/schemes","/api/streams","/api/webrtc","/api/ws","/api/config","/api/log","/api/streams.dot"]\n\n# ffmpeg needs the exec module\n# Restrict execution to only ffmpeg binary\nexec:\n allow_paths:\n - ffmpeg\n\nrtsp:\n listen: "127.0.0.1:18554"\n\nwebrtc:\n listen: ":18555/tcp"\n ice_servers: []\n',
b'# This file is managed by Home Assistant\n# Do not edit it manually\n\napp:\n modules: ["api","exec","ffmpeg","http","mjpeg","onvif","rtmp","rtsp","srtp","webrtc","ws","debug"]\n\napi:\n listen: ":11984"\n unix_listen: "/run/go2rtc.sock"\n allow_paths: ["/","/api","/api/frame.jpeg","/api/schemes","/api/streams","/api/webrtc","/api/ws","/api/config","/api/log","/api/streams.dot"]\n local_auth: true\n username: user\n password: pass\n\n# ffmpeg needs the exec module\n# Restrict execution to only ffmpeg binary\nexec:\n allow_paths:\n - ffmpeg\n\nrtsp:\n listen: "127.0.0.1:18554"\n\nwebrtc:\n listen: ":18555/tcp"\n ice_servers: []\n',
),
dict({
}),

View File

@@ -5,7 +5,7 @@ import logging
from typing import NamedTuple
from unittest.mock import ANY, AsyncMock, Mock, patch
from aiohttp import UnixConnector
from aiohttp import BasicAuth, UnixConnector
from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError
from awesomeversion import AwesomeVersion
from go2rtc_client import Stream
@@ -44,7 +44,7 @@ from homeassistant.components.go2rtc.const import (
)
from homeassistant.components.stream import Orientation
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_URL
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME
from homeassistant.core import EVENT_HOMEASSISTANT_STOP
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import issue_registry as ir
@@ -192,12 +192,40 @@ async def _test_setup_and_signaling(
"mock_go2rtc_entry",
)
@pytest.mark.parametrize(
("config", "ui_enabled"),
("config", "ui_enabled", "expected_username", "expected_password"),
[
({DOMAIN: {}}, False),
({DOMAIN: {CONF_DEBUG_UI: True}}, True),
({DEFAULT_CONFIG_DOMAIN: {}}, False),
({DEFAULT_CONFIG_DOMAIN: {}, DOMAIN: {CONF_DEBUG_UI: True}}, True),
({DOMAIN: {}}, False, "mock_username_token", "mock_password_token"),
(
{
DOMAIN: {
CONF_DEBUG_UI: True,
CONF_USERNAME: "test_username",
CONF_PASSWORD: "test",
}
},
True,
"test_username",
"test",
),
(
{DEFAULT_CONFIG_DOMAIN: {}},
False,
"mock_username_token",
"mock_password_token",
),
(
{
DEFAULT_CONFIG_DOMAIN: {},
DOMAIN: {
CONF_DEBUG_UI: True,
CONF_USERNAME: "test_username",
CONF_PASSWORD: "test",
},
},
True,
"test_username",
"test",
),
],
)
@pytest.mark.parametrize("has_go2rtc_entry", [True, False])
@@ -213,25 +241,39 @@ async def test_setup_go_binary(
has_go2rtc_entry: bool,
config: ConfigType,
ui_enabled: bool,
expected_username: str,
expected_password: str,
) -> None:
"""Test the go2rtc config entry with binary."""
assert (len(hass.config_entries.async_entries(DOMAIN)) == 1) == has_go2rtc_entry
def after_setup() -> None:
server.assert_called_once_with(
hass, "/usr/bin/go2rtc", ANY, enable_ui=ui_enabled
hass,
"/usr/bin/go2rtc",
ANY,
enable_ui=ui_enabled,
username=expected_username,
password=expected_password,
)
call_kwargs = server.call_args[1]
assert call_kwargs["username"] == expected_username
assert call_kwargs["password"] == expected_password
server_start.assert_called_once()
await _test_setup_and_signaling(
hass,
issue_registry,
rest_client,
ws_client,
config,
after_setup,
init_test_integration,
)
with patch("homeassistant.components.go2rtc.token_hex") as mock_token_hex:
# First call for username, second call for password
mock_token_hex.side_effect = ["mock_username_token", "mock_password_token"]
await _test_setup_and_signaling(
hass,
issue_registry,
rest_client,
ws_client,
config,
after_setup,
init_test_integration,
)
await hass.async_stop()
server_stop.assert_called_once()
@@ -424,6 +466,25 @@ ERR_UNSUPPORTED_VERSION = "The go2rtc server version is not supported"
_INVALID_CONFIG = "Invalid config for 'go2rtc': "
ERR_INVALID_URL = _INVALID_CONFIG + "invalid url"
ERR_EXCLUSIVE = _INVALID_CONFIG + DEBUG_UI_URL_MESSAGE
ERR_AUTH_WITHOUT_URL_OR_UI = (
_INVALID_CONFIG
+ "Username and password can only be set when a URL is configured or debug_ui is true"
)
ERR_AUTH_INCOMPLETE = (
_INVALID_CONFIG
+ "some but not all values in the same group of inclusion 'auth' 'go2rtc-><auth>',"
)
ERR_AUTH_REQUIRED_WITH_DEBUG_UI = (
_INVALID_CONFIG + "Username and password must be set when debug_ui is true"
)
ERR_USERNAME_EMPTY = (
_INVALID_CONFIG
+ "length of value must be at least 1 for dictionary value 'go2rtc->username'"
)
ERR_PASSWORD_EMPTY = (
_INVALID_CONFIG
+ "length of value must be at least 1 for dictionary value 'go2rtc->password'"
)
ERR_URL_REQUIRED = "Go2rtc URL required in non-docker installs"
@@ -464,6 +525,60 @@ async def test_non_user_setup_with_error(
True,
ERR_EXCLUSIVE,
),
(
{DOMAIN: {CONF_USERNAME: "test_user", CONF_PASSWORD: "test_pass"}},
"/usr/bin/go2rtc",
True,
ERR_AUTH_WITHOUT_URL_OR_UI,
),
(
{DOMAIN: {CONF_URL: "http://localhost:1984", CONF_USERNAME: "test_user"}},
None,
True,
ERR_AUTH_INCOMPLETE,
),
(
{DOMAIN: {CONF_URL: "http://localhost:1984", CONF_PASSWORD: "test_pass"}},
None,
True,
ERR_AUTH_INCOMPLETE,
),
(
{DOMAIN: {CONF_DEBUG_UI: True, CONF_USERNAME: "test_user"}},
"/usr/bin/go2rtc",
True,
ERR_AUTH_INCOMPLETE,
),
(
{DOMAIN: {CONF_DEBUG_UI: True, CONF_PASSWORD: "test_pass"}},
"/usr/bin/go2rtc",
True,
ERR_AUTH_INCOMPLETE,
),
(
{DOMAIN: {CONF_DEBUG_UI: True}},
"/usr/bin/go2rtc",
True,
ERR_AUTH_REQUIRED_WITH_DEBUG_UI,
),
(
{DOMAIN: {CONF_DEBUG_UI: True, CONF_USERNAME: "", CONF_PASSWORD: ""}},
"/usr/bin/go2rtc",
True,
ERR_USERNAME_EMPTY,
),
(
{
DOMAIN: {
CONF_DEBUG_UI: True,
CONF_USERNAME: "username",
CONF_PASSWORD: "",
}
},
"/usr/bin/go2rtc",
True,
ERR_PASSWORD_EMPTY,
),
],
)
@pytest.mark.parametrize("has_go2rtc_entry", [True, False])
@@ -923,20 +1038,32 @@ async def test_unix_socket_connection(hass: HomeAssistant) -> None:
"""Test Unix socket is used for HA-managed go2rtc instances."""
config = {DOMAIN: {}}
with patch("homeassistant.components.go2rtc.ClientSession") as mock_session_cls:
with (
patch("homeassistant.components.go2rtc.ClientSession") as mock_session_cls,
patch("homeassistant.components.go2rtc.token_hex") as mock_token_hex,
):
mock_session = AsyncMock()
mock_session_cls.return_value = mock_session
# First call for username, second call for password
mock_token_hex.side_effect = ["mock_username_token", "mock_password_token"]
assert await async_setup_component(hass, DOMAIN, config)
await hass.async_block_till_done(wait_background_tasks=True)
# Verify ClientSession was created with UnixConnector
# Verify ClientSession was created with UnixConnector and auth
mock_session_cls.assert_called_once()
call_kwargs = mock_session_cls.call_args[1]
assert "connector" in call_kwargs
connector = call_kwargs["connector"]
assert isinstance(connector, UnixConnector)
assert connector.path == HA_MANAGED_UNIX_SOCKET
# Auth should be auto-generated when credentials are not explicitly configured
assert "auth" in call_kwargs
auth = call_kwargs["auth"]
assert isinstance(auth, BasicAuth)
# Verify auto-generated credentials match our mocked values
assert auth.login == "mock_username_token"
assert auth.password == "mock_password_token"
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
await hass.async_block_till_done()
@@ -960,3 +1087,86 @@ async def test_unix_socket_not_used_for_custom_server(hass: HomeAssistant) -> No
# Verify standard clientsession was used, not UnixConnector
mock_get_session.assert_called_once_with(hass)
@pytest.mark.usefixtures("rest_client", "server")
async def test_basic_auth_with_custom_url(hass: HomeAssistant) -> None:
"""Test BasicAuth session is created when username and password are provided with custom URL."""
config = {
DOMAIN: {
CONF_URL: "http://localhost:1984/",
CONF_USERNAME: "test_user",
CONF_PASSWORD: "test_pass",
}
}
with patch(
"homeassistant.components.go2rtc.async_create_clientsession"
) as mock_create_session:
mock_session = AsyncMock()
mock_create_session.return_value = mock_session
assert await async_setup_component(hass, DOMAIN, config)
await hass.async_block_till_done(wait_background_tasks=True)
# Verify async_create_clientsession was called with BasicAuth
mock_create_session.assert_called_once()
call_kwargs = mock_create_session.call_args[1]
assert "auth" in call_kwargs
auth = call_kwargs["auth"]
assert isinstance(auth, BasicAuth)
assert auth.login == "test_user"
assert auth.password == "test_pass"
@pytest.mark.usefixtures("rest_client")
async def test_basic_auth_with_debug_ui(hass: HomeAssistant) -> None:
"""Test BasicAuth session is created when username and password are provided with debug_ui."""
config = {
DOMAIN: {
CONF_DEBUG_UI: True,
CONF_USERNAME: "test_user",
CONF_PASSWORD: "test_pass",
}
}
with (
patch(
"homeassistant.components.go2rtc.Server",
autospec=True,
) as mock_server_cls,
patch("homeassistant.components.go2rtc.ClientSession") as mock_session_cls,
patch("homeassistant.components.go2rtc.is_docker_env", return_value=True),
patch(
"homeassistant.components.go2rtc.shutil.which",
return_value="/usr/bin/go2rtc",
),
):
mock_session = AsyncMock()
mock_session_cls.return_value = mock_session
# Configure the Server mock instance
mock_server_instance = AsyncMock()
mock_server_cls.return_value = mock_server_instance
assert await async_setup_component(hass, DOMAIN, config)
await hass.async_block_till_done(wait_background_tasks=True)
# Verify ClientSession was created with BasicAuth and UnixConnector
mock_session_cls.assert_called_once()
call_kwargs = mock_session_cls.call_args[1]
assert "connector" in call_kwargs
connector = call_kwargs["connector"]
assert isinstance(connector, UnixConnector)
assert connector.path == HA_MANAGED_UNIX_SOCKET
assert "auth" in call_kwargs
auth = call_kwargs["auth"]
assert isinstance(auth, BasicAuth)
assert auth.login == "test_user"
assert auth.password == "test_pass"
# Verify Server was called with username and password
mock_server_cls.assert_called_once()
call_kwargs = mock_server_cls.call_args[1]
assert call_kwargs["username"] == "test_user"
assert call_kwargs["password"] == "test_pass"

View File

@@ -22,6 +22,18 @@ def enable_ui() -> bool:
return False
@pytest.fixture
def username() -> str:
"""Fixture to provide a username."""
return "user"
@pytest.fixture
def password() -> str:
"""Fixture to provide a password."""
return "pass"
@pytest.fixture
def mock_session() -> AsyncMock:
"""Fixture to provide a mock ClientSession."""
@@ -29,9 +41,22 @@ def mock_session() -> AsyncMock:
@pytest.fixture
def server(hass: HomeAssistant, mock_session: AsyncMock, enable_ui: bool) -> Server:
def server(
hass: HomeAssistant,
mock_session: AsyncMock,
enable_ui: bool,
username: str,
password: str,
) -> Server:
"""Fixture to initialize the Server."""
return Server(hass, binary=TEST_BINARY, session=mock_session, enable_ui=enable_ui)
return Server(
hass,
binary=TEST_BINARY,
session=mock_session,
enable_ui=enable_ui,
username=username,
password=password,
)
@pytest.fixture
@@ -82,8 +107,15 @@ def assert_server_output_not_logged(
@pytest.mark.parametrize(
"enable_ui",
[True, False],
("enable_ui", "username", "password"),
[
(True, "user", "pass"),
(
False,
"d2a0b844f4cdbe773702176c47c9a675eb0c56a0779b8f880cdb3b492ed3b1c1",
"bc495d266a32e66ba69b9c72546e00101e04fb573f1bd08863fe4ad1aac02949",
),
],
)
@pytest.mark.usefixtures("rest_client")
async def test_server_run_success(