keep registry accounts for the whole cluster and use them everywhere

- Registry accounts are kept in /etc/pve/priv/proxmenux/registry-auth.json, shared by every node, unless REGISTRY_AUTH_FILE says otherwise
- The custom image importer offers to sign in when a registry refuses an image, and reads it again
- Installers, updates, the watchdog and the Monitor update check read private images with the saved accounts
- The host backup includes the accounts, and uninstalling ProxMenux removes them and keeps the OCI records of the cluster
- Spanish written by hand and spacing fixed for the registry account messages in every locale
- Docs: private registries section, Registry accounts in the OCI menu, README storage notes
This commit is contained in:
MacRimi
2026-10-16 22:35:07 +02:00
parent e4c517f258
commit 248ac5dec6
29 changed files with 790 additions and 317 deletions
+37 -12
View File
@@ -2081,8 +2081,28 @@ class _DockerNoRedirect(urllib.request.HTTPRedirectHandler):
_docker_no_redirect_opener = urllib.request.build_opener(_DockerNoRedirect)
def _registry_bearer_token(challenge: dict) -> tuple[Optional[str], Optional[str]]:
"""Exchange a parsed ``WWW-Authenticate`` challenge for a pull token."""
_REGISTRY_AUTH_FILE = "/etc/pve/priv/proxmenux/registry-auth.json"
_DOCKER_HUB_API_HOSTS = {"docker.io", "index.docker.io", "registry-1.docker.io", "registry.hub.docker.com"}
def _registry_saved_login(api_host: str) -> Optional[str]:
"""The login saved in ProxMenux for a registry, as a Basic credential."""
try:
with open(_REGISTRY_AUTH_FILE, encoding="utf-8") as handle:
auths = (json.load(handle) or {}).get("auths") or {}
except (OSError, ValueError, AttributeError):
return None
host = (api_host or "").lower()
for key in ([host, "docker.io"] if host in _DOCKER_HUB_API_HOSTS else [host]):
entry = auths.get(key) if isinstance(auths, dict) else None
if isinstance(entry, dict) and isinstance(entry.get("auth"), str) and entry["auth"]:
return f"Basic {entry['auth']}"
return None
def _registry_bearer_token(challenge: dict, saved_login: Optional[str] = None) -> tuple[Optional[str], Optional[str]]:
"""Exchange a parsed ``WWW-Authenticate`` challenge for a pull token,
with the login saved for a private registry when there is one."""
query = {
key: challenge[key]
for key in ("service", "scope") if challenge.get(key)
@@ -2090,10 +2110,10 @@ def _registry_bearer_token(challenge: dict) -> tuple[Optional[str], Optional[str
token_url = challenge["realm"]
if query:
token_url += ("&" if "?" in token_url else "?") + urllib.parse.urlencode(query)
token_req = urllib.request.Request(
token_url,
headers={"User-Agent": "ProxMenux-Monitor", "Accept": "application/json"},
)
token_headers = {"User-Agent": "ProxMenux-Monitor", "Accept": "application/json"}
if saved_login:
token_headers["Authorization"] = saved_login
token_req = urllib.request.Request(token_url, headers=token_headers)
try:
with urllib.request.urlopen(token_req, timeout=_docker_registry_timeout()) as response:
body = response.read(65537)
@@ -2160,13 +2180,18 @@ def _registry_request(url: str, headers: dict, method: str = "HEAD",
if error:
return None, None, token, error
if status == 401:
challenge = _parse_bearer_challenge((response_headers or {}).get("WWW-Authenticate", ""))
if not challenge:
authenticate = (response_headers or {}).get("WWW-Authenticate", "")
saved_login = _registry_saved_login(urllib.parse.urlsplit(url).netloc)
challenge = _parse_bearer_challenge(authenticate)
if challenge:
token, token_error = _registry_bearer_token(challenge, saved_login)
if token_error:
return None, None, None, token_error
attempt_headers["Authorization"] = f"Bearer {token}"
elif saved_login and authenticate.lower().startswith("basic") and url.startswith("https://"):
attempt_headers["Authorization"] = saved_login
else:
return None, None, token, "registry authentication required"
token, token_error = _registry_bearer_token(challenge)
if token_error:
return None, None, None, token_error
attempt_headers["Authorization"] = f"Bearer {token}"
response_headers, body, status, location, error = _registry_open(
url, attempt_headers, method, max_bytes,
)