# ========================================================== # 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) -> dict|None # check_all(vmid, force=False) -> dict|None # get_active_apps() -> {str(vmid): [summary, …]} # get_suggestions(vmid) -> {name, port_suggestions[], web_path_hint} # ========================================================== from __future__ import annotations import datetime import json import os import re 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 _UPSTREAM_CACHE_TTL_SEC = 6 * 3600 # 6 h — GitHub is polite this way _VALID_METHODS = ("dpkg", "apk", "file", "binary", "python_dist", "docker_label", "docker_exec", "command", "manual") _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") _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]+)*)?$" ) # 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", ) _tracking_hints_lock = threading.RLock() _tracking_hints_cache: Optional[dict] = None _tracking_hints_ts: float = 0.0 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 _fetch_tracking_hints() -> dict: """Return the curated tracking-hint map (slug → hint dict). Fetch order: memory cache (fresh) → GitHub raw → on-disk cache from a prior fetch → bundled JSON shipped inside the AppImage. Never raises — a total failure returns an empty dict so callers can just ``.get(slug)``. Same shape and TTL discipline as managed_installs._fetch_helpers_cache. """ 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 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")) hints = raw if isinstance(raw, dict) else {} _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 = disk.get("hints") or {} _tracking_hints_ts = float(disk.get("ts") or 0) return _tracking_hints_cache except (OSError, json.JSONDecodeError): bundled = _load_bundled_hints() _tracking_hints_cache = bundled _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}$") # 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" def _read_sidecar(vmid) -> Optional[dict]: path = _sidecar_path(vmid) try: with open(path) as f: data = json.load(f) if isinstance(data, dict): return _migrate_legacy(data) except (FileNotFoundError, json.JSONDecodeError, OSError): pass 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 _write_sidecar(vmid, data: dict) -> bool: _ensure_dir() path = _sidecar_path(vmid) tmp = f"{path}.tmp.{os.getpid()}" try: with open(tmp, "w") as f: json.dump(data, f, indent=2, sort_keys=True) os.chmod(tmp, 0o600) os.replace(tmp, path) 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 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)}") backup = bool(payload.get("backup")) restart = bool(payload.get("restart")) 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, "backup": backup, "backup_storage": backup_storage, "restart": restart, } # 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"): v = payload.get(k) if v is not None: out[k] = v return True, out 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 "