1
0
mirror of https://github.com/home-assistant/supervisor.git synced 2026-04-02 00:07:16 +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."""
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

View File

@@ -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"

View File

@@ -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,