Merge pull request #503 from Vaso73/feature/local-app-icon-discovery

feat(monitor): discover icons from local LXC applications
This commit is contained in:
MacRimi
2026-10-19 23:27:37 +02:00
committed by GitHub
20 changed files with 1723 additions and 41 deletions
+3
View File
@@ -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",
+5 -1
View File
@@ -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'
+136 -13
View File
@@ -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 ? <img src={image.url} alt="" className={className} /> : null
}
export function ThemeAwareLogo({ src, className }: { src: string; className: string }) {
if (LOCAL_LOGO_CACHE_PATH.test(src)) return <CachedLocalLogo src={src} className={className} />
const themed = selfhstThemeLogos(src)
const hideBroken = (e: React.SyntheticEvent<HTMLImageElement>) => {
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<NativeDiscovery>(`/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<NativeDiscovery>(`/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<AbortController | null>(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. */}
<div className="pt-2 border-t border-border/50">
<div className="flex flex-wrap items-center justify-between gap-2">
<Label htmlFor="app-logo" className="text-xs uppercase tracking-wider text-muted-foreground">
{t("vmLxc.appEditor.logoUrlLabel")} <span className="normal-case tracking-normal text-[10px] opacity-70">{t("vmLxc.appEditor.optionalSuffix")}</span>
</Label>
<Button type="button" variant="outline" size="sm"
className="h-8 px-2 text-xs"
disabled={!selectedIconPort || discoveringLocalLogo || saving}
onClick={discoverLocalLogo}>
{discoveringLocalLogo ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Search className="h-3.5 w-3.5 mr-1" />}
{t("vmLxc.appEditor.discoverLocalLogo")}
</Button>
</div>
<div className="flex items-center gap-2 mt-2">
<Input
id="app-logo"
type="url"
type="text"
value={draft.logo_url || ""}
onChange={(e) => 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) &&
<ThemeAwareLogo src={draft.logo_url} className="h-8 w-8 shrink-0 object-contain" />}
</div>
{iconPorts.length > 1 && <select
aria-label={t("vmLxc.appEditor.localLogoPort")}
value={selectedIconPort ? endpointKey(selectedIconPort) : ""}
onChange={event => {
iconRequest.current += 1
iconAbort.current?.abort()
setDiscoveringLocalLogo(false)
setIconEndpoint(event.target.value)
}}
className="mt-2 rounded border bg-background px-2 py-1 text-xs"
>
{iconPorts.map(entry => <option key={endpointKey(entry)} value={endpointKey(entry)}>
{endpointKey(entry)}
</option>)}
</select>}
<p className="text-[10px] text-muted-foreground mt-1">{t("vmLxc.appEditor.localLogoHelp")}</p>
</div>
{/* Web links — each port becomes a clickable link (built as
+3 -1
View File
@@ -5227,7 +5227,9 @@ export function VirtualMachines() {
null
return (
<LxcAppPanel
key={`${selectedVM.vmid}-${selectedVM.modal_cache_revision ?? 0}`}
// Refresh server data without destroying an unsaved editor.
key={selectedVM.vmid}
cacheRevision={selectedVM.modal_cache_revision ?? 0}
vmid={selectedVM.vmid}
ctIp={ctIp}
onChange={() => mutate()}
+5
View File
@@ -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:",
+5
View File
@@ -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:",
+5
View File
@@ -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:",
+5
View File
@@ -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 :",
+5
View File
@@ -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:",
+5
View File
@@ -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:",
+5
View File
@@ -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:",
+5
View File
@@ -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:",
+8 -3
View File
@@ -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
+29
View File
@@ -14132,6 +14132,35 @@ def api_vm_apps_native_discovery(vmid):
result, suggestions, sidecar.get('apps') or []))
@app.route('/api/vms/<int:vmid>/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/<int:vmid>/apps/logo-cache/<token>', 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/<int:vmid>/apps/docker-discovery', methods=['GET', 'POST'])
@require_auth
def api_vm_apps_docker_discovery(vmid):
+598
View File
@@ -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)
+17 -15
View File
@@ -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 <img src>.
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
@@ -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()
@@ -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'<html>bad</html>', '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'''<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)" width="32" height="32">
<script>alert(1)</script><foreignObject><html/></foreignObject>
<image href="https://outside.test/icon"/><use href="https://outside.test/a"/>
<style>path{fill:url(https://outside.test)}</style>
<path d="M0 0h10v10z" style="fill:red" onclick="alert(1)" fill="url(https://outside.test/a)"/>
<defs><linearGradient id="paint"><stop offset="0" stop-color="#fff"/></linearGradient></defs>
<rect width="12" height="12" fill="url(#paint)"/>
</svg>'''
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'<image', b'<use'):
self.assertNotIn(forbidden, data)
self.assertIn(b'url(#paint)', data)
self.assertIn(b'<path', data)
self.assertEqual(icons.normalize(data, mime), (data, mime))
def test_svg_entities_malformed_deep_trees_rejected(self):
for data in [b'<!DOCTYPE svg [<!ENTITY a "test">]><svg>&a;</svg>', b'<svg>', b'<html/>',
b'<svg>' + b'<g>' * 34 + b'</g>' * 34 + b'</svg>']:
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'<svg width="88" height="88" viewBox="0 0 88 88"><image x="4" y="4" width="80" height="80" href="data:image/png;base64,{embedded}"/></svg>'.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'<svg onload="bad()"/>').decode()
for raw in [b'<svg/>', b'<svg><script>bad()</script></svg>',
b'<svg><image href="https://outside.test/icon"/></svg>',
b'<svg><image href="data:image/png;base64,bm90IGFuIGltYWdl"/></svg>',
f'<svg><image href="data:image/svg+xml;base64,{nested}"/></svg>'.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'<image href="data:image/png;base64,{embedded}"/>'
with self.assertRaises(icons.IconError):
icons.normalize(('<svg>' + image * 2 + '</svg>').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'<svg onload="bad()"><script>bad()</script><path d="M0 0"/></svg>')
(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'<html/>', 'external-icon': b'<link rel="icon" href="https://outside.test/x.png">',
}.get(mode[0], b'<link rel="icon" href="icon.png">'))
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), patch.object(icons, '_owns_address', 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'<svg xmlns="http://www.w3.org/2000/svg"/>', '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()
@@ -0,0 +1,151 @@
// 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 scanning = fixture()
scanning.renderParent(); await scanning.settle(); scanning.holdScan()
const pendingScan = scanning.button('searchApplications').props.onClick(); scanning.render()
scanning.renderParent(101, 1); await scanning.settle()
scanning.resolveScan(); await pendingScan; scanning.render()
assert.equal(scanning.button('searchApplications').props.disabled, false, 'revision must not leave an active Native Find stuck busy')
const existing = fixture({apps: [{id: 'existing', name: 'Saved app', ports: [{port: 8080, scheme: 'http'}], state: {}}]})
existing.renderParent(); await existing.settle()
const click = async key => {
const node = flatten(existing.tree()).find(n => ['button', 'Button'].includes(n.type) && text(n).includes('vmLxc.appEditor.' + key))
assert.ok(node, key); await node.props.onClick(); existing.render()
}
await click('editButton'); await click('editFieldsButton')
logo(existing).props.onChange({target: {value: 'https://example.test/draft.png'}}); existing.render()
existing.holdIcon()
const pendingExisting = existing.button('discoverLocalLogo').props.onClick(); existing.render()
const reads = existing.bundleReads()
existing.renderParent(101, 1); await existing.settle(); existing.render()
assert.equal(logo(existing)?.props.value, 'https://example.test/draft.png', 'lifecycle refresh must retain the open existing-app editor and unsaved fields')
assert.ok(existing.bundleReads() > reads, 'revision still reloads the saved app data')
existing.resolveIcon(); await pendingExisting; existing.render(); existing.render()
assert.match(logo(existing).props.value, /logo-cache/, 'successful lookup leaves existing editor open')
assert.equal(existing.calls.filter(c => ['POST', 'PUT', 'DELETE'].includes(c.method) && !c.url.endsWith('/logo-discovery')).length, 0)
await click('cancelButton')
await click('editFieldsButton')
assert.equal(logo(existing).props.value, '', 'cancel discards draft icon')
existing.renderParent(102, 1); await existing.settle(); existing.render()
assert.equal(logo(existing), undefined, 'switching guest must discard the old editor')
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})
@@ -23,15 +23,17 @@ const candidate = {
evidence: [{kind: 'installation_file', value: '/opt/handmade/server'}], default_ports: [],
}
function fixture({nativeCandidate = candidate, dockerCandidate = null} = {}) {
let cursor = 0, tree, props, effects = [], states = [], refs = [], effectStates = []
function fixture({nativeCandidate = candidate, dockerCandidate = null, apps = []} = {}) {
let cursor = 0, tree, props, effects = [], states = [], refs = [], effectStates = [], memoStates = []
const calls = [], cachedWrites = []
let native = {vmid: 101, status: 'not_scanned', candidates: [], stale: false}
let holdScan, scanGate, holdSave, saveGate, scanStatus = 'complete', scanIssues
let dockerStatus = "complete", dockerCached = {vmid:101,status:"not_scanned",candidates:[],stale:false}
let holdDocker, dockerGate
let cacheGate, resolveCache, cachePending = false
const seed = {sidecar: {vmid: 101, apps: []}, suggestions: {extras: [], port_suggestions: []}}
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: []}}
let bundleReads = 0
const react = {
useState(initial) {
const index = cursor++
@@ -39,7 +41,12 @@ function fixture({nativeCandidate = candidate, dockerCandidate = null} = {}) {
return [states[index], value => {states[index] = typeof value === 'function' ? value(states[index]) : value}]
},
useRef(value) {const index = cursor++; return refs[index] ||= {current: value}},
useMemo(fn) {cursor++; return fn()}, useCallback(fn) {cursor++; return fn},
useMemo(fn, deps) {
const index = cursor++, prior = memoStates[index]
if (!prior || !deps || deps.some((value, i) => value !== prior.deps?.[i])) memoStates[index] = {deps, value: fn()}
return memoStates[index].value
},
useCallback(fn, deps) {return react.useMemo(() => fn, deps)},
useEffect(fn, deps) {
const index = cursor++, prior = effectStates[index]
if (!prior || !deps || deps.some((value, i) => value !== prior.deps?.[i])) {
@@ -49,6 +56,11 @@ function fixture({nativeCandidate = candidate, dockerCandidate = null} = {}) {
}
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
@@ -79,11 +91,11 @@ function fixture({nativeCandidate = candidate, dockerCandidate = null} = {}) {
return []
}
const cache = {
getLxcAppsCached: () => null, fetchLxcApps: async () => seed,
getLxcAppsCached: () => null, fetchLxcApps: async () => {bundleReads++; return seed},
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})}
@@ -101,13 +113,37 @@ function fixture({nativeCandidate = candidate, dockerCandidate = null} = {}) {
for (const effect of effects) effect()
return tree
}
// Exercise React's key identity boundary using the actual parent expression.
const parent = ts.createSourceFile('parent.tsx', fs.readFileSync(path.join(root, 'components/virtual-machines.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
let keyExpression
function visit(node) {
if (ts.isJsxSelfClosingElement(node) && node.tagName.getText(parent) === 'LxcAppPanel') {
keyExpression = node.attributes.properties.find(p => p.name?.getText(parent) === 'key').initializer.expression.getText(parent)
}
ts.forEachChild(node, visit)
}
visit(parent)
const parentKey = new Function('selectedVM', 'return ' + keyExpression)
let lastKey
function renderParent(vmid = 101, revision = 0) {
const key = parentKey({vmid, modal_cache_revision: revision})
if (lastKey !== undefined && lastKey !== key) {
effectStates.forEach(effect => effect?.cleanup?.())
states = []; refs = []; effectStates = []; memoStates = []
}
lastKey = key
return render({vmid, cacheRevision: revision, initialData: seed})
}
const settle = async () => {for (let i = 0; i < 6; i++) await Promise.resolve(); render()}
const button = key => {
const match = flatten(tree).find(n => n.type === 'Button' && text(n).includes('vmLxc.appEditor.' + key))
assert.ok(match, 'Missing button ' + key)
return match
}
return {render, settle, calls, cachedWrites, button, tree: () => tree,
return {render, renderParent, settle, calls, cachedWrites, button, tree: () => tree, bundleReads: () => bundleReads,
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(),
@@ -242,5 +278,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)')
}
module.exports = {fixture, flatten, text}
module.exports = {fixture, flatten, text, candidate}
if (require.main === module) main().catch(error => {console.error(error); process.exitCode = 1})