Add private registry accounts to OCI manager Apps

This commit is contained in:
Jonatan
2026-10-16 19:58:40 +02:00
parent 3000c1cd66
commit 7d2d430320
10 changed files with 1298 additions and 5 deletions
+34
View File
@@ -0,0 +1,34 @@
name: Offline OCI registry tests
on:
pull_request:
paths:
- 'oci/**'
- '.github/workflows/test-oci-registries.yml'
push:
branches: [main, develop]
paths:
- 'oci/**'
- '.github/workflows/test-oci-registries.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
registry-accounts:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Install OCI test dependencies
run: python3 -m pip install -r oci/requirements.txt
- name: Run offline registry account tests
env:
PYTHONDONTWRITEBYTECODE: '1'
run: python3 -m unittest discover -s oci/tests -p 'test_registry_*.py' -v
+34
View File
@@ -10,6 +10,7 @@ This document describes the engine in this directory: how it is organised, how a
- [Structure](#structure)
- [How a container is created](#how-a-container-is-created)
- [Update, modify, recreate and remove](#update-modify-recreate-and-remove)
- [Private image registries](#private-image-registries)
- [From a Compose file or a docker run command to an oci-compose](#from-a-compose-file-or-a-docker-run-command-to-an-oci-compose)
- [The oci-compose, block by block](#the-oci-compose-block-by-block)
- [Writing and testing an oci-compose](#writing-and-testing-an-oci-compose)
@@ -60,6 +61,39 @@ There are three places an oci-compose comes from:
On a node, the engine is installed in `/usr/local/share/proxmenux/oci/engine/`. Every installation keeps its record in `/usr/local/share/proxmenux/oci/instances/<VMID>/oci-compose.json`: the oci-compose it was installed from, under `template`, together with the answers given during the installation and the configuration Proxmox VE ended with. Updates, changes and removals read that record.
## Private image registries
Open **OCI manager Apps → Registry accounts** on the Proxmox node. Sign in
with a registry hostname (for example `ghcr.io` or `registry.example.com:5443`),
a username and a password or token with image pull permission. Passwords are
entered in hidden fields and confirmed before the registry is contacted.
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.
Skopeo stores credentials in `/root/.config/containers/auth.json` by default.
`REGISTRY_AUTH_FILE` and `XDG_CONFIG_HOME` overrides are respected. 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
the previous file credentials intact; removing an account leaves running
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.
Image access checks have a 30-second timeout per request; sign-in has a 45-second
timeout. Errors identify rejected credentials, missing images, incompatible
architectures, network/TLS failures, rate limits and registry outages. Downloads
reuse the native update cache and layer integrity checks. Damaged cached images
are downloaded again, and storage failures leave existing applications alone.
## How a container is created
Selecting an application, or giving a definition, opens the same sequence.
+4
View File
@@ -559,6 +559,7 @@ def interactive(catalog: Catalog) -> int:
("search", translate("Search applications")),
("all", f"{translate('All applications'):<35} ({len(applications):>3})"),
("manage", translate("Manage installed OCI applications")),
("registries", translate("Registry accounts")),
("custom", translate("Install an image that is not in the catalog")),
("", ""),
] + [(number, f"{translate(labels[key]):<35} ({len(categories[key]):>3})")
@@ -580,6 +581,9 @@ def interactive(catalog: Catalog) -> int:
console.show_logo()
console.msg_title(translate("Manage installed OCI applications"))
interactive_management(PROJECT_ROOT, ui)
elif selection == "registries":
from .registry_accounts import open_registry_accounts
open_registry_accounts(ui)
elif selection == "custom":
from .custom import explore
explore(ui)
+13 -5
View File
@@ -22,9 +22,17 @@ 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 .ui import UserCancelled
IMAGE_REFERENCE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._/-]*(?::[A-Za-z0-9._-]+)?(?:@sha256:[a-f0-9]{64})?$')
def _valid_image_reference(reference: str) -> bool:
try:
validate_image(reference)
return True
except RegistryError:
return False
MAX_COMPOSE_BYTES = 256 * 1024
# An oci-compose: the file ProxMenux installs an application from. The catalog
# keeps one for each of its applications and every installation records its own.
@@ -176,7 +184,7 @@ def template_from_compose(text: str, title: str | None = None) -> dict[str, Any]
def compose_from_image(reference: str, title: str | None = None) -> str:
"""A Compose file written from what the image declares: its ports, its
persistent paths and its variables."""
if not IMAGE_REFERENCE.fullmatch(reference):
if not _valid_image_reference(reference):
raise ConversionError(f"{translate('This is not a valid image reference:')} {reference}")
result = subprocess.run(['skopeo', 'inspect', '--config', f'docker://{reference}'],
capture_output=True, text=True, check=False, timeout=120)
@@ -283,7 +291,7 @@ def compose_from_docker_run(command: str, title: str | None = None) -> str:
f"{', '.join(sorted(set(unsupported)))}")
if not image:
raise ConversionError(translate('The command does not name an image'))
if not IMAGE_REFERENCE.fullmatch(image):
if not _valid_image_reference(image):
raise ConversionError(f"{translate('This is not a valid image reference:')} {image}")
service['image'] = image
name = normalize_app_id(title or service.get('container_name')
@@ -402,7 +410,7 @@ def template_from_oci_compose(text: str) -> dict[str, Any]:
reference = image.get('reference') if isinstance(image, dict) else None
if not isinstance(reference, str) or not reference.strip():
raise ConversionError(translate('The oci-compose is incomplete: it does not say which image to install.'))
if not IMAGE_REFERENCE.fullmatch(reference):
if not _valid_image_reference(reference):
raise ConversionError(f"{translate('This is not a valid image reference:')} {reference}")
faulty = translate('The oci-compose has errors:')
@@ -726,7 +734,7 @@ def explore(ui) -> None:
summary += '\n\n' + '\n'.join(notes)
if not available:
advice = translate('Check the image reference and registry access. If the registry is unavailable, '
'try again later. A private registry needs credentials, which are not supported yet.')
'try again later. For a private image, sign in from Registry accounts in the OCI menu.')
ui.message(f'{summary}\n\n{advice}', title)
return
if not template.get('compatibility', {}).get('automatic_install_candidate'):
+282
View File
@@ -0,0 +1,282 @@
# ==========================================================
# ProxMenux - Private OCI registry accounts
# ==========================================================
# Author : Jonatan
# GitHub : github.com/jcastro
# Maintainer : MacRimi
# Copyright : (c) 2026 MacRimi & contributors
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
# Version : 1.0
# Last Updated: 16/10/2026
# ==========================================================
# Description:
# Manage private registry accounts from the native OCI menu.
# Check image access, download verified archives and hand image
# installation to the existing ProxMenux LXC wizard.
# ==========================================================
"""Private image registry accounts in the native ProxMenux OCI menu."""
from __future__ import annotations
from contextlib import contextmanager, redirect_stdout
import errno
import importlib
import io
import os
from pathlib import Path
import re
import subprocess
import sys
from .i18n import translate
from .registry_auth import (RegistryAuth, RegistryError, clean, require_root,
validate_registry, validate_image, registry_of,
registry_error, registry_environment)
ENGINE_ROOT = Path(__file__).resolve().parents[2]
REGISTRY_QUERY_TIMEOUT = 30
@contextmanager
def image_queries(reference):
"""Keep the native resolver while retaining safe, useful transport errors."""
images = importlib.import_module('oci_installation_state')
original = images.command
registry = registry_of(reference)
def query(*args):
if args[:2] != ('skopeo', 'inspect'):
return original(*args)
try:
result = subprocess.run(args, capture_output=True, timeout=REGISTRY_QUERY_TIMEOUT)
except subprocess.TimeoutExpired:
raise RegistryError(translate('{registry} did not respond within {timeout} seconds. Try again later.').format(registry=registry, timeout=REGISTRY_QUERY_TIMEOUT)) from None
except FileNotFoundError:
raise RegistryError(translate('Skopeo is not available. Restore the ProxMenux OCI dependencies and try again.')) from None
if result.returncode:
raise RegistryError(registry_error(registry, result.stderr.decode(errors='replace')))
return result.stdout
images.command = query
try:
yield images
finally:
images.command = original
def bytes_label(size):
value = float(size)
for unit in ('B', 'KiB', 'MiB', 'GiB', 'TiB'):
if value < 1024 or unit == 'TiB':
return f'{value:.0f} {unit}' if unit == 'B' else f'{value:.1f} {unit}'
value /= 1024
def host_architecture():
architecture = {'x86_64': 'amd64', 'aarch64': 'arm64'}.get(os.uname().machine)
if not architecture:
raise RegistryError(translate('This host architecture is not supported by the OCI image importer.'))
return architecture
def image_template(reference):
reference = validate_image(reference)
custom = importlib.import_module('proxmenux_oci.custom')
try:
compose = custom.compose_from_image(reference)
except (custom.ConversionError, subprocess.TimeoutExpired, ValueError) as error:
raise RegistryError(registry_error(registry_of(reference), error)) from None
try:
template = custom.template_from_compose(compose)
custom._ask_secrets(template)
except custom.ConversionError:
raise RegistryError(translate('ProxMenux could not translate this image. Try importing its Compose file from OCI manager Apps.')) from None
return template
def inspect_registry_image(reference):
reference = validate_image(reference)
try:
with registry_environment(), image_queries(reference) as images:
candidate = images.resolve_candidate(reference, host_architecture())
except RegistryError:
raise
except ValueError as error:
detail = str(error).casefold()
if 'platform' in detail or 'architecture' in detail:
raise RegistryError(translate('This image has no compatible Linux image for this host ({architecture}).').format(architecture=host_architecture())) from None
if 'manifest does not match' in detail or 'integrity' in detail:
raise RegistryError(translate('The image manifest failed integrity verification. Try again or contact the image publisher.')) from None
raise RegistryError(translate('The registry returned invalid or unsupported image metadata.')) from None
except (KeyError, AttributeError, TypeError):
raise RegistryError(translate('The registry returned incomplete or unsupported image metadata.')) from None
except (RuntimeError, subprocess.TimeoutExpired) as error:
raise RegistryError(registry_error(registry_of(reference), error)) from None
return {'reference': reference, 'architecture': host_architecture(), 'version': clean(candidate.get('version')) or None,
'digest': candidate['manifest_digest'], 'size': candidate.get('size') or 0}
def download_registry_image(reference, storage):
"""Download and verify with the exact native updater cache implementation."""
reference = validate_image(reference)
if not re.fullmatch(r'[A-Za-z0-9_-]+', storage):
raise RegistryError(translate('Invalid template storage name.'))
inspect_registry_image(reference)
native = importlib.import_module('oci_update_current')
desired = {'template': {'container_contract': {'image': {'reference': reference}}},
'deployment': {'template_storage': storage}}
try:
with native.instances.locked(native.instances.ROOT), registry_environment(), redirect_stdout(io.StringIO()):
path, digest = native.resolve_archive(desired, f'arch: {host_architecture()}\n'.encode())
except BlockingIOError:
raise RegistryError(translate('Another ProxMenux OCI operation is running. Try again when it finishes.')) from None
except RegistryError:
raise
except (RuntimeError, ValueError, OSError, subprocess.TimeoutExpired) as error:
detail = str(error).casefold()
if getattr(error, 'errno', None) == errno.ENOSPC or 'does not fit' in detail or 'no space left' in detail:
raise RegistryError(translate('Not enough free space on {storage}. Free some space or select another storage.').format(storage=storage)) from None
if isinstance(error, PermissionError) or getattr(error, 'errno', None) == errno.EROFS:
raise RegistryError(translate('Cannot write the image archive to {storage}. Check storage access and mount status.').format(storage=storage)) from None
if 'unsafe' in detail or 'symbolic link' in detail:
raise RegistryError(translate('The image cache path is unsafe. Check the template storage before trying again.')) from None
if 'integrity' in detail or 'intact image' in detail:
raise RegistryError(translate('Image integrity verification failed. The partial download was discarded; try again.')) from None
raise RegistryError(translate('The image could not be downloaded or verified. Check registry and storage access, then try again.')) from None
return {'reference': reference, 'archive': str(path), 'digest': digest, 'storage': storage,
'size': Path(path).stat().st_size, 'architecture': host_architecture()}
def registry_accounts_menu(ui, engine=ENGINE_ROOT, auth=None):
"""Manage private registry accounts and images in ProxMenux."""
require_root()
engine = Path(engine)
if str(engine / 'remote') not in sys.path:
sys.path.insert(0, str(engine / 'remote'))
auth = auth or RegistryAuth()
cancelled = importlib.import_module('proxmenux_oci.ui').UserCancelled
title = translate('Registry accounts')
while True:
# A broken authfile must let the user return to ProxMenux instead of
# showing the same error indefinitely before a menu can be displayed.
try:
rows = auth.accounts()
except (RegistryError, OSError, ValueError) as error:
ui.message(clean(error, 600), title)
return
try:
options = [('add', translate('Sign in to a registry'))]
options += [(f'account-{index}', f"{row['registry']:<28} {row['account'] or row['method']} [{translate('saved')}]")
for index, row in enumerate(rows)]
options += [('storage', translate('Credential storage and access'))]
count = translate('{count} saved account.') if len(rows) == 1 else translate('{count} saved accounts.')
choice = ui.choose(translate('Manage private image registries.\n'
'Saved credentials let ProxMenux access your private images.')
+ '\n\n' + count.format(count=len(rows)),
options, 'account-0' if rows else 'add', title=title, show_tags=False)
if choice is None:
return
row = None
if choice.startswith('account-'):
try:
row = rows[int(choice.partition('-')[2])]
except (ValueError, IndexError):
continue
choice = ui.choose(translate('Registry: {registry}\nAccount: {account}\n'
'Authentication: {method}\n\n'
'Saved credentials are verified when an image is accessed.').format(
registry=row['registry'], account=row['account'] or translate('Credential helper'),
method=row['method']),
[('inspect', translate('Check access to an image')),
('download', translate('Download an image to template storage')),
('install', translate('Install an image as a native LXC')),
('login', translate('Update credentials')),
('logout', translate('Remove saved credentials'))], 'inspect', title=title, show_tags=False)
if choice is None:
continue
if choice == 'storage':
ui.message(translate('Credentials file:\n{path}\n\n'
'Owned by root; directory mode 700 and file mode 600.\n'
'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)
elif choice in {'add', 'login'}:
registry = row['registry'].split('/', 1)[0] if row else ui.ask(translate('Registry hostname'), 'ghcr.io')
try:
registry = validate_registry(registry)
except RegistryError as error:
raise RegistryError(str(error)) from None
username = ui.ask(translate('Username'), row['account'] if row else None)
password = ui.password(translate('Password / access token (image pull permission)'))
ui.info(translate('Verifying registry access. Please wait...'))
auth.login(registry, username, password)
del password
ui.message(translate('Signed in to {registry} as {username}.\n\n'
'Credentials are saved for ProxMenux.\n'
'Select the account to check, download or install an image.').format(
registry=registry, username=clean(username)), title)
elif choice == 'logout' and row:
registry = row['registry'].split('/', 1)[0]
if ui.confirm(translate('Remove the saved credentials for {registry}?\nRunning apps will keep working.').format(registry=registry),
False, title=title):
auth.logout(registry)
ui.message(translate('Saved credentials for {registry} were removed.\n'
'Other Skopeo/Docker credential files are managed separately.').format(registry=registry), title)
elif choice in {'inspect', 'download', 'install'} and row:
registry = row['registry'].split('/', 1)[0]
owner = row['account'] or 'owner'
reference = validate_image(ui.ask(translate('Image reference'), f'{registry}/{owner}/app:latest'))
if registry_of(reference) != registry:
raise RegistryError(translate('This account is for {registry}. Enter an image from that registry.').format(registry=registry))
if choice == 'inspect':
ui.info(translate('Checking image access and metadata. Please wait...'))
result = inspect_registry_image(reference)
ui.message(translate('Image access confirmed.\n\n{reference}\n\n'
'Architecture: {architecture}\nVersion: {version}\n'
'Download size: {size}\nDigest: {digest}').format(
reference=reference, architecture=result['architecture'],
version=result['version'] or translate('Not declared'),
size=bytes_label(result['size']), digest=result['digest']), title)
elif choice == 'download':
host = importlib.import_module('proxmenux_oci.host')
storages = host.storages('vztmpl')
if not storages:
raise RegistryError(translate('No active Proxmox storage accepts OCI template archives.'))
names = [item['storage'] for item in storages]
storage = ui.choose(translate('Choose storage for the downloaded image.'),
[(item['storage'], f"{item['storage']} ({bytes_label(item.get('avail', 0))} {translate('available')})")
for item in storages], 'local' if 'local' in names else names[0], title=title)
if storage is None:
continue
ui.info(translate('Downloading and verifying the image.\nThis can take a few minutes. Please wait...'))
result = download_registry_image(reference, storage)
ui.message(translate('Image downloaded and verified.\n\n{reference}\n\n'
'Storage: {storage}\nArchive size: {size}\n'
'Archive: {archive}\n\nNo container was created.\n'
'ProxMenux removes unused cached images after successful updates.').format(
reference=reference, storage=storage, size=bytes_label(result['size']),
archive=result['archive']), title)
else:
ui.info(translate('Reading image metadata. Please wait...'))
with registry_environment():
template = image_template(reference)
if not template.get('compatibility', {}).get('automatic_install_candidate'):
raise RegistryError(translate('ProxMenux cannot install this image automatically. Import its Compose file instead.'))
installer = importlib.import_module('proxmenux_oci.installer')
native_cli = importlib.import_module('proxmenux_oci.cli')
mode = ui.choose(translate('Installation settings'), [(installer.DEFAULT_MODE, translate('Default settings')),
(installer.ADVANCED_MODE, translate('Advanced settings'))], installer.DEFAULT_MODE, title=title)
if mode is not None:
native_cli.install_template(ui, template, template['id'], mode)
except cancelled:
continue
except (RegistryError, OSError, ValueError) as error:
ui.message(clean(error, 600), title)
def open_registry_accounts(ui):
try:
registry_accounts_menu(ui)
except (RegistryError, OSError, ValueError) as error:
ui.message(clean(error, 600), translate('Registry accounts'))
+334
View File
@@ -0,0 +1,334 @@
# ==========================================================
# ProxMenux - Private OCI registry accounts
# ==========================================================
# Author : Jonatan
# GitHub : github.com/jcastro
# Maintainer : MacRimi
# Copyright : (c) 2026 MacRimi & contributors
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
# Version : 1.0
# Last Updated: 16/10/2026
# ==========================================================
# Description:
# Manage persistent Skopeo credentials for private registries.
# Validate accounts without exposing tokens and preserve saved
# file credentials when authentication fails.
# ==========================================================
"""Persistent Skopeo authentication for private ProxMenux OCI images."""
from __future__ import annotations
import base64
from contextlib import contextmanager
import fcntl
import ipaddress
import json
import os
from pathlib import Path
import re
import stat
import subprocess
import tempfile
from .i18n import translate
ANSI = re.compile(r'\x1b\][^\x07]*(?:\x07|\x1b\\)|\x1b\[[0-?]*[ -/]*[@-~]')
class RegistryError(RuntimeError):
pass
def clean(value, limit=180):
text = ANSI.sub('', str(value or ''))
return ''.join(c for c in text if ord(c) >= 32 and ord(c) != 127)[:limit]
def image_repository(reference):
repository = reference.split('@', 1)[0]
if ':' in repository.rsplit('/', 1)[-1]:
repository = repository.rsplit(':', 1)[0]
return repository
def require_root():
if os.geteuid() != 0:
raise RegistryError(translate('Manage registry accounts as root on the Proxmox node.'))
def validate_registry(value):
"""Accept a registry authority, never a URL, image path or credentials."""
value = str(value).strip().lower()
invalid = translate('Use a registry hostname, for example ghcr.io or registry.example.com:5000.')
if not value or len(value) > 260 or any(c in value for c in '/@?#\\'):
raise RegistryError(invalid)
if value.startswith('['):
match = re.fullmatch(r'\[([^]]+)\](?::([0-9]+))?', value)
try:
if not match or ipaddress.ip_address(match[1]).version != 6:
raise ValueError()
except ValueError:
raise RegistryError(invalid) from None
port = match[2]
else:
match = re.fullmatch(r'([a-z0-9](?:[a-z0-9.-]*[a-z0-9])?)(?::([0-9]+))?', value)
if not match or any(not re.fullmatch(r'[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?', part)
for part in match[1].split('.')):
raise RegistryError(invalid)
port = match[2]
if port and not 1 <= int(port) <= 65535:
raise RegistryError(invalid)
return {'index.docker.io': 'docker.io', 'registry-1.docker.io': 'docker.io'}.get(value, value)
def credential_key(value):
"""Compare existing Docker aliases without exposing credential contents."""
value = str(value).removeprefix('https://').removeprefix('http://').rstrip('/')
return {'index.docker.io/v1': 'docker.io', 'index.docker.io': 'docker.io',
'registry-1.docker.io': 'docker.io'}.get(value, value)
def registry_of(reference):
first = image_repository(reference).split('/', 1)[0]
if '/' in reference and ('.' in first or ':' in first or first == 'localhost'):
return first
return 'docker.io'
def validate_image(value):
"""Validate before the native importer, including registry ports and IPv6."""
invalid = translate('Use an image reference, for example ghcr.io/owner/app:latest.')
if not value or len(value) > 512 or any(c.isspace() or ord(c) < 32 for c in value):
raise RegistryError(invalid)
name, separator, digest = value.partition('@')
if separator and not re.fullmatch(r'sha256:[a-f0-9]{64}', digest):
raise RegistryError(translate('Only a full sha256 image digest is supported.'))
last = name.rsplit('/', 1)[-1]
if ':' in last:
name, tag = name.rsplit(':', 1)
if not re.fullmatch(r'[A-Za-z0-9_][A-Za-z0-9._-]{0,127}', tag):
raise RegistryError(translate('Invalid image tag.'))
parts = name.split('/')
if len(parts) > 1 and ('.' in parts[0] or ':' in parts[0] or parts[0] == 'localhost'):
try:
validate_registry(parts.pop(0))
except RegistryError:
raise RegistryError(invalid) from None
component = r'[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*'
if not parts or len(name) > 255 or any(not re.fullmatch(component, part) for part in parts):
raise RegistryError(invalid)
return value
def registry_error(registry, error='', login=False):
"""Classify errors without echoing registry-controlled text or a token."""
detail = str(error).casefold()
if any(word in detail for word in ('credential helper', 'docker-credential-', 'error getting credentials', 'error storing credentials')):
return translate('The configured credential helper could not be used. Check its installation and credential store.')
if any(word in detail for word in ('too many requests', 'toomanyrequests', 'statuscode: 429', 'status code: 429', 'status: 429')):
return translate('{registry} is rate limiting requests. Wait a while and try again.').format(registry=registry)
if any(word in detail for word in ('statuscode: 500', 'status code: 500', 'status: 500',
'statuscode: 502', 'status code: 502', 'status: 502',
'statuscode: 503', 'status code: 503', 'status: 503',
'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')):
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',
'first record does not look like a tls handshake')):
return translate('{registry} answered with plain HTTP. Use a registry with HTTPS; TLS verification remains enabled.').format(registry=registry)
if any(word in detail for word in ('x509', 'certificate', 'tls handshake')):
return translate('TLS verification failed for {registry}. Install the registry CA in /etc/containers/certs.d/{registry}/ca.crt.').format(registry=registry)
if any(word in detail for word in ('no such host', 'connection refused', 'deadline exceeded', 'timeout', 'timed out',
'network is unreachable', 'connection reset', 'unexpected eof')):
return translate('Could not reach {registry}. Check its address, DNS and network access.').format(registry=registry)
if any(word in detail for word in ('manifest unknown', 'name unknown', 'not found', 'statuscode: 404', 'status code: 404')):
return (translate('No registry API was found at {registry}. Check the hostname and port.').format(registry=registry) if login else
translate('The image or tag was not found on {registry}, or this account cannot access it. Check the reference.').format(registry=registry))
if any(word in detail for word in ('invalid manifest', 'invalid character', 'unsupported manifest', 'unsupported schema')):
return translate('{registry} returned invalid or unsupported image metadata.').format(registry=registry)
return (translate('Could not sign in to {registry}. Check registry access and token permissions.').format(registry=registry) if login else
translate('Could not read the image from {registry}. Check the reference and sign in from Registry accounts.').format(registry=registry))
class RegistryAuth:
"""Persistent Skopeo credentials for the native ProxMenux menu.
Only the selected authfile is managed. Login is staged so failed attempts
never replace saved file credentials. Skopeo still manages credential helpers.
"""
def __init__(self, path=None):
configured = os.environ.get('REGISTRY_AUTH_FILE')
config_home = Path(os.environ.get('XDG_CONFIG_HOME') or Path.home() / '.config')
self.path = Path(path if path is not None else configured or config_home / 'containers/auth.json')
if not self.path.is_absolute() or '..' in self.path.parts:
raise RegistryError(translate('The registry authentication file must have an absolute path.'))
def _validate_path(self):
for path in [self.path, *self.path.parents]:
if path.is_symlink():
raise RegistryError(translate('The registry authentication path cannot contain symbolic links.'))
for path in self.path.parents:
if not path.exists():
continue
info = path.stat()
shared_temporary = info.st_uid == 0 and info.st_mode & stat.S_ISVTX
if (not stat.S_ISDIR(info.st_mode) or info.st_uid not in {0, os.geteuid()}
or info.st_mode & 0o022 and not shared_temporary):
raise RegistryError(translate('The registry authentication path has an unsafe parent directory.'))
parent = self.path.parent
if parent.exists():
info = parent.stat()
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o077:
raise RegistryError(translate('The registry credential directory must be owned by root with mode 700.'))
def _read(self):
self._validate_path()
try:
fd = os.open(self.path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
except FileNotFoundError:
return {}, None
with os.fdopen(fd, 'rb') as source:
info = os.fstat(source.fileno())
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o077:
raise RegistryError(translate('The registry authentication file must be owned by root with mode 600.'))
raw = source.read(1024 * 1024 + 1)
try:
data = json.loads(raw) if len(raw) <= 1024 * 1024 else None
except ValueError:
data = None
if not isinstance(data, dict) or any(not isinstance(data.get(key, {}), dict) for key in ('auths', 'credHelpers')):
raise RegistryError(translate('The registry authentication file is invalid; it was not changed.'))
return data, raw
def accounts(self):
data, _ = self._read()
rows = {}
for key, credential in data.get('auths', {}).items():
if not isinstance(credential, dict):
continue
username = None
encoded = credential.get('auth')
if isinstance(encoded, str) and len(encoded) < 65536:
try:
decoded = base64.b64decode(encoded, validate=True).decode('utf-8')
if ':' in decoded:
username = clean(decoded.split(':', 1)[0], 120)
except (ValueError, UnicodeError):
pass
if encoded or credential.get('identitytoken') or credential.get('registrytoken'):
rows[credential_key(key)] = {'registry': clean(credential_key(key), 320), 'account': username,
'method': translate('Token') if not encoded else translate('Username / token'), 'status': 'Saved'}
for key, helper in data.get('credHelpers', {}).items():
rows[credential_key(key)] = {'registry': clean(credential_key(key), 320), 'account': None,
'method': translate('Helper:') + ' ' + clean(helper, 80), 'status': 'Configured'}
return sorted(rows.values(), key=lambda row: row['registry'])
@contextmanager
def _staged(self):
self._validate_path()
self.path.parent.mkdir(parents=True, mode=0o700, exist_ok=True)
self._validate_path()
lock = self.path.with_name('.' + self.path.name + '.proxmenux.lock')
fd = os.open(lock, os.O_CREAT | os.O_WRONLY | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600)
try:
info = os.fstat(fd)
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o077:
raise RegistryError(translate('Unsafe registry credential lock.'))
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise RegistryError(translate('Another registry account operation is running. Try again when it finishes.')) from None
data, before = self._read()
stage_fd, name = tempfile.mkstemp(prefix='.proxmenux-auth-', dir=self.path.parent)
stage = Path(name)
try:
with os.fdopen(stage_fd, 'wb') as out:
out.write(before if before is not None else b'{"auths":{}}\n')
yield stage, data
self._read_stage(stage)
if self._read()[1] != before:
raise RegistryError(translate('The credential file changed during this operation. Try again; it was not overwritten.'))
stage.chmod(0o600)
with stage.open('rb') as saved:
os.fsync(saved.fileno())
os.replace(stage, self.path)
finally:
stage.unlink(missing_ok=True)
finally:
os.close(fd)
@staticmethod
def _read_stage(stage):
try:
data = json.loads(stage.read_text())
if not isinstance(data, dict) or any(not isinstance(data.get(k, {}), dict) for k in ('auths', 'credHelpers')):
raise ValueError()
return data
except (OSError, ValueError):
raise RegistryError(translate('Skopeo returned an invalid credential file; saved credentials were not changed.')) from None
def login(self, registry, username, password, timeout=45):
registry = validate_registry(registry)
if not username or not username.strip() or len(username) > 512 or any(ord(c) < 32 or ord(c) == 127 or c == ':' for c in username):
raise RegistryError(translate('Enter a valid registry username.'))
if not password or len(password) > 65536 or any(c in password for c in '\r\n\0'):
raise RegistryError(translate('Enter a non-empty password or access token on a single line.'))
with self._staged() as (stage, _):
try:
process = subprocess.run(['skopeo', 'login', '--authfile', str(stage), '--username', username,
'--password-stdin', registry], input=password + '\n', text=True,
capture_output=True, timeout=timeout)
except subprocess.TimeoutExpired:
raise RegistryError(translate('Login to {registry} timed out. Saved credentials were not changed.').format(registry=registry)) from None
except FileNotFoundError:
raise RegistryError(translate('Skopeo is not available. Restore the ProxMenux OCI dependencies and try again.')) from None
if process.returncode:
raise RegistryError(registry_error(registry, process.stderr, login=True))
def logout(self, registry):
registry = validate_registry(registry)
data, _ = self._read()
matches = lambda key: credential_key(key).split('/', 1)[0] == registry
if not any(matches(key) for group in ('auths', 'credHelpers') for key in data.get(group, {})):
return False
with self._staged() as (stage, data):
# Skopeo logs out the authority itself; entries scoped to a
# repository are also removed from this managed authfile below.
if any(credential_key(key) == registry for group in ('auths', 'credHelpers') for key in data.get(group, {})):
try:
process = subprocess.run(['skopeo', 'logout', '--authfile', str(stage), registry],
capture_output=True, text=True, timeout=30)
except subprocess.TimeoutExpired:
raise RegistryError(translate('Logout from {registry} timed out. Saved file credentials were not changed.').format(registry=registry)) from None
except FileNotFoundError:
raise RegistryError(translate('Skopeo is not available. Restore the ProxMenux OCI dependencies and try again.')) from None
if process.returncode:
raise RegistryError(translate('Could not remove credentials for {registry}; saved file credentials were not changed.').format(registry=registry))
data = self._read_stage(stage)
for group in ('auths', 'credHelpers'):
data[group] = {key: value for key, value in data.get(group, {}).items() if not matches(key)}
stage.write_text(json.dumps(data))
return True
@contextmanager
def registry_environment():
"""Use durable credentials for every descendant, including update probes."""
previous = os.environ.get('REGISTRY_AUTH_FILE')
auth = RegistryAuth()
if auth.path.exists():
auth._read()
os.environ['REGISTRY_AUTH_FILE'] = str(auth.path)
try:
yield
finally:
if previous is None:
os.environ.pop('REGISTRY_AUTH_FILE', None)
else:
os.environ['REGISTRY_AUTH_FILE'] = previous
+168
View File
@@ -0,0 +1,168 @@
import contextlib
import io
from pathlib import Path
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"))
sys.path.insert(0, str(ROOT / "remote"))
from proxmenux_oci import registry_accounts as accounts
class Cancelled(RuntimeError):
pass
class UI:
def __init__(self, choices, answers=(), password='MENU_TEST_TOKEN'):
self.choices = iter(choices)
self.answers = iter(answers)
self.password_value = password
self.seen = []
def choose(self, text, options, default=None, **kwargs):
self.seen.append((text, options, kwargs))
return next(self.choices)
def ask(self, text, default=None):
self.seen.append(text)
return next(self.answers)
def password(self, text):
self.seen.append(text)
if isinstance(self.password_value, Exception):
raise self.password_value
return self.password_value
def message(self, text, title=None):
self.seen.append((title, text))
def info(self, text, title=None):
self.seen.append((title, text))
def confirm(self, text, default=False, **kwargs):
self.seen.append(text)
return False
class RegistryMenuTests(unittest.TestCase):
def run_menu(self, ui, auth, extra_modules=None):
modules = {'proxmenux_oci.ui': SimpleNamespace(UserCancelled=Cancelled),
'proxmenux_oci.host': SimpleNamespace(storages=lambda content: [{'storage': 'local', 'avail': 10**9}])}
modules.update(extra_modules or {})
with patch.object(accounts, 'require_root'), patch.object(accounts.importlib, 'import_module', side_effect=lambda name: modules[name]):
accounts.registry_accounts_menu(ui, engine='/fake/engine', auth=auth)
def account(self):
return {'registry': 'ghcr.io', 'account': 'example', 'method': 'Username / token', 'status': 'Saved'}
def auth(self, rows=None):
return SimpleNamespace(accounts=MagicMock(return_value=rows or []), login=MagicMock(), logout=MagicMock())
def test_native_menu_can_add_an_account_without_exposing_the_token(self):
auth = self.auth()
ui = UI(['add', None], ['ghcr.io', 'example'])
self.run_menu(ui, auth)
auth.login.assert_called_once_with('ghcr.io', 'example', 'MENU_TEST_TOKEN')
self.assertNotIn('MENU_TEST_TOKEN', repr(ui.seen))
def test_cancelled_password_does_not_change_credentials(self):
auth = self.auth()
ui = UI(['add', None], ['ghcr.io', 'example'], password=Cancelled())
self.run_menu(ui, auth)
auth.login.assert_not_called()
def test_invalid_authority_cannot_reach_login(self):
auth = self.auth()
ui = UI(['add', None], ['https://ghcr.io'])
self.run_menu(ui, auth)
auth.login.assert_not_called()
self.assertIn('registry hostname', repr(ui.seen))
def test_account_can_download_through_the_shared_native_cache_helper(self):
auth = self.auth([self.account()])
ui = UI(['account-0', 'download', 'local', None], ['ghcr.io/example/private:latest'])
result = {'archive': '/cache/proxmenux-update-amd64-fixture.tar', 'size': 1024}
with patch.object(accounts, 'download_registry_image', return_value=result) as download:
self.run_menu(ui, auth)
download.assert_called_once_with('ghcr.io/example/private:latest', 'local')
self.assertIn('Image downloaded and verified', repr(ui.seen))
def test_selected_account_does_not_pull_from_another_registry(self):
auth = self.auth([self.account()])
ui = UI(['account-0', 'download', None], ['quay.io/example/private:latest'])
with patch.object(accounts, 'download_registry_image') as download:
self.run_menu(ui, auth)
download.assert_not_called()
def test_logout_requires_confirmation_in_the_menu(self):
auth = self.auth([self.account()])
ui = UI(['account-0', 'logout', None])
self.run_menu(ui, auth)
auth.logout.assert_not_called()
def test_menu_offers_install_download_and_credential_management(self):
auth = self.auth([self.account()])
ui = UI(['account-0', None, None])
self.run_menu(ui, auth)
actions = ui.seen[1][1]
self.assertEqual([tag for tag, label in actions], ['inspect', 'download', 'install', 'login', 'logout'])
def test_menu_preserves_the_configured_language_when_an_operation_fails(self):
from proxmenux_oci import i18n
cache = {'Saved': 'Guardado'}
with patch.object(i18n, '_language', 'es'), patch.object(i18n, '_cache', cache), \
patch.object(accounts, 'registry_accounts_menu', side_effect=RuntimeError('test')):
with self.assertRaises(RuntimeError):
accounts.open_registry_accounts(UI([]))
self.assertEqual(i18n._language, 'es')
self.assertIs(i18n._cache, cache)
def test_native_main_menu_dispatches_to_registry_accounts(self):
from proxmenux_oci import cli
catalog = SimpleNamespace(index_path=SimpleNamespace(exists=lambda: True),
load_index=lambda: {'applications': []})
ui = UI(['registries', None])
with patch.object(cli, 'interactive_ui', return_value=ui), \
patch.object(accounts, 'open_registry_accounts') as open_accounts:
self.assertEqual(cli.interactive(catalog), 0)
open_accounts.assert_called_once_with(ui)
def test_terminal_fallback_can_sign_in_without_exposing_the_password(self):
from proxmenux_oci.ui import TerminalUI
auth = self.auth()
output = io.StringIO()
with patch.object(accounts, 'require_root'), \
patch('builtins.input', side_effect=['1', 'registry.test', 'example', '0']), \
patch('getpass.getpass', return_value='MENU_TEST_TOKEN'), \
contextlib.redirect_stdout(output):
accounts.registry_accounts_menu(TerminalUI(), auth=auth)
auth.login.assert_called_once_with('registry.test', 'example', 'MENU_TEST_TOKEN')
self.assertIn('Signed in to registry.test', output.getvalue())
self.assertNotIn('MENU_TEST_TOKEN', output.getvalue())
def test_startup_configuration_errors_return_a_message_to_the_main_menu(self):
from proxmenux_oci.registry_auth import RegistryError
ui = UI([])
with patch.object(accounts, 'registry_accounts_menu', side_effect=RegistryError('Invalid auth path')):
accounts.open_registry_accounts(ui)
self.assertIn('Invalid auth path', repr(ui.seen))
def test_private_image_install_uses_the_existing_native_lxc_wizard(self):
auth = self.auth([self.account()])
ui = UI(['account-0', 'install', 'default', None], ['ghcr.io/example/private:latest'])
template = {'id': 'private-app', 'compatibility': {'automatic_install_candidate': True}}
install = MagicMock()
modules = {'proxmenux_oci.installer': SimpleNamespace(DEFAULT_MODE='default', ADVANCED_MODE='advanced'),
'proxmenux_oci.cli': SimpleNamespace(install_template=install)}
with patch.object(accounts, 'image_template', return_value=template), \
patch.object(accounts, 'registry_environment', contextlib.nullcontext):
self.run_menu(ui, auth, modules)
install.assert_called_once_with(ui, template, 'private-app', 'default')
if __name__ == '__main__':
unittest.main()
+224
View File
@@ -0,0 +1,224 @@
import base64
import contextlib
import errno
import io
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
sys.path.insert(0, str(ROOT / "remote"))
from proxmenux_oci import registry_auth as auth_module
class AuthenticationTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.directory = Path(self.temp.name).resolve() / 'containers'
self.directory.mkdir(mode=0o700)
self.path = self.directory / 'auth.json'
self.auth = auth_module.RegistryAuth(self.path)
self.token = 'TEST_TOKEN_MUST_NEVER_APPEAR_IN_OUTPUT'
self.output = io.StringIO()
def save(self, registry='ghcr.io', username='example', token='old-token'):
data = {'auths': {registry: {'auth': base64.b64encode(f'{username}:{token}'.encode()).decode()}}}
self.path.write_text(json.dumps(data))
self.path.chmod(0o600)
return data
def skopeo_login(self, argv, **kwargs):
self.assertEqual(argv[:2], ['skopeo', 'login'])
self.assertNotIn(self.token, ' '.join(argv))
self.assertNotIn('env', kwargs)
self.assertEqual(kwargs['input'], self.token + '\n')
stage = Path(argv[argv.index('--authfile') + 1])
self.assertEqual(stage.stat().st_mode & 0o777, 0o600)
data = json.loads(stage.read_text())
data.setdefault('auths', {})[argv[-1]] = {'auth': base64.b64encode(f'new-user:{self.token}'.encode()).decode()}
stage.write_text(json.dumps(data))
return SimpleNamespace(returncode=0, stderr='', stdout='Login Succeeded!')
def test_login_is_atomic_private_and_preserves_other_accounts(self):
original = self.save('docker.io')
with patch.object(auth_module.subprocess, 'run', side_effect=self.skopeo_login):
self.auth.login('ghcr.io', 'new-user', self.token)
data = json.loads(self.path.read_text())
self.assertEqual(data['auths']['docker.io'], original['auths']['docker.io'])
self.assertIn('ghcr.io', data['auths'])
self.assertEqual(self.path.stat().st_mode & 0o777, 0o600)
self.assertEqual(list(self.directory.glob('.proxmenux-auth-*')), [])
def test_failed_login_retains_previous_file_and_never_echoes_registry_error(self):
self.save()
before = self.path.read_bytes()
result = SimpleNamespace(returncode=1, stderr='unauthorized: ' + self.token)
with patch.object(auth_module.subprocess, 'run', return_value=result), self.assertRaises(auth_module.RegistryError) as error:
self.auth.login('ghcr.io', 'new-user', self.token)
self.assertIn('Authentication failed', str(error.exception))
self.assertNotIn(self.token, str(error.exception))
self.assertEqual(self.path.read_bytes(), before)
self.assertEqual(list(self.directory.glob('.proxmenux-auth-*')), [])
def test_timed_out_login_does_not_create_a_credential_file(self):
with patch.object(auth_module.subprocess, 'run', side_effect=subprocess.TimeoutExpired('skopeo', 5)), \
self.assertRaises(auth_module.RegistryError):
self.auth.login('ghcr.io', 'new-user', self.token, 5)
self.assertFalse(self.path.exists())
def test_external_credential_edit_is_not_overwritten(self):
self.save()
def concurrent_edit(argv, **kwargs):
result = self.skopeo_login(argv, **kwargs)
self.save('quay.io', 'other-task')
return result
with patch.object(auth_module.subprocess, 'run', side_effect=concurrent_edit), self.assertRaises(auth_module.RegistryError):
self.auth.login('ghcr.io', 'new-user', self.token)
self.assertEqual(set(json.loads(self.path.read_text())['auths']), {'quay.io'})
def test_empty_listing_does_not_create_paths(self):
path = self.directory / 'new' / 'auth.json'
auth = auth_module.RegistryAuth(path)
self.assertEqual(auth.accounts(), [])
self.assertFalse(path.parent.exists())
def test_logout_preserves_unrelated_accounts(self):
self.save()
data = json.loads(self.path.read_text())
data['auths']['docker.io'] = {'auth': base64.b64encode(b'user:other-token').decode()}
self.path.write_text(json.dumps(data))
def logout(argv, **kwargs):
stage = Path(argv[argv.index('--authfile') + 1])
saved = json.loads(stage.read_text())
saved['auths'].pop('ghcr.io')
stage.write_text(json.dumps(saved))
return SimpleNamespace(returncode=0)
with patch.object(auth_module.subprocess, 'run', side_effect=logout):
self.assertTrue(self.auth.logout('ghcr.io'))
self.assertEqual(set(json.loads(self.path.read_text())['auths']), {'docker.io'})
def test_logout_unknown_registry_does_not_modify_file(self):
self.save()
before = self.path.read_bytes()
with patch.object(auth_module.subprocess, 'run') as run:
self.assertFalse(self.auth.logout('quay.io'))
run.assert_not_called()
self.assertEqual(self.path.read_bytes(), before)
def test_logout_removes_repository_scopes_without_affecting_other_registries(self):
self.save('ghcr.io/private/team')
with patch.object(auth_module.subprocess, 'run') as run:
self.assertTrue(self.auth.logout('ghcr.io'))
run.assert_not_called()
self.assertFalse(json.loads(self.path.read_text())['auths'])
def test_symlink_authfile_is_rejected_without_reading_target(self):
target = self.directory / 'other'
target.write_text('sensitive target')
self.path.symlink_to(target)
with self.assertRaises(auth_module.RegistryError):
self.auth.accounts()
self.assertEqual(target.read_text(), 'sensitive target')
def test_insecure_directory_or_file_permissions_are_rejected(self):
self.save()
self.path.chmod(0o644)
with self.assertRaises(auth_module.RegistryError):
self.auth.accounts()
self.path.chmod(0o600)
self.directory.chmod(0o755)
with self.assertRaises(auth_module.RegistryError):
self.auth.accounts()
def test_non_regular_credential_file_cannot_block_the_command(self):
os.mkfifo(self.path, mode=0o600)
with self.assertRaises(auth_module.RegistryError):
self.auth.accounts()
def test_writable_parent_directory_is_rejected(self):
self.directory.parent.chmod(0o777)
self.addCleanup(self.directory.parent.chmod, 0o700)
with self.assertRaises(auth_module.RegistryError):
self.auth.accounts()
def test_corrupt_file_is_not_overwritten(self):
self.path.write_text('{invalid')
self.path.chmod(0o600)
with patch.object(auth_module.subprocess, 'run') as run, self.assertRaises(auth_module.RegistryError):
self.auth.login('ghcr.io', 'new-user', self.token)
run.assert_not_called()
self.assertEqual(self.path.read_text(), '{invalid')
def test_durable_authfile_is_used_by_descendants_and_environment_is_restored(self):
self.save()
with patch.dict(os.environ, {'REGISTRY_AUTH_FILE': str(self.path)}):
with auth_module.registry_environment():
self.assertEqual(os.environ['REGISTRY_AUTH_FILE'], str(self.path))
self.assertEqual(os.environ['REGISTRY_AUTH_FILE'], str(self.path))
with patch.dict(os.environ, clear=True), patch.object(auth_module.Path, 'home', return_value=self.directory.parent):
# Use the normal XDG_CONFIG_HOME fallback without touching /root.
os.environ['XDG_CONFIG_HOME'] = str(self.directory.parent)
with auth_module.registry_environment():
self.assertEqual(os.environ['REGISTRY_AUTH_FILE'], str(self.path))
self.assertNotIn('REGISTRY_AUTH_FILE', os.environ)
def test_account_listing_never_returns_a_password_or_token(self):
self.save(token=self.token)
with patch.object(auth_module.subprocess, 'run') as run:
rows = self.auth.accounts()
run.assert_not_called()
text = json.dumps(rows)
self.assertEqual(rows[0]['account'], 'example')
self.assertNotIn(self.token, text)
self.assertNotIn(base64.b64encode(f'example:{self.token}'.encode()).decode(), text)
def test_invalid_staged_credentials_cannot_replace_a_working_account(self):
self.save()
before = self.path.read_bytes()
def corrupt_login(argv, **kwargs):
Path(argv[argv.index('--authfile') + 1]).write_text('{invalid')
return SimpleNamespace(returncode=0, stderr='')
with patch.object(auth_module.subprocess, 'run', side_effect=corrupt_login), \
self.assertRaises(auth_module.RegistryError):
self.auth.login('ghcr.io', 'new-user', self.token)
self.assertEqual(self.path.read_bytes(), before)
self.assertEqual(list(self.directory.glob('.proxmenux-auth-*')), [])
def test_credential_commit_failure_preserves_saved_file_and_cleans_staging(self):
for failure in (OSError(errno.ENOSPC, 'No space left on device'),
PermissionError(errno.EACCES, 'Permission denied')):
self.save()
before = self.path.read_bytes()
with self.subTest(error=type(failure).__name__), \
patch.object(auth_module.subprocess, 'run', side_effect=self.skopeo_login), \
patch.object(auth_module.os, 'replace', side_effect=failure), \
self.assertRaises(OSError):
self.auth.login('ghcr.io', 'new-user', self.token)
self.assertEqual(self.path.read_bytes(), before)
self.assertEqual(list(self.directory.glob('.proxmenux-auth-*')), [])
def test_failed_or_timed_out_logout_keeps_saved_accounts(self):
self.save()
before = self.path.read_bytes()
for failure in (None, subprocess.TimeoutExpired('skopeo', 30), FileNotFoundError()):
response = SimpleNamespace(returncode=1, stderr=self.token)
with self.subTest(error=type(failure).__name__), \
patch.object(auth_module.subprocess, 'run', side_effect=failure, return_value=response), \
self.assertRaises(auth_module.RegistryError) as error:
self.auth.logout('ghcr.io')
self.assertNotIn(self.token, str(error.exception))
self.assertEqual(self.path.read_bytes(), before)
self.assertEqual(list(self.directory.glob('.proxmenux-auth-*')), [])
if __name__ == '__main__':
unittest.main()
+160
View File
@@ -0,0 +1,160 @@
from contextlib import nullcontext
import errno
import io
from pathlib import Path
import subprocess
import sys
import tempfile
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"))
sys.path.insert(0, str(ROOT / "remote"))
from proxmenux_oci import registry_auth as auth
from proxmenux_oci import registry_accounts as accounts
class FailureHandlingTests(unittest.TestCase):
def setUp(self):
self.platform = patch.object(accounts, 'host_architecture', return_value='amd64')
self.platform.start()
self.addCleanup(self.platform.stop)
def test_transport_errors_are_specific_and_never_echo_server_text(self):
cases = [
('unauthorized', 'Access denied'), ('denied', 'Access denied'),
('manifest unknown', 'image or tag was not found'),
('received unexpected HTTP status: 404 Not Found', 'image or tag was not found'),
('toomanyrequests', 'rate limiting'), ('StatusCode: 429', 'rate limiting'),
('500 Internal Server Error', 'server error'), ('503 Service Unavailable', 'server error'),
('x509: certificate has expired', 'TLS verification failed'),
('x509: certificate signed by unknown authority', 'TLS verification failed'),
('http: server gave HTTP response to HTTPS client', 'plain HTTP'),
('tls: first record does not look like a TLS handshake', 'plain HTTP'),
('dial: no such host', 'Could not reach'), ('connection refused', 'Could not reach'),
('i/o timeout', 'Could not reach'), ('connection reset', 'Could not reach'),
('invalid character in manifest', 'invalid or unsupported'),
('docker-credential-missing: executable file not found', 'credential helper'),
]
for error, expected in cases:
with self.subTest(error=error):
message = auth.registry_error('registry.test', error + ' SECRET_TOKEN\x1b[31m')
self.assertIn(expected, message)
self.assertNotIn('SECRET_TOKEN', message)
self.assertNotIn('\x1b', message)
def test_bad_login_credentials_have_a_specific_safe_error(self):
for error in ('invalid username/password', 'invalid username', 'invalid password',
'invalid credentials', 'unauthorized',
'Requesting bearer token: invalid status code from registry 403 (Forbidden)'):
with self.subTest(error=error):
message = auth.registry_error('registry.test', error + ' SECRET_TOKEN', login=True)
self.assertIn('Authentication failed', message)
self.assertNotIn('SECRET_TOKEN', message)
def test_corrupt_authfile_does_not_trap_the_user_in_a_message_loop(self):
class Cancelled(RuntimeError):
pass
ui = SimpleNamespace(message=MagicMock(), choose=MagicMock())
saved = SimpleNamespace(accounts=MagicMock(side_effect=auth.RegistryError('Invalid auth file')))
with patch.object(accounts, 'require_root'), \
patch.object(accounts.importlib, 'import_module', return_value=SimpleNamespace(UserCancelled=Cancelled)):
accounts.registry_accounts_menu(ui, auth=saved)
ui.message.assert_called_once()
ui.choose.assert_not_called()
def images(self):
image = SimpleNamespace(command=MagicMock())
def resolve(ref, architecture):
image.command('skopeo', 'inspect', '--raw', 'docker://' + ref)
return {'manifest_digest': 'sha256:' + 'a' * 64, 'size': 10, 'version': '1.0'}
image.resolve_candidate = resolve
return image
def test_native_resolver_transport_hook_is_scoped_and_restored(self):
image = self.images()
original = image.command
with patch.object(accounts.importlib, 'import_module', return_value=image), \
patch.object(accounts, 'registry_environment', nullcontext), \
patch.object(accounts.subprocess, 'run', return_value=SimpleNamespace(returncode=0, stdout=b'{}')):
self.assertEqual(accounts.inspect_registry_image('registry.test/team/app:latest')['version'], '1.0')
self.assertIs(image.command, original)
def test_native_resolver_restores_transport_hook_after_access_denied(self):
image = self.images()
original = image.command
response = SimpleNamespace(returncode=1, stderr=b'unauthorized SECRET_TOKEN')
with patch.object(accounts.importlib, 'import_module', return_value=image), \
patch.object(accounts, 'registry_environment', nullcontext), \
patch.object(accounts.subprocess, 'run', return_value=response), \
self.assertRaises(auth.RegistryError) as error:
accounts.inspect_registry_image('registry.test/team/app:latest')
self.assertIn('Access denied', str(error.exception))
self.assertNotIn('SECRET_TOKEN', str(error.exception))
self.assertIs(image.command, original)
def test_image_query_timeout_does_not_fall_back_to_a_long_native_timeout(self):
image = self.images()
with patch.object(accounts.importlib, 'import_module', return_value=image), \
patch.object(accounts, 'registry_environment', nullcontext), \
patch.object(accounts.subprocess, 'run', side_effect=subprocess.TimeoutExpired('skopeo', 30)), \
self.assertRaises(auth.RegistryError) as error:
accounts.inspect_registry_image('registry.test/team/app:latest')
self.assertIn('within 30 seconds', str(error.exception))
def test_wrong_architecture_and_malformed_metadata_do_not_recommend_relogin(self):
for native_error, expected in [(ValueError('A single matching image platform cannot be resolved'), 'compatible Linux'),
(ValueError('Invalid JSON'), 'invalid or unsupported'),
(KeyError('config'), 'incomplete or unsupported'),
(AttributeError('bad config'), 'incomplete or unsupported')]:
image = self.images()
image.resolve_candidate = MagicMock(side_effect=native_error)
with self.subTest(error=type(native_error).__name__), \
patch.object(accounts.importlib, 'import_module', return_value=image), \
patch.object(accounts, 'registry_environment', nullcontext), \
self.assertRaises(auth.RegistryError) as error:
accounts.inspect_registry_image('registry.test/team/app:latest')
self.assertIn(expected, str(error.exception))
def test_storage_failures_have_storage_messages_and_keep_native_lock_scope(self):
for failure, expected in [(ValueError('The new image does not fit on the storage'), 'Not enough free space'),
(OSError(errno.ENOSPC, 'No space left on device'), 'Not enough free space'),
(PermissionError('denied'), 'Cannot write'),
(OSError(errno.EROFS, 'Read-only file system'), 'Cannot write'),
(ValueError('Unsafe OCI archive path'), 'unsafe'),
(RuntimeError('Could not obtain an intact image'), 'integrity verification')]:
native = SimpleNamespace(instances=SimpleNamespace(ROOT='/fixture', locked=lambda root: nullcontext()),
resolve_archive=MagicMock(side_effect=failure))
with self.subTest(error=str(failure)), patch.object(accounts, 'inspect_registry_image'), \
patch.object(accounts.importlib, 'import_module', return_value=native), \
patch.object(accounts, 'registry_environment', nullcontext), \
self.assertRaises(auth.RegistryError) as error:
accounts.download_registry_image('registry.test/team/app:latest', 'local')
self.assertIn(expected, str(error.exception))
def test_missing_skopeo_does_not_create_a_saved_account(self):
with tempfile.TemporaryDirectory() as temp:
path = Path(temp).resolve() / 'containers/auth.json'
manager = auth.RegistryAuth(path)
with patch.object(auth.subprocess, 'run', side_effect=FileNotFoundError()), \
self.assertRaises(auth.RegistryError) as error:
manager.login('registry.test', 'example', 'example-token')
self.assertIn('Skopeo is not available', str(error.exception))
self.assertFalse(path.exists())
def test_invalid_usernames_and_multiline_or_empty_tokens_never_reach_skopeo(self):
with tempfile.TemporaryDirectory() as temp:
manager = auth.RegistryAuth(Path(temp).resolve() / 'containers/auth.json')
for username, token in [('', 'token'), (' ', 'token'), ('user:colon', 'token'),
('user\x7f', 'token'), ('name', ''), ('name', 'x\ny'),
('name', 'x\0y'), ('name', 'x' * 65537)]:
with self.subTest(username=username, token_length=len(token)), \
patch.object(auth.subprocess, 'run') as run, self.assertRaises(auth.RegistryError):
manager.login('registry.test', username, token)
run.assert_not_called()
if __name__ == '__main__':
unittest.main()
+45
View File
@@ -0,0 +1,45 @@
from pathlib import Path
import json
import sys
import unittest
from types import SimpleNamespace
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
sys.path.insert(0, str(ROOT / "remote"))
from proxmenux_oci.registry_auth import RegistryError, validate_registry, validate_image
class ReferenceTests(unittest.TestCase):
def test_native_image_importer_preserves_registry_port_and_digest(self):
from proxmenux_oci import custom
import yaml
for reference in ('registry.test:5443/team/app:stable',
'[::1]:5443/team/app@sha256:' + 'a' * 64):
with self.subTest(reference=reference), \
patch.object(custom.subprocess, 'run', return_value=SimpleNamespace(
returncode=0, stdout=json.dumps({'config': {}}))) as run:
compose = yaml.safe_load(custom.compose_from_image(reference))
self.assertEqual(next(iter(compose['services'].values()))['image'], reference)
self.assertEqual(run.call_args.args[0][-1], 'docker://' + reference)
def test_private_registries_with_ports_and_digest_references_are_valid(self):
for reference in ('ghcr.io/team/app:latest', 'registry.local:5000/team/app:stable',
'[::1]:5000/team/app:latest', 'ghcr.io/team/app@sha256:' + 'a' * 64):
self.assertEqual(validate_image(reference), reference)
def test_credentials_and_urls_cannot_be_used_as_image_references(self):
for reference in ('https://ghcr.io/team/app', 'user:token@ghcr.io/app', 'ghcr.io/app;id',
'ghcr.io/../app', 'ghcr.io/app@sha256:short'):
with self.assertRaises(RegistryError):
validate_image(reference)
def test_registry_authorities_do_not_accept_image_paths_or_credentials(self):
for registry in ('https://ghcr.io', 'ghcr.io/team', 'user:token@ghcr.io', 'host:99999'):
with self.assertRaises(RegistryError):
validate_registry(registry)
if __name__ == '__main__':
unittest.main()