mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
Merge pull request #503 from Vaso73/feature/local-app-icon-discovery
feat(monitor): discover icons from local LXC applications
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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 :",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user