mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 18:32:13 +00:00
- Check that the Monitor answers on its socket, not only that it exists - Report the applications whose check failed instead of failing the whole request - Show those applications as not checked, without their older result - Tell the user in the menu when a check fails
162 lines
6.0 KiB
Python
162 lines
6.0 KiB
Python
"""Root-only local access to the Monitor's existing OCI update checker.
|
|
|
|
The terminal menu must not run a second registry checker or write sidecars in
|
|
another process. Requests execute in the Monitor, using its normal locks,
|
|
registration rules, retries and /etc/proxmenux/apps results. No TCP API or
|
|
authentication setting is changed.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import atexit
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
import errno
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import socket
|
|
import socketserver
|
|
import stat
|
|
import struct
|
|
import threading
|
|
|
|
SOCKET = Path('/run/proxmenux-monitor/oci-check.sock')
|
|
_check_lock = threading.Lock()
|
|
_MAX_REQUEST = 128 * 1024
|
|
_CHECK_WORKERS = 16
|
|
|
|
|
|
def _root_peer(connection):
|
|
if not hasattr(socket, 'SO_PEERCRED'):
|
|
return False
|
|
credentials = connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize('3i'))
|
|
return struct.unpack('3i', credentials)[1] == 0
|
|
|
|
|
|
def _applications(request):
|
|
if not isinstance(request, dict) or set(request) != {'applications'}:
|
|
raise ValueError('Invalid request')
|
|
rows = request['applications']
|
|
if not isinstance(rows, list) or len(rows) > 1000:
|
|
raise ValueError('Invalid applications')
|
|
seen = set()
|
|
for row in rows:
|
|
if (not isinstance(row, dict) or set(row) != {'vmid', 'installation_id'}
|
|
or type(row['vmid']) is not int or not 100 <= row['vmid'] <= 999999999
|
|
or row['vmid'] in seen or not isinstance(row['installation_id'], str)
|
|
or not 1 <= len(row['installation_id']) <= 128):
|
|
raise ValueError('Invalid application')
|
|
seen.add(row['vmid'])
|
|
return rows
|
|
|
|
|
|
def check_applications(module, rows, report):
|
|
"""Delegate exclusively to check_app, exactly as the Monitor's Check does."""
|
|
report({'completed': 0, 'total': len(rows)})
|
|
stopped = threading.Event()
|
|
|
|
def check_one(row):
|
|
if stopped.is_set():
|
|
return
|
|
vmid = row['vmid']
|
|
record = module._read_oci_record(vmid) or {}
|
|
if (record.get('installation_id') == row['installation_id']
|
|
and record.get('status') == 'installed'
|
|
and not record.get('pending_transaction')
|
|
and not record.get('pending_stack_transaction')
|
|
and not module._oci_secondary_member(record)):
|
|
# Honour dismissals and reuse the Monitor's registration and
|
|
# checker. The menu never invents registrations or notifications.
|
|
module.ensure_oci_registration(vmid)
|
|
sidecar = module.load_sidecar(vmid) or {}
|
|
app = next((app for app in sidecar.get('apps', [])
|
|
if app.get('installed_via') == 'oci_image'), None)
|
|
if app:
|
|
module.check_app(vmid, app['id'], force=True, notify=False)
|
|
|
|
pool = ThreadPoolExecutor(max_workers=_CHECK_WORKERS, thread_name_prefix='oci-check')
|
|
failed = []
|
|
try:
|
|
futures = {pool.submit(check_one, row): row['vmid'] for row in rows}
|
|
for completed, future in enumerate(as_completed(futures), 1):
|
|
try:
|
|
future.result()
|
|
except Exception:
|
|
failed.append(futures[future]) # One failure never stops the other applications.
|
|
report({'completed': completed, 'total': len(rows)})
|
|
finally:
|
|
stopped.set()
|
|
pool.shutdown(wait=True, cancel_futures=True)
|
|
report({'done': True, 'failed': sorted(failed)})
|
|
|
|
|
|
class _Handler(socketserver.StreamRequestHandler):
|
|
def handle(self):
|
|
if not _root_peer(self.request):
|
|
return
|
|
self.request.settimeout(10)
|
|
acquired = False
|
|
try:
|
|
line = self.rfile.readline(_MAX_REQUEST + 1)
|
|
if len(line) > _MAX_REQUEST or not line.endswith(b'\n'):
|
|
return
|
|
rows = _applications(json.loads(line))
|
|
acquired = _check_lock.acquire(blocking=False)
|
|
if not acquired:
|
|
self.report({'error': 'check_running'})
|
|
return
|
|
check_applications(self.server.apps, rows, self.report)
|
|
except (BrokenPipeError, ConnectionResetError, TimeoutError):
|
|
pass # Closing the menu stops the rest of this request.
|
|
except Exception:
|
|
try:
|
|
self.report({'error': 'check_failed'})
|
|
except OSError:
|
|
pass
|
|
finally:
|
|
if acquired:
|
|
_check_lock.release()
|
|
|
|
def report(self, event):
|
|
self.wfile.write((json.dumps(event) + '\n').encode())
|
|
self.wfile.flush()
|
|
|
|
|
|
class _Server(socketserver.ThreadingUnixStreamServer):
|
|
daemon_threads = True
|
|
block_on_close = False
|
|
|
|
|
|
def start(apps, path=SOCKET):
|
|
"""Start alongside Flask; a private socket avoids web login bypasses."""
|
|
path = Path(path)
|
|
if os.geteuid() != 0:
|
|
return None
|
|
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
parent = path.parent.lstat()
|
|
if not stat.S_ISDIR(parent.st_mode) or parent.st_uid != 0 or parent.st_mode & 0o077:
|
|
raise OSError('Unsafe Monitor runtime directory')
|
|
if path.exists() or path.is_symlink():
|
|
old = path.lstat()
|
|
if not stat.S_ISSOCK(old.st_mode) or old.st_uid != 0:
|
|
raise OSError('Unsafe Monitor socket')
|
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as probe:
|
|
probe.settimeout(.2)
|
|
try:
|
|
probe.connect(str(path))
|
|
return None # Already owned by a running Monitor.
|
|
except OSError as exc:
|
|
if exc.errno not in (errno.ECONNREFUSED, errno.ENOENT):
|
|
raise
|
|
path.unlink(missing_ok=True)
|
|
server = _Server(str(path), _Handler)
|
|
server.apps = apps
|
|
os.chmod(path, 0o600)
|
|
threading.Thread(target=server.serve_forever, name='oci-menu-check', daemon=True).start()
|
|
|
|
def close():
|
|
server.shutdown()
|
|
server.server_close()
|
|
path.unlink(missing_ok=True)
|
|
atexit.register(close)
|
|
return server
|