mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 18:32:13 +00:00
599 lines
25 KiB
Python
599 lines
25 KiB
Python
"""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)
|