diff --git a/.github/scripts/run_offline_qualified.py b/.github/scripts/run_offline_qualified.py index a1404590..8a9548cd 100644 --- a/.github/scripts/run_offline_qualified.py +++ b/.github/scripts/run_offline_qualified.py @@ -13,6 +13,8 @@ import sys import tempfile PYTHON_FILES = ( + "AppImage/scripts/tests/test_local_app_icon_isolation.py", + "AppImage/scripts/tests/test_local_app_icons.py", "AppImage/scripts/tests/test_docker_app_discovery.py", "AppImage/scripts/tests/test_native_app_discovery.py", "tests/test_audit_presentation.py", @@ -26,6 +28,7 @@ PYTHON_FILES = ( "AppImage/scripts/tests/test_health_persistence_recurrence.py", ) NODE_FILES = ( + "tests/lxc_updates/test_local_app_icons_ui.cjs", "tests/lxc_updates/test_docker_app_discovery_ui.cjs", "tests/lxc_updates/test_native_app_discovery_ui.cjs", "tests/lxc_updates/test_docker_delegated_ui.cjs", diff --git a/.github/workflows/test-offline-qualified.yml b/.github/workflows/test-offline-qualified.yml index bc092ae9..1993c320 100644 --- a/.github/workflows/test-offline-qualified.yml +++ b/.github/workflows/test-offline-qualified.yml @@ -13,6 +13,8 @@ on: - 'AppImage/scripts/*.py' - 'AppImage/scripts/tests/test_health_persistence_recurrence.py' - 'AppImage/scripts/tests/test_native_app_discovery.py' + - 'AppImage/scripts/tests/test_local_app_icons.py' + - 'AppImage/scripts/tests/test_local_app_icon_isolation.py' - 'AppImage/scripts/tests/test_docker_app_discovery.py' - 'AppImage/scripts/tests/fixtures/docker_discovery_inspect.json' - 'AppImage/app/**' @@ -36,6 +38,8 @@ on: - 'AppImage/scripts/*.py' - 'AppImage/scripts/tests/test_health_persistence_recurrence.py' - 'AppImage/scripts/tests/test_native_app_discovery.py' + - 'AppImage/scripts/tests/test_local_app_icons.py' + - 'AppImage/scripts/tests/test_local_app_icon_isolation.py' - 'AppImage/scripts/tests/test_docker_app_discovery.py' - 'AppImage/scripts/tests/fixtures/docker_discovery_inspect.json' - 'AppImage/app/**' @@ -63,7 +67,7 @@ jobs: with: python-version: '3.11' - name: Install native discovery route fixture dependency - run: python3 -m pip install 'Flask>=3,<4' + run: python3 -m pip install 'Flask>=3,<4' 'Pillow==12.3.0' 'defusedxml==0.7.1' - name: Run qualified Python files env: PYTHONDONTWRITEBYTECODE: '1' diff --git a/AppImage/components/lxc-app-panel.tsx b/AppImage/components/lxc-app-panel.tsx index fcdbaa6d..ffd06ea5 100644 --- a/AppImage/components/lxc-app-panel.tsx +++ b/AppImage/components/lxc-app-panel.tsx @@ -32,7 +32,7 @@ import { Input } from "./ui/input" import { Label } from "./ui/label" import { Badge } from "./ui/badge" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" -import { fetchApi } from "../lib/api-config" +import { fetchApi, getApiUrl, getAuthToken } from "../lib/api-config" import { fetchLxcApps, getLxcAppsCached, setLxcAppsCached, subscribeLxcApps } from "../lib/lxc-apps-cache" import { categoryChipStyle, useIsLightTheme } from "../lib/category-color" import { getCategoryLabel } from "../lib/category-label" @@ -356,6 +356,7 @@ interface ManagedAppInfo { interface Props { vmid: number + cacheRevision?: number ctIp?: string | null onChange?: () => void managed?: ManagedAppInfo | null @@ -411,7 +412,40 @@ function selfhstThemeLogos(src: string) { return match ? SELFHST_THEME_LOGOS[match[1]] : null } +const LOCAL_LOGO_CACHE_PATH = /^\/api\/vms\/[1-9]\d*\/apps\/logo-cache\/[a-f0-9]{32}$/ + +function CachedLocalLogo({ src, className }: { src: string; className: string }) { + const [image, setImage] = useState<{ src: string; url: string } | null>(null) + useEffect(() => { + const controller = new AbortController() + let active = true + let url: string | null = null + const token = getAuthToken() + fetch(getApiUrl(src), { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + signal: controller.signal, cache: "no-store", + }).then(async response => { + if (!response.ok) throw new Error("cached icon unavailable") + const blob = await response.blob() + const mime = blob.type.split(";", 1)[0].trim().toLowerCase() + if (!["image/png", "image/svg+xml"].includes(mime) || blob.size > 262144) { + throw new Error("invalid cached icon") + } + if (!active) return + url = URL.createObjectURL(blob) + setImage({ src, url }) + }).catch(() => { if (active) setImage(null) }) + return () => { + active = false + controller.abort() + if (url) URL.revokeObjectURL(url) + } + }, [src]) + return image?.src === src ? : null +} + export function ThemeAwareLogo({ src, className }: { src: string; className: string }) { + if (LOCAL_LOGO_CACHE_PATH.test(src)) return const themed = selfhstThemeLogos(src) const hideBroken = (e: React.SyntheticEvent) => { e.currentTarget.style.display = "none" @@ -464,7 +498,7 @@ function parseArgvInput(value: string): string[] { return value.split(",").map((item) => item.trim()).filter(Boolean) } -export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: Props) { +export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, cacheRevision = 0 }: Props) { const t = useT() const isLightTheme = useIsLightTheme() // Seed from `initialData` first, then fall back to the shared cache @@ -488,21 +522,25 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: targetGeneration.current += 1 } useEffect(() => { - const generation = targetGeneration.current - const request = ++nativeRequest.current setNativeDiscovery(null) setDockerDiscovery(null) setEditing(null) setDetectionNotice(null) setSearchingApplications(false) setSaving(false) - if (managed) return - fetchApi(`/api/vms/${vmid}/apps/native-discovery`).then(result => { - if (targetRef.current === vmid && targetGeneration.current === generation - && nativeRequest.current === request) setNativeDiscovery(result) - }).catch(() => { /* cached metadata is optional; never initiate a scan */ }) return () => { targetGeneration.current += 1 } }, [vmid, managed]) + useEffect(() => { + const generation = targetGeneration.current + const request = nativeRequest.current + let cancelled = false + if (managed) return + fetchApi(`/api/vms/${vmid}/apps/native-discovery`).then(result => { + if (!cancelled && targetRef.current === vmid && targetGeneration.current === generation + && nativeRequest.current === request) setNativeDiscovery(result) + }).catch(() => { /* cached metadata is optional; never initiate a scan */ }) + return () => { cancelled = true } + }, [vmid, managed, cacheRevision]) useEffect(() => { const generation = targetGeneration.current const request = dockerRequest.current @@ -519,7 +557,29 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: const [searchingApplications, setSearchingApplications] = useState(false) const [detectionNotice, setDetectionNotice] = useState<{ found: boolean; text: string } | null>(null) // Editor state - const [editing, setEditing] = useState<{ appId: string | null; draft: AppConfig; targetVmid: number } | null>(null) + const [editing, setEditingState] = useState<{ appId: string | null; draft: AppConfig; targetVmid: number } | null>(null) + const editingRef = useRef(editing) + editingRef.current = editing + const [iconEndpoint, setIconEndpoint] = useState("") + const [discoveringLocalLogo, setDiscoveringLocalLogo] = useState(false) + const iconRequest = useRef(0) + const iconAbort = useRef(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) @@ -597,7 +657,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: if (isCurrent()) setLoading(false) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [vmid, managed]) + }, [vmid, managed, cacheRevision]) useEffect(() => { if (managed) return @@ -1394,6 +1454,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, @@ -1626,18 +1720,47 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: from https://selfh.st/icons); empty → no logo in the app card header. */}
+
+ +
+
setField({ logo_url: e.target.value })} placeholder={t("vmLxc.appEditor.portLogoPlaceholder")} maxLength={512} - className="text-sm mt-2 font-mono" + className="text-sm font-mono" /> + {draft.logo_url && LOCAL_LOGO_CACHE_PATH.test(draft.logo_url) && + } +
+ {iconPorts.length > 1 && } +

{t("vmLxc.appEditor.localLogoHelp")}

{/* Web links — each port becomes a clickable link (built as diff --git a/AppImage/components/virtual-machines.tsx b/AppImage/components/virtual-machines.tsx index a03b5685..52833003 100644 --- a/AppImage/components/virtual-machines.tsx +++ b/AppImage/components/virtual-machines.tsx @@ -5227,7 +5227,9 @@ export function VirtualMachines() { null return ( mutate()} diff --git a/AppImage/messages/de/common.json b/AppImage/messages/de/common.json index 060ec0fa..870a21ea 100644 --- a/AppImage/messages/de/common.json +++ b/AppImage/messages/de/common.json @@ -1694,6 +1694,11 @@ "catalogPort": "Port {port}", "trackingAvailable": "Tracking verfügbar", "logoUrlLabel": "Logo-URL", + "discoverLocalLogo": "Symbol suchen", + "localLogoPort": "Anwendungsport für die Symbolsuche", + "localLogoHelp": "Liest nur den gewählten Port dieses laufenden Containers. Externe Symbole werden nicht abgerufen. Das Symbol wird beim Speichern übernommen.", + "localLogoStopped": "Der Container ist gestoppt. Starten Sie ihn, bevor Sie sein Symbol suchen.", + "localLogoUnavailable": "Kein verwendbares lokales Symbol gefunden. Prüfen Sie den gewählten Webport und versuchen Sie es erneut. Ihr aktuelles Symbol bleibt unverändert.", "webLinks": "Weblinks", "addPort": "Port hinzufügen", "detectedPorts": "Im Container erkannte Ports – klicken Sie zum Hinzufügen:", diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index 6a14b8ba..cb93dc90 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -1811,6 +1811,11 @@ "catalogPort": "port {port}", "trackingAvailable": "tracking available", "logoUrlLabel": "Logo URL", + "discoverLocalLogo": "Find icon", + "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/es/common.json b/AppImage/messages/es/common.json index d80db724..451ee66b 100644 --- a/AppImage/messages/es/common.json +++ b/AppImage/messages/es/common.json @@ -1789,6 +1789,11 @@ "catalogPort": "puerto {port}", "trackingAvailable": "seguimiento disponible", "logoUrlLabel": "URL del logotipo", + "discoverLocalLogo": "Buscar icono", + "localLogoPort": "Puerto de la aplicación para buscar el icono", + "localLogoHelp": "Solo se consulta el puerto elegido de este contenedor en marcha. No se descargan iconos externos. El icono se aplica al guardar.", + "localLogoStopped": "El contenedor está parado. Arráncalo antes de buscar su icono.", + "localLogoUnavailable": "No se ha encontrado un icono local válido. Revisa el puerto web elegido y vuelve a intentarlo. Tu icono actual no cambia.", "webLinks": "Enlaces web", "addPort": "Añadir puerto", "detectedPorts": "Puertos detectados en el contenedor: haz clic para añadir:", diff --git a/AppImage/messages/fr/common.json b/AppImage/messages/fr/common.json index 9467544a..eaa33dab 100644 --- a/AppImage/messages/fr/common.json +++ b/AppImage/messages/fr/common.json @@ -1694,6 +1694,11 @@ "catalogPort": "port {port}", "trackingAvailable": "suivi disponible", "logoUrlLabel": "URL du logo", + "discoverLocalLogo": "Chercher l'icône", + "localLogoPort": "Port de l'application pour chercher l'icône", + "localLogoHelp": "Lit uniquement le port choisi de ce conteneur en cours d'exécution. Aucune icône externe n'est téléchargée. L'icône est appliquée à l'enregistrement.", + "localLogoStopped": "Le conteneur est arrêté. Démarrez-le avant de chercher son icône.", + "localLogoUnavailable": "Aucune icône locale utilisable n'a été trouvée. Vérifiez le port web choisi et réessayez. Votre icône actuelle ne change pas.", "webLinks": "Liens Internet", "addPort": "Ajouter un port", "detectedPorts": "Ports détectés dans le conteneur — cliquez pour ajouter :", diff --git a/AppImage/messages/it/common.json b/AppImage/messages/it/common.json index 5c008de9..ff4ab2dc 100644 --- a/AppImage/messages/it/common.json +++ b/AppImage/messages/it/common.json @@ -1694,6 +1694,11 @@ "catalogPort": "porta {port}", "trackingAvailable": "monitoraggio disponibile", "logoUrlLabel": "URL logo", + "discoverLocalLogo": "Cerca icona", + "localLogoPort": "Porta dell'applicazione per cercare l'icona", + "localLogoHelp": "Legge solo la porta scelta di questo container in esecuzione. Le icone esterne non vengono scaricate. L'icona viene applicata al salvataggio.", + "localLogoStopped": "Il container è fermo. Avvialo prima di cercarne l'icona.", + "localLogoUnavailable": "Nessuna icona locale utilizzabile trovata. Controlla la porta web scelta e riprova. La tua icona attuale non cambia.", "webLinks": "Collegamenti web", "addPort": "Aggiungi porta", "detectedPorts": "Porte rilevate nel container: fai clic per aggiungere:", diff --git a/AppImage/messages/pt/common.json b/AppImage/messages/pt/common.json index 5fc5282c..e7154223 100644 --- a/AppImage/messages/pt/common.json +++ b/AppImage/messages/pt/common.json @@ -1694,6 +1694,11 @@ "catalogPort": "porta {port}", "trackingAvailable": "rastreamento disponível", "logoUrlLabel": "URL do logotipo", + "discoverLocalLogo": "Procurar ícone", + "localLogoPort": "Porta da aplicação para procurar o ícone", + "localLogoHelp": "Lê apenas a porta escolhida deste contentor em execução. Não são descarregados ícones externos. O ícone é aplicado ao guardar.", + "localLogoStopped": "O contentor está parado. Inicie-o antes de procurar o seu ícone.", + "localLogoUnavailable": "Não foi encontrado um ícone local utilizável. Verifique a porta web escolhida e tente novamente. O seu ícone atual não muda.", "webLinks": "Links da web", "addPort": "Adicionar porta", "detectedPorts": "Portas detectadas no contêiner — clique para adicionar:", diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index 373a1b72..18c9805b 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -1736,6 +1736,11 @@ "catalogPort": "port {port}", "trackingAvailable": "možno sledovať verziu", "logoUrlLabel": "URL loga", + "discoverLocalLogo": "Zistiť ikonu", + "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/messages/sv/common.json b/AppImage/messages/sv/common.json index 7c4a1dd0..942691e7 100644 --- a/AppImage/messages/sv/common.json +++ b/AppImage/messages/sv/common.json @@ -1694,6 +1694,11 @@ "catalogPort": "port {port}", "trackingAvailable": "spårning tillgänglig", "logoUrlLabel": "Logotyp URL", + "discoverLocalLogo": "Sök ikon", + "localLogoPort": "Programport för ikonsökning", + "localLogoHelp": "Läser bara den valda porten i den här körande containern. Externa ikoner hämtas inte. Ikonen tillämpas när du sparar.", + "localLogoStopped": "Containern är stoppad. Starta den innan du söker dess ikon.", + "localLogoUnavailable": "Ingen användbar lokal ikon hittades. Kontrollera den valda webbporten och försök igen. Din nuvarande ikon ändras inte.", "webLinks": "Webblänkar", "addPort": "Lägg till port", "detectedPorts": "Portar upptäckta i behållaren — klicka för att lägga till:", diff --git a/AppImage/scripts/build_appimage.sh b/AppImage/scripts/build_appimage.sh index f4f3564f..86eeb956 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/docker_app_discovery.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/oci_menu_bridge.py" "$APP_DIR/usr/bin/" @@ -360,7 +361,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 \ @@ -388,6 +390,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" \ @@ -397,7 +400,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==12.3.0" } install_gevent_runtime "3.11" "cp311" @@ -410,7 +414,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 0bd2a7bd..854e76a7 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -14132,6 +14132,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/docker-discovery', methods=['GET', 'POST']) @require_auth def api_vm_apps_docker_discovery(vmid): diff --git a/AppImage/scripts/local_app_icons.py b/AppImage/scripts/local_app_icons.py new file mode 100644 index 00000000..6edcdd79 --- /dev/null +++ b/AppImage/scripts/local_app_icons.py @@ -0,0 +1,598 @@ +"""Explicit LXC icon lookup. No guest execution, background scan or app writes. + +Untrusted HTTP and image parsing run in an unprivileged bounded child. Only +the target CT's pinned network namespace may be used for HTTP. 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 signal +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') +PROC = Path('/proc') +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) +DECODERS = 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'} +RASTER_FORMATS = {'image/png': 'PNG', 'image/jpeg': 'JPEG', 'image/gif': 'GIF', + 'image/webp': 'WEBP', 'image/x-icon': 'ICO', 'image/vnd.microsoft.icon': 'ICO'} + + +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 + # Preserve lxc-info's reported order (CT215: eth0 then Docker bridges). + # Unordered set iteration selected an internal bridge at random. Keep + # runtime order within each address family, including when deduplicating. + addresses = list(dict.fromkeys(str(ipaddress.ip_address(ip)) for ip in ips if _private_ip(ip))) + addresses.sort(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 _init_pid(vmid): + value = _run(['lxc-info', '-n', str(vmid), '-pH']).strip() + if not re.fullmatch(r'[1-9][0-9]{0,9}', value) or int(value) <= 1: + raise IconError('container init process unavailable') + return int(value) + + +def _process_start(pid): + # comm may contain spaces and parentheses; field 22 follows the last ')'. + value = (PROC / str(pid) / 'stat').read_text().rsplit(')', 1)[1].split()[19] + if not value.isdecimal(): + raise IconError('container process identity unavailable') + return value + + +def _ns_id(info): + return info.st_dev, info.st_ino + + +def _network_unchanged(vmid, identity): + pid, started, namespace = identity + return (_init_pid(vmid) == pid and _process_start(pid) == started + and _ns_id((PROC / str(pid) / 'ns/net').stat()) == namespace) + + +@contextlib.contextmanager +def _network(vmid): + """Pin nsfs before launching; revalidate PID/start time to reject PID reuse.""" + fd = None + try: + pid = _init_pid(vmid) + started = _process_start(pid) + fd = os.open(PROC / str(pid) / 'ns/net', os.O_RDONLY | os.O_CLOEXEC) + identity = pid, started, _ns_id(os.fstat(fd)) + if (identity[2] == _ns_id((PROC / 'self/ns/net').stat()) + or not _network_unchanged(vmid, identity)): + raise IconError('container network is not isolated or changed') + yield fd, identity + except (OSError, IndexError, ValueError) as exc: + raise IconError('container network unavailable') from exc + finally: + if fd is not None: + os.close(fd) + + +def _owns_address(value): + """Called in the CT network namespace, never trust lxc-info as a target.""" + try: + interfaces = json.loads(_run(['/usr/sbin/ip', '-json', 'address', 'show'])) + owned = {str(ipaddress.ip_address(a['local'])) for interface in interfaces + for a in interface.get('addr_info', []) if 'local' in a} + return str(ipaddress.ip_address(value)) in owned + except (ValueError, TypeError, KeyError, AttributeError) as exc: + raise IconError('container address ownership unavailable') from exc + + +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', 'image'} + 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 + images = 0 + def clean(node, depth=0): + nonlocal count, images + 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 + embedded = None + if name == 'image': + refs = {value for key, value in node.attrib.items() + if key in ('href', '{http://www.w3.org/1999/xlink}href')} + if len(refs) != 1: + return None + match = re.fullmatch(r'data:(image/(?:png|jpeg|gif|webp|x-icon|vnd\.microsoft\.icon));base64,([A-Za-z0-9+/=]+)', refs.pop()) + if not match: + return None # No external resource or recursive SVG data URI. + images += 1 + if images > 1: + raise IconError('SVG embedded image limit') + try: + raster = base64.b64decode(match[2], validate=True) + except ValueError as exc: + raise IconError('invalid embedded image') from exc + raster, _mime = _normalize(raster, match[1]) + embedded = 'data:image/png;base64,' + base64.b64encode(raster).decode() + 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, filters or use. + # A single validated raster data URI is installed separately below. + 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 embedded: + out.set('href', embedded) + 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) + def has_drawing(node): + name = node.tag.removeprefix('{' + ns + '}') + if name in ('defs', 'clipPath'): + return False + return name in {'path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon', 'image'} or any(has_drawing(child) for child in node) + if not has_drawing(result): + raise IconError('SVG contains no supported drawing') + 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') + # Cache GETs, including legacy data, use the same bounded isolation even + # when the Monitor itself has been configured to run without root. + if not DECODERS.acquire(timeout=WORKER_TIMEOUT): + raise IconError('local icon decoder busy') + try: + return _decode_worker(body, mime) + finally: + DECODERS.release() + + +def _normalize(body, mime): + if os.geteuid() == 0: + raise IconError('image decoding as root is forbidden') + 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 + try: + with Image.open(io.BytesIO(body), formats=[RASTER_FORMATS[mime]]) as picture: + if picture.format != RASTER_FORMATS[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() + converted = picture.convert('RGBA') + converted.info.clear() + converted.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') + if not _owns_address(parsed.hostname): + raise IconError('selected address does not belong to this container') + 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_command(arguments, network_fd): + # No guest binaries, rootfs, environment, user namespace or mount namespace. + # setpriv execs the host Python only after dropping credentials/capabilities. + command = ['/usr/bin/setpriv', '--no-new-privs'] + if os.geteuid() == 0: + command += ['--reuid=65534', '--regid=65534', '--clear-groups', + '--bounding-set=-all', '--inh-caps=-all', '--ambient-caps=-all'] + command += ['--', sys.executable, '-B', str(Path(__file__).resolve()), *arguments] + if network_fd is not None: + return ['/usr/bin/nsenter', f'--net=/proc/self/fd/{network_fd}', '--no-fork', '--', *command] + if os.geteuid() == 0: + # Legacy/cache decoding cannot make any network request, even if a + # decoder is compromised. No CT probe is needed for a cache-only GET. + return ['/usr/bin/unshare', '--net', '--', *command] + return command + + +def _run_worker(arguments, *, network_fd=None, data=None): + process = None + try: + with tempfile.TemporaryFile() as output: + process = subprocess.Popen(_worker_command(arguments, network_fd), + stdin=subprocess.PIPE if data is not None else subprocess.DEVNULL, + stdout=output, stderr=subprocess.DEVNULL, cwd='/', start_new_session=True, + pass_fds=() if network_fd is None else (network_fd,)) + process.communicate(input=data, timeout=WORKER_TIMEOUT) + output.seek(0) + raw = output.read(MAX_BYTES * 2 + 1) + if process.returncode or len(raw) > MAX_BYTES * 2: + raise IconError('local icon unavailable') + response = json.loads(raw) + body = base64.b64decode(response['body'], validate=True) + if not body or len(body) > MAX_BYTES or response['mime'] not in ('image/png', 'image/svg+xml'): + raise IconError('invalid image worker response') + return body, response['mime'] + except (OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError) as exc: + raise IconError('local icon unavailable') from exc + finally: + if process is not None: + # The nsenter/setpriv chain execs without forking; also terminate + # any descendants a faulty decoder could have left behind. + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait() + + +def _worker(origin, network_fd): + return _run_worker(['--fetch', origin, str(network_fd)], network_fd=network_fd) + + +def _decode_worker(body, mime): + data = json.dumps({'body': base64.b64encode(body).decode(), 'mime': mime}).encode() + return _run_worker(['--decode'], data=data) + + +def _assert_worker(): + fields = dict(line.split(':', 1) for line in Path('/proc/self/status').read_text().splitlines() if ':' in line) + if (os.geteuid() == 0 or os.getegid() == 0 or 0 in os.getgroups() + or fields.get('NoNewPrivs', '').strip() != '1' + or any(int(fields.get(key, '1').strip(), 16) for key in ('CapEff', 'CapPrm', 'CapAmb'))): + raise IconError('image worker is not isolated') + + +@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) + with _network(vmid) as (network_fd, identity): + body, mime = _worker(origin, network_fd) + if _origin(vmid, payload) != origin or not _network_unchanged(vmid, identity): + raise IconError('container address or network 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)) + resource.setrlimit(resource.RLIMIT_FSIZE, (MAX_BYTES * 2, MAX_BYTES * 2)) + try: + _assert_worker() + if len(sys.argv) == 4 and sys.argv[1] == '--fetch': + fd = int(sys.argv[3]) + if _ns_id(os.fstat(fd)) != _ns_id(Path('/proc/self/ns/net').stat()): + raise IconError('image worker network mismatch') + os.close(fd) + body, mime = _fetch(sys.argv[2]) + elif len(sys.argv) == 2 and sys.argv[1] == '--decode': + raw = sys.stdin.buffer.read(MAX_BYTES * 2 + 1) + if len(raw) > MAX_BYTES * 2: + raise IconError('image input too large') + data = json.loads(raw) + body, mime = _normalize(base64.b64decode(data['body'], validate=True), data['mime']) + else: + raise IconError('invalid worker arguments') + 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 d61e9b8f..033375f9 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_icon_isolation.py b/AppImage/scripts/tests/test_local_app_icon_isolation.py new file mode 100644 index 00000000..383f05c7 --- /dev/null +++ b/AppImage/scripts/tests/test_local_app_icon_isolation.py @@ -0,0 +1,220 @@ +"""Security boundaries independent of UI/cache successes; no live CT required.""" +import base64 +import contextlib +import io +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import Mock, patch + +from PIL import Image + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import local_app_icons as icons + + +def png(): + output = io.BytesIO() + Image.new('RGB', (8, 8)).save(output, 'PNG') + return output.getvalue() + + +class Isolation(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + + def test_root_normalization_never_opens_an_image_in_the_monitor(self): + data = png() + with patch.object(icons.os, 'geteuid', return_value=0), \ + patch.object(icons, '_decode_worker', return_value=(data, 'image/png'), create=True) as child, \ + patch.object(Image, 'open', side_effect=AssertionError('root decoder forbidden')) as opened: + self.assertEqual(icons.normalize(data, 'image/png'), (data, 'image/png')) + child.assert_called_once_with(data, 'image/png') + opened.assert_not_called() + + def test_only_the_selected_raster_decoder_is_enabled(self): + data = png() + with patch.object(icons.os, 'geteuid', return_value=65534), patch.object(Image, 'open', wraps=Image.open) as opened: + getattr(icons, '_normalize', icons.normalize)(data, 'image/png') + self.assertEqual(opened.call_args.kwargs.get('formats'), ['PNG']) + + def test_unprivileged_monitor_also_uses_a_bounded_decoder(self): + data=png() + with patch.object(icons.os,'geteuid',return_value=1001), \ + patch.object(icons,'_decode_worker',return_value=(data,'image/png')) as decoder: + self.assertEqual(icons.normalize(data,'image/png'),(data,'image/png')) + decoder.assert_called_once_with(data,'image/png') + + def test_direct_root_decoder_is_rejected_before_pillow(self): + data=png() + with patch.object(icons.os,'geteuid',return_value=0),patch.object(Image,'open') as opened: + with self.assertRaises(icons.IconError):icons._normalize(data,'image/png') + opened.assert_not_called() + + def test_saturated_decoder_has_zero_child_or_image_calls(self): + data=png();slots=Mock();slots.acquire.return_value=False + with patch.object(icons,'DECODERS',slots),patch.object(icons,'_decode_worker') as worker,patch.object(Image,'open') as opened: + with self.assertRaises(icons.IconError):icons.normalize(data,'image/png') + worker.assert_not_called();opened.assert_not_called();slots.release.assert_not_called() + + def test_decoder_failure_always_releases_its_slot(self): + data=png();slots=Mock();slots.acquire.return_value=True + with patch.object(icons,'DECODERS',slots),patch.object(icons,'_decode_worker',side_effect=icons.IconError('unavailable')): + with self.assertRaises(icons.IconError):icons.normalize(data,'image/png') + slots.release.assert_called_once() + + def test_foreign_address_has_zero_http_calls_even_when_lxc_reports_it(self): + with patch.object(icons, '_owns_address', return_value=False, create=True), \ + patch.object(icons, '_open', side_effect=AssertionError('foreign network forbidden')) as http: + with self.assertRaises(icons.IconError): + icons._fetch('http://10.0.0.99:8080') + http.assert_not_called() + + def fake_proc(self): + for folder in ('self/ns', '2468/ns'): + (self.root / folder).mkdir(parents=True) + (self.root / folder / 'net').write_text('namespace fixture') + (self.root / '2468/stat').write_text('2468 (container (init)) S ' + '0 ' * 18 + '12345 0\n') + + def test_namespace_descriptor_is_pinned_and_closed(self): + self.fake_proc() + with patch.object(icons, 'PROC', self.root), patch.object(icons, '_run', return_value='2468\n') as run: + with icons._network(101) as (fd, identity): + self.assertEqual(identity[0:2], (2468, '12345')) + self.assertEqual(os.fstat(fd).st_ino, (self.root / '2468/ns/net').stat().st_ino) + self.assertTrue(icons._network_unchanged(101, identity)) + with self.assertRaises(OSError):os.fstat(fd) + self.assertTrue(all(c.args[0] == ['lxc-info', '-n', '101', '-pH'] for c in run.call_args_list)) + + def test_host_namespace_pid_reuse_and_changed_pid_never_fetch_or_store(self): + for case in ('host', 'pid', 'start', 'namespace'): + with self.subTest(case=case), tempfile.TemporaryDirectory() as temporary: + self.root = Path(temporary);self.fake_proc() + def identify(args): + if case == 'pid':return '2469' if identify.calls else self.first(identify) + if identify.calls: + if case == 'start':(self.root / '2468/stat').write_text('2468 (init) S ' + '0 ' * 18 + '54321\n') + if case == 'namespace': + (self.root / '2468/ns/new').write_text('replacement') + os.replace(self.root / '2468/ns/new', self.root / '2468/ns/net') + identify.calls += 1 + return '2468' + identify.calls=0 + if case=='host': + (self.root / '2468/ns/net').unlink() + os.link(self.root / 'self/ns/net', self.root / '2468/ns/net') + with patch.object(icons, 'PROC', self.root), patch.object(icons, '_run', side_effect=identify), \ + patch.object(icons, '_origin', return_value='http://10.0.0.42:80'), \ + patch.object(icons, '_worker') as worker, patch.object(icons, 'store') as store: + with self.assertRaises(icons.IconError):icons.discover(101, {'port':80,'scheme':'http'}) + worker.assert_not_called();store.assert_not_called() + + @staticmethod + def first(function): + function.calls+=1 + return '2468' + + def test_missing_or_invalid_pid_cannot_start_a_worker(self): + for value in ('', '0', '1', '-3', '2468\n2469', 'text'): + with self.subTest(pid=value), patch.object(icons, '_run', return_value=value), \ + patch.object(icons, '_origin', return_value='http://10.0.0.42:80'), \ + patch.object(icons, '_worker') as worker, patch.object(icons, 'store') as store, \ + patch.object(icons.os, 'open') as opened: + with self.assertRaises(icons.IconError):icons.discover(101, {'port':80,'scheme':'http'}) + worker.assert_not_called();store.assert_not_called();opened.assert_not_called() + + def test_partial_process_identity_and_missing_namespace_fail_without_effects(self): + for value in ('', '2468 (truncated)', '2468 (init) S 0 0'): + with self.subTest(stat=value),tempfile.TemporaryDirectory() as temporary: + root=Path(temporary);(root/'2468').mkdir();(root/'2468/stat').write_text(value) + with patch.object(icons,'PROC',root),patch.object(icons,'_run',return_value='2468'), \ + patch.object(icons,'_origin',return_value='http://10.0.0.42:80'), \ + patch.object(icons,'_worker') as worker,patch.object(icons,'store') as store: + with self.assertRaises(icons.IconError):icons.discover(101,{'port':80,'scheme':'http'}) + worker.assert_not_called();store.assert_not_called() + self.fake_proc();(self.root/'2468/ns/net').unlink() + with patch.object(icons,'PROC',self.root),patch.object(icons,'_run',return_value='2468'), \ + patch.object(icons,'_origin',return_value='http://10.0.0.42:80'), \ + patch.object(icons,'_worker') as worker,patch.object(icons,'store') as store: + with self.assertRaises(icons.IconError):icons.discover(101,{'port':80,'scheme':'http'}) + worker.assert_not_called();store.assert_not_called() + + def test_failed_init_pid_command_has_zero_network_or_cache_effects(self): + with patch.object(icons,'_run',side_effect=icons.IconError('lxc-info timed out')), \ + patch.object(icons,'_origin',return_value='http://10.0.0.42:80'), \ + patch.object(icons,'_worker') as worker,patch.object(icons,'store') as store: + with self.assertRaises(icons.IconError):icons.discover(101,{'port':80,'scheme':'http'}) + worker.assert_not_called();store.assert_not_called() + + def test_guest_restart_after_worker_preserves_existing_cache(self): + with patch.object(icons, '_origin', return_value='http://10.0.0.42:80'), \ + patch.object(icons, '_network', return_value=contextlib.nullcontext((81,'old'))), \ + patch.object(icons, '_worker', return_value=(png(),'image/png')), \ + patch.object(icons, '_network_unchanged', return_value=False), patch.object(icons,'store') as store: + with self.assertRaises(icons.IconError):icons.discover(101, {'port':80,'scheme':'http'}) + store.assert_not_called() + + def test_only_ct_network_namespace_and_unprivileged_credentials_are_selected(self): + with patch.object(icons.os,'geteuid',return_value=0): + command=icons._worker_command(['--fetch','http://10.0.0.42:80','81'],81) + decoder=icons._worker_command(['--decode'],None) + self.assertEqual(command[:4],['/usr/bin/nsenter','--net=/proc/self/fd/81','--no-fork','--']) + for required in ('--reuid=65534','--regid=65534','--clear-groups','--no-new-privs','--bounding-set=-all','--inh-caps=-all','--ambient-caps=-all'): + self.assertIn(required,command);self.assertIn(required,decoder) + self.assertEqual(decoder[:3],['/usr/bin/unshare','--net','--']) + self.assertNotIn('--all',command);self.assertNotIn('--mount',command);self.assertNotIn('--user',command) + + def test_failure_to_enter_namespace_has_no_host_fallback(self): + with patch.object(icons.subprocess,'Popen',side_effect=OSError('nsenter unavailable')) as start: + with self.assertRaises(icons.IconError):icons._worker('http://10.0.0.42:80',81) + self.assertEqual(start.call_count,1) + self.assertEqual(start.call_args.args[0][0],'/usr/bin/nsenter') + self.assertEqual(start.call_args.kwargs['pass_fds'],(81,)) + + def test_address_ownership_uses_only_namespace_local_interfaces(self): + for address in ('10.0.0.42','fd12::42'): + with patch.object(icons,'_run',return_value=json.dumps([{'addr_info':[{'local':address}]}])) as run: + self.assertTrue(icons._owns_address(address));self.assertFalse(icons._owns_address('10.0.0.99')) + self.assertEqual(run.call_args.args[0],['/usr/sbin/ip','-json','address','show']) + with patch.object(icons,'_run',return_value='partial JSON'), patch.object(icons,'_open') as network: + with self.assertRaises(icons.IconError):icons._fetch('http://10.0.0.42:80') + network.assert_not_called() + + def test_worker_security_guard_precedes_decode_or_network(self): + fields='NoNewPrivs:\t1\nCapEff:\t0\nCapPrm:\t0\nCapAmb:\t0\n' + for uid,gid,groups,body in ((0,65534,[],fields),(65534,0,[],fields), + (65534,65534,[0],fields),(65534,65534,[],fields.replace('NoNewPrivs:\t1','NoNewPrivs:\t0')), + *[(65534,65534,[],fields.replace(key+':\t0',key+':\t1')) for key in ('CapEff','CapPrm','CapAmb')]): + with patch.object(icons.os,'geteuid',return_value=uid),patch.object(icons.os,'getegid',return_value=gid), \ + patch.object(icons.os,'getgroups',return_value=groups),patch.object(Path,'read_text',return_value=body): + with self.assertRaises(icons.IconError):icons._assert_worker() + + def test_oversized_and_malformed_worker_output_is_rejected(self): + helper=self.root/'output.py' + for body in ('x'*(icons.MAX_BYTES*2+1),'not-json',json.dumps({'mime':'image/png','body':'bad base64'})): + helper.write_text('import sys\nsys.stdout.write('+repr(body)+')\n') + with patch.object(icons,'_worker_command',return_value=[sys.executable,str(helper)]): + with self.assertRaises(icons.IconError):icons._run_worker(['--decode']) + + def test_root_legacy_and_current_cache_decode_without_guest_probes(self): + data=png();root=self.root/'101';root.mkdir() + for legacy in (False,True): + token=('a' if legacy else 'b')*32 + if legacy: + (root/(token+'.bin')).write_bytes(data) + (root/(token+'.json')).write_text(json.dumps({'content_type':'image/png'})) + else:(root/(token+'.icon')).write_text(json.dumps({'mime':'image/png','body':base64.b64encode(data).decode()})) + with patch.object(icons,'CACHE',self.root),patch.object(icons.os,'geteuid',return_value=0), \ + patch.object(icons,'_decode_worker',return_value=(data,'image/png')) as decoder, \ + patch.object(icons,'_run') as guest,patch.object(Image,'open') as raster: + self.assertEqual(icons.read(101,token),(data,'image/png')) + decoder.assert_called_once();guest.assert_not_called();raster.assert_not_called() + + +if __name__=='__main__':unittest.main() 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..2562cd3b --- /dev/null +++ b/AppImage/scripts/tests/test_local_app_icons.py @@ -0,0 +1,469 @@ +"""Offline icon contracts: actual codecs, HTTP, cache transactions and routes.""" +import ast +import base64 +import contextlib +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'} + # Namespace identity is exercised separately by test_local_app_icon_isolation. + for name, value in (('_network', contextlib.nullcontext((81, 'fixture'))), + ('_network_unchanged', True)): + p = patch.object(icons, name, return_value=value) + p.start() + self.addCleanup(p.stop) + + 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']>&a;', b'', b'', + b'' + b'' * 34 + b'' * 34 + b'']: + with self.assertRaises(icons.IconError): + icons.normalize(data, 'image/svg+xml') + + def test_svg_embedded_raster_is_validated_and_preserved_without_external_fetch(self): + from xml.etree import ElementTree as xml + embedded = base64.b64encode(picture()).decode() + raw = f''.encode() + with patch.object(icons, '_open') as network: + data, mime = icons.normalize(raw, 'image/svg+xml') + network.assert_not_called() + image = xml.fromstring(data).find('{http://www.w3.org/2000/svg}image') + self.assertIsNotNone(image, 'sanitizing must not report an empty SVG as a found icon') + self.assertEqual(image.get('x'), '4') + self.assertEqual(image.get('width'), '80') + decoded = base64.b64decode(image.get('href').split(',', 1)[1], validate=True) + self.assertEqual(icons.normalize(decoded, 'image/png')[0], decoded) + self.assertEqual(icons.normalize(data, mime), (data, mime)) + + def test_raster_normalization_discards_source_metadata(self): + source = io.BytesIO() + Image.new('RGB', (2, 2), 'red').save(source, format='PNG', icc_profile=b'fixture-private-metadata') + data, _mime = icons.normalize(source.getvalue(), 'image/png') + with Image.open(io.BytesIO(data)) as result: + self.assertNotIn('icc_profile', result.info) + + def test_svg_empty_invalid_or_recursive_embedded_images_are_rejected(self): + nested = base64.b64encode(b'').decode() + for raw in [b'', b'', + b'', + b'', + f''.encode()]: + with self.assertRaises(icons.IconError), patch.object(icons, '_open') as network: + icons.normalize(raw, 'image/svg+xml') + network.assert_not_called() + embedded = base64.b64encode(picture()).decode() + image = f'' + with self.assertRaises(icons.IconError): + icons.normalize(('' + image * 2 + '').encode(), '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', 81) + 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_multiple_private_addresses_retain_runtime_order_not_set_order(self): + # Real CT215 lxc-info: eth0 followed by three Docker bridges and ULA. + observed = ['10.0.0.27', '172.17.0.1', '172.18.0.1', '172.19.0.1', + 'fd40:359f:8a1b:f9b3:be24:11ff:fe85:bd05'] + for addresses in (observed, list(reversed(observed))): + expected = next(ip for ip in addresses if ':' not in ip) + def resolve(args): + return '\n'.join(addresses + addresses) if args[0] == 'lxc-info' else self.resolve(args) + with patch.object(icons, '_run', side_effect=resolve), patch.object(icons, '_worker', return_value=(self.png, 'image/png')) as worker: + icons.discover(101, self.payload) + worker.assert_called_once_with(f'http://{expected}:8080', 81) + + 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, 'Popen', side_effect=OSError('cannot start worker')): + with self.assertRaises(icons.IconError): + icons._worker('http://10.0.0.42:80', 81) + + 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, '_worker_command', return_value=[sys.executable, str(worker)]), patch.object(icons, 'WORKER_TIMEOUT', .15): + with self.assertRaises(icons.IconError): + icons._run_worker(['--decode']) + 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'