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
+13 -6
View File
@@ -72,10 +72,17 @@ Select a saved account to check access to an image, download it to template
storage, install it with the existing LXC wizard, update its credentials or
remove them. Image references retain their tag or full `sha256` digest.
The normal custom-image importer can also use the saved credentials, including
Compose files whose services come from private registries.
Compose files whose services come from private registries. When a registry
refuses an image there, the importer offers to sign in on the spot and reads
the image again. Updates, Modify, Recreate, the watchdog and the update check
of the Monitor use the saved accounts too.
Skopeo stores credentials in `/root/.config/containers/auth.json` by default.
`REGISTRY_AUTH_FILE` and `XDG_CONFIG_HOME` overrides are respected. The
ProxMenux keeps the accounts in `/etc/pve/priv/proxmenux/registry-auth.json`,
where Proxmox keeps its own storage passwords: readable only by root and shared
by every node of the cluster. The host backup includes it, and uninstalling
ProxMenux removes it. A `REGISTRY_AUTH_FILE` set by the user is respected;
without the cluster filesystem, Skopeo's default `/root/.config/containers/auth.json`
is used. The
credential directory must be owned by root with mode `700`, and the file with
mode `600`. Basic credentials are base64 encoded, not encrypted. Configured
Skopeo credential helpers use their own stores. Failed sign-in attempts leave
@@ -84,9 +91,9 @@ applications unchanged. Other Skopeo/Docker authentication files are managed
separately and may still grant access.
TLS verification remains enabled. Install a private registry CA in
`/etc/containers/certs.d/REGISTRY[:PORT]/ca.crt` when required. Configure accounts
on each node that pulls images; registry credentials are not written into the
application template or copied into its LXC.
`/etc/containers/certs.d/REGISTRY[:PORT]/ca.crt` when required, on each node
that pulls images. Registry credentials are not written into the application
template or copied into its LXC.
Image access checks have a 30-second timeout per request; sign-in has a 45-second
timeout. Errors identify rejected credentials, missing images, incompatible
+4
View File
@@ -1,5 +1,9 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# A registry login saved by ProxMenux lets skopeo read private images.
if [[ -z ${REGISTRY_AUTH_FILE:-} && -f /etc/pve/priv/proxmenux/registry-auth.json ]]; then
export REGISTRY_AUTH_FILE=/etc/pve/priv/proxmenux/registry-auth.json
fi
TEMPLATE_FILE=${1:?template JSON required}
DEPLOYMENT_FILE=${2:?deployment JSON required}
+4
View File
@@ -1,5 +1,9 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# A registry login saved by ProxMenux lets skopeo read private images.
if [[ -z ${REGISTRY_AUTH_FILE:-} && -f /etc/pve/priv/proxmenux/registry-auth.json ]]; then
export REGISTRY_AUTH_FILE=/etc/pve/priv/proxmenux/registry-auth.json
fi
TEMPLATE_FILE=${1:?template JSON required}
DEPLOYMENT_FILE=${2:?deployment JSON required}
+4
View File
@@ -1,5 +1,9 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# A registry login saved by ProxMenux lets skopeo read private images.
if [[ -z ${REGISTRY_AUTH_FILE:-} && -f /etc/pve/priv/proxmenux/registry-auth.json ]]; then
export REGISTRY_AUTH_FILE=/etc/pve/priv/proxmenux/registry-auth.json
fi
TEMPLATE_FILE=${1:?template JSON required}
DEPLOYMENT_FILE=${2:?deployment JSON required}
+4
View File
@@ -1,5 +1,9 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# A registry login saved by ProxMenux lets skopeo read private images.
if [[ -z ${REGISTRY_AUTH_FILE:-} && -f /etc/pve/priv/proxmenux/registry-auth.json ]]; then
export REGISTRY_AUTH_FILE=/etc/pve/priv/proxmenux/registry-auth.json
fi
TEMPLATE_FILE=${1:?template JSON required}
DEPLOYMENT_FILE=${2:?deployment JSON required}
+4
View File
@@ -1,5 +1,9 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# A registry login saved by ProxMenux lets skopeo read private images.
if [[ -z ${REGISTRY_AUTH_FILE:-} && -f /etc/pve/priv/proxmenux/registry-auth.json ]]; then
export REGISTRY_AUTH_FILE=/etc/pve/priv/proxmenux/registry-auth.json
fi
TEMPLATE_FILE=${1:?template JSON required}
DEPLOYMENT_FILE=${2:?deployment JSON required}
+5
View File
@@ -19,6 +19,11 @@ import uuid
from oci_ui import translate, msg_error, msg_ok
# A registry login saved by ProxMenux lets skopeo read private images.
REGISTRY_AUTH_FILE = '/etc/pve/priv/proxmenux/registry-auth.json'
if not os.environ.get('REGISTRY_AUTH_FILE') and os.path.isfile(REGISTRY_AUTH_FILE):
os.environ['REGISTRY_AUTH_FILE'] = REGISTRY_AUTH_FILE
ROOT = Path('/var/lib/proxmenux/oci-installations')
FIELDS = ('Entrypoint', 'Cmd', 'Env', 'User', 'WorkingDir', 'StopSignal', 'Volumes', 'ExposedPorts', 'Healthcheck')
VERSION_ENV_RE = re.compile(r'^([A-Z][A-Z0-9_]*)_VERSION$')
+5
View File
@@ -16,6 +16,11 @@ import oci_work_backup as work_backup
from oci_installation_state import image_from_archive, same_image
from oci_ui import translate, msg_info, msg_ok, msg_warn, msg_error, msg_info2
# A registry login saved by ProxMenux lets skopeo read private images.
REGISTRY_AUTH_FILE = '/etc/pve/priv/proxmenux/registry-auth.json'
if not os.environ.get('REGISTRY_AUTH_FILE') and os.path.isfile(REGISTRY_AUTH_FILE):
os.environ['REGISTRY_AUTH_FILE'] = REGISTRY_AUTH_FILE
def repository(reference):
repo = reference.split('@', 1)[0]
+5
View File
@@ -1,3 +1,8 @@
"""ProxMenux LinuxServer-to-Proxmox OCI laboratory tools."""
from .registry_auth import use_cluster_auth_file as _use_cluster_auth_file
__version__ = "0.1.0"
# Every skopeo call of the engine reads the registry accounts kept for the cluster.
_use_cluster_auth_file()
+23 -2
View File
@@ -22,7 +22,7 @@ from .i18n import source_text
from .installer import ADVANCED_MODE, DEFAULT_MODE
from .converter import ConversionError
from .i18n import translate
from .registry_auth import RegistryError, validate_image
from .registry_auth import RegistryError, refuses_access, validate_image
from .ui import UserCancelled
def _valid_image_reference(reference: str) -> bool:
@@ -523,6 +523,12 @@ def read_definition(ui) -> tuple[str, str]:
return compose_from_docker_run(_read_pasted(ui, translate('docker run command of the application'))), \
translate('docker run command')
reference = ui.ask(translate('Image reference (for example ghcr.io/user/application:latest)'), '')
try:
return compose_from_image(reference), translate('image itself')
except ConversionError as error:
from .registry_accounts import offer_sign_in
if not (refuses_access(error) and offer_sign_in(ui, reference)):
raise
return compose_from_image(reference), translate('image itself')
@@ -618,6 +624,16 @@ def registry_report(reference: str) -> tuple[bool, str]:
return True, f"{translate('The image is in its registry:')} {', '.join(architectures)}"
def _registry_refused(reference: str) -> bool:
"""Whether the registry answered that the image needs an account."""
try:
result = subprocess.run(['skopeo', 'inspect', '--raw', f'docker://{reference}'],
capture_output=True, text=True, check=False, timeout=120)
except (OSError, subprocess.TimeoutExpired):
return False
return result.returncode != 0 and refuses_access(result.stderr)
def describe(template: dict[str, Any]) -> str:
"""What the translation understood, in the words of the installer, with
everything that changes how the container is created."""
@@ -728,7 +744,12 @@ def explore(ui) -> None:
template = template_from_compose(text)
title = translate('Image that is not in the catalog')
summary = describe(template) + (describe_oci_compose(template) if written else '')
available, report = registry_report(template['container_contract']['image']['reference'])
reference = template['container_contract']['image']['reference']
available, report = registry_report(reference)
if not available and _registry_refused(reference):
from .registry_accounts import offer_sign_in
if offer_sign_in(ui, reference, title):
available, report = registry_report(reference)
summary += '\n\n' + report
if notes:
summary += '\n\n' + '\n'.join(notes)
+29 -1
View File
@@ -200,7 +200,7 @@ def registry_accounts_menu(ui, engine=ENGINE_ROOT, auth=None):
'Basic credentials are base64 encoded, not encrypted.\n'
'Credential helpers use their own storage.\n\n'
'Use an access token with permission to pull images.\n'
'Configure credentials on each node that downloads images.').format(path=auth.path), title)
'Every node of the cluster uses the accounts saved here.').format(path=auth.path), title)
elif choice in {'add', 'login'}:
registry = row['registry'].split('/', 1)[0] if row else ui.ask(translate('Registry hostname'), 'ghcr.io')
try:
@@ -280,3 +280,31 @@ def open_registry_accounts(ui):
registry_accounts_menu(ui)
except (RegistryError, OSError, ValueError) as error:
ui.message(clean(error, 600), translate('Registry accounts'))
def offer_sign_in(ui, reference, title=None):
"""When a registry refuses an image, an account for it can be added on the
spot; the image is then read again with it."""
try:
registry = validate_registry(registry_of(reference))
except RegistryError:
return False
question = translate('{registry} refused access to this image:\n{reference}\n\n'
'If the image is private, sign in with an account that can pull it.\n\n'
'Sign in now?').format(registry=registry, reference=clean(reference, 300))
if not ui.confirm(question, True, title=title):
return False
cancelled = importlib.import_module('proxmenux_oci.ui').UserCancelled
try:
require_root()
username = ui.ask(translate('Username'))
password = ui.password(translate('Password / access token (image pull permission)'))
ui.info(translate('Verifying registry access. Please wait...'))
RegistryAuth().login(registry, username, password)
del password
except cancelled:
return False
except (RegistryError, OSError, ValueError) as error:
ui.message(clean(error, 600), title)
return False
return True
+21 -4
View File
@@ -33,6 +33,13 @@ import tempfile
from .i18n import translate
ANSI = re.compile(r'\x1b\][^\x07]*(?:\x07|\x1b\\)|\x1b\[[0-?]*[ -/]*[@-~]')
# Where Proxmox keeps its own storage passwords: readable only by root and
# shared by every node of the cluster.
CLUSTER_AUTH_FILE = Path('/etc/pve/priv/proxmenux/registry-auth.json')
ACCESS_REFUSED = ('unauthorized', 'authentication required', 'denied', 'forbidden', 'invalid username',
'invalid password', 'invalid credentials', 'username/password',
'statuscode: 401', 'statuscode: 403', 'status code from registry 401',
'status code from registry 403')
class RegistryError(RuntimeError):
@@ -120,6 +127,19 @@ def validate_image(value):
return value
def use_cluster_auth_file(environment=os.environ):
"""ProxMenux keeps its registry accounts in the cluster filesystem, so
every node installs and updates private images with them."""
if not environment.get('REGISTRY_AUTH_FILE') and os.path.ismount(CLUSTER_AUTH_FILE.parents[2]):
environment['REGISTRY_AUTH_FILE'] = str(CLUSTER_AUTH_FILE)
def refuses_access(error) -> bool:
"""Whether a registry error says the image needs an account."""
detail = str(error).casefold()
return any(word in detail for word in ACCESS_REFUSED)
def registry_error(registry, error='', login=False):
"""Classify errors without echoing registry-controlled text or a token."""
detail = str(error).casefold()
@@ -133,10 +153,7 @@ def registry_error(registry, error='', login=False):
'statuscode: 504', 'status code: 504', 'status: 504',
'internal server error', 'service unavailable', 'bad gateway')):
return translate('{registry} reported a server error. Try again later.').format(registry=registry)
if any(word in detail for word in ('unauthorized', 'authentication required', 'denied', 'forbidden', 'invalid username',
'invalid password', 'invalid credentials', 'username/password',
'statuscode: 401', 'statuscode: 403', 'status code from registry 401',
'status code from registry 403')):
if refuses_access(detail):
return (translate('Authentication failed for {registry}. Check the account and token permissions.').format(registry=registry) if login else
translate('Access denied by {registry}. Check the image name and sign in from Registry accounts.').format(registry=registry))
if any(word in detail for word in ('http response to https client',
+145
View File
@@ -0,0 +1,145 @@
"""Registry accounts are kept for the whole cluster, offered when a registry
refuses an image, and read by every program that downloads images."""
from pathlib import Path
import re
import sys
from types import SimpleNamespace
import unittest
from unittest.mock import MagicMock, patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from proxmenux_oci import custom, registry_accounts, registry_auth
from proxmenux_oci.ui import UserCancelled
CLUSTER_FILE = "/etc/pve/priv/proxmenux/registry-auth.json"
GHCR_PRIVATE = ('time="2026-10-16T18:06:24+02:00" level=fatal msg="Error parsing image name '
'\\"docker://ghcr.io/macrimi/private-test:latest\\": Requesting bearer token: '
'invalid status code from registry 403 (Forbidden)"')
HUB_PRIVATE = 'reading manifest latest in docker.io/macrimi/private-test: requested access to the resource is denied'
class FakeUI:
def __init__(self, answers=(), confirms=(True,)):
self.answers = list(answers)
self.confirms = list(confirms)
self.messages = []
def ask(self, text, default=None, required=True):
answer = self.answers.pop(0)
if answer is UserCancelled:
raise UserCancelled(text)
return answer
def password(self, text, required=True):
return self.answers.pop(0)
def confirm(self, text, default=False, title=None):
return self.confirms.pop(0)
def info(self, text, title=None):
pass
def message(self, text, title=None):
self.messages.append(text)
class ClusterFile(unittest.TestCase):
def test_the_cluster_file_is_used_when_the_cluster_filesystem_is_mounted(self):
environment = {}
with patch.object(registry_auth.os.path, "ismount", return_value=True):
registry_auth.use_cluster_auth_file(environment)
self.assertEqual(environment, {"REGISTRY_AUTH_FILE": CLUSTER_FILE})
def test_nothing_is_chosen_without_the_cluster_filesystem(self):
environment = {}
with patch.object(registry_auth.os.path, "ismount", return_value=False):
registry_auth.use_cluster_auth_file(environment)
self.assertEqual(environment, {})
def test_a_file_chosen_by_the_user_is_kept(self):
environment = {"REGISTRY_AUTH_FILE": "/root/own.json"}
with patch.object(registry_auth.os.path, "ismount", return_value=True):
registry_auth.use_cluster_auth_file(environment)
self.assertEqual(environment, {"REGISTRY_AUTH_FILE": "/root/own.json"})
def test_the_accounts_menu_reads_and_writes_the_cluster_file(self):
with patch.dict(registry_auth.os.environ, {"REGISTRY_AUTH_FILE": CLUSTER_FILE}):
self.assertEqual(str(registry_auth.RegistryAuth().path), CLUSTER_FILE)
class RefusedAccess(unittest.TestCase):
def test_a_refusal_is_told_apart_from_other_failures(self):
for text in (GHCR_PRIVATE, HUB_PRIVATE, "unauthorized: authentication required"):
self.assertTrue(registry_auth.refuses_access(text), text)
for text in ("dial tcp: lookup ghcr.io: no such host", "manifest unknown", ""):
self.assertFalse(registry_auth.refuses_access(text), text)
def test_the_classifier_still_names_the_refusal(self):
self.assertIn("Access denied by ghcr.io", registry_auth.registry_error("ghcr.io", GHCR_PRIVATE))
class SignInFromTheImporter(unittest.TestCase):
def sign_in(self, ui, login=None):
auth = MagicMock()
if login:
auth.login.side_effect = login
with patch.object(registry_accounts, "require_root"), \
patch.object(registry_accounts, "RegistryAuth", return_value=auth):
result = registry_accounts.offer_sign_in(ui, "ghcr.io/macrimi/app:latest", "Title")
return result, auth
def test_an_accepted_account_is_saved(self):
ui = FakeUI(["macrimi", "ghp_secret"])
result, auth = self.sign_in(ui)
self.assertTrue(result)
auth.login.assert_called_once_with("ghcr.io", "macrimi", "ghp_secret")
def test_a_declined_offer_asks_nothing(self):
ui = FakeUI([], confirms=[False])
result, auth = self.sign_in(ui)
self.assertFalse(result)
auth.login.assert_not_called()
def test_a_cancelled_question_returns_without_signing_in(self):
ui = FakeUI([UserCancelled])
result, auth = self.sign_in(ui)
self.assertFalse(result)
auth.login.assert_not_called()
def test_a_refused_account_is_explained(self):
ui = FakeUI(["macrimi", "wrong"])
refused = registry_auth.RegistryError("Authentication failed for ghcr.io.")
result, _ = self.sign_in(ui, login=refused)
self.assertFalse(result)
self.assertEqual(ui.messages, ["Authentication failed for ghcr.io."])
def test_a_refused_image_is_checked_again_after_signing_in(self):
compose = "services:\n app:\n image: ghcr.io/macrimi/app:latest\n"
reports = iter([(False, "Could not inspect the image in its registry"), (True, "The image is in its registry")])
ui = SimpleNamespace(message=lambda *a, **k: None, review=lambda *a, **k: False)
with patch.object(custom, "read_definition", return_value=(compose, "pasted Compose file")), \
patch.object(custom, "registry_report", side_effect=lambda _: next(reports)) as report, \
patch.object(custom, "_registry_refused", return_value=True), \
patch.object(registry_accounts, "offer_sign_in", return_value=True) as offer:
custom.explore(ui)
offer.assert_called_once()
self.assertEqual(report.call_count, 2)
class EveryImageReaderUsesTheAccounts(unittest.TestCase):
def test_every_program_that_runs_skopeo_reads_the_cluster_file(self):
readers = [path for path in sorted((ROOT / "remote").iterdir())
if path.suffix in {".sh", ".py"}
and re.search(r"skopeo['\",]*\s+(inspect|copy)|'skopeo',\s*'(inspect|copy)'", path.read_text())]
self.assertGreaterEqual(len(readers), 7)
for path in readers:
with self.subTest(path=path.name):
self.assertIn(CLUSTER_FILE, path.read_text())
self.assertIn("_use_cluster_auth_file()", (ROOT / "src/proxmenux_oci/__init__.py").read_text())
if __name__ == "__main__":
unittest.main()