From 1fd78dfc4e04562ff7a31125d200878eb06147dc Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Mon, 30 Mar 2026 14:43:18 +0200 Subject: [PATCH] Fix Docker Hub registry auth for containerd image store (#6677) aiodocker derives ServerAddress for X-Registry-Auth by doing image.partition("/"). For Docker Hub images like "homeassistant/amd64-supervisor", this extracts "homeassistant" (the namespace) instead of "docker.io" (the registry). With the classic graphdriver image store, ServerAddress was never checked and credentials were sent regardless. With the containerd image store (default since Docker v29 / HAOS 15), the resolver compares ServerAddress against the actual registry host and silently drops credentials on mismatch, falling back to anonymous access. Fix by prefixing Docker Hub images with "docker.io/" when registry credentials are configured, so aiodocker sets ServerAddress correctly. Co-authored-by: Claude Opus 4.6 (1M context) --- supervisor/docker/interface.py | 31 ++++++++++++++++++------- tests/docker/test_credentials.py | 40 +++++++++++++++++++++----------- tests/docker/test_interface.py | 20 ++++++++++++---- 3 files changed, 64 insertions(+), 27 deletions(-) diff --git a/supervisor/docker/interface.py b/supervisor/docker/interface.py index 038b62e7c..c10d1a9d0 100644 --- a/supervisor/docker/interface.py +++ b/supervisor/docker/interface.py @@ -182,18 +182,31 @@ class DockerInterface(JobGroup, ABC): """Healthcheck of instance if it has one.""" return self.meta_config.get("Healthcheck") - def _get_credentials(self, image: str) -> dict: - """Return a dictionary with credentials for docker login.""" + def _get_credentials(self, image: str) -> tuple[dict, str]: + """Return credentials for docker login and the qualified image name. + + Returns a tuple of (credentials_dict, qualified_image) where the image + is prefixed with the registry when needed. This ensures aiodocker sets + the correct ServerAddress in the X-Registry-Auth header, which Docker's + containerd image store requires to match the actual registry host. + """ credentials = {} registry = self.sys_docker.config.get_registry_for_image(image) + qualified_image = image if registry: stored = self.sys_docker.config.registries[registry] credentials[ATTR_USERNAME] = stored[ATTR_USERNAME] credentials[ATTR_PASSWORD] = stored[ATTR_PASSWORD] - # Don't include registry for Docker Hub (both official and legacy) - if registry not in (DOCKER_HUB, DOCKER_HUB_LEGACY): - credentials[ATTR_REGISTRY] = registry + credentials[ATTR_REGISTRY] = registry + + # For Docker Hub images, the image name typically lacks a registry + # prefix (e.g. "homeassistant/foo" instead of "docker.io/homeassistant/foo"). + # aiodocker derives ServerAddress from image.partition("/"), so without + # the prefix it would use the namespace ("homeassistant") as ServerAddress, + # which Docker's containerd resolver rejects as a host mismatch. + if registry in (DOCKER_HUB, DOCKER_HUB_LEGACY): + qualified_image = f"{DOCKER_HUB}/{image}" _LOGGER.debug( "Logging in to %s as %s", @@ -201,7 +214,7 @@ class DockerInterface(JobGroup, ABC): stored[ATTR_USERNAME], ) - return credentials + return credentials, qualified_image @Job( name="docker_interface_install", @@ -288,15 +301,15 @@ class DockerInterface(JobGroup, ABC): _LOGGER.info("Downloading docker image %s with tag %s.", image, version) try: # Get credentials for private registries to pass to aiodocker - credentials = self._get_credentials(image) or None + credentials, pull_image_name = self._get_credentials(image) # Pull new image, passing credentials to aiodocker docker_image = await self.sys_docker.pull_image( current_job.uuid, - image, + pull_image_name, str(version), platform=platform, - auth=credentials, + auth=credentials or None, ) # Tag latest diff --git a/tests/docker/test_credentials.py b/tests/docker/test_credentials.py index 979ef9941..607dd75a3 100644 --- a/tests/docker/test_credentials.py +++ b/tests/docker/test_credentials.py @@ -51,10 +51,15 @@ def test_no_credentials(coresys: CoreSys, test_docker_interface: DockerInterface coresys.docker.config._data["registries"] = { DOCKER_HUB: {"username": "Spongebob Squarepants", "password": "Password1!"} } - assert not test_docker_interface._get_credentials("ghcr.io/homeassistant") - assert not test_docker_interface._get_credentials( + credentials, image = test_docker_interface._get_credentials("ghcr.io/homeassistant") + assert not credentials + assert image == "ghcr.io/homeassistant" + + credentials, image = test_docker_interface._get_credentials( "ghcr.io/homeassistant/amd64-supervisor" ) + assert not credentials + assert image == "ghcr.io/homeassistant/amd64-supervisor" def test_no_matching_credentials( @@ -64,29 +69,37 @@ def test_no_matching_credentials( coresys.docker.config._data["registries"] = { DOCKER_HUB: {"username": "Spongebob Squarepants", "password": "Password1!"} } - assert not test_docker_interface._get_credentials("ghcr.io/homeassistant") - assert not test_docker_interface._get_credentials( + credentials, image = test_docker_interface._get_credentials("ghcr.io/homeassistant") + assert not credentials + assert image == "ghcr.io/homeassistant" + + credentials, image = test_docker_interface._get_credentials( "ghcr.io/homeassistant/amd64-supervisor" ) + assert not credentials + assert image == "ghcr.io/homeassistant/amd64-supervisor" def test_matching_credentials(coresys: CoreSys, test_docker_interface: DockerInterface): - """Test no matching credentials.""" + """Test matching credentials.""" coresys.docker.config._data["registries"] = { "ghcr.io": {"username": "Octocat", "password": "Password1!"}, DOCKER_HUB: {"username": "Spongebob Squarepants", "password": "Password1!"}, } - credentials = test_docker_interface._get_credentials( + credentials, image = test_docker_interface._get_credentials( "ghcr.io/homeassistant/amd64-supervisor" ) assert credentials["registry"] == "ghcr.io" + assert image == "ghcr.io/homeassistant/amd64-supervisor" - credentials = test_docker_interface._get_credentials( + credentials, image = test_docker_interface._get_credentials( "homeassistant/amd64-supervisor" ) assert credentials["username"] == "Spongebob Squarepants" - assert "registry" not in credentials + assert credentials["registry"] == DOCKER_HUB + # Docker Hub images should be prefixed with docker.io/ for correct ServerAddress + assert image == f"{DOCKER_HUB}/homeassistant/amd64-supervisor" def test_legacy_docker_hub_credentials( @@ -97,12 +110,12 @@ def test_legacy_docker_hub_credentials( DOCKER_HUB_LEGACY: {"username": "LegacyUser", "password": "Password1!"}, } - credentials = test_docker_interface._get_credentials( + credentials, image = test_docker_interface._get_credentials( "homeassistant/amd64-supervisor" ) assert credentials["username"] == "LegacyUser" - # No registry should be included for Docker Hub - assert "registry" not in credentials + assert credentials["registry"] == DOCKER_HUB_LEGACY + assert image == f"{DOCKER_HUB}/homeassistant/amd64-supervisor" def test_docker_hub_preferred_over_legacy( @@ -114,9 +127,10 @@ def test_docker_hub_preferred_over_legacy( DOCKER_HUB_LEGACY: {"username": "LegacyUser", "password": "Password2!"}, } - credentials = test_docker_interface._get_credentials( + credentials, image = test_docker_interface._get_credentials( "homeassistant/amd64-supervisor" ) # docker.io should be preferred assert credentials["username"] == "NewUser" - assert "registry" not in credentials + assert credentials["registry"] == DOCKER_HUB + assert image == f"{DOCKER_HUB}/homeassistant/amd64-supervisor" diff --git a/tests/docker/test_interface.py b/tests/docker/test_interface.py index b355abea4..ab9fd5b3d 100644 --- a/tests/docker/test_interface.py +++ b/tests/docker/test_interface.py @@ -14,7 +14,7 @@ from supervisor.addons.manager import Addon from supervisor.const import BusEvent, CoreState, CpuArch from supervisor.coresys import CoreSys from supervisor.docker.const import ContainerState -from supervisor.docker.interface import DOCKER_HUB, DockerInterface +from supervisor.docker.interface import DOCKER_HUB, DOCKER_HUB_LEGACY, DockerInterface from supervisor.docker.manager import PullLogEntry, PullProgressDetail from supervisor.docker.monitor import DockerContainerStateEvent from supervisor.exceptions import ( @@ -105,12 +105,22 @@ async def test_private_registry_credentials_passed_to_pull( ) # Verify credentials were passed to aiodocker - expected_auth = {"username": "testuser", "password": "testpass"} - if registry_key != DOCKER_HUB: - expected_auth["registry"] = registry_key + expected_auth = { + "username": "testuser", + "password": "testpass", + "registry": registry_key, + } + + # For Docker Hub, image should be prefixed with docker.io/ so aiodocker + # sets the correct ServerAddress in X-Registry-Auth + expected_image = ( + f"{DOCKER_HUB}/{image}" + if registry_key in (DOCKER_HUB, DOCKER_HUB_LEGACY) + else image + ) coresys.docker.images.pull.assert_called_once_with( - image, + expected_image, tag="1.2.3", platform="linux/amd64", auth=expected_auth,