mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-14 18:56:52 +00:00
Stable release consolidating the v1.2.4 beta cycle (1.2.4.1-beta and 1.2.4.2-beta) into 1.2.5. Highlights: - Apps dashboard: single launcher for every LXC-registered app and user-defined Custom Web Link, with category badges, search, sort and one-click deep-links back to the guest modal. - LXC Apps & Updates end-to-end: App tab inside every guest modal, upstream version tracking, and Easy Updates that cover OS packages, registered apps, Docker Engine and per-image updates on the same 24-hour cycle. - Application detection catalog with 380+ tracked workloads generated live from community-scripts across seven detector methods. - Monitor now speaks 8 languages: English, Spanish, German, French, Italian, Portuguese, Slovak and Swedish (i18n scaffolding by @vaso73). - NVIDIA multi-GPU passthrough by exact BDF so one card can be assigned to a VM while another stays operational on the host or LXC. - Navigation reorder, Memory & Swap real memory-pressure signal, native Pushover channel, Actions API, plus wide-reaching improvements across health, hardware, network, backup and post-install. Full release notes: see CHANGELOG.md and https://github.com/MacRimi/ProxMenux/releases
277 lines
10 KiB
Python
277 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""User-defined web links surfaced in the Apps dashboard alongside
|
|
LXC-registered apps. Kept in a single sidecar
|
|
(/etc/proxmenux/custom_links.json) because the collection is small,
|
|
global, and never bound to a specific guest by ProxMenux itself.
|
|
|
|
Schema of each entry
|
|
--------------------
|
|
{
|
|
"id": "<uuid4>",
|
|
"name": "<display name>", # required
|
|
"url": "<http(s) URL>", # required
|
|
"logo_url": "<http(s) URL or ''>", # optional
|
|
"category": "<free text or ''>", # optional
|
|
"binding": { # optional; null when unbound
|
|
"vmid": <int>,
|
|
"guest_type": "lxc" | "qemu"
|
|
},
|
|
"created_at": <unix ts>,
|
|
"updated_at": <unix ts>
|
|
}
|
|
|
|
Design notes
|
|
------------
|
|
* One file (not per-VM). Volume is small; unbound links have no natural
|
|
home; global lookups are O(N) with N tiny.
|
|
* All writes go through `save_all` which does the classic
|
|
write-temp+rename dance so a crash mid-save can't corrupt the file.
|
|
* Validation is strict at the boundary — the frontend can send whatever;
|
|
the backend refuses anything malformed. Fields that survive are
|
|
exactly the schema above; unknown keys are dropped silently.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from typing import Any, Optional
|
|
|
|
|
|
_CUSTOM_LINKS_PATH = "/etc/proxmenux/custom_links.json"
|
|
_lock = threading.RLock()
|
|
|
|
# In-memory copy of the full list. Populated on first read (or by
|
|
# `warmup()` at Monitor startup) and refreshed only when a write goes
|
|
# through this module. The sidecar file is our source of truth; the
|
|
# cache exists so `/api/apps/custom-links` doesn't hit disk on every
|
|
# request. Reads always return a fresh copy so callers can't mutate
|
|
# the cached state by accident.
|
|
_cached_entries: Optional[list[dict]] = None
|
|
|
|
# Same character set / max length as the LXC-app editor uses so users
|
|
# don't have to learn two different rulesets.
|
|
_NAME_RE = re.compile(r"^[\w\s._+\-()/:,&]{1,80}$", re.UNICODE)
|
|
_URL_RE = re.compile(r"^https?://[\w\-._~:/?#\[\]@!$&'()*+,;=%]{1,510}$")
|
|
_CATEGORY_RE = re.compile(r"^[\w\s&/,.\-*+()]{1,60}$", re.UNICODE)
|
|
_UUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
|
|
_GUEST_TYPES = frozenset({"lxc", "qemu"})
|
|
|
|
|
|
def _err(msg: str) -> tuple[bool, str]:
|
|
return False, msg
|
|
|
|
|
|
# ── Persistence ────────────────────────────────────────────────────
|
|
|
|
def _read_from_disk() -> list[dict]:
|
|
"""Actually parse the sidecar file. A missing/empty/corrupt file
|
|
returns [] — we never let bad JSON take down the whole Apps
|
|
dashboard, the user's other data is fine."""
|
|
try:
|
|
with open(_CUSTOM_LINKS_PATH, encoding="utf-8") as f:
|
|
raw = json.load(f)
|
|
except (FileNotFoundError, PermissionError):
|
|
return []
|
|
except (OSError, ValueError):
|
|
return []
|
|
if not isinstance(raw, list):
|
|
return []
|
|
return [entry for entry in raw if isinstance(entry, dict)]
|
|
|
|
|
|
def load_all() -> list[dict]:
|
|
"""Return the current list of custom links from the in-memory
|
|
cache. First call after a Monitor restart pays one disk read
|
|
(~1 ms); every subsequent call is a memory op. Writes go through
|
|
`save_all` which also refreshes the cache, so callers never see
|
|
stale data.
|
|
"""
|
|
global _cached_entries
|
|
with _lock:
|
|
if _cached_entries is None:
|
|
_cached_entries = _read_from_disk()
|
|
return [dict(entry) for entry in _cached_entries]
|
|
|
|
|
|
def warmup() -> int:
|
|
"""Force the cache to populate now. Invoked from Monitor startup
|
|
so the very first `/api/apps/custom-links` request is served
|
|
straight from memory. Returns the entry count for the log line."""
|
|
global _cached_entries
|
|
with _lock:
|
|
_cached_entries = _read_from_disk()
|
|
return len(_cached_entries)
|
|
|
|
|
|
def save_all(entries: list[dict]) -> None:
|
|
"""Persist the full list. Write-temp+rename so a crash cannot
|
|
leave a half-written JSON on disk. Also refreshes the in-memory
|
|
cache so the next `load_all` returns the new state without a
|
|
disk read. Caller must have validated every entry — this
|
|
function trusts its input and writes verbatim.
|
|
"""
|
|
global _cached_entries
|
|
directory = os.path.dirname(_CUSTOM_LINKS_PATH)
|
|
os.makedirs(directory, exist_ok=True)
|
|
payload = json.dumps(entries, ensure_ascii=False, indent=2)
|
|
with _lock:
|
|
tmp = f"{_CUSTOM_LINKS_PATH}.tmp.{os.getpid()}"
|
|
try:
|
|
with open(tmp, "w", encoding="utf-8") as f:
|
|
f.write(payload)
|
|
f.write("\n")
|
|
os.replace(tmp, _CUSTOM_LINKS_PATH)
|
|
_cached_entries = [dict(e) for e in entries]
|
|
finally:
|
|
try:
|
|
if os.path.exists(tmp):
|
|
os.remove(tmp)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
# ── Validation ─────────────────────────────────────────────────────
|
|
|
|
def _validate_binding(raw: Any) -> tuple[bool, Any]:
|
|
"""Accepts either null (unbound) or {vmid, guest_type}. Coerces
|
|
vmid to int and guest_type to one of the allowed literals."""
|
|
if raw in (None, "", {}):
|
|
return True, None
|
|
if not isinstance(raw, dict):
|
|
return _err("binding must be an object with {vmid, guest_type}")
|
|
vmid_raw = raw.get("vmid")
|
|
try:
|
|
vmid = int(vmid_raw)
|
|
except (TypeError, ValueError):
|
|
return _err("binding.vmid must be an integer")
|
|
if not (0 < vmid <= 999_999_999):
|
|
return _err("binding.vmid out of range")
|
|
guest_type = (raw.get("guest_type") or "").strip().lower()
|
|
if guest_type not in _GUEST_TYPES:
|
|
return _err("binding.guest_type must be 'lxc' or 'qemu'")
|
|
return True, {"vmid": vmid, "guest_type": guest_type}
|
|
|
|
|
|
def validate_entry(raw: Any, existing_id: Optional[str] = None) -> tuple[bool, Any]:
|
|
"""Validate a single link payload from the API layer. Returns
|
|
(True, sanitised_dict) or (False, error_string). Fields absent in
|
|
the input default to safe values; unknown keys are ignored."""
|
|
if not isinstance(raw, dict):
|
|
return _err("payload must be a JSON object")
|
|
|
|
name = (raw.get("name") or "").strip()
|
|
if not name:
|
|
return _err("name is required")
|
|
if not _NAME_RE.match(name):
|
|
return _err("name contains invalid characters or exceeds 80 chars")
|
|
|
|
url = (raw.get("url") or "").strip()
|
|
if not url:
|
|
return _err("url is required")
|
|
if not _URL_RE.match(url):
|
|
return _err("url must be an http(s) URL (max 512 chars)")
|
|
|
|
logo_url = (raw.get("logo_url") or "").strip()
|
|
if logo_url and not _URL_RE.match(logo_url):
|
|
return _err("logo_url must be an http(s) URL (max 512 chars)")
|
|
|
|
category = (raw.get("category") or "").strip()
|
|
if category and not _CATEGORY_RE.match(category):
|
|
return _err("category contains invalid characters or exceeds 60 chars")
|
|
|
|
ok, binding = _validate_binding(raw.get("binding"))
|
|
if not ok:
|
|
return _err(binding)
|
|
|
|
entry_id = existing_id or raw.get("id") or str(uuid.uuid4())
|
|
if not _UUID_RE.match(entry_id):
|
|
entry_id = str(uuid.uuid4())
|
|
|
|
now = int(time.time())
|
|
return True, {
|
|
"id": entry_id,
|
|
"name": name,
|
|
"url": url,
|
|
"logo_url": logo_url,
|
|
"category": category,
|
|
"binding": binding,
|
|
"created_at": int(raw.get("created_at") or now),
|
|
"updated_at": now,
|
|
}
|
|
|
|
|
|
# ── CRUD helpers used by the Flask endpoints ───────────────────────
|
|
|
|
def create(payload: dict) -> tuple[bool, Any]:
|
|
"""Add a new link. Assigns a fresh UUID and appends to the file."""
|
|
ok, entry = validate_entry(payload)
|
|
if not ok:
|
|
return False, entry
|
|
with _lock:
|
|
current = load_all()
|
|
current.append(entry)
|
|
save_all(current)
|
|
return True, entry
|
|
|
|
|
|
def update(link_id: str, payload: dict) -> tuple[bool, Any]:
|
|
"""Replace one link by id. 404 if the id is unknown."""
|
|
if not _UUID_RE.match(link_id or ""):
|
|
return _err("invalid link id")
|
|
with _lock:
|
|
current = load_all()
|
|
for i, existing in enumerate(current):
|
|
if existing.get("id") == link_id:
|
|
merged = dict(existing)
|
|
merged.update(payload)
|
|
merged["id"] = link_id # id is immutable
|
|
merged["created_at"] = existing.get("created_at")
|
|
ok, entry = validate_entry(merged, existing_id=link_id)
|
|
if not ok:
|
|
return False, entry
|
|
current[i] = entry
|
|
save_all(current)
|
|
return True, entry
|
|
return _err("link not found")
|
|
|
|
|
|
def delete(link_id: str) -> tuple[bool, Any]:
|
|
"""Remove one link by id. Idempotent — deleting an unknown id
|
|
returns success so the UI doesn't have to distinguish."""
|
|
if not _UUID_RE.match(link_id or ""):
|
|
return _err("invalid link id")
|
|
with _lock:
|
|
current = load_all()
|
|
remaining = [e for e in current if e.get("id") != link_id]
|
|
if len(remaining) != len(current):
|
|
save_all(remaining)
|
|
return True, {"deleted": link_id}
|
|
|
|
|
|
def purge_binding_for_vmid(vmid: int) -> int:
|
|
"""Clear the `binding` on every link that pointed to a guest that
|
|
no longer exists. Called from the guest lifecycle hook when a VM
|
|
or CT is destroyed so the dashboard never surfaces a dead ID.
|
|
Returns the number of links updated (0 or more)."""
|
|
try:
|
|
target = int(vmid)
|
|
except (TypeError, ValueError):
|
|
return 0
|
|
changed = 0
|
|
with _lock:
|
|
current = load_all()
|
|
for entry in current:
|
|
binding = entry.get("binding") or {}
|
|
if isinstance(binding, dict) and binding.get("vmid") == target:
|
|
entry["binding"] = None
|
|
entry["updated_at"] = int(time.time())
|
|
changed += 1
|
|
if changed:
|
|
save_all(current)
|
|
return changed
|