mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 19:02:31 +00:00
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:
+13
-6
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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$')
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user