# ========================================================== # ProxMenux — LXC App Watch # ========================================================== # Per-CT user-registered application metadata + upstream version # tracking. Sidecar-per-CT under /etc/proxmenux/apps/.json, # mode 0600. Each sidecar carries a LIST of apps because a single # CT may host several services (e.g. Frigate on 5000 + go2rtc on # 1984, or a media server that also runs a metrics agent). # # The four ``installed_via`` methods (dpkg / apk / file / binary / # docker) all use ``pct exec`` argv-style — NEVER through ``sh -c``, # so a user-typed package name or image tag can't inject a shell. # # Public surface (called by flask_server.py): # load_sidecar(vmid) -> dict|None {vmid, apps[], …} # add_app(vmid, config) -> (bool, saved|error) appends to list # update_app(vmid, app_id, config) -> (bool, …) # delete_app(vmid, app_id) -> bool # delete_all(vmid) -> bool # check_app(vmid, app_id, force=False, notify=True) -> dict|None # check_all(vmid, force=False, notify=True) -> dict|None # get_active_apps() -> {str(vmid): [summary, …]} # get_suggestions(vmid) -> {name, port_suggestions[], web_path_hint} # ========================================================== from __future__ import annotations import datetime import copy import concurrent.futures import hashlib import json import os import re import signal import shlex import socket import subprocess import threading import time import urllib.error import urllib.parse import urllib.request import uuid from typing import Any, Optional _APPS_DIR = "/etc/proxmenux/apps" _PCT_BIN = "/usr/sbin/pct" _PROBE_TIMEOUT_SEC = 15 _GITHUB_TIMEOUT_SEC = 15 # Aligned with the master LXC update cycle in # notification_events.PollingCollector (UPDATE_CHECK_INTERVAL = 24 h). # Previously this was 6 h — half a day out of sync with the apt/apk # scan — so `refresh_all_apps` inside the 24 h collector would still # hit GitHub for apps whose upstream TTL had elapsed, doubling # checks. Unifying both to 24 h means one poll per day drives every # update flavour (OS packages + community-scripts app upstream). # Manual "Check" button + post-apply hook still pass force=True and # ignore this TTL, so the user never has to wait for the timer to # see a fresh result they explicitly asked for. _UPSTREAM_CACHE_TTL_SEC = 24 * 3600 _VALID_METHODS = ("dpkg", "apk", "file", "binary", "python_dist", "docker_label", "docker_exec", "command", "manual") _DETECTOR_FIELDS = ( "package", "file_path", "file_regex", "binary_path", "binary_args", "python_path", "distribution", "container_name", "label", "command_argv", "installed_version", ) _VALID_SOURCES = ("releases", "tags") # Max args for binary / docker_exec / command — bounded so a malformed # hint can't blow up pct exec with megabytes of argv. _MAX_BINARY_ARGS = 8 _MAX_BINARY_ARG_LEN = 128 # `command` method is more permissive on arg count than binary_args # (users may need slightly longer pipelines through subcommands). _MAX_COMMAND_ARGV = 12 _MAX_COMMAND_ARGV_LEN = 256 # `manual` method holds a user-typed version string. Kept small so a # broken paste can't blow up the sidecar or downstream renderers. _MAX_MANUAL_VERSION_LEN = 64 _MAX_UPDATE_COMMAND_LEN = 4096 _MAX_UPSTREAM_URL_LEN = 512 _MAX_UPSTREAM_JSON_PATH_LEN = 128 _MAX_DOCKER_IMAGE_LEN = 255 _VALID_UPSTREAM_TYPES = ("github", "http_json", "docker_hub") # Scheduled updates: cron-driven runs of apply_updates.sh. Config # lives at the sidecar top level (per-CT, not per-app). Cron parser # below supports the standard 5-field syntax with `*`, exact numbers, # `*/N` step, and comma lists — that covers every preset the UI # exposes and the freeform "custom" text field. _VALID_SCHEDULE_TARGETS = ("os", "app", "both") _SCHEDULE_TARGET_ID_RE = re.compile( r"^(?:os|apps|app:[A-Za-z0-9_-]{1,64}|docker-engine|docker-(?:compose|container):[A-Za-z0-9][A-Za-z0-9_.-]{0,127}|docker-unit:[a-f0-9]{20})$" ) _BULK_TARGET_ID_RE = re.compile( r"^(?:os|app:[A-Za-z0-9_-]{1,64}|docker-engine|docker-unit:[a-f0-9]{20})$" ) _MAX_CRON_FIELD_LEN = 64 # JSONPath (simplified): letters/digits/dots/underscores/hyphens + [N] # array indices. Rejects wildcards, filters, .. recursion — we don't # need JSONPath's full grammar and refusing them keeps parsing tight. _JSON_PATH_RE = re.compile(r"^[A-Za-z0-9._\-\[\]]+$") # Docker Hub image: `owner/name` or `name` (defaults to library/name). # Lowercase per Docker's registry rules; underscore/dash/period allowed. _DOCKER_IMAGE_RE = re.compile( r"^[a-z0-9]+(?:[._-][a-z0-9]+)*(?:/[a-z0-9]+(?:[._-][a-z0-9]+)*)?$" ) _DOCKER_COMPOSE_PROJECT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$") # Curated tracking hints, keyed by the slug we can recognise for the # CT (typically the community-scripts slug extracted from # /usr/bin/update, but any stable identifier works). Each hint carries # the exact installed_via method + package / binary_path / file # metadata + GitHub repo + tag_regex we've verified in a real # container, so the App tab can auto-fill every advanced field. # # The map is NOT embedded in this module — it lives in # json/app_tracking_hints.json in the repo and is fetched at runtime # with a 7-day cache. Adding a new hint (or fixing a broken one) is # a commit to that JSON — no AppImage rebuild required, every Monitor # picks the update up on its next refresh. See _fetch_tracking_hints # for the fetch pipeline (network → disk cache → bundled fallback). _TRACKING_HINTS_URL = ( "https://raw.githubusercontent.com/MacRimi/ProxMenux/" "refs/heads/main/json/app_tracking_hints.json" ) _TRACKING_HINTS_DISK = "/var/lib/proxmenux/app_tracking_hints.json" _TRACKING_HINTS_TTL = 7 * 24 * 3600 _TRACKING_HINTS_HTTP_TIMEOUT = 10 # Bundled fallback: build_appimage.sh copies the JSON next to this # module so the very first Monitor startup works even offline / before # the JSON has been merged to main. _TRACKING_HINTS_BUNDLED = os.path.join( os.path.dirname(os.path.abspath(__file__)), "app_tracking_hints.json", ) # Runtime-verified detector overrides ship with the AppImage. Unlike the # regular catalog, they are deliberately not fetched from main: an installed # Monitor may otherwise download an older catalog entry that resurrects a # stale helper marker over a detector verified on a real container. _RUNTIME_VERIFIED_OVERRIDES_BUNDLED = os.path.join( os.path.dirname(os.path.abspath(__file__)), "runtime_verified_overrides.json", ) _tracking_hints_lock = threading.RLock() _tracking_hints_cache: Optional[dict] = None _tracking_hints_ts: float = 0.0 # Docker Hub tag previews are requested while the user edits a form. # Cache the raw repository tag list (not the regex result) so changing # filters does not create another external request. Sixty seconds is # enough to absorb typing bursts while still feeling live. _DOCKER_HUB_TAG_CACHE_TTL_SEC = 60 _DOCKER_HUB_TAG_PREVIEW_LIMIT = 5 _DEFAULT_DOCKER_HUB_TAG_REGEX = ( r"(?i)^v?(\d+\.\d+\.\d+(?:[-+._][0-9A-Za-z.-]+)?)$" ) _MOVING_DOCKER_TAGS = { "latest", "main", "master", "edge", "stable", "nightly", "develop", "dev", "rolling", "lts", } _docker_hub_tag_cache_lock = threading.RLock() _docker_hub_tag_cache: dict[str, dict[str, Any]] = {} # Docker image inventory is deliberately separate from App Watch. The # Docker engine version and the applications delivered by its images have # different lifecycles: updating docker-ce does not update a Portainer or # LinuxServer image. Inventory checks are read-only (docker image ls + # registry manifest HEAD), cached only in process memory, and never # pull/recreate anything. Do not create runtime files outside ProxMenux's # owned application directory just to preserve this derived inventory. # Docker registry drift follows the same daily rolling check as registered # application releases. Opening the Updates tab reads this in-memory value; # only the daily collector, an explicit user check or a completed update forces # a new registry comparison. _DOCKER_INVENTORY_TTL_SEC = 24 * 3600 _DOCKER_REGISTRY_TIMEOUT_SEC = 8 _DOCKER_MAX_IMAGES = 50 _DOCKER_MANIFEST_ACCEPT = ", ".join(( "application/vnd.oci.image.index.v1+json", "application/vnd.oci.image.manifest.v1+json", "application/vnd.docker.distribution.manifest.list.v2+json", "application/vnd.docker.distribution.manifest.v2+json", )) # Reading the remote image config is content-addressed: every document is # requested BY DIGEST and verified against it, so the answer cannot be # swapped for another image. A digest never changes content, so the cache # has no TTL — only a bound. _DOCKER_REMOTE_CONFIG_MAX_BYTES = 1 << 20 _DOCKER_REMOTE_CONFIG_CACHE_MAX = 500 _DOCKER_INDEX_MEDIA_TYPES = { "application/vnd.oci.image.index.v1+json", "application/vnd.docker.distribution.manifest.list.v2+json", } _DOCKER_IMAGE_CONFIG_MEDIA_TYPES = { "application/vnd.oci.image.config.v1+json", "application/vnd.docker.container.image.v1+json", } _docker_remote_config_lock = threading.RLock() _docker_remote_config_cache: dict[tuple, dict] = {} _docker_remote_config_flights = [threading.Lock() for _ in range(16)] # Optional labels must never hold up the usable local/digest inventory. _docker_metadata_pool = concurrent.futures.ThreadPoolExecutor(max_workers=4) _docker_metadata_slots = threading.BoundedSemaphore(64) _docker_metadata_context = threading.local() _docker_slug_index_cache = (None, {}) _docker_inventory_lock = threading.RLock() _docker_inventory_cache: dict[str, dict] = {} def _load_bundled_hints() -> dict: try: with open(_TRACKING_HINTS_BUNDLED) as f: data = json.load(f) return data if isinstance(data, dict) else {} except (OSError, json.JSONDecodeError): return {} def _load_runtime_verified_overrides() -> dict: """Load optional live-detector promotions packaged with the Monitor.""" try: with open(_RUNTIME_VERIFIED_OVERRIDES_BUNDLED) as f: data = json.load(f) apps = data.get("apps") if isinstance(data, dict) else None return apps if isinstance(apps, dict) else {} except (OSError, json.JSONDecodeError): return {} def _is_helper_marker_detector(detector: dict) -> bool: return ( detector.get("installed_via") == "file" and bool(re.fullmatch( r"/root/\.[A-Za-z0-9_.-]+", str(detector.get("file_path") or "") )) ) def _apply_runtime_verified_overrides(hints: dict, overrides: dict) -> dict: """Promote packaged, runtime-proven detectors over a remote catalog. The remote catalog remains the normal no-rebuild update channel. Entries marked non-operational are skipped; all other runtime-verified entries replace detector fields. Presentation metadata and non-conflicting fallbacks remain intact. A legacy ``/root/.`` primary is retained as a fallback so older helper layouts do not lose their only version source. """ result = { slug: dict(hint) for slug, hint in (hints or {}).items() if isinstance(slug, str) and isinstance(hint, dict) } for slug, spec in (overrides or {}).items(): if not isinstance(slug, str) or not isinstance(spec, dict): continue detector = spec.get("detector") if not bool(spec.get("operational", True)) or not isinstance(detector, dict): continue method = detector.get("installed_via") if method not in _VALID_METHODS: continue current = dict(result.get(slug) or {}) marker_fallbacks = [] if _is_helper_marker_detector(current): marker_fallbacks.append({ "path": current["file_path"], "regex": current.get("file_regex") or current.get("installed_regex") or "", "source": "helper_marker", }) # Detector and upstream fields must be replaced as one coherent set; # retaining e.g. an old file_path next to a binary detector is what # previously kept stale helper marker versions alive. for key in _DETECTOR_FIELDS + ( "installed_via", "repo", "github_source", "tag_regex", "installed_regex", "upstream_type", "upstream_url", "upstream_json_path", "docker_image", ): current.pop(key, None) current.update(detector) fallbacks = [] for candidate in (current.get("file_fallbacks") or []): if isinstance(candidate, dict) and candidate.get("path"): fallbacks.append(dict(candidate)) spec_fallbacks = spec.get("file_fallbacks") if not isinstance(spec_fallbacks, list): spec_fallbacks = [] for candidate in spec_fallbacks + marker_fallbacks: if isinstance(candidate, dict) and candidate.get("path"): fallbacks.append(dict(candidate)) if fallbacks: unique_fallbacks = [] known_paths = set() for candidate in fallbacks: path = candidate.get("path") if path in known_paths: continue known_paths.add(path) unique_fallbacks.append(candidate) current["file_fallbacks"] = unique_fallbacks # These optional fields are explicitly allowed to update runtime # behavior too, while ordinary catalog presentation remains untouched. for key in ("alt_detectors", "default_ports", "logo", "website"): if key in spec: current[key] = spec[key] result[slug] = current return result def _fetch_tracking_hints() -> dict: """Return the curated tracking-hint map (slug → hint dict). Fetch order: memory cache (fresh) → GitHub raw merged with the bundled catalog → on-disk cache → bundled JSON. Packaged runtime-verified overrides are then applied to every source. They prevent a stale remote catalog from downgrading a detector already proven live, while all normal catalog updates continue to arrive without an AppImage rebuild. Never raises — a total failure returns an empty dict so callers can just ``.get(slug)``. """ global _tracking_hints_cache, _tracking_hints_ts with _tracking_hints_lock: now = time.time() if _tracking_hints_cache is not None and (now - _tracking_hints_ts) < _TRACKING_HINTS_TTL: return _tracking_hints_cache bundled = _load_bundled_hints() runtime_overrides = _load_runtime_verified_overrides() try: req = urllib.request.Request( _TRACKING_HINTS_URL, headers={"User-Agent": "ProxMenux-Monitor"}, ) with urllib.request.urlopen(req, timeout=_TRACKING_HINTS_HTTP_TIMEOUT) as r: raw = json.loads(r.read().decode("utf-8")) remote = raw if isinstance(raw, dict) else {} if len(remote) >= len(bundled): hints = dict(bundled) hints.update(remote) else: hints = dict(remote) hints.update(bundled) hints = _apply_runtime_verified_overrides(hints, runtime_overrides) _tracking_hints_cache = hints _tracking_hints_ts = now try: os.makedirs(os.path.dirname(_TRACKING_HINTS_DISK), exist_ok=True) tmp = f"{_TRACKING_HINTS_DISK}.tmp.{os.getpid()}" with open(tmp, "w") as f: json.dump({"ts": now, "hints": hints}, f) os.replace(tmp, _TRACKING_HINTS_DISK) except OSError: pass return hints except Exception: if _tracking_hints_cache is not None: return _tracking_hints_cache try: with open(_TRACKING_HINTS_DISK) as f: disk = json.load(f) _tracking_hints_cache = _apply_runtime_verified_overrides( disk.get("hints") or {}, runtime_overrides ) _tracking_hints_ts = float(disk.get("ts") or 0) return _tracking_hints_cache except (OSError, json.JSONDecodeError): _tracking_hints_cache = _apply_runtime_verified_overrides( bundled, runtime_overrides ) _tracking_hints_ts = now # avoid re-hammering return _tracking_hints_cache # Cheap guardrails on user input. Not exhaustive — the point is to # reject obvious footguns (shell metachars) before the value ends up # as a pct-exec argv entry. Real safety comes from never using sh -c. _PACKAGE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.+@:/\-]{0,127}$") _PATH_RE = re.compile(r"^/[A-Za-z0-9._/\-+@]{1,255}$") _REPO_RE = re.compile(r"^[A-Za-z0-9._\-]+/[A-Za-z0-9._\-]+$") _NAME_RE = re.compile(r"^[\w\s._+\-()/]{1,64}$", re.UNICODE) # Docker container name / id: lowercase letters/digits/underscore/./- _DOCKER_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.\-]{0,63}$") _DESC_RE = re.compile(r"^[\w\s._+\-()/:,]{0,64}$", re.UNICODE) _WEB_PATH_RE = re.compile(r"^/[\w\-._~:/?#\[\]@!$&'()*+,;=%]{0,254}$") # http(s) URL for the app logo — restrictive scheme allow-list prevents # javascript:/data:/file: sneak-ins through the App card's . _LOGO_URL_RE = re.compile(r"^https?://[\w\-._~:/?#\[\]@!$&'()*+,;=%]{1,510}$") # Community-scripts slug — lowercase letters/digits/dashes/underscores/dots. # Same shape helpers_cache uses for its own slug field. _HELPER_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$") # Web Link category — free-text label the user picks from the presets # built from helpers_cache.category_names, or types freely. Keep the # charset permissive enough for community-scripts labels ("Media & # Streaming", "*Arr Suite", "AI / Coding & Dev-Tools"). _CATEGORY_RE = re.compile(r"^[\w\s&/,.\-*+()]{1,60}$", re.UNICODE) # OCI label key (e.g. org.opencontainers.image.version) — reverse-DNS # style dot-separated identifiers. _OCI_LABEL_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9._\-]{0,127}$") # PEP 503 Python distribution name — flexible enough for `open-webui`, # `python_dotenv`, `Werkzeug`, etc. Case is preserved but comparison # is case-insensitive at pip level. _PYDIST_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._\-]{0,127}$") _cache_lock = threading.RLock() # ── Storage ──────────────────────────────────────────────────────── def _ensure_dir() -> None: try: os.makedirs(_APPS_DIR, mode=0o700, exist_ok=True) except OSError: pass def _sidecar_path(vmid) -> str: return f"{_APPS_DIR}/{int(vmid)}.json" def _now_iso() -> str: return datetime.datetime.utcnow().replace(microsecond=0).isoformat() + "Z" _sidecar_cache: dict = {} _sidecar_cache_lock = threading.RLock() _sidecar_revision = 0 def _sidecar_signature(stat) -> tuple: return (stat.st_dev, stat.st_ino, stat.st_mtime_ns, stat.st_ctime_ns, stat.st_size) def _publish_sidecar_snapshot(path: str, data: dict, signature: tuple) -> dict: """Publish under _sidecar_cache_lock; revisions exist only in memory.""" global _sidecar_revision _sidecar_revision = max(_sidecar_revision + 1, int(time.time() * 1000)) snapshot = _migrate_legacy(copy.deepcopy(data)) _migrate_update_methods(snapshot) snapshot['_revision'] = _sidecar_revision _sidecar_cache[path] = (signature, snapshot) return snapshot def _read_sidecar(vmid) -> Optional[dict]: path = _sidecar_path(vmid) with _sidecar_cache_lock: try: signature = _sidecar_signature(os.stat(path)) cached = _sidecar_cache.get(path) if cached is not None and cached[0] == signature: return copy.deepcopy(cached[1]) with open(path) as f: data = json.load(f) signature = _sidecar_signature(os.fstat(f.fileno())) if isinstance(data, dict): snapshot = _publish_sidecar_snapshot(path, data, signature) return copy.deepcopy(snapshot) except (FileNotFoundError, json.JSONDecodeError, OSError): pass _sidecar_cache.pop(path, None) return None def _migrate_legacy(data: dict) -> dict: """The Phase 2c.0 shape stored a single {config, state}. Convert those files on-read into the new {apps: [...]} shape so upgrades don't lose the user's registration.""" if "apps" in data and isinstance(data["apps"], list): return data if "config" in data and isinstance(data["config"], dict): legacy_cfg = data["config"] legacy_state = data.get("state") or {} # Move the single port + web_path onto the ports[] array port = legacy_cfg.pop("port", None) web_path = legacy_cfg.pop("web_path", None) ports = [] if port: ports.append({ "port": int(port), "description": "", "web_path": web_path or "/", }) migrated = { "vmid": data.get("vmid"), "apps": [{ "id": data.get("app_id") or _new_app_id(), **legacy_cfg, "ports": ports, "state": legacy_state, }], "created_at": data.get("created_at") or _now_iso(), "updated_at": data.get("updated_at") or _now_iso(), } return migrated return {"vmid": data.get("vmid"), "apps": [], "created_at": data.get("created_at") or _now_iso(), "updated_at": data.get("updated_at") or _now_iso()} def _migrate_update_methods(data: dict) -> None: """Preserve saved choices, never turn detection into updater consent. Old commands and explicitly saved bulk/enabled schedule selections keep working. New registrations always carry update_method, so an old `apps` wildcard cannot opt newly registered applications into Helper-Scripts. Projection is read-only; the next normal sidecar write persists it. """ schedule = data.get("schedule") or {} selected = set((data.get("bulk_update") or {}).get("targets") or []) if schedule.get("enabled"): targets = schedule.get("targets") if not targets: targets = ["apps"] if schedule.get("target", "both") in ("app", "both") else [] selected.update(targets) for app in data.get("apps") or []: if "update_method" in app: continue if (app.get("update_command") or "").strip(): app["update_method"] = "custom" elif (app.get("helper_slug") and app.get("helper_slug") not in ("docker", "adguard") and ("apps" in selected or f"app:{app.get('id')}" in selected)): app["update_method"] = "helper" else: app["update_method"] = "none" def protect_download_update_command(command: str) -> str: """Guard the historical downloaded-shell launcher at execution time. Only a literal, standalone wget/curl + shell -c launcher is recognised. Other custom commands are returned byte-for-byte, never evaluated here. Saved configuration is not rewritten. Grouping preserves && composition. """ launcher = re.fullmatch( r'''\s*(?PPHS_SILENT=[01][ \t]+)?(?P(?:/bin/|/usr/bin/)?(?:bash|sh))[ \t]+-c[ \t]+"\$\((?P[^\n]+)\)"\s*''', command, ) if not launcher: return command fetch = re.fullmatch( r'''(?Pwget|curl)[ \t]+(?P-qLO[ \t]+-|-qO[ \t]+-|-qO-|-fsSL|-fSL)[ \t]+(?P['"]?)(?Phttps?://[A-Za-z0-9_./:%?=&+#@,~!;-]+)(?P=quote)''', launcher['fetch'], ) if not fetch: return command # Require the original shell token to be literal too. Unquoted shell # operators or glob patterns are not this known launcher format. if not fetch['quote'] and any(c in fetch['url'] for c in '&;?'): return command flags = fetch['flags'].split() if ((fetch['tool'] == 'wget' and flags not in (['-qLO', '-'], ['-qO', '-'], ['-qO-'])) or (fetch['tool'] == 'curl' and flags not in (['-fsSL'], ['-fSL']))): return command fetch_command = shlex.join([fetch['tool'], *flags, fetch['url']]) invocation = (launcher['prefix'] or '') + launcher['shell'] return ( '(\n' f'_proxmenux_updater=$({fetch_command}) || {{\n' ' echo "ERROR: updater download failed; nothing was executed." >&2\n' ' exit 1\n' '}\n' '[ -n "$_proxmenux_updater" ] || {\n' ' echo "ERROR: downloaded updater is empty; nothing was executed." >&2\n' ' exit 1\n' '}\n' f'{invocation} -c "$_proxmenux_updater"\n' ')' ) def helper_update_selected(vmid, slug: str, targets=None) -> bool: """Execution-time consent check, shared with the shell runner. Wrapper provenance is independently verified by the caller. Duplicate registrations with different choices must not run a CT-wide helper. """ apps = (_read_sidecar(vmid) or {}).get("apps") or [] matching = [app for app in apps if app.get("helper_slug") == slug and not app.get("managed_oci_app_id")] if not matching or any(app.get("update_method") != "helper" or (app.get("update_command") or "").strip() for app in matching): return False if targets is None or "apps" in targets: return True return any(f"app:{app.get('id')}" in targets for app in matching) def _write_sidecar(vmid, data: dict) -> bool: _migrate_update_methods(data) _ensure_dir() 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'] return True except OSError as e: print(f"[ProxMenux] lxc_apps: could not write sidecar {path}: {e}") try: os.unlink(tmp) except OSError: pass return False def _new_app_id() -> str: return uuid.uuid4().hex[:12] # ── Validation ───────────────────────────────────────────────────── def _err(msg: str) -> tuple[bool, str]: return False, msg def _validate_command_argv(raw: Any) -> tuple[bool, Any]: """Validate ``command`` method's argv list. Same shape/rules as ``_validate_binary_args`` but with looser count/length limits and a REQUIRED non-empty first arg (the command to run). Every arg is passed argv-style through ``pct exec`` — no shell interpretation, never — so the only guardrails are size and control characters. Reference: security policy is "the user typed the command; user is responsible for what it does". We reject only what would break the pct-exec argv wire format. """ if raw is None or raw == "": return _err("command_argv is required (non-empty list)") if not isinstance(raw, list) or not raw: return _err("command_argv must be a non-empty list of strings") if len(raw) > _MAX_COMMAND_ARGV: return _err(f"command_argv accepts at most {_MAX_COMMAND_ARGV} entries") out: list = [] for i, item in enumerate(raw): if not isinstance(item, str) or not item: return _err(f"command_argv[{i}] must be a non-empty string") if len(item) > _MAX_COMMAND_ARGV_LEN: return _err(f"command_argv[{i}] exceeds {_MAX_COMMAND_ARGV_LEN} chars") if "\x00" in item or "\n" in item or "\r" in item: return _err(f"command_argv[{i}] contains a forbidden control character") out.append(item) return True, out def _validate_binary_args(raw: Any) -> tuple[bool, Any]: """Return (True, [args…]) or (False, error). Optional field: an empty/None input returns ``(True, [])``. Args are passed through ``pct exec`` argv-style — no shell interpretation ever — so the guardrails are just count/length + reject null bytes and newlines which would confuse the pct-exec argv wire format. """ if raw in (None, ""): return True, [] if not isinstance(raw, list): return _err("binary_args must be a list of strings") if len(raw) > _MAX_BINARY_ARGS: return _err(f"binary_args accepts at most {_MAX_BINARY_ARGS} entries") out: list = [] for i, item in enumerate(raw): if not isinstance(item, str) or not item: return _err(f"binary_args[{i}] must be a non-empty string") if len(item) > _MAX_BINARY_ARG_LEN: return _err(f"binary_args[{i}] exceeds {_MAX_BINARY_ARG_LEN} chars") if "\x00" in item or "\n" in item or "\r" in item: return _err(f"binary_args[{i}] contains a forbidden control character") out.append(item) return True, out def _validate_ports(ports_in: Any) -> tuple[bool, Any]: """Validate ports[] array: each entry {port: int, description: str, scheme: "http"|"https", web_path: str}. Empty list = no port assignment (fine).""" if ports_in in (None, ""): return True, [] if not isinstance(ports_in, list): return _err("ports must be a list of {port, description, scheme}") out: list = [] seen_ports: set = set() for i, item in enumerate(ports_in): if not isinstance(item, dict): return _err(f"ports[{i}] must be an object") raw_port = item.get("port") if raw_port in (None, "", 0): return _err(f"ports[{i}].port is required") try: p = int(raw_port) except (TypeError, ValueError): return _err(f"ports[{i}].port must be an integer") if not (1 <= p <= 65535): return _err(f"ports[{i}].port must be 1-65535") if p in seen_ports: return _err(f"port {p} appears more than once for this app") seen_ports.add(p) desc = (item.get("description") or "").strip() if desc and not _DESC_RE.match(desc): return _err(f"ports[{i}].description has invalid characters") scheme = (item.get("scheme") or "http").strip().lower() if scheme not in ("http", "https"): return _err(f"ports[{i}].scheme must be 'http' or 'https'") web = (item.get("web_path") or "/").strip() if not _WEB_PATH_RE.match(web): return _err(f"ports[{i}].web_path must be a valid URL path (max 255 chars)") entry = {"port": p, "description": desc, "scheme": scheme, "web_path": web} # Per-link logo — optional. Same http(s) allow-list as the # app-level logo. Used to render each Web Link with its own # icon (e.g. Portainer on 9000, MakeMKV on 5800). link_logo = (item.get("logo_url") or "").strip() if link_logo: if not _LOGO_URL_RE.match(link_logo): return _err(f"ports[{i}].logo_url must be an http(s) URL (max 512 chars)") entry["logo_url"] = link_logo # Per-link category — optional free-text label the user picks # from the presets sourced from helpers_cache.category_names. # Powers the Apps dashboard (filter/group by category). category = (item.get("category") or "").strip() if category: if not _CATEGORY_RE.match(category): return _err(f"ports[{i}].category has invalid characters or is too long") entry["category"] = category # Optional custom URL — takes precedence over the ip:port # composition when present. Used for apps reached through a # reverse-proxy domain (e.g. https://vault.example.com) so the # Apps dashboard opens the public URL instead of the internal # ip:port. Same http(s) allow-list as the app-level logo. custom_url = (item.get("custom_url") or "").strip() if custom_url: if not _LOGO_URL_RE.match(custom_url): return _err(f"ports[{i}].custom_url must be an http(s) URL (max 512 chars)") entry["custom_url"] = custom_url out.append(entry) return True, out def _parse_cron_field(field: str, min_v: int, max_v: int) -> Optional[set]: """Expand a single cron field into the set of integers it covers. Supports: ``*`` (all), ``N`` (exact), ``*/N`` (step), and comma-separated combinations of those. Returns None on any parse failure. Ranges (``1-5``) are deliberately unsupported for now — every UI preset boils down to *, N, or */N. """ if not isinstance(field, str) or not field or len(field) > _MAX_CRON_FIELD_LEN: return None field = field.strip() out: set = set() for part in field.split(","): part = part.strip() if not part: return None if part == "*": out.update(range(min_v, max_v + 1)) continue if part.startswith("*/"): try: step = int(part[2:]) except ValueError: return None if step <= 0: return None out.update(range(min_v, max_v + 1, step)) continue try: n = int(part) except ValueError: return None if n < min_v or n > max_v: return None out.add(n) return out if out else None def _validate_cron(expr: str) -> Optional[str]: """Return None if `expr` is a valid 5-field cron the internal scheduler can honour, else a short error string. Mirrors the fields expected by `cron_matches` below.""" if not isinstance(expr, str): return "cron must be a string" parts = expr.strip().split() if len(parts) != 5: return "cron must have exactly 5 space-separated fields (minute hour day month weekday)" bounds = ((0, 59), (0, 23), (1, 31), (1, 12), (0, 6)) for p, (lo, hi) in zip(parts, bounds): if _parse_cron_field(p, lo, hi) is None: return f"cron field '{p}' is not valid" return None def cron_matches(expr: str, dt: datetime.datetime) -> bool: """True when the cron expression matches the given datetime at minute granularity. Called every 60s by the scheduler thread; a False from the parser (invalid expr) matches nothing so a malformed schedule silently no-ops instead of firing anything unexpected.""" parts = expr.strip().split() if len(parts) != 5: return False m_set = _parse_cron_field(parts[0], 0, 59) h_set = _parse_cron_field(parts[1], 0, 23) d_set = _parse_cron_field(parts[2], 1, 31) mon_set = _parse_cron_field(parts[3], 1, 12) dow_set = _parse_cron_field(parts[4], 0, 6) if not (m_set and h_set and d_set and mon_set and dow_set): return False # Python weekday(): Monday=0..Sunday=6. Cron: Sunday=0..Saturday=6. # Convert Python weekday to cron weekday. cron_dow = (dt.weekday() + 1) % 7 return (dt.minute in m_set and dt.hour in h_set and dt.day in d_set and dt.month in mon_set and cron_dow in dow_set) def validate_schedule(payload: Any) -> tuple[bool, Any]: """Validate a schedule config block. Returns ``(True, normalised_schedule)`` or ``(False, error)``. Called by both the endpoint handler and by config migration so the same shape check applies everywhere. When `enabled` is false only the minimum fields are required; the rest are kept so re-enabling doesn't wipe the operator's cron + toggles.""" if not isinstance(payload, dict): return _err("schedule must be a JSON object") enabled = bool(payload.get("enabled")) cron = (payload.get("cron") or "").strip() if enabled and not cron: return _err("cron is required when schedule is enabled") if cron: err = _validate_cron(cron) if err: return _err(err) target = (payload.get("target") or "both").strip().lower() if target not in _VALID_SCHEDULE_TARGETS: return _err(f"target must be one of: {', '.join(_VALID_SCHEDULE_TARGETS)}") targets_raw = payload.get("targets") if targets_raw is None: # Backward-compatible migration for schedules saved before the # per-app selector existed. `apps` means every eligible registered # app and is expanded by the runner at execution time. targets = (["os"] if target in ("os", "both") else []) + (["apps"] if target in ("app", "both") else []) else: if not isinstance(targets_raw, list): return _err("targets must be a JSON array") targets = [] for value in targets_raw: item = str(value or "").strip() if not _SCHEDULE_TARGET_ID_RE.match(item): return _err(f"invalid schedule target: {item[:80]}") if item not in targets: targets.append(item) if len(targets) > 128: return _err("targets may contain at most 128 items") if enabled and not targets: return _err("at least one schedule target is required when enabled") target = "both" if "os" in targets and any(item != "os" for item in targets) else ("os" if targets == ["os"] else "app") backup = bool(payload.get("backup")) restart = bool(payload.get("restart")) release_delay_raw = payload.get("release_delay_days", 0) try: release_delay_days = int(release_delay_raw) except (TypeError, ValueError): return _err("release_delay_days must be an integer from 0 to 365") if release_delay_days < 0 or release_delay_days > 365: return _err("release_delay_days must be an integer from 0 to 365") backup_storage = (payload.get("backup_storage") or "").strip() if backup and not backup_storage: # Not fatal — the runner falls back to the first vzdump-capable # storage the frontend passes at run time. Persist as empty so # the UI knows the user relied on the default. backup_storage = "" if backup_storage and (len(backup_storage) > 64 or not re.match(r"^[A-Za-z0-9._\-]+$", backup_storage)): return _err("backup_storage must be a valid PVE storage name") out: dict = { "enabled": enabled, "cron": cron, "target": target, "targets": targets, "backup": backup, "backup_storage": backup_storage, "restart": restart, "release_delay_days": release_delay_days, } # Preserve `last_run_at` / `last_run_status` when the caller sent # them (typical when the scheduler writes back after firing); # otherwise leave the field unset so persisted values survive. for k in ("last_run_at", "last_run_status", "last_run_target", "last_run_reason"): v = payload.get(k) if v is not None: out[k] = v return True, out def validate_bulk_update(payload: Any) -> tuple[bool, Any]: """Validate the reusable manual bulk-update selection. This configuration is deliberately independent from ``schedule``: changing what a manual bulk run does must never rewrite the operator's cron automation. ``os`` is mandatory and at least one additional, explicit update method must be selected. """ if not isinstance(payload, dict): return _err("bulk_update must be a JSON object") raw_targets = payload.get("targets") if not isinstance(raw_targets, list): return _err("targets must be a JSON array") targets: list[str] = [] for value in raw_targets: item = str(value or "").strip() if not _BULK_TARGET_ID_RE.match(item): return _err(f"invalid bulk update target: {item[:80]}") if item not in targets: targets.append(item) if len(targets) > 128: return _err("targets may contain at most 128 items") if "os" not in targets: return _err("the OS target is required for a bulk update") if not any(item != "os" for item in targets): return _err("at least one application target is required for a bulk update") return True, {"targets": ["os", *sorted(item for item in targets if item != "os")]} def validate_config(payload: dict) -> 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 accepted; each has its own required field set.""" if not isinstance(payload, dict): return _err("payload must be a JSON object") name = (payload.get("name") or "").strip() if not name or not _NAME_RE.match(name): return _err("name is required and must be 1-64 chars of letters/digits/spaces/._+-()/") # `installed_via` is OPTIONAL now. When empty, the app is # "register-only" — we produce clickable web links but never try # to detect a version, never fetch upstream, never emit warnings. # This is the default for casual users who just want a link, and # for docker apps (whose version lifecycle Docker owns). method = (payload.get("installed_via") or "").strip().lower() if method and method not in _VALID_METHODS: return _err(f"installed_via must be one of: {', '.join(_VALID_METHODS)} or empty") conf: dict = {"name": name} if method: conf["installed_via"] = method if method in ("dpkg", "apk"): pkg = (payload.get("package") or "").strip() if not pkg or not _PACKAGE_RE.match(pkg): return _err("package is required (letters/digits/._+@:/ up to 127 chars)") conf["package"] = pkg elif method == "file": fp = (payload.get("file_path") or "").strip() if not fp or not _PATH_RE.match(fp): return _err("file_path is required and must be an absolute path") fr = payload.get("file_regex") or "" if not isinstance(fr, str) or not fr.strip(): return _err("file_regex is required") try: re.compile(fr) except re.error as e: return _err(f"file_regex is not a valid regex: {e}") conf["file_path"] = fp conf["file_regex"] = fr.strip() elif method == "binary": bp = (payload.get("binary_path") or "").strip() if not bp or not _PATH_RE.match(bp): return _err("binary_path is required and must be an absolute path") conf["binary_path"] = bp ok, args = _validate_binary_args(payload.get("binary_args")) if not ok: return _err(args) if args: conf["binary_args"] = args elif method == "python_dist": # importlib.metadata.version() run through the # configured venv's python interpreter. Zero shell, argv-only. pp = (payload.get("python_path") or "").strip() if not pp or not _PATH_RE.match(pp): return _err("python_path is required and must be an absolute path") dist = (payload.get("distribution") or "").strip() if not dist or not _PYDIST_RE.match(dist): return _err("distribution is required (PEP 503 name)") conf["python_path"] = pp conf["distribution"] = dist elif method == "docker_label": # docker inspect --format '{{index .Config.Labels "