diff --git a/supervisor/addons/build.py b/supervisor/addons/build.py index ddc2a002e..ad39929e8 100644 --- a/supervisor/addons/build.py +++ b/supervisor/addons/build.py @@ -22,7 +22,7 @@ from ..const import ( SOCKET_DOCKER, ) from ..coresys import CoreSys, CoreSysAttributes -from ..docker.const import DOCKER_HUB +from ..docker.const import DOCKER_HUB, DOCKER_HUB_LEGACY from ..docker.interface import MAP_ARCH from ..exceptions import ConfigurationFileError, HassioArchNotFound from ..utils.common import FileConfiguration, find_one_filetype @@ -154,8 +154,11 @@ class AddonBuild(FileConfiguration, CoreSysAttributes): # Use the actual registry URL for the key # Docker Hub uses "https://index.docker.io/v1/" as the key + # Support both docker.io (official) and hub.docker.com (legacy) registry_key = ( - "https://index.docker.io/v1/" if registry == DOCKER_HUB else registry + "https://index.docker.io/v1/" + if registry in (DOCKER_HUB, DOCKER_HUB_LEGACY) + else registry ) config = {"auths": {registry_key: {"auth": auth_string}}} diff --git a/supervisor/docker/const.py b/supervisor/docker/const.py index a13fbb22f..ae85a47c6 100644 --- a/supervisor/docker/const.py +++ b/supervisor/docker/const.py @@ -15,11 +15,64 @@ from ..const import MACHINE_ID RE_RETRYING_DOWNLOAD_STATUS = re.compile(r"Retrying in \d+ seconds?") -# Docker Hub registry identifier -DOCKER_HUB = "hub.docker.com" +# Docker Hub registry identifier (official default) +# Docker's default registry is docker.io +DOCKER_HUB = "docker.io" -# Regex to match images with a registry host (e.g., ghcr.io/org/image) -IMAGE_WITH_HOST = re.compile(r"^((?:[a-z0-9]+(?:-[a-z0-9]+)*\.)+[a-z]{2,})\/.+") +# Legacy Docker Hub identifier for backward compatibility +DOCKER_HUB_LEGACY = "hub.docker.com" + +# Docker image reference domain regex +# Based on Docker's reference implementation: +# vendor/github.com/distribution/reference/normalize.go +# +# A domain is detected if the part before the first / contains: +# - "localhost" (with optional port) +# - Contains "." (like registry.example.com or 127.0.0.1) +# - Contains ":" (like myregistry:5000) +# - IPv6 addresses in brackets (like [::1]:5000) +# +# Note: Docker also treats uppercase letters as domain indicators since +# namespaces must be lowercase, but this regex handles lowercase matching +# and the get_domain() function validates the domain rules. +IMAGE_DOMAIN_REGEX = re.compile( + r"^(" + r"localhost(?::[0-9]+)?|" # localhost with optional port + r"(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])" # domain component + r"(?:\.(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))*" # more components + r"(?::[0-9]+)?|" # optional port + r"\[[a-fA-F0-9:]+\](?::[0-9]+)?" # IPv6 with optional port + r")/" # must be followed by / +) + + +def get_domain(image_ref: str) -> str | None: + """Extract domain from Docker image reference. + + Returns the registry domain if the image reference contains one, + or None if the image uses Docker Hub (docker.io). + + Based on Docker's reference implementation: + vendor/github.com/distribution/reference/normalize.go + + Examples: + get_domain("nginx") -> None (docker.io) + get_domain("library/nginx") -> None (docker.io) + get_domain("myregistry.com/nginx") -> "myregistry.com" + get_domain("localhost/myimage") -> "localhost" + get_domain("localhost:5000/myimage") -> "localhost:5000" + get_domain("registry.io:5000/org/app:v1") -> "registry.io:5000" + get_domain("[::1]:5000/myimage") -> "[::1]:5000" + + """ + match = IMAGE_DOMAIN_REGEX.match(image_ref) + if match: + domain = match.group(1) + # Must contain '.' or ':' or be 'localhost' to be a real domain + # This prevents treating "myuser/myimage" as having domain "myuser" + if "." in domain or ":" in domain or domain == "localhost": + return domain + return None # No domain = Docker Hub (docker.io) class Capabilities(StrEnum): diff --git a/supervisor/docker/interface.py b/supervisor/docker/interface.py index 31b226220..64c4c5472 100644 --- a/supervisor/docker/interface.py +++ b/supervisor/docker/interface.py @@ -45,7 +45,13 @@ from ..jobs.decorator import Job from ..jobs.job_group import JobGroup from ..resolution.const import ContextType, IssueType, SuggestionType from ..utils.sentry import async_capture_exception -from .const import DOCKER_HUB, ContainerState, PullImageLayerStage, RestartPolicy +from .const import ( + DOCKER_HUB, + DOCKER_HUB_LEGACY, + ContainerState, + PullImageLayerStage, + RestartPolicy, +) from .manager import CommandReturn, PullLogEntry from .monitor import DockerContainerStateEvent from .stats import DockerStats @@ -184,7 +190,8 @@ class DockerInterface(JobGroup, ABC): stored = self.sys_docker.config.registries[registry] credentials[ATTR_USERNAME] = stored[ATTR_USERNAME] credentials[ATTR_PASSWORD] = stored[ATTR_PASSWORD] - if registry != DOCKER_HUB: + # Don't include registry for Docker Hub (both official and legacy) + if registry not in (DOCKER_HUB, DOCKER_HUB_LEGACY): credentials[ATTR_REGISTRY] = registry _LOGGER.debug( diff --git a/supervisor/docker/manager.py b/supervisor/docker/manager.py index 66ca04b20..158fc44ae 100644 --- a/supervisor/docker/manager.py +++ b/supervisor/docker/manager.py @@ -49,7 +49,7 @@ from ..exceptions import ( ) from ..utils.common import FileConfiguration from ..validate import SCHEMA_DOCKER_CONFIG -from .const import DOCKER_HUB, IMAGE_WITH_HOST, LABEL_MANAGED +from .const import DOCKER_HUB, DOCKER_HUB_LEGACY, LABEL_MANAGED, get_domain from .monitor import DockerMonitor from .network import DockerNetwork @@ -207,19 +207,25 @@ class DockerConfig(FileConfiguration): Matches the image against configured registries and returns the registry name if found, or None if no matching credentials are configured. + + Uses Docker's domain detection logic from: + vendor/github.com/distribution/reference/normalize.go """ if not self.registries: return None # Check if image uses a custom registry (e.g., ghcr.io/org/image) - matcher = IMAGE_WITH_HOST.match(image) - if matcher: - registry = matcher.group(1) - if registry in self.registries: - return registry - # If no registry prefix, check for Docker Hub credentials - elif DOCKER_HUB in self.registries: - return DOCKER_HUB + domain = get_domain(image) + if domain: + if domain in self.registries: + return domain + else: + # No domain prefix means Docker Hub + # Support both docker.io (official) and hub.docker.com (legacy) + if DOCKER_HUB in self.registries: + return DOCKER_HUB + if DOCKER_HUB_LEGACY in self.registries: + return DOCKER_HUB_LEGACY return None diff --git a/tests/docker/test_credentials.py b/tests/docker/test_credentials.py index 2a1ec8519..fa4f7e08b 100644 --- a/tests/docker/test_credentials.py +++ b/tests/docker/test_credentials.py @@ -1,11 +1,50 @@ """Test docker login.""" +import pytest + # pylint: disable=protected-access from supervisor.coresys import CoreSys -from supervisor.docker.const import DOCKER_HUB +from supervisor.docker.const import DOCKER_HUB, DOCKER_HUB_LEGACY, get_domain from supervisor.docker.interface import DockerInterface +@pytest.mark.parametrize( + ("image_ref", "expected_domain"), + [ + # No domain - Docker Hub images + ("nginx", None), + ("nginx:latest", None), + ("library/nginx", None), + ("library/nginx:latest", None), + ("homeassistant/amd64-supervisor", None), + ("homeassistant/amd64-supervisor:1.2.3", None), + # Domain with dot + ("ghcr.io/homeassistant/amd64-supervisor", "ghcr.io"), + ("ghcr.io/homeassistant/amd64-supervisor:latest", "ghcr.io"), + ("myregistry.com/nginx", "myregistry.com"), + ("registry.example.com/org/image:v1", "registry.example.com"), + ("127.0.0.1/myimage", "127.0.0.1"), + # Domain with port + ("myregistry:5000/myimage", "myregistry:5000"), + ("localhost:5000/myimage", "localhost:5000"), + ("registry.io:5000/org/app:v1", "registry.io:5000"), + # localhost special case + ("localhost/myimage", "localhost"), + ("localhost/myimage:tag", "localhost"), + # IPv6 + ("[::1]:5000/myimage", "[::1]:5000"), + ("[2001:db8::1]:5000/myimage:tag", "[2001:db8::1]:5000"), + ], +) +def test_get_domain(image_ref: str, expected_domain: str | None): + """Test get_domain extracts registry domain from image reference. + + Based on Docker's reference implementation: + vendor/github.com/distribution/reference/normalize.go + """ + assert get_domain(image_ref) == expected_domain + + def test_no_credentials(coresys: CoreSys, test_docker_interface: DockerInterface): """Test no credentials.""" coresys.docker.config._data["registries"] = { @@ -47,3 +86,36 @@ def test_matching_credentials(coresys: CoreSys, test_docker_interface: DockerInt ) assert credentials["username"] == "Spongebob Squarepants" assert "registry" not in credentials + + +def test_legacy_docker_hub_credentials( + coresys: CoreSys, test_docker_interface: DockerInterface +): + """Test legacy hub.docker.com credentials are used for Docker Hub images.""" + coresys.docker.config._data["registries"] = { + DOCKER_HUB_LEGACY: {"username": "LegacyUser", "password": "Password1!"}, + } + + credentials = 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 + + +def test_docker_hub_preferred_over_legacy( + coresys: CoreSys, test_docker_interface: DockerInterface +): + """Test docker.io is preferred over legacy hub.docker.com when both exist.""" + coresys.docker.config._data["registries"] = { + DOCKER_HUB: {"username": "NewUser", "password": "Password1!"}, + DOCKER_HUB_LEGACY: {"username": "LegacyUser", "password": "Password2!"}, + } + + credentials = test_docker_interface._get_credentials( + "homeassistant/amd64-supervisor" + ) + # docker.io should be preferred + assert credentials["username"] == "NewUser" + assert "registry" not in credentials