1
0
mirror of https://github.com/home-assistant/supervisor.git synced 2026-04-17 23:33:35 +01:00

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) <noreply@anthropic.com>
This commit is contained in:
Stefan Agner
2026-03-30 14:43:18 +02:00
committed by GitHub
parent 6b41fd4112
commit 1fd78dfc4e
3 changed files with 64 additions and 27 deletions

View File

@@ -182,18 +182,31 @@ class DockerInterface(JobGroup, ABC):
"""Healthcheck of instance if it has one.""" """Healthcheck of instance if it has one."""
return self.meta_config.get("Healthcheck") return self.meta_config.get("Healthcheck")
def _get_credentials(self, image: str) -> dict: def _get_credentials(self, image: str) -> tuple[dict, str]:
"""Return a dictionary with credentials for docker login.""" """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 = {} credentials = {}
registry = self.sys_docker.config.get_registry_for_image(image) registry = self.sys_docker.config.get_registry_for_image(image)
qualified_image = image
if registry: if registry:
stored = self.sys_docker.config.registries[registry] stored = self.sys_docker.config.registries[registry]
credentials[ATTR_USERNAME] = stored[ATTR_USERNAME] credentials[ATTR_USERNAME] = stored[ATTR_USERNAME]
credentials[ATTR_PASSWORD] = stored[ATTR_PASSWORD] credentials[ATTR_PASSWORD] = stored[ATTR_PASSWORD]
# Don't include registry for Docker Hub (both official and legacy) credentials[ATTR_REGISTRY] = registry
if registry not in (DOCKER_HUB, DOCKER_HUB_LEGACY):
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( _LOGGER.debug(
"Logging in to %s as %s", "Logging in to %s as %s",
@@ -201,7 +214,7 @@ class DockerInterface(JobGroup, ABC):
stored[ATTR_USERNAME], stored[ATTR_USERNAME],
) )
return credentials return credentials, qualified_image
@Job( @Job(
name="docker_interface_install", name="docker_interface_install",
@@ -288,15 +301,15 @@ class DockerInterface(JobGroup, ABC):
_LOGGER.info("Downloading docker image %s with tag %s.", image, version) _LOGGER.info("Downloading docker image %s with tag %s.", image, version)
try: try:
# Get credentials for private registries to pass to aiodocker # 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 # Pull new image, passing credentials to aiodocker
docker_image = await self.sys_docker.pull_image( docker_image = await self.sys_docker.pull_image(
current_job.uuid, current_job.uuid,
image, pull_image_name,
str(version), str(version),
platform=platform, platform=platform,
auth=credentials, auth=credentials or None,
) )
# Tag latest # Tag latest

View File

@@ -51,10 +51,15 @@ def test_no_credentials(coresys: CoreSys, test_docker_interface: DockerInterface
coresys.docker.config._data["registries"] = { coresys.docker.config._data["registries"] = {
DOCKER_HUB: {"username": "Spongebob Squarepants", "password": "Password1!"} DOCKER_HUB: {"username": "Spongebob Squarepants", "password": "Password1!"}
} }
assert not test_docker_interface._get_credentials("ghcr.io/homeassistant") credentials, image = test_docker_interface._get_credentials("ghcr.io/homeassistant")
assert not test_docker_interface._get_credentials( assert not credentials
assert image == "ghcr.io/homeassistant"
credentials, image = test_docker_interface._get_credentials(
"ghcr.io/homeassistant/amd64-supervisor" "ghcr.io/homeassistant/amd64-supervisor"
) )
assert not credentials
assert image == "ghcr.io/homeassistant/amd64-supervisor"
def test_no_matching_credentials( def test_no_matching_credentials(
@@ -64,29 +69,37 @@ def test_no_matching_credentials(
coresys.docker.config._data["registries"] = { coresys.docker.config._data["registries"] = {
DOCKER_HUB: {"username": "Spongebob Squarepants", "password": "Password1!"} DOCKER_HUB: {"username": "Spongebob Squarepants", "password": "Password1!"}
} }
assert not test_docker_interface._get_credentials("ghcr.io/homeassistant") credentials, image = test_docker_interface._get_credentials("ghcr.io/homeassistant")
assert not test_docker_interface._get_credentials( assert not credentials
assert image == "ghcr.io/homeassistant"
credentials, image = test_docker_interface._get_credentials(
"ghcr.io/homeassistant/amd64-supervisor" "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): def test_matching_credentials(coresys: CoreSys, test_docker_interface: DockerInterface):
"""Test no matching credentials.""" """Test matching credentials."""
coresys.docker.config._data["registries"] = { coresys.docker.config._data["registries"] = {
"ghcr.io": {"username": "Octocat", "password": "Password1!"}, "ghcr.io": {"username": "Octocat", "password": "Password1!"},
DOCKER_HUB: {"username": "Spongebob Squarepants", "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" "ghcr.io/homeassistant/amd64-supervisor"
) )
assert credentials["registry"] == "ghcr.io" 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" "homeassistant/amd64-supervisor"
) )
assert credentials["username"] == "Spongebob Squarepants" 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( def test_legacy_docker_hub_credentials(
@@ -97,12 +110,12 @@ def test_legacy_docker_hub_credentials(
DOCKER_HUB_LEGACY: {"username": "LegacyUser", "password": "Password1!"}, DOCKER_HUB_LEGACY: {"username": "LegacyUser", "password": "Password1!"},
} }
credentials = test_docker_interface._get_credentials( credentials, image = test_docker_interface._get_credentials(
"homeassistant/amd64-supervisor" "homeassistant/amd64-supervisor"
) )
assert credentials["username"] == "LegacyUser" assert credentials["username"] == "LegacyUser"
# No registry should be included for Docker Hub assert credentials["registry"] == DOCKER_HUB_LEGACY
assert "registry" not in credentials assert image == f"{DOCKER_HUB}/homeassistant/amd64-supervisor"
def test_docker_hub_preferred_over_legacy( 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!"}, DOCKER_HUB_LEGACY: {"username": "LegacyUser", "password": "Password2!"},
} }
credentials = test_docker_interface._get_credentials( credentials, image = test_docker_interface._get_credentials(
"homeassistant/amd64-supervisor" "homeassistant/amd64-supervisor"
) )
# docker.io should be preferred # docker.io should be preferred
assert credentials["username"] == "NewUser" assert credentials["username"] == "NewUser"
assert "registry" not in credentials assert credentials["registry"] == DOCKER_HUB
assert image == f"{DOCKER_HUB}/homeassistant/amd64-supervisor"

View File

@@ -14,7 +14,7 @@ from supervisor.addons.manager import Addon
from supervisor.const import BusEvent, CoreState, CpuArch from supervisor.const import BusEvent, CoreState, CpuArch
from supervisor.coresys import CoreSys from supervisor.coresys import CoreSys
from supervisor.docker.const import ContainerState 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.manager import PullLogEntry, PullProgressDetail
from supervisor.docker.monitor import DockerContainerStateEvent from supervisor.docker.monitor import DockerContainerStateEvent
from supervisor.exceptions import ( from supervisor.exceptions import (
@@ -105,12 +105,22 @@ async def test_private_registry_credentials_passed_to_pull(
) )
# Verify credentials were passed to aiodocker # Verify credentials were passed to aiodocker
expected_auth = {"username": "testuser", "password": "testpass"} expected_auth = {
if registry_key != DOCKER_HUB: "username": "testuser",
expected_auth["registry"] = registry_key "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( coresys.docker.images.pull.assert_called_once_with(
image, expected_image,
tag="1.2.3", tag="1.2.3",
platform="linux/amd64", platform="linux/amd64",
auth=expected_auth, auth=expected_auth,