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,
)
@@ -0,0 +1,101 @@
"""The update check of a private image uses the login saved in ProxMenux:
it is sent to the token service the registry names, or to the registry
itself when it asks for Basic authentication, and never anywhere else."""
import base64
import json
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
import lxc_apps
CREDENTIAL = base64.b64encode(b"macrimi:ghp_secret").decode()
MANIFEST = "https://ghcr.io/v2/macrimi/app/manifests/latest"
class SavedLoginTests(unittest.TestCase):
def setUp(self):
self.folder = tempfile.TemporaryDirectory()
self.auth_file = Path(self.folder.name) / "registry-auth.json"
self.auth_file.write_text(json.dumps({"auths": {"ghcr.io": {"auth": CREDENTIAL},
"docker.io": {"auth": CREDENTIAL},
"registry.example.com:5000": {"auth": CREDENTIAL}}}))
patcher = mock.patch.object(lxc_apps, "_REGISTRY_AUTH_FILE", str(self.auth_file))
patcher.start()
self.addCleanup(patcher.stop)
self.addCleanup(self.folder.cleanup)
def test_the_login_is_found_by_registry_host(self):
self.assertEqual(lxc_apps._registry_saved_login("ghcr.io"), f"Basic {CREDENTIAL}")
self.assertEqual(lxc_apps._registry_saved_login("registry-1.docker.io"), f"Basic {CREDENTIAL}")
self.assertEqual(lxc_apps._registry_saved_login("registry.example.com:5000"), f"Basic {CREDENTIAL}")
self.assertIsNone(lxc_apps._registry_saved_login("quay.io"))
def test_without_the_file_nothing_is_sent(self):
self.auth_file.unlink()
self.assertIsNone(lxc_apps._registry_saved_login("ghcr.io"))
def test_a_bearer_challenge_asks_the_token_with_the_saved_login(self):
challenge = 'Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:macrimi/app:pull"'
answers = [({"WWW-Authenticate": challenge}, b"", 401, None, None),
({"Docker-Content-Digest": "sha256:abc"}, b"", 200, None, None)]
sent = []
def token_service(request, timeout):
sent.append(dict(request.header_items()))
response = mock.MagicMock()
response.__enter__.return_value.read.return_value = b'{"token": "pull-token"}'
return response
with mock.patch.object(lxc_apps, "_registry_open", side_effect=answers) as registry, \
mock.patch.object(lxc_apps.urllib.request, "urlopen", side_effect=token_service):
headers, _body, token, error = lxc_apps._registry_request(MANIFEST, {"Accept": "*/*"})
self.assertIsNone(error)
self.assertEqual(token, "pull-token")
self.assertEqual(sent[0]["Authorization"], f"Basic {CREDENTIAL}")
self.assertEqual(registry.call_args_list[1].args[1]["Authorization"], "Bearer pull-token")
def test_a_basic_challenge_is_answered_with_the_saved_login(self):
answers = [({"WWW-Authenticate": 'Basic realm="registry"'}, b"", 401, None, None),
({"Docker-Content-Digest": "sha256:abc"}, b"", 200, None, None)]
url = "https://registry.example.com:5000/v2/team/app/manifests/latest"
with mock.patch.object(lxc_apps, "_registry_open", side_effect=answers) as registry:
_headers, _body, _token, error = lxc_apps._registry_request(url, {"Accept": "*/*"})
self.assertIsNone(error)
self.assertEqual(registry.call_args_list[1].args[1]["Authorization"], f"Basic {CREDENTIAL}")
def test_a_basic_challenge_over_http_gets_no_login(self):
answers = [({"WWW-Authenticate": 'Basic realm="registry"'}, b"", 401, None, None)]
url = "http://registry.example.com:5000/v2/team/app/manifests/latest"
with mock.patch.object(lxc_apps, "_registry_open", side_effect=answers) as registry:
_headers, _body, _token, error = lxc_apps._registry_request(url, {"Accept": "*/*"})
self.assertEqual(error, "registry authentication required")
self.assertEqual(registry.call_count, 1)
def test_a_public_image_asks_its_token_without_a_login(self):
challenge = 'Bearer realm="https://auth.example.org/token",service="example",scope="repository:x:pull"'
answers = [({"WWW-Authenticate": challenge}, b"", 401, None, None),
({"Docker-Content-Digest": "sha256:abc"}, b"", 200, None, None)]
sent = []
def token_service(request, timeout):
sent.append(dict(request.header_items()))
response = mock.MagicMock()
response.__enter__.return_value.read.return_value = b'{"token": "t"}'
return response
with mock.patch.object(lxc_apps, "_registry_open", side_effect=answers), \
mock.patch.object(lxc_apps.urllib.request, "urlopen", side_effect=token_service):
lxc_apps._registry_request("https://quay.io/v2/x/manifests/latest", {})
self.assertNotIn("Authorization", sent[0])
if __name__ == "__main__":
unittest.main()