ProxMenux 1.2.6.2-beta: OCI containers in the Monitor, docs and fixes

OCI manager Apps
- App tab: containers installed from an OCI image are identified from their
  installation record; the application and image versions are shown and an
  update is detected by image digest; repository link; Refresh data.
- Updates tab for OCI containers: Update and Recreate run the same flow as the
  OCI menu in the Monitor terminal; the pre-update backup can be kept in a
  backup storage; scheduled image updates with an optional minimum age.
- Logs tab: console output of the application, kept on the host
  (lxc.console.logfile + logrotate) and followed live.
- The Proxmox console opens a shell (cmode: shell) when the image has one.
- A damaged image download is fetched again before failing.
- Multi-container applications open at their LAN address; volume mount
  points on block storage report their usage.

Monitor
- Proxmox notifications are delivered to a loopback-only HTTP listener when
  HTTPS is enabled, so they no longer fail certificate verification.
- Log persistence counts recurring patterns only; an ended burst is not
  reported as persistent and its warning clears on its own (#386).
- Proxmox notification config backups are deduplicated and capped at three.
- The update icon on the Apps page opens the container on its Updates tab.
- Version 1.2.6.2-beta and its release notes in every Monitor language.

Docs
- OCI manager Apps and Audit & Report rebuilt as per-page message files,
  with a new page for OCI containers in the Monitor.
- Seven pages fixed where rich-text tags were missing from t.rich.

Translations
- Spanish fixes across the OCI engine, the Monitor and the TUI menus.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MacRimi
2026-09-25 21:51:12 +02:00
co-authored by Claude Opus 5.5
parent 386d33df6e
commit 4437a671d2
524 changed files with 14459 additions and 3841 deletions
@@ -0,0 +1,217 @@
import importlib.util
import json
import shutil
import socket
import ssl
import subprocess
import tempfile
import threading
import unittest
from pathlib import Path
from unittest import mock
MODULE_PATH = Path(__file__).resolve().parents[1] / "auth_manager.py"
SPEC = importlib.util.spec_from_file_location("auth_manager_ssl_under_test", MODULE_PATH)
auth_manager = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(auth_manager)
class _FakeSslSocket:
def __init__(self, context):
self.context = context
class ProxmoxCertificateHotReloadTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
if shutil.which("openssl") is None:
raise unittest.SkipTest("openssl is required for TLS fixture generation")
cls.fixture_dir = tempfile.TemporaryDirectory()
fixture_path = Path(cls.fixture_dir.name)
cls.pairs = []
for name in ("original", "renewed"):
cert_path = fixture_path / f"{name}.pem"
key_path = fixture_path / f"{name}.key"
subprocess.run(
[
"openssl", "req", "-x509", "-newkey", "rsa:2048",
"-nodes", "-days", "1", "-subj", f"/CN={name}.test",
"-keyout", str(key_path), "-out", str(cert_path),
],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
cls.pairs.append((cert_path, key_path))
@classmethod
def tearDownClass(cls):
cls.fixture_dir.cleanup()
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.temp_dir.cleanup)
temp_path = Path(self.temp_dir.name)
self.active_cert = temp_path / "pveproxy-ssl.pem"
self.active_key = temp_path / "pveproxy-ssl.key"
self.ssl_config = temp_path / "ssl_config.json"
self._install_pair(0)
self._write_config("proxmox")
self.patch = mock.patch.multiple(
auth_manager,
SSL_CONFIG_FILE=self.ssl_config,
PROXMOX_CUSTOM_CERT_PATH=str(self.active_cert),
PROXMOX_CUSTOM_KEY_PATH=str(self.active_key),
PROXMOX_CERT_PATH=str(temp_path / "missing-pve-ssl.pem"),
PROXMOX_KEY_PATH=str(temp_path / "missing-pve-ssl.key"),
)
self.patch.start()
self.addCleanup(self.patch.stop)
self.addCleanup(self._reset_runtime)
def _reset_runtime(self):
with auth_manager._SSL_RUNTIME_LOCK:
auth_manager._SSL_RUNTIME_CONTEXT = None
auth_manager._SSL_RUNTIME_FINGERPRINT = ""
auth_manager._SSL_RUNTIME_CERT_PATH = ""
auth_manager._SSL_RUNTIME_KEY_PATH = ""
auth_manager._SSL_RUNTIME_SOURCE = "none"
auth_manager._SSL_RUNTIME_LAST_REFRESH_ERROR = ""
def _install_pair(self, index):
cert_path, key_path = self.pairs[index]
shutil.copyfile(cert_path, self.active_cert)
shutil.copyfile(key_path, self.active_key)
def _write_config(self, source):
self.ssl_config.write_text(json.dumps({
"enabled": True,
"cert_path": str(self.active_cert),
"key_path": str(self.active_key),
"source": source,
}))
def _create_context(self):
return auth_manager.create_reloadable_ssl_context(
str(self.active_cert), str(self.active_key)
)
def test_unchanged_pair_does_not_rebuild_the_context(self):
context = self._create_context()
ssl_socket = _FakeSslSocket(context)
with mock.patch.object(
auth_manager,
"reload_server_ssl_context",
wraps=auth_manager.reload_server_ssl_context,
) as reload_mock:
context.sni_callback(ssl_socket, "proxmenux.test", context)
reload_mock.assert_not_called()
self.assertIs(ssl_socket.context, context)
def test_valid_renewed_pair_is_activated_during_the_handshake(self):
context = self._create_context()
previous_fingerprint = auth_manager._SSL_RUNTIME_FINGERPRINT
self._install_pair(1)
ssl_socket = _FakeSslSocket(context)
context.sni_callback(ssl_socket, "proxmenux.test", context)
self.assertNotEqual(previous_fingerprint, auth_manager._SSL_RUNTIME_FINGERPRINT)
self.assertIs(ssl_socket.context, auth_manager._SSL_RUNTIME_CONTEXT)
self.assertIsNot(ssl_socket.context, context)
self.assertEqual(auth_manager._SSL_RUNTIME_LAST_REFRESH_ERROR, "")
def test_mismatched_pair_keeps_the_previous_context(self):
context = self._create_context()
previous_fingerprint = auth_manager._SSL_RUNTIME_FINGERPRINT
shutil.copyfile(self.pairs[1][0], self.active_cert)
ssl_socket = _FakeSslSocket(context)
context.sni_callback(ssl_socket, "proxmenux.test", context)
self.assertEqual(previous_fingerprint, auth_manager._SSL_RUNTIME_FINGERPRINT)
self.assertIs(auth_manager._SSL_RUNTIME_CONTEXT, context)
self.assertIs(ssl_socket.context, context)
self.assertTrue(auth_manager._SSL_RUNTIME_LAST_REFRESH_ERROR)
def test_custom_certificate_source_is_not_examined_automatically(self):
self._write_config("custom")
context = self._create_context()
previous_fingerprint = auth_manager._SSL_RUNTIME_FINGERPRINT
self._install_pair(1)
ssl_socket = _FakeSslSocket(context)
context.sni_callback(ssl_socket, "proxmenux.test", context)
self.assertEqual(previous_fingerprint, auth_manager._SSL_RUNTIME_FINGERPRINT)
self.assertIs(ssl_socket.context, context)
def test_first_real_tls_connection_receives_the_renewed_certificate(self):
server_context = self._create_context()
self._install_pair(1)
server_socket, client_socket = socket.socketpair()
server_socket.settimeout(5)
client_socket.settimeout(5)
server_error = []
def serve_once():
try:
with server_context.wrap_socket(server_socket, server_side=True) as tls_socket:
tls_socket.recv(1)
except Exception as error: # pragma: no cover - asserted below
server_error.append(error)
thread = threading.Thread(target=serve_once)
thread.start()
client_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
client_context.check_hostname = False
client_context.verify_mode = ssl.CERT_NONE
with client_context.wrap_socket(
client_socket, server_hostname="proxmenux.test"
) as tls_client:
received_der = tls_client.getpeercert(binary_form=True)
tls_client.sendall(b"x")
thread.join(timeout=5)
self.assertFalse(thread.is_alive())
self.assertEqual(server_error, [])
renewed_pem = self.pairs[1][0].read_text()
self.assertEqual(received_der, ssl.PEM_cert_to_DER_cert(renewed_pem))
def test_tls_connection_without_sni_also_receives_the_renewed_certificate(self):
server_context = self._create_context()
self._install_pair(1)
server_socket, client_socket = socket.socketpair()
server_socket.settimeout(5)
client_socket.settimeout(5)
server_error = []
def serve_once():
try:
with server_context.wrap_socket(server_socket, server_side=True) as tls_socket:
tls_socket.recv(1)
except Exception as error: # pragma: no cover - asserted below
server_error.append(error)
thread = threading.Thread(target=serve_once)
thread.start()
client_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
client_context.check_hostname = False
client_context.verify_mode = ssl.CERT_NONE
with client_context.wrap_socket(client_socket) as tls_client:
received_der = tls_client.getpeercert(binary_form=True)
tls_client.sendall(b"x")
thread.join(timeout=5)
self.assertFalse(thread.is_alive())
self.assertEqual(server_error, [])
renewed_pem = self.pairs[1][0].read_text()
self.assertEqual(received_der, ssl.PEM_cert_to_DER_cert(renewed_pem))
if __name__ == "__main__":
unittest.main()