feat: discover validated icons from local LXC applications

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