(null)
+ const setEditing: typeof setEditingState = value => {
+ // Invalidate synchronously, including before React commits a batched edit.
+ iconRequest.current += 1
+ iconAbort.current?.abort()
+ setDiscoveringLocalLogo(false)
+ setEditingState(value)
+ }
+ useEffect(() => {
+ iconRequest.current += 1
+ iconAbort.current?.abort()
+ setDiscoveringLocalLogo(false)
+ return () => {
+ iconRequest.current += 1
+ iconAbort.current?.abort()
+ }
+ }, [editing, vmid, iconEndpoint])
const [binaryArgsInput, setBinaryArgsInput] = useState("")
const [commandArgvInput, setCommandArgvInput] = useState("")
const [saving, setSaving] = useState(false)
@@ -1365,6 +1420,40 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
setDetectorTest(null)
setEditing({ ...editing, draft: { ...draft, ...patch } })
}
+ const iconPorts = draft.ports.filter(entry => typeof entry.port === "number" && entry.port > 0 && entry.port <= 65535)
+ const endpointKey = (entry: PortEntry) => `${entry.scheme || defaultSchemeFor(entry.port)}:${entry.port}`
+ const selectedIconPort = iconPorts.find(entry => endpointKey(entry) === iconEndpoint) || iconPorts[0]
+ const discoverLocalLogo = async () => {
+ if (!selectedIconPort || discoveringLocalLogo) return
+ const snapshot = editing
+ const generation = targetGeneration.current
+ const requestId = ++iconRequest.current
+ const controller = new AbortController()
+ iconAbort.current = controller
+ const current = () => iconRequest.current === requestId && editingRef.current === snapshot
+ && targetRef.current === vmid && targetGeneration.current === generation
+ setDiscoveringLocalLogo(true)
+ setError(null)
+ try {
+ const result = await fetchApi<{ logo_url: string }>(`/api/vms/${vmid}/apps/logo-discovery`, {
+ method: "POST", signal: controller.signal,
+ body: JSON.stringify({ port: selectedIconPort.port, scheme: selectedIconPort.scheme || defaultSchemeFor(selectedIconPort.port) }),
+ })
+ if (!current()) return
+ if (!LOCAL_LOGO_CACHE_PATH.test(result.logo_url) || !result.logo_url.startsWith(`/api/vms/${vmid}/`)) {
+ throw new Error("invalid icon response")
+ }
+ setEditing(previous => previous === snapshot
+ ? { ...previous, draft: { ...previous.draft, logo_url: result.logo_url } } : previous)
+ } catch (error: unknown) {
+ if (current()) {
+ const code = (error as { body?: { code?: string } })?.body?.code
+ setError(t(code === "local_logo_stopped" ? "vmLxc.appEditor.localLogoStopped" : "vmLxc.appEditor.localLogoUnavailable"))
+ }
+ } finally {
+ if (current()) setDiscoveringLocalLogo(false)
+ }
+ }
// Editing the Name auto-fills the Package field on packaged
// methods when it's still empty. Rationale: 90% of the time the
// dpkg/apk package name mirrors the friendly app name (jellyfin,
@@ -1602,13 +1691,39 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
setField({ logo_url: e.target.value })}
placeholder={t("vmLxc.appEditor.portLogoPlaceholder")}
maxLength={512}
className="text-sm mt-2 font-mono"
/>
+
+ {iconPorts.length > 1 && }
+
+ {draft.logo_url && LOCAL_LOGO_CACHE_PATH.test(draft.logo_url) &&
+ }
+
+ {t("vmLxc.appEditor.localLogoHelp")}
{/* Web links — each port becomes a clickable link (built as
diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json
index 050cd5ab..140bcca0 100644
--- a/AppImage/messages/en/common.json
+++ b/AppImage/messages/en/common.json
@@ -1790,6 +1790,11 @@
"catalogPort": "port {port}",
"trackingAvailable": "tracking available",
"logoUrlLabel": "Logo URL",
+ "discoverLocalLogo": "Find icon from local application",
+ "localLogoPort": "Application port for icon lookup",
+ "localLogoHelp": "Reads only the selected port in this running container. External icons are not fetched. The icon is applied when you save.",
+ "localLogoStopped": "The container is stopped. Start it before looking for its icon.",
+ "localLogoUnavailable": "No usable local icon was found. Check the selected web port and try again. Your current icon is unchanged.",
"webLinks": "Web links",
"addPort": "Add port",
"detectedPorts": "Ports detected in the container — click to add:",
diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json
index e2156568..5b9a15e5 100644
--- a/AppImage/messages/sk/common.json
+++ b/AppImage/messages/sk/common.json
@@ -1729,6 +1729,11 @@
"catalogPort": "port {port}",
"trackingAvailable": "možno sledovať verziu",
"logoUrlLabel": "URL loga",
+ "discoverLocalLogo": "Zistiť ikonu z lokálnej aplikácie",
+ "localLogoPort": "Port aplikácie na hľadanie ikony",
+ "localLogoHelp": "Číta iba vybraný port v tomto spustenom kontajneri. Externé ikony nesťahuje. Ikona sa použije až po uložení.",
+ "localLogoStopped": "Kontajner je vypnutý. Pred hľadaním ikony ho spusti.",
+ "localLogoUnavailable": "Použiteľná lokálna ikona sa nenašla. Skontroluj vybraný webový port a skús to znova. Pôvodná ikona zostala nezmenená.",
"webLinks": "Webové odkazy",
"addPort": "Pridať port",
"detectedPorts": "Porty nájdené v kontajneri — kliknutím ich pridáte:",
diff --git a/AppImage/scripts/build_appimage.sh b/AppImage/scripts/build_appimage.sh
index 4bee9953..af79e072 100755
--- a/AppImage/scripts/build_appimage.sh
+++ b/AppImage/scripts/build_appimage.sh
@@ -150,6 +150,7 @@ cp "$SCRIPT_DIR/lxc_update_observation.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/lxc_update_finalization.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/lxc_check_job.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/native_app_discovery.py" "$APP_DIR/usr/bin/"
+cp "$SCRIPT_DIR/local_app_icons.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/lxc_apps.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ lxc_apps.py not found"
cp "$SCRIPT_DIR/custom_links.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ custom_links.py not found"
cp "$SCRIPT_DIR/recreate_docker_container.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ recreate_docker_container.py not found"
@@ -358,7 +359,8 @@ pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" --upgrade --no-de
PyJWT \
pyotp \
segno \
- beautifulsoup4
+ beautifulsoup4 \
+ defusedxml==0.7.1
# WebSocket with modern h11 (no need for the legacy pin anymore)
pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" --upgrade \
@@ -386,6 +388,7 @@ install_gevent_runtime() {
--upgrade \
--only-binary=:all: \
--platform manylinux_2_28_x86_64 \
+ --platform manylinux_2_27_x86_64 \
--platform manylinux2014_x86_64 \
--implementation cp \
--python-version "$python_version" \
@@ -395,7 +398,8 @@ install_gevent_runtime() {
"greenlet==${GREENLET_VERSION}" \
"zope.interface==${ZOPE_INTERFACE_VERSION}" \
"zope.event==${ZOPE_EVENT_VERSION}" \
- "gevent-websocket==${GEVENT_WEBSOCKET_VERSION}"
+ "gevent-websocket==${GEVENT_WEBSOCKET_VERSION}" \
+ "Pillow==13.0.0"
}
install_gevent_runtime "3.11" "cp311"
@@ -408,7 +412,8 @@ for abi in cp311 cp313; do
for extension in \
"gevent/_gevent_c_hub_local.cpython-${abi_digits}-*.so" \
"greenlet/_greenlet.cpython-${abi_digits}-*.so" \
- "zope/interface/_zope_interface_coptimizations.cpython-${abi_digits}-*.so"; do
+ "zope/interface/_zope_interface_coptimizations.cpython-${abi_digits}-*.so" \
+ "PIL/_imaging.cpython-${abi_digits}-*.so"; do
if ! compgen -G "$abi_dir/$extension" >/dev/null; then
echo "❌ Missing ${abi} extension: ${extension}" >&2
exit 1
diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py
index 70cc4174..edd06170 100644
--- a/AppImage/scripts/flask_server.py
+++ b/AppImage/scripts/flask_server.py
@@ -14130,6 +14130,35 @@ def api_vm_apps_native_discovery(vmid):
result, suggestions, sidecar.get('apps') or []))
+@app.route('/api/vms//apps/logo-discovery', methods=['POST'])
+@require_auth
+def api_vm_apps_logo_discovery(vmid):
+ """Explicit lookup of a port on this LXC; clients cannot supply a host."""
+ import local_app_icons
+ try:
+ return jsonify(local_app_icons.discover(vmid, request.get_json(silent=True)))
+ except local_app_icons.IconError as exc:
+ return jsonify({'error': 'Local icon unavailable', 'code': exc.code}), 400
+ except Exception:
+ return jsonify({'error': 'Local icon lookup failed', 'code': 'local_logo_unavailable'}), 500
+
+
+@app.route('/api/vms//apps/logo-cache/', methods=['GET'])
+@require_auth
+def api_vm_apps_logo_cache(vmid, token):
+ """Authenticated, cache-only display, including while the LXC is stopped."""
+ import local_app_icons
+ cached = local_app_icons.read(vmid, token)
+ if not cached:
+ return jsonify({'error': 'Cached icon not found'}), 404
+ body, mime = cached
+ return Response(body, mimetype=mime, headers={
+ 'Cache-Control': 'private, no-store',
+ 'Content-Security-Policy': "default-src 'none'; style-src 'none'; sandbox",
+ 'X-Content-Type-Options': 'nosniff',
+ })
+
+
@app.route('/api/vms//apps/adguard-setup', methods=['GET'])
@require_auth
def api_vm_apps_adguard_setup(vmid):
diff --git a/AppImage/scripts/local_app_icons.py b/AppImage/scripts/local_app_icons.py
new file mode 100644
index 00000000..d61dc6f4
--- /dev/null
+++ b/AppImage/scripts/local_app_icons.py
@@ -0,0 +1,423 @@
+"""Explicit LXC icon lookup. No guest execution, background scan or app writes.
+
+Untrusted HTTP and image parsing run in a bounded child. Cache objects are
+immutable: fetching a draft cannot overwrite a registered application's icon.
+"""
+from __future__ import annotations
+
+import base64
+import contextlib
+import fcntl
+import hashlib
+import io
+import ipaddress
+import json
+import os
+from pathlib import Path
+import re
+import socket
+import ssl
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+from html.parser import HTMLParser
+
+CACHE = Path('/etc/proxmenux/apps/logo-cache')
+APPS = Path('/etc/proxmenux/apps')
+CONFIG = Path('/etc/pve/lxc')
+MAX_BYTES = 256 * 1024
+MAX_ENTRIES = 32
+MAX_DIMENSION = 2048
+MAX_PIXELS = 1024 * 1024
+WORKER_TIMEOUT = 8
+UNUSED_TTL = 24 * 3600
+SLOTS = threading.BoundedSemaphore(4)
+TOKEN = re.compile(r'^[a-f0-9]{32}$')
+URL = re.compile(r'^/api/vms/([1-9][0-9]*)/apps/logo-cache/([a-f0-9]{32})$')
+MIMES = {'image/png', 'image/jpeg', 'image/gif', 'image/webp',
+ 'image/x-icon', 'image/vnd.microsoft.icon', 'image/svg+xml'}
+
+
+class IconError(ValueError):
+ def __init__(self, message, code='local_logo_unavailable'):
+ super().__init__(message)
+ self.code = code
+
+
+def valid_url(vmid, value):
+ match = URL.fullmatch(value) if isinstance(value, str) else None
+ return bool(match and vmid is not None and int(match[1]) == int(vmid))
+
+
+def _private_ip(value):
+ try:
+ address = ipaddress.ip_address(value)
+ networks = ('10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16') if address.version == 4 else ('fc00::/7',)
+ return any(address in ipaddress.ip_network(n) for n in networks)
+ except ValueError:
+ return False
+
+
+def _run(args):
+ try:
+ result = subprocess.run(args, capture_output=True, text=True, timeout=3)
+ except (OSError, subprocess.SubprocessError) as exc:
+ raise IconError('container address unavailable') from exc
+ if result.returncode:
+ raise IconError('container address unavailable')
+ return result.stdout
+
+
+def _origin(vmid, payload):
+ if (not isinstance(payload, dict) or set(payload) - {'port', 'scheme'}
+ or type(payload.get('port')) is not int or not 1 <= payload['port'] <= 65535
+ or payload.get('scheme') not in ('http', 'https')):
+ raise IconError('select a valid web port and scheme')
+ try:
+ config = (CONFIG / f'{int(vmid)}.conf').read_text().split('\n[', 1)[0]
+ except OSError as exc:
+ raise IconError('container network unavailable') from exc
+ if (not re.search(r'^net\d+:', config, re.M) or 'proxmenux/host-monitor' in config
+ or re.search(r'^lxc\.net\.', config, re.M)):
+ raise IconError('container needs an isolated network')
+ if _run(['/usr/sbin/pct', 'status', str(vmid)]).strip() != 'status: running':
+ raise IconError('container is not running', 'local_logo_stopped')
+ ips = _run(['lxc-info', '-n', str(vmid), '-iH']).split()
+ try:
+ host_data = json.loads(_run(['ip', '-json', 'address', 'show']))
+ host_ips = {str(ipaddress.ip_address(a['local'])) for interface in host_data
+ for a in interface.get('addr_info', []) if 'local' in a}
+ except (ValueError, TypeError, KeyError) as exc:
+ raise IconError('host address check unavailable') from exc
+ addresses = sorted({str(ipaddress.ip_address(ip)) for ip in ips if _private_ip(ip)}, key=lambda ip: ':' in ip)
+ addresses = [ip for ip in addresses if ip not in host_ips]
+ if not addresses:
+ raise IconError('container has no isolated private address')
+ host = addresses[0]
+ return f"{payload['scheme']}://{'[' + host + ']' if ':' in host else host}:{payload['port']}"
+
+
+def _same_origin(left, right):
+ try:
+ a, b = urllib.parse.urlsplit(left), urllib.parse.urlsplit(right)
+ return (a.username is None and b.username is None and a.password is None and b.password is None
+ and (a.scheme, a.hostname, a.port or (443 if a.scheme == 'https' else 80))
+ == (b.scheme, b.hostname, b.port or (443 if b.scheme == 'https' else 80)))
+ except ValueError:
+ return False
+
+
+class _Redirect(urllib.request.HTTPRedirectHandler):
+ def __init__(self, origin, page):
+ self.origin, self.remaining = origin, int(page)
+
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
+ if not self.remaining or not _same_origin(self.origin, newurl):
+ raise IconError('redirect outside selected application')
+ self.remaining -= 1
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
+
+
+def _open(url, page=False):
+ # Deliberately do not inherit HTTP(S)_PROXY or credentials/cookies.
+ context = ssl.create_default_context()
+ context.check_hostname = False
+ context.verify_mode = ssl.CERT_NONE
+ opener = urllib.request.build_opener(urllib.request.ProxyHandler({}),
+ _Redirect(url, page), urllib.request.HTTPSHandler(context=context))
+ return opener.open(urllib.request.Request(url, headers={
+ 'User-Agent': 'ProxMenux-Monitor/local-icon', 'Accept-Encoding': 'identity',
+ }), timeout=3)
+
+
+def _read(response):
+ body = response.read(MAX_BYTES + 1)
+ if not body or len(body) > MAX_BYTES:
+ raise IconError('empty or oversized icon response')
+ return body
+
+
+class _Links(HTMLParser):
+ def __init__(self):
+ super().__init__(convert_charrefs=True)
+ self.links = []
+
+ def handle_starttag(self, tag, attrs):
+ values = dict(attrs)
+ if tag.lower() == 'link' and set((values.get('rel') or '').lower().split()) & {'icon', 'apple-touch-icon'}:
+ self.links.append(values.get('href') or '')
+
+
+def _svg(body):
+ from defusedxml import ElementTree as safe_xml
+ from xml.etree import ElementTree as xml
+ try:
+ original = safe_xml.fromstring(body, forbid_dtd=True, forbid_entities=True, forbid_external=True)
+ except Exception as exc:
+ raise IconError('invalid SVG') from exc
+ ns = 'http://www.w3.org/2000/svg'
+ tags = {'svg', 'g', 'defs', 'path', 'rect', 'circle', 'ellipse', 'line', 'polyline',
+ 'polygon', 'linearGradient', 'radialGradient', 'stop', 'clipPath', 'title', 'desc'}
+ attrs = {'viewBox', 'width', 'height', 'x', 'y', 'x1', 'x2', 'y1', 'y2', 'cx', 'cy',
+ 'r', 'rx', 'ry', 'd', 'points', 'fill', 'stroke', 'stroke-width', 'opacity',
+ 'fill-opacity', 'stroke-opacity', 'fill-rule', 'clip-rule', 'stroke-linecap',
+ 'stroke-linejoin', 'stroke-miterlimit', 'transform', 'id', 'offset',
+ 'stop-color', 'stop-opacity', 'gradientUnits', 'gradientTransform', 'fx', 'fy', 'clip-path'}
+ if original.tag not in ('svg', '{' + ns + '}svg'):
+ raise IconError('invalid SVG root')
+ count = 0
+ def clean(node, depth=0):
+ nonlocal count
+ count += 1
+ if depth > 32 or count > 2048:
+ raise IconError('SVG complexity limit')
+ name = node.tag.removeprefix('{' + ns + '}')
+ if name not in tags:
+ return None
+ out = xml.Element('{' + ns + '}' + name)
+ for key, value in node.attrib.items():
+ if key not in attrs or len(value) > 32768:
+ continue
+ # Only local fragment paint/clip references; no CSS, href, events,
+ # foreign namespaces, entities, animation, images, filters or use.
+ if re.search(r'url\s*\(', value, re.I):
+ if not re.fullmatch(r'url\(#[A-Za-z_][\w.-]*\)', value):
+ continue
+ elif any(char in value for char in ('\\', ':', '<', '>', '&')):
+ continue
+ if key in ('width', 'height') and name == 'svg':
+ if not re.fullmatch(r'\d+(?:\.\d+)?(?:px)?', value) or not 0 < float(value.removesuffix('px')) <= MAX_DIMENSION:
+ continue
+ out.set(key, value)
+ if name in ('title', 'desc'):
+ out.text = (node.text or '')[:512]
+ for child in node:
+ cleaned = clean(child, depth + 1)
+ if cleaned is not None:
+ out.append(cleaned)
+ return out
+ result = clean(original)
+ xml.register_namespace('', ns)
+ output = xml.tostring(result, encoding='utf-8')
+ if len(output) > MAX_BYTES:
+ raise IconError('normalized SVG too large')
+ return output, 'image/svg+xml'
+
+
+def normalize(body, mime):
+ if not isinstance(body, bytes) or not 0 < len(body) <= MAX_BYTES or mime not in MIMES:
+ raise IconError('invalid icon type or size')
+ if mime == 'image/svg+xml':
+ return _svg(body)
+ from PIL import Image
+ expected = {'image/png': 'PNG', 'image/jpeg': 'JPEG', 'image/gif': 'GIF',
+ 'image/webp': 'WEBP', 'image/x-icon': 'ICO', 'image/vnd.microsoft.icon': 'ICO'}
+ try:
+ with Image.open(io.BytesIO(body)) as picture:
+ if picture.format != expected[mime]:
+ raise IconError('image format does not match content type')
+ w, h = picture.size
+ if not 0 < w <= MAX_DIMENSION or not 0 < h <= MAX_DIMENSION or w * h > MAX_PIXELS:
+ raise IconError('image dimensions too large')
+ picture.load()
+ out = io.BytesIO()
+ picture.convert('RGBA').save(out, format='PNG')
+ result = out.getvalue()
+ if len(result) > MAX_BYTES:
+ raise IconError('normalized icon too large')
+ return result, 'image/png'
+ except IconError:
+ raise
+ except Exception as exc:
+ raise IconError('invalid image data') from exc
+
+
+def _fetch(origin):
+ parsed = urllib.parse.urlsplit(origin)
+ if (parsed.scheme not in ('http', 'https') or not _private_ip(parsed.hostname or '')
+ or parsed.username is not None or parsed.password is not None or parsed.path
+ or parsed.query or parsed.fragment or not parsed.port):
+ raise IconError('invalid local origin')
+ with _open(origin + '/', page=True) as response:
+ final = response.geturl()
+ if not _same_origin(origin, final) or response.headers.get_content_type() not in ('text/html', 'application/xhtml+xml'):
+ raise IconError('invalid application page')
+ document = _read(response)
+ parser = _Links()
+ parser.feed(document.decode('utf-8', errors='replace'))
+ links = [urllib.parse.urljoin(final, link) for link in parser.links if link]
+ candidates = [link for link in links if _same_origin(origin, link)]
+ if links and not candidates:
+ raise IconError('external icon not permitted')
+ icon_url = candidates[0] if candidates else origin + '/favicon.ico'
+ with _open(icon_url) as response:
+ if response.geturl() != icon_url:
+ raise IconError('icon redirect not permitted')
+ return normalize(_read(response), response.headers.get_content_type())
+
+
+def _worker(origin):
+ try:
+ result = subprocess.run([sys.executable, str(Path(__file__).resolve()), '--fetch', origin],
+ capture_output=True, timeout=WORKER_TIMEOUT)
+ if result.returncode or len(result.stdout) > MAX_BYTES * 2:
+ raise IconError('local icon unavailable')
+ data = json.loads(result.stdout)
+ body = base64.b64decode(data['body'], validate=True)
+ if not body or len(body) > MAX_BYTES or data['mime'] not in ('image/png', 'image/svg+xml'):
+ raise IconError('invalid image worker response')
+ return body, data['mime']
+ except (OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError) as exc:
+ raise IconError('local icon unavailable') from exc
+
+
+@contextlib.contextmanager
+def _cache_lock(vmid):
+ directory = CACHE / str(int(vmid))
+ directory.mkdir(parents=True, exist_ok=True, mode=0o700)
+ with open(directory / '.lock', 'a') as handle:
+ deadline = time.monotonic() + 2
+ while True:
+ try:
+ fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ break
+ except BlockingIOError:
+ if time.monotonic() >= deadline:
+ raise IconError('local icon cache busy')
+ time.sleep(0.02)
+ yield directory
+
+
+def _references(sidecar):
+ apps = sidecar['apps']
+ if not isinstance(apps, list):
+ raise ValueError('invalid apps')
+ refs = {a.get('logo_url') for a in apps}
+ refs.update(p.get('logo_url') for a in apps for p in a.get('ports', []))
+ return refs
+
+
+@contextlib.contextmanager
+def protect_references(vmid, sidecar):
+ """Serialize persistence against GC; an expired draft cannot save a dead URL.
+
+ Existing missing references are preserved so unrelated checks/edits can
+ still save. Only newly introduced local URLs require a readable object.
+ Callers hold this lock through their atomic sidecar replacement.
+ """
+ refs = {url for url in _references(sidecar) if valid_url(vmid, url)}
+ if not refs:
+ yield
+ return
+ with _cache_lock(vmid):
+ try:
+ previous = _references(json.loads((APPS / f'{vmid}.json').read_text()))
+ except FileNotFoundError:
+ previous = set()
+ except (ValueError, TypeError, KeyError, AttributeError) as exc:
+ raise IconError('cannot verify saved icon references') from exc
+ for url in refs - previous:
+ if read(vmid, url.rsplit('/', 1)[1]) is None:
+ raise IconError('cached icon expired; find the icon again')
+ yield
+
+
+def _prune(vmid, directory):
+ # Read failure means unknown references: retain everything.
+ try:
+ sidecar = json.loads((APPS / f'{vmid}.json').read_text())
+ refs = _references(sidecar)
+ except FileNotFoundError:
+ refs = set()
+ except (ValueError, KeyError, TypeError, OSError, AttributeError):
+ return
+ for path in directory.glob('*.icon'):
+ url = f'/api/vms/{vmid}/apps/logo-cache/{path.stem}'
+ if url not in refs and time.time() - path.stat().st_mtime > UNUSED_TTL:
+ path.unlink()
+
+
+def store(vmid, body, mime):
+ token = hashlib.sha256(str(vmid).encode() + b'\0' + mime.encode() + b'\0' + body).hexdigest()[:32]
+ with _cache_lock(vmid) as directory:
+ dest = directory / (token + '.icon')
+ if dest.exists():
+ # Renew the draft's lease without changing immutable image bytes.
+ # Re-finding an old orphan must give the editor a full save window.
+ os.utime(dest, None)
+ else:
+ _prune(vmid, directory)
+ if len(list(directory.glob('*.icon'))) >= MAX_ENTRIES:
+ raise IconError('local icon cache is full')
+ tmp = None
+ try:
+ with tempfile.NamedTemporaryFile(mode='w', dir=directory, delete=False) as handle:
+ tmp = handle.name
+ json.dump({'mime': mime, 'body': base64.b64encode(body).decode()}, handle)
+ handle.flush()
+ os.fsync(handle.fileno())
+ os.replace(tmp, dest)
+ finally:
+ if tmp and os.path.exists(tmp):
+ os.unlink(tmp)
+ return f'/api/vms/{vmid}/apps/logo-cache/{token}'
+
+
+def discover(vmid, payload):
+ if not SLOTS.acquire(blocking=False):
+ raise IconError('local icon lookup busy')
+ try:
+ origin = _origin(vmid, payload)
+ body, mime = _worker(origin)
+ # Confirm that the guest still owns the chosen address before caching.
+ if _origin(vmid, payload) != origin:
+ raise IconError('container address changed')
+ return {'logo_url': store(vmid, body, mime)}
+ except OSError as exc:
+ raise IconError('could not cache the icon') from exc
+ finally:
+ SLOTS.release()
+
+
+def read(vmid, token):
+ """Cache-only read, including validation of older testhost cache objects."""
+ if not TOKEN.fullmatch(token):
+ return None
+ directory = CACHE / str(int(vmid))
+ try:
+ path = directory / (token + '.icon')
+ if path.exists():
+ with path.open('rb') as handle:
+ raw = handle.read(MAX_BYTES * 2 + 1)
+ if len(raw) > MAX_BYTES * 2:
+ return None
+ data = json.loads(raw)
+ body, mime = base64.b64decode(data['body'], validate=True), data['mime']
+ else:
+ # Compatibility with earlier local builds; never serve legacy raw SVG.
+ with (directory / (token + '.json')).open('rb') as handle:
+ meta = json.loads(handle.read(4096))
+ with (directory / (token + '.bin')).open('rb') as handle:
+ body = handle.read(MAX_BYTES + 1)
+ mime = meta['content_type']
+ return normalize(body, mime)
+ except (OSError, ValueError, KeyError, TypeError, ImportError):
+ return None
+
+
+if __name__ == '__main__':
+ import resource
+ resource.setrlimit(resource.RLIMIT_AS, (512 * 1024 * 1024,) * 2)
+ resource.setrlimit(resource.RLIMIT_CPU, (4, 4))
+ try:
+ if len(sys.argv) != 3 or sys.argv[1] != '--fetch':
+ raise IconError('invalid worker arguments')
+ body, mime = _fetch(sys.argv[2])
+ print(json.dumps({'mime': mime, 'body': base64.b64encode(body).decode()}))
+ except Exception:
+ sys.exit(1)
diff --git a/AppImage/scripts/lxc_apps.py b/AppImage/scripts/lxc_apps.py
index 4b6a5a2e..3bcda894 100644
--- a/AppImage/scripts/lxc_apps.py
+++ b/AppImage/scripts/lxc_apps.py
@@ -45,6 +45,7 @@ import urllib.parse
import urllib.request
import uuid
from typing import Any, Optional
+import local_app_icons
_APPS_DIR = "/etc/proxmenux/apps"
_PCT_BIN = "/usr/sbin/pct"
@@ -598,15 +599,16 @@ def _write_sidecar(vmid, data: dict) -> bool:
path = _sidecar_path(vmid)
tmp = f"{path}.tmp.{os.getpid()}"
try:
- with open(tmp, "w") as f:
- json.dump({k: v for k, v in data.items() if k != '_revision'}, f, indent=2, sort_keys=True)
- os.chmod(tmp, 0o600)
- with _sidecar_cache_lock:
- os.replace(tmp, path)
- snapshot = _publish_sidecar_snapshot(path, data, _sidecar_signature(os.stat(path)))
- data['_revision'] = snapshot['_revision']
+ with local_app_icons.protect_references(vmid, data):
+ with open(tmp, "w") as f:
+ json.dump({k: v for k, v in data.items() if k != '_revision'}, f, indent=2, sort_keys=True)
+ os.chmod(tmp, 0o600)
+ with _sidecar_cache_lock:
+ os.replace(tmp, path)
+ snapshot = _publish_sidecar_snapshot(path, data, _sidecar_signature(os.stat(path)))
+ data['_revision'] = snapshot['_revision']
return True
- except OSError as e:
+ except (OSError, local_app_icons.IconError) as e:
print(f"[ProxMenux] lxc_apps: could not write sidecar {path}: {e}")
try:
os.unlink(tmp)
@@ -933,7 +935,7 @@ def validate_bulk_update(payload: Any) -> tuple[bool, Any]:
return True, {"targets": ["os", *sorted(item for item in targets if item != "os")]}
-def validate_config(payload: dict) -> tuple[bool, Any]:
+def validate_config(payload: dict, *, vmid: Optional[int] = None) -> tuple[bool, Any]:
"""Return (True, normalised_config_without_state_id) or
(False, error). Rejects anything that would give shell-injection
at check-time. Only the five fixed installed_via methods are
@@ -1175,8 +1177,8 @@ def validate_config(payload: dict) -> tuple[bool, Any]:
# payloads through the App card's
.
logo = (payload.get("logo_url") or "").strip()
if logo:
- if not _LOGO_URL_RE.match(logo):
- return _err("logo_url must be an http(s) URL (max 512 chars)")
+ if not _LOGO_URL_RE.match(logo) and not local_app_icons.valid_url(vmid, logo):
+ return _err("logo_url must be an http(s) URL or this VM's cached local icon")
conf["logo_url"] = logo
# Native discovery identity is separate from a curated helper/updater.
@@ -3324,7 +3326,7 @@ def test_config(vmid, payload: dict) -> tuple[bool, Any]:
bad local detector from a bad repository/tag filter. Probe output is
never returned: a user-selected file or command could contain secrets.
"""
- ok, config = validate_config(payload)
+ ok, config = validate_config(payload, vmid=vmid)
if not ok:
return False, config
@@ -3408,7 +3410,7 @@ def _find_app(sidecar: dict, app_id: str) -> Optional[dict]:
def add_app(vmid, payload: dict, notify: bool = True) -> tuple[bool, Any]:
- ok, cfg = validate_config(payload)
+ ok, cfg = validate_config(payload, vmid=vmid)
if not ok:
return False, cfg
with _cache_lock:
@@ -3433,7 +3435,7 @@ def add_app(vmid, payload: dict, notify: bool = True) -> tuple[bool, Any]:
def update_app(vmid, app_id: str, payload: dict) -> tuple[bool, Any]:
- ok, cfg = validate_config(payload)
+ ok, cfg = validate_config(payload, vmid=vmid)
if not ok:
return False, cfg
with _cache_lock:
@@ -3511,7 +3513,7 @@ def delete_app(vmid, app_id: str) -> bool:
except OSError:
pass
if before != len(sidecar["apps"]):
- _write_sidecar(vmid, sidecar)
+ return _write_sidecar(vmid, sidecar)
return True
diff --git a/AppImage/scripts/tests/test_local_app_icons.py b/AppImage/scripts/tests/test_local_app_icons.py
new file mode 100644
index 00000000..0e9aede7
--- /dev/null
+++ b/AppImage/scripts/tests/test_local_app_icons.py
@@ -0,0 +1,414 @@
+"""Offline icon contracts: actual codecs, HTTP, cache transactions and routes."""
+import ast
+import base64
+from concurrent.futures import ThreadPoolExecutor
+from functools import wraps
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+import io
+import json
+import os
+from pathlib import Path
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+import unittest
+from unittest.mock import Mock, patch
+
+from flask import Flask, Response, jsonify, request
+from PIL import Image
+
+SCRIPTS = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(SCRIPTS))
+import local_app_icons as icons
+import lxc_apps
+
+
+def picture(fmt='PNG', color='red', size=(16, 16)):
+ out = io.BytesIO()
+ Image.new('RGB', size, color).save(out, format=fmt)
+ return out.getvalue()
+
+
+class Icons(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ self.root = Path(self.tmp.name)
+ self.apps = self.root / 'apps'
+ self.apps.mkdir()
+ self.config = self.root / 'lxc'
+ self.config.mkdir()
+ (self.config / '101.conf').write_text('net0: name=eth0,bridge=vmbr0,ip=dhcp\n')
+ for target, name, value in ((icons, 'CACHE', self.apps / 'logo-cache'),
+ (icons, 'APPS', self.apps), (icons, 'CONFIG', self.config),
+ (lxc_apps, '_APPS_DIR', str(self.apps))):
+ p = patch.object(target, name, value)
+ p.start()
+ self.addCleanup(p.stop)
+ self.png = picture()
+ self.payload = {'port': 8080, 'scheme': 'http'}
+
+ def resolve(self, args):
+ if args[:2] == ['/usr/sbin/pct', 'status']:
+ return 'status: running\n'
+ if args[0] == 'lxc-info':
+ return '10.0.0.42\n'
+ if args[0] == 'ip':
+ return '[{"addr_info":[{"local":"10.0.0.1"}]}]'
+ self.fail(args)
+
+ def test_actual_supported_raster_codecs_are_decoded_and_reencoded(self):
+ for fmt, mime in [('PNG', 'image/png'), ('JPEG', 'image/jpeg'), ('GIF', 'image/gif'),
+ ('WEBP', 'image/webp'), ('ICO', 'image/x-icon')]:
+ with self.subTest(fmt=fmt):
+ data, kind = icons.normalize(picture(fmt), mime)
+ self.assertEqual(kind, 'image/png')
+ self.assertEqual(Image.open(io.BytesIO(data)).size, (16, 16))
+
+ def test_html_mislabeled_as_png_wrong_mime_truncation_and_oversize_rejected(self):
+ for data, mime in [(b'bad', 'image/png'), (self.png, 'image/jpeg'),
+ (self.png[:30], 'image/png'), (self.png, 'text/html'),
+ (b'x' * (icons.MAX_BYTES + 1), 'image/png'), (b'', 'image/png'),
+ (picture(size=(2049, 1)), 'image/png'), (picture(size=(1025, 1025)), 'image/png')]:
+ with self.subTest(size=len(data), mime=mime), self.assertRaises(icons.IconError):
+ icons.normalize(data, mime)
+
+ def test_svg_active_content_external_resources_and_css_are_removed(self):
+ raw = b''''''
+ data, mime = icons.normalize(raw, 'image/svg+xml')
+ self.assertEqual(mime, 'image/svg+xml')
+ for forbidden in (b'script', b'onload', b'onclick', b'style', b'href', b'outside.test', b'foreignObject', b']>', b'',
+ b'']:
+ with self.assertRaises(icons.IconError):
+ icons.normalize(data, 'image/svg+xml')
+
+ def test_explicit_lookup_derives_address_rechecks_and_never_writes_apps(self):
+ with patch.object(icons, '_run', side_effect=self.resolve) as run, patch.object(icons, '_worker', return_value=(self.png, 'image/png')) as worker:
+ result = icons.discover(101, self.payload)
+ worker.assert_called_once_with('http://10.0.0.42:8080')
+ self.assertEqual(run.call_count, 6)
+ self.assertFalse((self.apps / '101.json').exists())
+ self.assertEqual(icons.read(101, result['logo_url'].split('/')[-1])[1], 'image/png')
+
+ def test_stopped_container_has_no_address_or_network_or_cache_calls(self):
+ with patch.object(icons, '_run', return_value='status: stopped') as run, patch.object(icons, '_worker') as worker, patch.object(icons, 'store') as store:
+ with self.assertRaises(icons.IconError) as error:
+ icons.discover(101, self.payload)
+ self.assertEqual(error.exception.code, 'local_logo_stopped')
+ self.assertEqual(run.call_count, 1)
+ worker.assert_not_called()
+ store.assert_not_called()
+
+ def test_invalid_payload_missing_guest_and_shared_network_fail_before_probe(self):
+ with patch.object(icons, '_run') as run, patch.object(icons, '_worker') as worker:
+ for payload in [None, [], {}, {'port': True, 'scheme': 'http'}, {'port': 0, 'scheme': 'http'},
+ {'port': 80, 'scheme': 'ftp'}, {**self.payload, 'url': 'http://outside.test'}]:
+ with self.assertRaises(icons.IconError):
+ icons.discover(101, payload)
+ for config in ['', 'net0: x\nlxc.net.0.type: none\n', 'net0: x\n# proxmenux/host-monitor\n']:
+ (self.config / '101.conf').write_text(config)
+ with self.assertRaises(icons.IconError):
+ icons.discover(101, self.payload)
+ with self.assertRaises(icons.IconError):
+ icons.discover(999, self.payload)
+ run.assert_not_called()
+ worker.assert_not_called()
+
+ def test_nonprivate_and_host_addresses_fail_without_network(self):
+ for ip in ['127.0.0.1', '169.254.169.254', '8.8.8.8', '::1', 'fe80::1', '10.0.0.1', 'invalid']:
+ def run(args):
+ return ip if args[0] == 'lxc-info' else self.resolve(args)
+ with patch.object(icons, '_run', side_effect=run), patch.object(icons, '_worker') as worker:
+ with self.assertRaises(icons.IconError):
+ icons.discover(101, self.payload)
+ worker.assert_not_called()
+
+ def test_ipv6_literal_and_default_ports(self):
+ with patch.object(icons, '_run', side_effect=lambda args: 'fd12::3' if args[0] == 'lxc-info' else self.resolve(args)):
+ self.assertEqual(icons._origin(101, {'port': 443, 'scheme': 'https'}), 'https://[fd12::3]:443')
+ self.assertTrue(icons._same_origin('https://10.0.0.42:443', 'https://10.0.0.42/icon'))
+ self.assertFalse(icons._same_origin('https://10.0.0.42', 'https://user@10.0.0.42/icon'))
+
+ def test_address_change_or_stop_during_request_cannot_cache(self):
+ for second in ['http://10.0.0.43:8080', icons.IconError('stopped')]:
+ with patch.object(icons, '_origin', side_effect=['http://10.0.0.42:8080', second]), patch.object(icons, '_worker', return_value=(self.png, 'image/png')), patch.object(icons, 'store') as store:
+ with self.assertRaises(icons.IconError):
+ icons.discover(101, self.payload)
+ store.assert_not_called()
+
+ def test_saturated_lookup_and_failed_process_have_no_side_effects(self):
+ slot = Mock()
+ slot.acquire.return_value = False
+ with patch.object(icons, 'SLOTS', slot), patch.object(icons, '_origin') as origin:
+ with self.assertRaises(icons.IconError):
+ icons.discover(101, self.payload)
+ origin.assert_not_called()
+ with patch.object(icons.subprocess, 'run', side_effect=subprocess.TimeoutExpired('worker', 8)):
+ with self.assertRaises(icons.IconError):
+ icons._worker('http://10.0.0.42:80')
+
+ def test_real_worker_timeout_kills_child_and_returns_promptly(self):
+ worker = self.root / 'slow.py'
+ worker.write_text('import time\ntime.sleep(20)\n')
+ started = time.monotonic()
+ with patch.object(icons, '__file__', str(worker)), patch.object(icons, 'WORKER_TIMEOUT', .15):
+ with self.assertRaises(icons.IconError):
+ icons._worker('http://10.0.0.42:80')
+ self.assertLess(time.monotonic() - started, 2)
+
+ def test_proxy_environment_is_ignored_and_private_https_context_is_explicit(self):
+ opener = Mock()
+ with patch.dict(os.environ, {'http_proxy': 'http://outside.test:1234', 'https_proxy': 'http://outside.test:1234'}), patch.object(icons.urllib.request, 'build_opener', return_value=opener) as build:
+ icons._open('https://10.0.0.42:443/')
+ handlers = build.call_args.args
+ self.assertEqual(handlers[0].proxies, {})
+ self.assertFalse(handlers[2]._context.check_hostname)
+ self.assertEqual(handlers[2]._context.verify_mode, 0)
+ req = opener.open.call_args.args[0]
+ self.assertNotIn('Authorization', req.headers)
+ self.assertNotIn('Cookie', req.headers)
+
+ def test_immutable_objects_cancel_and_failed_sidecar_write_preserve_saved_icon(self):
+ first = icons.store(101, self.png, 'image/png')
+ self.assertTrue(lxc_apps._write_sidecar(101, {'apps': [{'id': 'a', 'logo_url': first}]}))
+ second = icons.store(101, picture(color='blue'), 'image/png')
+ self.assertNotEqual(first, second)
+ original = (self.apps / '101.json').read_bytes()
+ self.assertEqual(icons.read(101, first.split('/')[-1])[0], icons.normalize(self.png, 'image/png')[0])
+ with patch.object(lxc_apps.os, 'replace', side_effect=OSError('disk full')):
+ self.assertFalse(lxc_apps._write_sidecar(101, {'apps': [{'id': 'a', 'logo_url': second}]}))
+ self.assertEqual((self.apps / '101.json').read_bytes(), original)
+ self.assertIsNotNone(icons.read(101, first.split('/')[-1]))
+
+ def test_failed_delete_reports_failure_and_keeps_all_icons_and_registration(self):
+ logo = icons.store(101, self.png, 'image/png')
+ self.assertTrue(lxc_apps._write_sidecar(101, {'apps': [{'id': 'a', 'logo_url': logo}, {'id': 'b'}]}))
+ original = (self.apps / '101.json').read_bytes()
+ with patch.object(lxc_apps, '_write_sidecar', return_value=False):
+ self.assertFalse(lxc_apps.delete_app(101, 'a'))
+ self.assertEqual((self.apps / '101.json').read_bytes(), original)
+ self.assertIsNotNone(icons.read(101, logo.split('/')[-1]))
+
+ def test_atomic_cache_write_failure_leaves_no_partial_objects(self):
+ with patch.object(icons.os, 'replace', side_effect=OSError('disk full')):
+ with self.assertRaises(OSError):
+ icons.store(101, self.png, 'image/png')
+ self.assertEqual([p.name for p in (icons.CACHE / '101').iterdir()], ['.lock'])
+
+ def test_parallel_identical_requests_make_one_immutable_object(self):
+ with ThreadPoolExecutor(max_workers=4) as pool:
+ urls = list(pool.map(lambda _: icons.store(101, self.png, 'image/png'), range(12)))
+ self.assertEqual(len(set(urls)), 1)
+ self.assertEqual(len(list((icons.CACHE / '101').glob('*.icon'))), 1)
+
+ def test_cache_ttl_boundaries_protect_saved_refs_and_reject_expired_drafts(self):
+ a = icons.store(101, self.png, 'image/png')
+ b = icons.store(101, picture(color='blue'), 'image/png')
+ self.assertTrue(lxc_apps._write_sidecar(101, {'apps': [{'logo_url': a}]}))
+ directory = icons.CACHE / '101'
+ for obj in directory.glob('*.icon'):
+ os.utime(obj, (1000, 1000))
+ for age in [icons.UNUSED_TTL - 1, icons.UNUSED_TTL]:
+ with patch.object(icons.time, 'time', return_value=1000 + age):
+ icons._prune(101, directory)
+ self.assertIsNotNone(icons.read(101, b.split('/')[-1]))
+ with patch.object(icons.time, 'time', return_value=1001 + icons.UNUSED_TTL):
+ icons._prune(101, directory)
+ self.assertIsNotNone(icons.read(101, a.split('/')[-1]))
+ self.assertIsNone(icons.read(101, b.split('/')[-1]))
+ before = (self.apps / '101.json').read_bytes()
+ self.assertFalse(lxc_apps._write_sidecar(101, {'apps': [{'logo_url': b}]}))
+ self.assertEqual((self.apps / '101.json').read_bytes(), before)
+
+ def test_rediscovered_same_content_renews_unsaved_draft_lease(self):
+ url = icons.store(101, self.png, 'image/png')
+ obj = next((icons.CACHE / '101').glob('*.icon'))
+ original = obj.read_bytes()
+ os.utime(obj, (1, 1))
+ self.assertEqual(icons.store(101, self.png, 'image/png'), url)
+ icons._prune(101, obj.parent)
+ self.assertTrue(obj.exists(), 'a just rediscovered draft must not expire using its original creation time')
+ self.assertEqual(obj.read_bytes(), original)
+
+ def test_corrupt_sidecar_retains_cache_and_quota_cannot_evict_registered_icon(self):
+ url = icons.store(101, self.png, 'image/png')
+ obj = next((icons.CACHE / '101').glob('*.icon'))
+ os.utime(obj, (1, 1))
+ (self.apps / '101.json').write_text('{broken')
+ with patch.object(icons, 'MAX_ENTRIES', 1):
+ with self.assertRaises(icons.IconError):
+ icons.store(101, picture(color='blue'), 'image/png')
+ self.assertIsNotNone(icons.read(101, url.split('/')[-1]))
+
+ def test_cache_read_has_no_probes_and_legacy_svg_is_sanitized(self):
+ directory = icons.CACHE / '101'
+ directory.mkdir(parents=True)
+ token = 'a' * 32
+ (directory / (token + '.bin')).write_bytes(b'')
+ (directory / (token + '.json')).write_text('{"content_type":"image/svg+xml"}')
+ with patch.object(icons, '_run') as run, patch.object(icons, '_worker') as worker:
+ result = icons.read(101, token)
+ self.assertNotIn(b'bad', result[0])
+ self.assertIsNone(icons.read(102, token))
+ self.assertIsNone(icons.read(101, '../' + token))
+ run.assert_not_called()
+ worker.assert_not_called()
+
+ def test_config_accepts_only_same_guest_cache_urls_and_all_existing_formats(self):
+ for url, expected in [('https://example.test/icon.png', True), ('', True),
+ ('/api/vms/101/apps/logo-cache/' + 'a' * 32, True),
+ ('/api/vms/102/apps/logo-cache/' + 'a' * 32, False), ('javascript:bad()', False)]:
+ self.assertEqual(lxc_apps.validate_config({'name': 'Example', 'ports': [], 'logo_url': url}, vmid=101)[0], expected)
+
+ def test_real_add_edit_diagnostic_and_delete_contracts(self):
+ first = icons.store(101, self.png, 'image/png')
+ second = icons.store(101, picture(color='blue'), 'image/png')
+ draft = {'name': 'Example', 'ports': [], 'logo_url': first}
+ with patch.object(lxc_apps, 'check_app') as check:
+ ok, state = lxc_apps.add_app(101, draft)
+ self.assertTrue(ok, state)
+ app_id = state['apps'][0]['id']
+ ok, state = lxc_apps.update_app(101, app_id, {**draft, 'logo_url': second})
+ self.assertTrue(ok, state)
+ self.assertEqual(state['apps'][0]['logo_url'], second)
+ self.assertEqual(check.call_count, 2)
+ # Register-only diagnostic must accept the cached URL without probing.
+ with patch.object(lxc_apps, '_pct_exec') as execute:
+ self.assertTrue(lxc_apps.test_config(101, {**draft, 'logo_url': second})[0])
+ execute.assert_not_called()
+ self.assertTrue(lxc_apps.delete_app(101, app_id))
+ self.assertFalse((self.apps / '101.json').exists())
+ for url in [first, second]:
+ self.assertIsNotNone(icons.read(101, url.split('/')[-1]))
+
+ def test_save_serializes_against_pruning_and_keeps_new_reference(self):
+ url = icons.store(101, self.png, 'image/png')
+ obj = next((icons.CACHE / '101').glob('*.icon'))
+ os.utime(obj, (1, 1))
+ entered = threading.Event()
+ def prune():
+ entered.set()
+ with icons._cache_lock(101) as directory:
+ icons._prune(101, directory)
+ with ThreadPoolExecutor(max_workers=1) as pool:
+ with icons.protect_references(101, {'apps': [{'logo_url': url}]}):
+ job = pool.submit(prune)
+ self.assertTrue(entered.wait(1))
+ self.assertFalse(job.done())
+ (self.apps / '101.json').write_text(json.dumps({'apps': [{'logo_url': url}]}))
+ job.result(timeout=3)
+ self.assertTrue(obj.exists())
+
+
+class HttpContract(unittest.TestCase):
+ def test_real_http_local_redirect_relative_icon_fallback_and_blocked_redirects(self):
+ png = picture()
+ requests = []
+ mode = ['relative']
+ class Handler(BaseHTTPRequestHandler):
+ def log_message(self, *_args): pass
+ def do_GET(self):
+ requests.append(self.path)
+ if self.path == '/' and mode[0] in ('redirect', 'external', 'loop'):
+ self.send_response(302)
+ self.send_header('Location', {'redirect': '/login/', 'external': 'http://outside.test/', 'loop': '/'}[mode[0]])
+ self.end_headers()
+ return
+ if self.path.endswith('.png') and mode[0] == 'icon-redirect':
+ self.send_response(302)
+ self.send_header('Location', '/other.png')
+ self.end_headers()
+ return
+ self.send_response(200)
+ image = self.path.endswith(('.png', '.ico'))
+ self.send_header('Content-Type', 'image/png' if image else 'text/html')
+ self.end_headers()
+ self.wfile.write(png if image else {
+ 'fallback': b'', 'external-icon': b'',
+ }.get(mode[0], b''))
+ server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ try:
+ origin = f'http://127.0.0.1:{server.server_port}'
+ # Production rejects loopback; this fixture tests only the HTTP contract.
+ with patch.object(icons, '_private_ip', return_value=True):
+ for case, paths in [('relative', ['/', '/icon.png']), ('redirect', ['/', '/login/', '/login/icon.png']), ('fallback', ['/', '/favicon.ico'])]:
+ mode[0] = case; requests.clear()
+ self.assertEqual(icons._fetch(origin)[1], 'image/png')
+ self.assertEqual(requests, paths)
+ for case in ['external', 'external-icon', 'loop', 'icon-redirect']:
+ mode[0] = case; requests.clear()
+ with self.assertRaises(icons.IconError):
+ icons._fetch(origin)
+ self.assertNotIn('/other.png', requests)
+ finally:
+ server.shutdown(); server.server_close(); thread.join()
+
+
+class Routes(unittest.TestCase):
+ def setUp(self):
+ app = Flask('icon-test')
+ def require_auth(fn):
+ @wraps(fn)
+ def authenticated(*args, **kwargs):
+ if request.headers.get('Authorization') != 'Bearer fixture':
+ return jsonify({'error': 'unauthorized'}), 401
+ return fn(*args, **kwargs)
+ return authenticated
+ env = dict(app=app, require_auth=require_auth, jsonify=jsonify, request=request, Response=Response)
+ tree = ast.parse((SCRIPTS / 'flask_server.py').read_text())
+ functions = [n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name in ('api_vm_apps_logo_discovery', 'api_vm_apps_logo_cache')]
+ self.assertEqual(len(functions), 2)
+ exec(compile(ast.Module(body=functions, type_ignores=[]), 'flask_server.py', 'exec'), env)
+ self.client = app.test_client()
+ self.headers = {'Authorization': 'Bearer fixture'}
+
+ def test_auth_post_only_and_cache_get_no_discovery(self):
+ with patch.object(icons, 'discover', return_value={'logo_url': '/test'}) as discover, patch.object(icons, 'read', return_value=(picture(), 'image/png')) as read:
+ self.assertEqual(self.client.post('/api/vms/101/apps/logo-discovery', json={}).status_code, 401)
+ self.assertEqual(self.client.get('/api/vms/101/apps/logo-cache/' + 'a' * 32).status_code, 401)
+ discover.assert_not_called(); read.assert_not_called()
+ self.assertEqual(self.client.get('/api/vms/101/apps/logo-discovery', headers=self.headers).status_code, 405)
+ response = self.client.get('/api/vms/101/apps/logo-cache/' + 'a' * 32, headers=self.headers)
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.headers['X-Content-Type-Options'], 'nosniff')
+ self.assertIn('sandbox', response.headers['Content-Security-Policy'])
+ discover.assert_not_called()
+ self.assertEqual(self.client.post('/api/vms/101/apps/logo-discovery', json={'port': 80, 'scheme': 'http'}, headers=self.headers).status_code, 200)
+ discover.assert_called_once_with(101, {'port': 80, 'scheme': 'http'})
+
+ def test_error_code_is_safe_stopped_is_distinct_and_missing_cache_is_404(self):
+ with patch.object(icons, 'discover', side_effect=icons.IconError('internal path', 'local_logo_stopped')):
+ response = self.client.post('/api/vms/101/apps/logo-discovery', json={}, headers=self.headers)
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(response.json['code'], 'local_logo_stopped')
+ self.assertNotIn('internal path', response.get_data(as_text=True))
+ with patch.object(icons, 'read', return_value=None):
+ self.assertEqual(self.client.get('/api/vms/101/apps/logo-cache/' + 'a' * 32, headers=self.headers).status_code, 404)
+
+ def test_svg_response_preserves_real_charset_contract_for_browser(self):
+ with patch.object(icons, 'read', return_value=(b'', 'image/svg+xml')):
+ response = self.client.get('/api/vms/101/apps/logo-cache/' + 'a' * 32, headers=self.headers)
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.headers['Content-Type'], 'image/svg+xml; charset=utf-8')
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/lxc_updates/test_local_app_icons_ui.cjs b/tests/lxc_updates/test_local_app_icons_ui.cjs
new file mode 100644
index 00000000..a30db6ba
--- /dev/null
+++ b/tests/lxc_updates/test_local_app_icons_ui.cjs
@@ -0,0 +1,123 @@
+// Real panel handlers under the same deterministic hook fixture as native discovery.
+const assert = require('node:assert/strict')
+const {fixture, flatten, text, candidate} = require('./test_native_app_discovery_ui.cjs')
+const logo = f => flatten(f.tree()).find(n => n.props?.id === 'app-logo')
+const posts = f => f.calls.filter(c => c.method === 'POST' && c.url.endsWith('/apps'))
+async function cachedImageContract() {
+ const fs = require('node:fs'), path = require('node:path'), vm = require('node:vm')
+ const root = path.resolve(__dirname, '../../AppImage')
+ const ts = require(path.join(root, 'node_modules/typescript'))
+ const source = fs.readFileSync(path.join(root, 'components/lxc-app-panel.tsx'), 'utf8')
+ const parsed = ts.createSourceFile('panel.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
+ const fn = parsed.statements.find(n => ts.isFunctionDeclaration(n) && n.name?.text === 'CachedLocalLogo')
+ assert.ok(fn)
+ const code = ts.transpileModule('export ' + fn.getText(parsed), {compilerOptions: {
+ module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.ReactJSX,
+ }}).outputText
+ let state, deps, cleanup, pendingEffect
+ const requests = [], created = [], revoked = [], exported = {}
+ vm.runInNewContext(code, {
+ exports: exported, AbortController,
+ require: () => ({jsx: (type, props) => ({type, props})}),
+ useState: () => [state, value => {state = value}],
+ useEffect: (fn, next) => {
+ if (!deps || deps[0] !== next[0]) pendingEffect = () => {cleanup?.(); deps = next; cleanup = fn()}
+ },
+ getAuthToken: () => 'fixture-token', getApiUrl: src => '/proxy' + src,
+ fetch: (url, opts) => new Promise(resolve => requests.push({url, opts, resolve})),
+ URL: {createObjectURL: () => {const url = 'blob:fixture-' + created.length; created.push(url); return url}, revokeObjectURL: url => revoked.push(url)},
+ })
+ const render = src => {pendingEffect = null; const tree = exported.CachedLocalLogo({src, className: 'icon'}); pendingEffect?.(); return tree}
+ const settle = async () => {for (let n = 0; n < 8; n++) await Promise.resolve()}
+ // Flask's real SVG response includes a charset parameter.
+ const reply = req => req.resolve({ok: true, blob: async () => ({type: 'image/svg+xml; charset=utf-8', size: 100})})
+ const a = '/api/vms/101/apps/logo-cache/' + 'a'.repeat(32)
+ const b = '/api/vms/102/apps/logo-cache/' + 'b'.repeat(32)
+ render(a)
+ assert.equal(requests[0].opts.headers.Authorization, 'Bearer fixture-token')
+ assert.equal(requests[0].url, '/proxy' + a)
+ reply(requests[0]); await settle()
+ assert.equal(render(a).props.src, 'blob:fixture-0')
+ assert.equal(render(b), null, 'never flash previous guest icon')
+ assert.deepEqual(revoked, ['blob:fixture-0'])
+ assert.equal(requests[0].opts.signal.aborted, true)
+ cleanup(); reply(requests[1]); await settle()
+ assert.equal(created.length, 1, 'late unmounted response creates no blob')
+ render(a); requests[2].resolve({ok: false}); await settle()
+ assert.equal(render(a), null, 'unauthorized/missing cache never falls back to raw authenticated URL')
+}
+async function editor(ports = [8080]) {
+ const f = fixture({nativeCandidate: {...candidate, default_ports: ports}})
+ f.render(); await f.settle()
+ await f.button('searchApplications').props.onClick(); f.render()
+ f.button('registerButton').props.onClick(); f.render(); f.render()
+ return f
+}
+async function main() {
+ await cachedImageContract()
+ const f = await editor([8080, 8443])
+ assert.equal(f.calls.filter(c => c.url.endsWith('/logo-discovery')).length, 0)
+ const select = flatten(f.tree()).find(n => n.props?.['aria-label'] === 'vmLxc.appEditor.localLogoPort')
+ select.props.onChange({target: {value: 'https:8443'}}); f.render(); f.render()
+ await f.button('discoverLocalLogo').props.onClick(); f.render(); f.render()
+ const request = f.calls.find(c => c.url.endsWith('/logo-discovery'))
+ assert.deepEqual(JSON.parse(request.body), {port: 8443, scheme: 'https'})
+ assert.match(logo(f).props.value, /^\/api\/vms\/101\/apps\/logo-cache\//)
+ assert.equal(posts(f).length, 0, 'lookup only changes the draft')
+ await f.button('saveButton').props.onClick(); f.render()
+ assert.match(JSON.parse(posts(f)[0].body).logo_url, /logo-cache/)
+
+ for (const action of ['logo-edit', 'port-edit', 'cancel-reopen', 'guest-roundtrip', 'endpoint-change']) {
+ const g = await editor([8080, 8443])
+ g.holdIcon()
+ const pending = g.button('discoverLocalLogo').props.onClick()
+ g.render()
+ if (action === 'logo-edit') logo(g).props.onChange({target: {value: 'https://example.test/manual.png'}})
+ if (action === 'port-edit') {
+ const input = flatten(g.tree()).find(n => n.type === 'Input' && n.props.value === 8080)
+ assert.ok(input); input.props.onChange({target: {value: '8090'}})
+ }
+ if (action === 'cancel-reopen') {
+ g.button('cancelButton').props.onClick(); g.render()
+ g.button('registerButton').props.onClick()
+ }
+ if (action === 'guest-roundtrip') {
+ g.render({vmid: 102}); g.render({vmid: 101}); await g.settle()
+ }
+ if (action === 'endpoint-change') {
+ flatten(g.tree()).find(n => n.props?.['aria-label'] === 'vmLxc.appEditor.localLogoPort').props.onChange({target: {value: 'https:8443'}})
+ }
+ g.render(); g.render(); g.resolveIcon(); await pending; g.render()
+ assert.ok(!String(logo(g)?.props.value || '').includes('/logo-cache/'), action + ' rejects late success')
+ assert.equal(posts(g).length, 0)
+ assert.equal(g.calls.find(c => c.url.endsWith('/logo-discovery')).signal.aborted, true)
+ }
+
+ for (const code of ['local_logo_unavailable', 'local_logo_stopped']) {
+ const g = await editor()
+ logo(g).props.onChange({target: {value: 'https://example.test/existing.png'}}); g.render(); g.render()
+ g.setIconError({body: {code}})
+ await g.button('discoverLocalLogo').props.onClick(); g.render()
+ assert.equal(logo(g).props.value, 'https://example.test/existing.png')
+ assert.ok(text(g.tree()).includes(code === 'local_logo_stopped' ? 'localLogoStopped' : 'localLogoUnavailable'))
+ assert.equal(posts(g).length, 0)
+ }
+ const wrong = await editor()
+ wrong.setIconResult({logo_url: '/api/vms/102/apps/logo-cache/' + 'b'.repeat(32)})
+ await wrong.button('discoverLocalLogo').props.onClick(); wrong.render()
+ assert.ok(!String(logo(wrong).props.value).includes('/logo-cache/'))
+ const immediate = await editor()
+ immediate.holdIcon(); immediate.setIconError({body: {code: 'local_logo_unavailable'}})
+ const lateError = immediate.button('discoverLocalLogo').props.onClick()
+ logo(immediate).props.onChange({target: {value: 'https://example.test/typed.png'}})
+ // Deliberately resolve before another render: batched input must invalidate too.
+ immediate.resolveIcon(); await lateError; immediate.render()
+ assert.equal(logo(immediate).props.value, 'https://example.test/typed.png')
+ assert.ok(!text(immediate.tree()).includes('localLogoUnavailable'))
+ const noPort = await editor([])
+ assert.equal(noPort.button('discoverLocalLogo').props.disabled, true)
+ await noPort.button('discoverLocalLogo').props.onClick()
+ assert.equal(noPort.calls.filter(c => c.url.endsWith('/logo-discovery')).length, 0)
+ console.log('PASS local icons: explicit selected port, draft/save, no-port, errors, cancel/reopen, edit/guest races and forbidden writes')
+}
+main().catch(error => {console.error(error); process.exitCode = 1})
diff --git a/tests/lxc_updates/test_native_app_discovery_ui.cjs b/tests/lxc_updates/test_native_app_discovery_ui.cjs
index 3cfc5a09..7606065f 100644
--- a/tests/lxc_updates/test_native_app_discovery_ui.cjs
+++ b/tests/lxc_updates/test_native_app_discovery_ui.cjs
@@ -29,6 +29,7 @@ function fixture({nativeCandidate = candidate} = {}) {
let native = {vmid: 101, status: 'not_scanned', candidates: [], stale: false}
let holdScan, scanGate, holdSave, saveGate, scanStatus = 'complete', scanIssues
let cacheGate, resolveCache, cachePending = false
+ let iconGate, resolveIcon, iconResult = {logo_url: '/api/vms/101/apps/logo-cache/' + 'b'.repeat(32)}, iconError
const seed = {sidecar: {vmid: 101, apps: []}, suggestions: {extras: [], port_suggestions: []}}
const react = {
useState(initial) {
@@ -47,6 +48,11 @@ function fixture({nativeCandidate = candidate} = {}) {
}
const api = async (url, options = {}) => {
calls.push({url, ...options})
+ if (url.endsWith('/logo-discovery')) {
+ if (iconGate) await iconGate
+ if (iconError) throw iconError
+ return iconResult
+ }
if (url.endsWith('/native-discovery')) {
if (options.method !== 'POST' && cachePending) {
cachePending = false
@@ -73,7 +79,7 @@ function fixture({nativeCandidate = candidate} = {}) {
subscribeLxcApps: () => () => {}, setLxcAppsCached: (...args) => cachedWrites.push(args),
}
const exports = {}
- vm.runInNewContext(compiled.outputText, {exports, console, setTimeout, clearTimeout,
+ vm.runInNewContext(compiled.outputText, {exports, console, setTimeout, clearTimeout, AbortController,
require: name => {
if (name === 'react') return react
if (name === 'react/jsx-runtime') return {jsx: (type, props) => ({type, props}), jsxs: (type, props) => ({type, props})}
@@ -98,6 +104,9 @@ function fixture({nativeCandidate = candidate} = {}) {
return match
}
return {render, settle, calls, cachedWrites, button, tree: () => tree,
+ holdIcon: () => {iconGate = new Promise(resolve => {resolveIcon = resolve})},
+ resolveIcon: () => resolveIcon(), setIconResult: value => {iconResult = value},
+ setIconError: value => {iconError = value},
setNative: value => {native = value},
holdInitialCache: () => {cachePending = true; cacheGate = new Promise(resolve => {resolveCache = resolve})},
resolveInitialCache: () => resolveCache(),
@@ -230,4 +239,5 @@ async function main() {
assert.equal(oci.calls.filter(c => c.url.endsWith('/native-discovery') && c.method === 'POST').length, 0, 'OCI refresh never native-scans')
console.log('PASS native discovery: real panel Find → editor → Cancel/Save, no automatic scan, safe payload, CT switching and OCI refresh (fixtures)')
}
-main().catch(error => {console.error(error); process.exitCode = 1})
+module.exports = {fixture, flatten, text, candidate}
+if (require.main === module) main().catch(error => {console.error(error); process.exitCode = 1})