Merge pull request #281 from MacRimi/feature/lxc-updates-refresh

Feature/lxc updates refresh
This commit is contained in:
MacRimi
2026-08-09 01:02:22 +02:00
committed by GitHub
19 changed files with 10437 additions and 263 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,160 @@
name: Update App Tracking Hints
on:
# Manual trigger from the Actions UI
workflow_dispatch:
# Re-merge whenever the generator, workflow, or the maintainer-
# curated runtime overrides change. `runtime_verified_overrides.json`
# is the file to edit when a real LXC reveals a canonical path the
# community-scripts helper doesn't ship (legacy /app/package.json,
# /opt/vaultwarden/bin/vaultwarden, etc.) — the generator folds it
# into the operational catalog every run.
push:
branches: [main]
paths:
- ".github/scripts/generate_app_tracking_catalog.py"
- ".github/workflows/update-app-tracking-hints.yml"
- "json/runtime_verified_overrides.json"
# Regen every 6h — picks up new community-scripts LXC apps and
# detector-relevant script edits without needing a manual trigger.
schedule:
- cron: "0 */6 * * *"
jobs:
update-hints:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: ⬇️ Checkout the repository
uses: actions/checkout@v6
- name: 🐍 Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.11"
- name: ⚙️ Generate app_tracking_hints.generated.json (intermediate)
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# The generator writes 4 files; only `.generated.json` is
# consumed downstream by the merge step. The v2 catalog and
# per-app audit are useful for local review but not kept in
# the repo — written under /tmp so they never appear as
# dirty files here.
#
# `--runtime-overrides` folds real-CT evidence into the
# operational hints (canonical paths, cross-method fallbacks
# per app) so the runtime doesn't get fed helper-marker
# false-positives.
run: |
python .github/scripts/generate_app_tracking_catalog.py \
--helpers-cache json/helpers_cache.json \
--existing json/app_tracking_hints.json \
--runtime-overrides json/runtime_verified_overrides.json \
--output json/app_tracking_hints.generated.json \
--v2-output /tmp/app_tracking_catalog.v2.json \
--audit-output /tmp/app_tracking_hints.audit.json
- name: 🧬 Smart-merge generated into app_tracking_hints.json
# Single source of truth: `app_tracking_hints.json` is the ONE
# file. It contains 3 kinds of entries:
# 1. Auto-verified from community-scripts (the generator
# manages every "generator-owned" field on these).
# 2. User-edited additions to those entries — extra fields
# the generator doesn't touch (default_ports,
# file_fallbacks, custom logo overrides…).
# 3. User-only entries the generator can't verify (Docker,
# AdGuard, Pi-hole, WireGuard, …) — left alone.
# Merge rule: for slugs the generator produces, refresh only
# the whitelisted fields; preserve everything else. For slugs
# NOT in the generator's output, keep the existing entry
# untouched.
run: |
python - <<'PY'
import json
from pathlib import Path
GEN = Path("json/app_tracking_hints.generated.json")
OUT = Path("json/app_tracking_hints.json")
# Fields owned by the generator — refreshed on every run.
# These are all populated deterministically by the generator
# (the audit script folds `runtime_verified_overrides.json`
# in as it runs), so a local hand-edit for a generator-known
# slug would get overwritten on the next tick. To add a new
# canonical path or a cross-method fallback for a slug the
# generator already knows, edit `runtime_verified_overrides
# .json` — that file IS the maintainer-controlled input.
#
# For user-only slugs (Docker, WireGuard, Pi-hole and any
# other entry not in the generator's output) EVERY field is
# preserved verbatim by the merge below — the whitelist only
# governs generator-covered slugs.
GENERATOR_FIELDS = {
"installed_via", "package", "file_path", "file_regex",
"binary_path", "repo", "github_source", "tag_regex",
"installed_regex",
# Upstream source discriminator + per-type fields
# (http_json + docker_hub). Kept in the whitelist so a
# curated entry in runtime_verified_overrides.json can
# supply them and the smart merge won't drop them on the
# next regeneration.
"upstream_type", "upstream_url", "upstream_json_path",
"docker_image",
"logo", "website",
"default_ports", "file_fallbacks", "alt_detectors",
}
generated = json.loads(GEN.read_text(encoding="utf-8"))
existing = {}
if OUT.is_file():
try:
existing = json.loads(OUT.read_text(encoding="utf-8"))
if not isinstance(existing, dict):
existing = {}
except json.JSONDecodeError:
existing = {}
merged = {}
for slug, gen_entry in generated.items():
base = dict(existing.get(slug) or {})
# Refresh generator-owned fields (add/update).
for k, v in gen_entry.items():
if k in GENERATOR_FIELDS:
base[k] = v
# Drop generator-owned fields that the generator no
# longer emits for this slug (e.g. path renamed away).
for k in list(base):
if k in GENERATOR_FIELDS and k not in gen_entry:
del base[k]
merged[slug] = base
# Preserve user-only entries the generator can't verify.
for slug, entry in existing.items():
if slug not in generated and isinstance(entry, dict):
merged[slug] = dict(entry)
OUT.write_text(json.dumps(merged, indent=2, sort_keys=True) + "\n", encoding="utf-8")
added = sorted(set(generated) - set(existing))
removed = sorted(set(existing) - set(generated) - {s for s, e in existing.items() if not (
set(e.keys()) - GENERATOR_FIELDS
)})
print(f"merged: {len(merged)} entries "
f"(generated={len(generated)}, existing={len(existing)})")
if added:
print(f" new from generator: {len(added)}")
# Clean up the intermediate file so it doesn't get committed.
GEN.unlink()
PY
- name: 📤 Commit + push if changed
run: |
git config user.name "ProxMenuxBot"
git config user.email "bot@proxmenux.local"
git add json/app_tracking_hints.json
git diff --cached --quiet || git commit -m "Update app tracking hints"
git push
+2 -2
View File
@@ -32,7 +32,7 @@ import {
FileText, FileText,
RefreshCw, RefreshCw,
Shield, Shield,
Download, ArrowUpCircle,
X, X,
Clock, Clock,
BellOff, BellOff,
@@ -777,7 +777,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
onClick={() => setShowUpdateTerminal(true)} onClick={() => setShowUpdateTerminal(true)}
className="bg-purple-600/15 hover:bg-purple-600/25 border border-purple-500/40 text-purple-300 hover:text-purple-200" className="bg-purple-600/15 hover:bg-purple-600/25 border border-purple-500/40 text-purple-300 hover:text-purple-200"
> >
<Download className="h-4 w-4 mr-1.5" /> <ArrowUpCircle className="h-4 w-4 mr-1.5" />
{t("healthStatus.updateNow")} {t("healthStatus.updateNow")}
</Button> </Button>
</div> </div>
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -16,7 +16,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type,
// 1px blue ring + matching border so a focused input now sits at the // 1px blue ring + matching border so a focused input now sits at the
// same visual weight as the colored card selectors used elsewhere // same visual weight as the colored card selectors used elsewhere
// (Backend picker, etc.). // (Backend picker, etc.).
"flex h-10 w-full rounded-lg border border-input bg-background px-4 py-2 text-sm shadow-sm transition-all file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500 focus-visible:border-blue-500 disabled:cursor-not-allowed disabled:opacity-50 hover:border-ring/50", "flex h-10 w-full rounded-lg border border-input bg-background px-4 py-2 text-sm shadow-sm transition-all file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground placeholder:opacity-40 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500 focus-visible:border-blue-500 disabled:cursor-not-allowed disabled:opacity-50 hover:border-ring/50",
className, className,
)} )}
ref={ref} ref={ref}
+1 -1
View File
@@ -10,7 +10,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
return ( return (
<textarea <textarea
className={cn( className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500 focus-visible:border-blue-500 disabled:cursor-not-allowed disabled:opacity-50", "flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground placeholder:opacity-40 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500 focus-visible:border-blue-500 disabled:cursor-not-allowed disabled:opacity-50",
className className
)} )}
ref={ref} ref={ref}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2
View File
@@ -126,6 +126,8 @@ cp "$SCRIPT_DIR/lxc_mount_points.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "
cp "$SCRIPT_DIR/disk_temperature_history.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ disk_temperature_history.py not found" cp "$SCRIPT_DIR/disk_temperature_history.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ disk_temperature_history.py not found"
cp "$SCRIPT_DIR/health_thresholds.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ health_thresholds.py not found" cp "$SCRIPT_DIR/health_thresholds.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ health_thresholds.py not found"
cp "$SCRIPT_DIR/managed_installs.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ managed_installs.py not found" cp "$SCRIPT_DIR/managed_installs.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ managed_installs.py not found"
cp "$SCRIPT_DIR/lxc_apps.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ lxc_apps.py not found"
cp "$APPIMAGE_ROOT/../json/app_tracking_hints.json" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ app_tracking_hints.json not found"
cp "$SCRIPT_DIR/flask_terminal_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_terminal_routes.py not found" cp "$SCRIPT_DIR/flask_terminal_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_terminal_routes.py not found"
cp "$SCRIPT_DIR/hardware_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ hardware_monitor.py not found" cp "$SCRIPT_DIR/hardware_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ hardware_monitor.py not found"
cp "$SCRIPT_DIR/proxmox_storage_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ proxmox_storage_monitor.py not found" cp "$SCRIPT_DIR/proxmox_storage_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ proxmox_storage_monitor.py not found"
+534
View File
@@ -5882,15 +5882,115 @@ def _get_lxc_update_status_map() -> dict:
'error': update.get('error'), 'error': update.get('error'),
# Cap packages list shipped to UI — modal uses first 30 max # Cap packages list shipped to UI — modal uses first 30 max
'packages': (update.get('_packages') or [])[:30], 'packages': (update.get('_packages') or [])[:30],
# OCI-image CTs: suppress apt/apk detection (managed_installs
# short-circuits the checker), UI hides the "N packages
# pending" badge and shows a dedicated OCI panel in the
# Updates modal instead.
'is_oci_lxc': bool(it.get('_is_oci')),
# Community-scripts convention: /usr/bin/update present in
# the CT AND the app is marked `updateable: true` in the
# helpers_cache. Only then does the modal offer the
# "Apply application update" button — 47/733 apps ship an
# updater that's known to fail, so blindly running
# /usr/bin/update on every CT would be a footgun.
'app_updater_present': bool(it.get('_has_app_updater')),
# App identity + updateable-known flag for the modal:
# when we detected /usr/bin/update but the slug wasn't in
# the cache, `app_updater_present` is False and
# `helper_updateable_known` is False — the modal renders a
# neutral "Unknown app" hint instead of the Apply button.
# When we know the slug but it's flagged updateable=false,
# `app_updater_present` is False and `helper_updateable_known`
# is True — the modal shows an explicit "This app doesn't
# support in-place updates" note so the user isn't left
# wondering why the button is missing.
'helper_slug': it.get('_helper_slug'),
'helper_app_name': it.get('_helper_app_name'),
'helper_updateable_known': bool(it.get('_helper_updateable_known')),
# ProxMenux-managed OCI apps (Secure Gateway / Tailscale etc)
# share the same underlying LXC. When set, the Updates modal
# redirects to the OCI dashboard's own updater instead of
# exposing our generic apt/apk flow — those apps carry
# per-package hooks (e.g. tailscale service restart) that
# the generic runner is blind to.
'managed_oci_app': it.get('_managed_oci_app'),
'os_family': it.get('_os_family'),
} }
return out return out
def _get_lxc_app_watch_map() -> dict:
"""Read every /etc/proxmenux/apps/<vmid>.json sidecar into a
``{vmid_str: summary}`` lookup, so `/api/vms` can decorate each
LXC row without a second frontend call. Never triggers a check
that's the caller's responsibility (the daily poll or the modal's
"Check now" button).
For CTs managed by oci_manager (Secure Gateway etc.) we synthesise
a matching summary from that module's own state so the App tab
doesn't ask the user to "register" what ProxMenux itself
installed. The synthesised entry carries `managed_oci_app_id` so
the frontend renders a read-only view + wires the Update button
to /api/oci/installed/<app_id>/update instead of user CRUD.
Managed data always wins over any stale user sidecar for the same
vmid (rare, but possible if a CT was registered before it got
adopted by oci_manager).
"""
# {vmid_str: [app_summary, ...]} — user-registered apps
out: dict = {}
try:
import lxc_apps
out.update(lxc_apps.get_active_apps() or {})
except Exception:
pass
# Overlay managed OCI-app state. Managed apps prepend a synthetic
# entry with `managed_oci_app_id` set — the frontend renders it
# read-only and never allows user CRUD on it.
try:
import managed_installs
items = managed_installs.get_active_items() or []
for it in items:
if it.get("type") != "oci_app":
continue
vmid = it.get("_vmid")
if vmid is None:
continue
uc = it.get("update_check") or {}
managed_entry = {
"id": f"managed:{it.get('_oci_app_id')}",
"name": it.get("name") or "Managed app",
"installed_via": "managed",
"ports": [],
"health_path": None,
"installed_version": it.get("current_version") or uc.get("current"),
"latest_version": uc.get("latest"),
"update_available": bool(uc.get("available")),
"error": uc.get("error"),
"checked_at": uc.get("last_check"),
"has_repo": False,
"managed_oci_app_id": it.get("_oci_app_id"),
# `packages` mirrors the Security page — a short list
# of "N other packages pending" alongside the primary
# tailscale bump.
"packages": (uc.get("_packages") or [])[:30],
}
existing = out.get(str(vmid)) or []
# Managed goes first; user-registered apps follow.
out[str(vmid)] = [managed_entry] + [a for a in existing
if not a.get("managed_oci_app_id")]
except Exception as e:
print(f"[ProxMenux] lxc_apps overlay for managed OCI failed: {e}")
return out
def get_proxmox_vms(): def get_proxmox_vms():
"""Get Proxmox VM and LXC information (requires pvesh command) - only from local node""" """Get Proxmox VM and LXC information (requires pvesh command) - only from local node"""
try: try:
all_vms = [] all_vms = []
lxc_updates_map = _get_lxc_update_status_map() lxc_updates_map = _get_lxc_update_status_map()
lxc_app_map = _get_lxc_app_watch_map()
try: try:
# local_node = socket.gethostname() # local_node = socket.gethostname()
@@ -5931,6 +6031,13 @@ def get_proxmox_vms():
upd = lxc_updates_map.get(str(resource.get('vmid'))) upd = lxc_updates_map.get(str(resource.get('vmid')))
if upd is not None: if upd is not None:
vm_data['update_check'] = upd vm_data['update_check'] = upd
# App Watch (Phase 2c) — list of registered
# apps per CT (0..N). Populates header badge,
# Updates modal connected row, and the App
# tab. Absent key = no apps registered.
app_list = lxc_app_map.get(str(resource.get('vmid')))
if app_list:
vm_data['app_watches'] = app_list
# PVE's cluster resources API reports disk=0 for most # PVE's cluster resources API reports disk=0 for most
# QEMU VMs — it can't see inside the guest filesystem # QEMU VMs — it can't see inside the guest filesystem
@@ -12347,6 +12454,273 @@ def api_lxc_updates_detection_set():
return jsonify({'success': False, 'message': str(e)}), 500 return jsonify({'success': False, 'message': str(e)}), 500
# ─── LXC App Watch (Phase 2c) ───────────────────────────────────────────────
# Per-CT user-registered application metadata + upstream version tracking.
# Sidecar at /etc/proxmenux/apps/<vmid>.json managed by the lxc_apps module.
# Endpoints kept in the same "/api/vms/<vmid>/…" family so the UI's fetch
# footprint stays symmetric with the rest of the per-CT resources.
#
# Notification (`app_update_available`) is fired from `check_app` — not from
# here — so scheduled re-checks and on-demand /check both trigger it.
# Per-CT App Watch CRUD. Multi-app: each sidecar is a list of apps.
# GET /api/vms/<vmid>/apps → list all apps for this CT
# POST /api/vms/<vmid>/apps → add a new app (server assigns id)
# PUT /api/vms/<vmid>/apps/<app_id> → update an existing app
# DELETE /api/vms/<vmid>/apps/<app_id> → remove one app
# DELETE /api/vms/<vmid>/apps → remove all apps for this CT
# POST /api/vms/<vmid>/apps/<app_id>/check → force check one app
# POST /api/vms/<vmid>/apps/check → force check every app
# GET /api/vms/<vmid>/apps/suggestions → name + listening ports hint
# POST /api/vms/<vmid>/apps/dismiss → hide an auto-detected chip
@app.route('/api/vms/<int:vmid>/apps', methods=['GET'])
@require_auth
def api_vm_apps_get(vmid):
try:
import lxc_apps
sidecar = lxc_apps.load_sidecar(vmid)
if not sidecar:
return jsonify({'vmid': vmid, 'apps': []}), 200
return jsonify(sidecar)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/vms/<int:vmid>/apps', methods=['POST'])
@require_auth
def api_vm_apps_add(vmid):
payload = request.get_json(silent=True) or {}
try:
import lxc_apps
ok, result = lxc_apps.add_app(vmid, payload)
if not ok:
return jsonify({'error': result}), 400
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/vms/<int:vmid>/apps/<app_id>', methods=['PUT'])
@require_auth
def api_vm_apps_update(vmid, app_id):
payload = request.get_json(silent=True) or {}
try:
import lxc_apps
ok, result = lxc_apps.update_app(vmid, app_id, payload)
if not ok:
code = 404 if 'not found' in str(result).lower() else 400
return jsonify({'error': result}), code
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/vms/<int:vmid>/apps/<app_id>', methods=['DELETE'])
@require_auth
def api_vm_apps_delete_one(vmid, app_id):
try:
import lxc_apps
ok = lxc_apps.delete_app(vmid, app_id)
return jsonify({'success': ok, 'vmid': vmid, 'app_id': app_id}), 200
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/vms/<int:vmid>/apps', methods=['DELETE'])
@require_auth
def api_vm_apps_delete_all(vmid):
try:
import lxc_apps
ok = lxc_apps.delete_all(vmid)
return jsonify({'success': ok, 'vmid': vmid}), 200
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/vms/<int:vmid>/apps/<app_id>/check', methods=['POST'])
@require_auth
def api_vm_apps_check_one(vmid, app_id):
try:
import lxc_apps
sidecar = lxc_apps.check_app(vmid, app_id, force=True)
if not sidecar:
return jsonify({'error': 'app not found'}), 404
return jsonify(sidecar)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/vms/<int:vmid>/apps/check', methods=['POST'])
@require_auth
def api_vm_apps_check_all(vmid):
try:
import lxc_apps
sidecar = lxc_apps.check_all(vmid, force=True)
if not sidecar:
return jsonify({'vmid': vmid, 'apps': []}), 200
return jsonify(sidecar)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/vms/<int:vmid>/schedule', methods=['GET', 'PUT', 'DELETE'])
@require_auth
def api_vm_apps_schedule(vmid):
"""Scheduled update CRUD. GET returns the current schedule (or
{} if unset), PUT persists a new one, DELETE removes it entirely
(equivalent to setting enabled=false + wiping the cron). Handled
on the sidecar directly by lxc_apps the scheduler thread reads
the same source of truth on its next tick."""
try:
import lxc_apps
except Exception as e:
return jsonify({'error': f'lxc_apps unavailable: {e}'}), 500
if request.method == 'GET':
sched = lxc_apps.get_schedule(vmid) or {}
# Enrich with detection of any host-level community-scripts
# update cron so the UI can render the "leverage what's
# already there" state without a second round-trip.
try:
ext = lxc_apps.detect_external_update_cron()
except Exception:
ext = None
if ext:
sched = dict(sched)
sched["external_cron"] = ext
return jsonify(sched)
if request.method == 'DELETE':
ok = lxc_apps.delete_schedule(vmid)
return jsonify({'success': bool(ok), 'vmid': vmid}), 200
payload = request.get_json(silent=True) or {}
ok, result = lxc_apps.update_schedule(vmid, payload)
if not ok:
return jsonify({'error': result}), 400
return jsonify(result)
@app.route('/api/vms/<int:vmid>/apps/suggestions', methods=['GET'])
@require_auth
def api_vm_apps_suggestions(vmid):
try:
import lxc_apps
return jsonify(lxc_apps.get_suggestions(vmid))
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/apps/catalog', methods=['GET'])
@require_auth
def api_apps_catalog():
"""Compact catalog of every registerable app the frontend picker
can offer [{slug, name, logo, default_port, has_tracking}].
Cache-friendly: same content for every user, only changes when
helpers_cache.json or app_tracking_hints.json refresh."""
try:
import lxc_apps
return jsonify(lxc_apps.get_catalog())
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/apps/catalog/<slug>', methods=['GET'])
@require_auth
def api_apps_catalog_slug(slug):
"""Detail for a single catalog slug — used to seed the editor
after the user picks an app in the Name Combobox. Includes the
curated tracking_suggestion when we have one for the slug."""
try:
import lxc_apps
entry = lxc_apps.get_catalog_entry(slug)
if entry is None:
return jsonify({'error': 'not found'}), 404
return jsonify(entry)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/vms/<int:vmid>/apps/dismiss', methods=['POST'])
@require_auth
def api_vm_apps_dismiss(vmid):
"""Persist a per-CT dismiss / un-dismiss for an auto-detected
slug. Body: ``{"slug": str, "dismissed": bool}``. Detected chips
hidden this way don't come back on future page loads.
"""
try:
import lxc_apps
payload = request.get_json(silent=True) or {}
slug = payload.get('slug', '')
dismissed = bool(payload.get('dismissed', True))
ok, result = lxc_apps.set_dismissed_slug(vmid, slug, dismissed)
if not ok:
return jsonify({'error': result}), 400
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
# ─── LXC Update — post-apply hook ───────────────────────────────────────────
# Called by the UI right after `apply_updates.sh` exits in the
# ScriptTerminalModal. Two responsibilities:
# 1. Emit the `lxc_update_applied` notification event with the actual
# exit code + duration + target, so the user's chosen channels
# (Telegram/Discord/email/…) confirm completion. Firing from here
# keeps the shell script free of Python coupling.
# 2. Force-refresh the affected CT's update state so the badge in
# the VMs & Containers list disappears immediately instead of
# waiting for the next 24 h polling cycle.
@app.route('/api/lxc-updates/<int:vmid>/applied', methods=['POST'])
@require_auth
def api_lxc_updates_applied(vmid):
payload = request.get_json(silent=True) or {}
success = bool(payload.get('success'))
target = str(payload.get('target') or 'os').lower()
duration_seconds = payload.get('duration_seconds')
ct_name = str(payload.get('ct_name') or f'CT-{vmid}')
# Duration formatting mirrors the backup runner's convention:
# <60s → seconds, otherwise Nm Ms.
try:
secs = int(duration_seconds) if duration_seconds is not None else 0
except (TypeError, ValueError):
secs = 0
duration_str = f'{secs}s' if secs < 60 else f'{secs // 60}m {secs % 60}s'
# Fire via notification_manager.emit_event — same public API the
# health monitor uses (see line 1328 for the reference call), so we
# inherit templating, per-channel fan-out and suppression logic.
try:
notification_manager.emit_event(
event_type='lxc_update_applied',
severity='INFO' if success else 'WARNING',
data={
'hostname': get_proxmox_node_name(),
'vmid': vmid,
'ct_name': ct_name,
'target': target,
'result': 'succeeded' if success else 'failed',
'duration': duration_str,
},
source='api',
entity='ct',
entity_id=str(vmid),
)
except Exception as e:
# Don't fail the whole hook on notif error — the refresh below
# is still valuable, and the UI already saw the exit code.
print(f'[ProxMenux] lxc_update_applied notif enqueue failed: {e}')
# Force-refresh the LXC update check so the badge state matches
# reality without the 24 h wait.
try:
import managed_installs
managed_installs.check_for_updates(force=True)
except Exception as e:
print(f'[ProxMenux] managed_installs.check_for_updates failed: {e}')
return jsonify({'success': True})
@app.route('/api/health/thresholds', methods=['GET']) @app.route('/api/health/thresholds', methods=['GET'])
@require_auth @require_auth
def api_health_thresholds_get(): def api_health_thresholds_get():
@@ -19257,6 +19631,154 @@ def api_proxmenux_self_update_status():
return jsonify(_action_state('proxmenux-action-self-update')) return jsonify(_action_state('proxmenux-action-self-update'))
# ── Scheduled LXC updates ───────────────────────────────────────────
#
# Ticks every 60s. For every sidecar with an enabled schedule whose
# cron matches the current minute, invokes `apply_updates.sh` in a
# subprocess with the schedule's env vars, then records the outcome
# back to the sidecar via `record_schedule_run`. UPDATE_COMMAND for
# `target in (app, both)` chains each registered app's own
# `update_command` with `;` so a failure in one doesn't abort the
# rest. The community-scripts helper `/usr/bin/update` is handled
# by apply_updates.sh itself (invoked from host with CTID env).
#
# Runs are dedup-guarded by minute+vmid so a schedule that fires at
# `* * * * *` doesn't ever double-fire on the same minute inside
# one process. Nothing races against manual applies — those go
# through the WS terminal path and the shell script itself takes
# care of concurrent invocation (last one wins with vzdump lock).
_APPLY_UPDATES_SCRIPT = "/usr/local/share/proxmenux/scripts/lxc/apply_updates.sh"
_scheduled_fired_this_minute: set = set()
def _compose_scheduled_update_command(vmid: int, target: str) -> str:
"""Chain every registered app's own `update_command` for the
scheduled run. Returns empty string when target == "os" or when
no custom commands are registered. The helper `/usr/bin/update`
is handled internally by apply_updates.sh (invoked from host
with CTID env), so it does NOT belong in UPDATE_COMMAND."""
if target not in ("app", "both"):
return ""
try:
import lxc_apps
sidecar = lxc_apps._read_sidecar(vmid) or {}
except Exception:
return ""
apps = sidecar.get("apps") or []
parts: list = []
for a in apps:
if a.get("managed_oci_app_id"):
continue
cmd = (a.get("update_command") or "").strip()
if cmd:
parts.append(cmd)
return "; ".join(parts)
def _run_scheduled_update(vmid: int, sched: dict) -> str:
"""Fire `apply_updates.sh` headless with the schedule's env vars.
Returns "success" | "failure" | "skipped" the last one when the
script binary isn't installed. Blocks until the run completes;
caller runs us in a worker thread so the 60s scheduler tick isn't
held up by a long-running apply."""
if not os.path.isfile(_APPLY_UPDATES_SCRIPT):
return "skipped"
target = sched.get("target") or "both"
env = dict(os.environ)
env["VMID"] = str(vmid)
env["TARGET"] = target
env["BACKUP"] = "1" if sched.get("backup") else "0"
env["BACKUP_STORAGE"] = sched.get("backup_storage") or ""
env["RESTART"] = "1" if sched.get("restart") else "0"
env["UPDATE_COMMAND"] = _compose_scheduled_update_command(vmid, target)
env["APP_NAME"] = ""
# HELPER_SLUG lets apply_updates.sh run the community-scripts helper
# from host even when /usr/bin/update was removed inside the CT
# (older installs). Read from the same source the UI uses.
try:
import managed_installs as _mi
for _it in _mi.get_active_items() or []:
if _it.get("type") == "lxc" and str(_it.get("_vmid")) == str(vmid):
env["HELPER_SLUG"] = _it.get("_helper_slug") or ""
break
else:
env["HELPER_SLUG"] = ""
except Exception:
env["HELPER_SLUG"] = ""
try:
r = subprocess.run(
["bash", _APPLY_UPDATES_SCRIPT],
env=env,
capture_output=True,
text=True,
timeout=60 * 60, # 1h hard cap so a stuck run doesn't
# block the queue forever
)
return "success" if r.returncode == 0 else "failure"
except subprocess.TimeoutExpired:
return "failure"
except Exception:
return "failure"
def _scheduler_loop():
"""Every 60s, sweep every sidecar with an enabled schedule and
fire the ones whose cron matches the current minute. Each fire
runs in its own worker thread so a slow apply on one CT doesn't
delay the next tick or hold up other CTs' schedules."""
global _scheduled_fired_this_minute
# Wait a bit after startup so we don't race with the initial
# sidecar load / migration path.
time.sleep(30)
print("[ProxMenux] LXC update scheduler started (60s interval)")
last_minute_key = None
while True:
try:
now = datetime.now()
minute_key = now.strftime("%Y-%m-%d %H:%M")
if minute_key != last_minute_key:
_scheduled_fired_this_minute = set()
last_minute_key = minute_key
try:
import lxc_apps
items = lxc_apps.get_all_schedules() or []
except Exception as e:
print(f"[ProxMenux] scheduler: reading schedules failed: {e}")
items = []
for entry in items:
vmid = entry.get("vmid")
sched = entry.get("schedule") or {}
if not sched.get("enabled") or not sched.get("cron"):
continue
if vmid in _scheduled_fired_this_minute:
continue
try:
if not lxc_apps.cron_matches(sched["cron"], now):
continue
except Exception:
continue
_scheduled_fired_this_minute.add(vmid)
# Fire in a worker so we don't block the loop.
def _worker(_vmid=vmid, _sched=dict(sched)):
print(f"[ProxMenux] scheduler: firing update for CT {_vmid} "
f"(target={_sched.get('target')}, backup={_sched.get('backup')})")
status = _run_scheduled_update(_vmid, _sched)
try:
lxc_apps.record_schedule_run(_vmid, status, _sched.get("target") or "both")
except Exception as e:
print(f"[ProxMenux] scheduler: could not record run for {_vmid}: {e}")
print(f"[ProxMenux] scheduler: CT {_vmid} finished with status={status}")
threading.Thread(target=_worker, daemon=True).start()
except Exception as e:
print(f"[ProxMenux] scheduler loop error: {e}")
# Sleep to the next minute boundary + a small offset so we
# tick predictably in phase with the wall clock's minute.
now = datetime.now()
secs_left = 60 - now.second
time.sleep(max(1, secs_left) + 2)
if __name__ == '__main__': if __name__ == '__main__':
import sys import sys
import logging import logging
@@ -19461,6 +19983,18 @@ if __name__ == '__main__':
except Exception as e: except Exception as e:
print(f"[ProxMenux] Notification service failed to start: {e}") print(f"[ProxMenux] Notification service failed to start: {e}")
# ── Scheduled LXC update scheduler ──
# Ticks every minute, fires apply_updates.sh headless for CTs
# whose schedule cron matches. Runs go through the same shell
# script as the manual "Apply update" flow so behaviour stays
# identical (backup, restart, /usr/bin/update + custom command
# chain) between manual and scheduled invocations.
try:
scheduler_thread = threading.Thread(target=_scheduler_loop, daemon=True)
scheduler_thread.start()
except Exception as e:
print(f"[ProxMenux] LXC update scheduler failed to start: {e}")
# Check for SSL configuration # Check for SSL configuration
ssl_ctx = None ssl_ctx = None
ssl_cert = None ssl_cert = None
File diff suppressed because it is too large Load Diff
+346 -7
View File
@@ -297,10 +297,40 @@ def _detect_oci_apps() -> list[dict]:
# Stash the raw app_id so the checker can find it without # Stash the raw app_id so the checker can find it without
# parsing the prefixed registry id. # parsing the prefixed registry id.
"_oci_app_id": app_id, "_oci_app_id": app_id,
# Cache the CT vmid so `_detect_lxc_containers` can flag the
# matching LXC row as OCI-managed (avoids the LXC update flow
# competing with the Secure Gateway panel's own updater).
"_vmid": app.get("vmid"),
}) })
return out return out
def _get_oci_managed_vmids() -> dict[str, str]:
"""Return {vmid_str: oci_app_id} for every CT under oci_manager.
Used by `_detect_lxc_containers` to route those CTs through the
Secure Gateway update flow instead of the generic apt/apk path
the two share the same `apk upgrade` at the bottom but the OCI
manager also does app-specific hooks (e.g. restarting tailscale
when the package moved) that the generic runner is blind to.
"""
try:
import oci_manager
except Exception:
return {}
try:
installed = oci_manager.list_installed_apps() or []
except Exception:
return {}
mapping: dict[str, str] = {}
for app in installed:
vmid = app.get("vmid")
app_id = app.get("id") or app.get("app_id")
if vmid is None or not app_id:
continue
mapping[str(vmid)] = str(app_id)
return mapping
# ── LXC containers (Phase 1: apt-based update detection) ──────────── # ── LXC containers (Phase 1: apt-based update detection) ────────────
# #
# Each running Debian/Ubuntu CT becomes a registry entry of type "lxc". # Each running Debian/Ubuntu CT becomes a registry entry of type "lxc".
@@ -461,6 +491,246 @@ def _list_pve_lxcs() -> list[dict]:
_SUPPORTED_OS_FAMILIES = ("debian", "ubuntu", "alpine") _SUPPORTED_OS_FAMILIES = ("debian", "ubuntu", "alpine")
# Detectors for the CT origin. `pct config` writes machine-friendly
# keys that reveal how a container was created. The most reliable
# OCI-image indicator across PVE 9.1+ is `lxc.environment.runtime:` —
# it's populated from every Dockerfile ENV (nearly universal) whereas
# `entrypoint:` requires the image to define ENTRYPOINT (CMD-only
# images lack it). We match by prefix, one hit is enough.
_OCI_LXC_MARKERS = (
"lxc.environment.runtime:",
"lxc.init.cwd:",
"lxc.signal.halt:",
)
def _probe_lxc_is_oci(vmid: str) -> bool:
"""Return True if the CT was created from an OCI (Docker) image via
PVE 9.1+'s native ``pct create <vmid> <oci-ref>`` path.
OCI-image containers are IMMUTABLE by design running apt/apk
upgrade inside them contradicts the container model and can break
the image (bootstrap deps, baked-in configs). The correct workflow
is to pull a newer image tag and rebuild. We use this probe to
SUPPRESS the apt/apk detection for these CTs so the UI doesn't
show a misleading "packages pending" badge that would nudge users
toward the anti-pattern.
Reads the CT config file directly (cheaper than `pct config`)
the file lives at /etc/pve/lxc/<vmid>.conf and is always present
on the node hosting the CT.
"""
conf_path = f"/etc/pve/lxc/{vmid}.conf"
try:
with open(conf_path) as f:
for line in f:
stripped = line.lstrip()
for marker in _OCI_LXC_MARKERS:
if stripped.startswith(marker):
return True
except (FileNotFoundError, PermissionError, OSError):
pass
return False
# Cross-reference against the ProxMenux helpers catalogue (generated
# by .github/scripts/generate_helpers_cache.py from the
# community-scripts registry). Each entry carries `updateable: bool`
# — the community-scripts folks know which of their apps ship a
# working updater and which don't (47 out of 733 at last count are
# updateable=false). Without this we'd offer an Apply button on
# every CT with /usr/bin/update, and 6-7% of them would fail hard.
_HELPERS_CACHE_URL = (
"https://raw.githubusercontent.com/MacRimi/ProxMenux/"
"refs/heads/main/json/helpers_cache.json"
)
_HELPERS_CACHE_DISK = "/var/lib/proxmenux/helpers_cache.json"
_HELPERS_CACHE_TTL = 7 * 24 * 3600 # 7 days — the catalogue changes rarely
_HELPERS_CACHE_HTTP_TIMEOUT = 10
_helpers_cache_lock = threading.RLock()
_helpers_cache: Optional[dict] = None
_helpers_cache_ts: float = 0.0
_UPDATE_SLUG_RE = re.compile(r"ct/([a-z0-9_-]+)\.sh")
def _fetch_helpers_cache() -> dict:
"""Return the slug→metadata index for community-scripts apps.
Shape: ``{slug: {"name": str, "updateable": bool}}``. Fetched on
demand from the ProxMenux repo, cached in memory for 7 days and
persisted to :data:`_HELPERS_CACHE_DISK` so a Monitor restart
doesn't refetch. On any network failure returns the last known
good copy never raises, so callers can just ``.get(slug)``.
"""
global _helpers_cache, _helpers_cache_ts
with _helpers_cache_lock:
now = time.time()
if _helpers_cache is not None and (now - _helpers_cache_ts) < _HELPERS_CACHE_TTL:
return _helpers_cache
# In-memory expired or empty — try network first, then disk.
try:
req = urllib.request.Request(
_HELPERS_CACHE_URL,
headers={"User-Agent": "ProxMenux-Monitor"},
)
with urllib.request.urlopen(req, timeout=_HELPERS_CACHE_HTTP_TIMEOUT) as r:
raw = json.loads(r.read().decode("utf-8"))
index: dict = {}
for entry in raw or []:
slug = entry.get("slug")
if not slug:
continue
# `default_port` powers the App tab's port pre-fill
# fallback for apps that don't have a curated
# default_ports entry in app_tracking_hints.json.
# `logo` is the selfh.st/icons URL from the
# community-scripts catalog — fallback for slugs
# whose curated tracking hint doesn't ship one.
index[slug] = {
"name": entry.get("name") or slug,
"updateable": bool(entry.get("updateable")),
"default_port": entry.get("port") or 0,
"logo": entry.get("logo") or "",
}
_helpers_cache = index
_helpers_cache_ts = now
try:
os.makedirs(os.path.dirname(_HELPERS_CACHE_DISK), exist_ok=True)
tmp = f"{_HELPERS_CACHE_DISK}.tmp.{os.getpid()}"
with open(tmp, "w") as f:
json.dump({"ts": now, "index": index}, f)
os.replace(tmp, _HELPERS_CACHE_DISK)
except OSError:
# Persistence is best-effort — memory copy is enough.
pass
return index
except Exception:
# Network failed. Fall back to whatever we have in memory,
# then to the on-disk copy from a previous run.
if _helpers_cache is not None:
return _helpers_cache
try:
with open(_HELPERS_CACHE_DISK) as f:
disk = json.load(f)
_helpers_cache = disk.get("index") or {}
_helpers_cache_ts = float(disk.get("ts") or 0)
return _helpers_cache
except (OSError, json.JSONDecodeError):
_helpers_cache = {}
_helpers_cache_ts = now # avoid hammering the retry loop
return _helpers_cache
def _probe_helper_scripts_slug(vmid: str) -> Optional[str]:
"""Return the community-scripts app slug for a CT by extracting the
``ct/<slug>.sh`` reference embedded in ``/usr/bin/update``.
The community-scripts installers write ``/usr/bin/update`` as a
single line: ``bash -c "$(curl -fsSL …/ct/<slug>.sh)"``. Parsing
that URL gives us both the app identity AND a stable key into
:func:`_fetch_helpers_cache`. Returns None when the file is
missing, unreadable, or doesn't match the expected pattern.
"""
try:
r = subprocess.run(
[_PCT_BIN, "exec", str(vmid), "--", "cat", "/usr/bin/update"],
capture_output=True, text=True,
timeout=_LXC_OS_PROBE_TIMEOUT_SEC,
)
if r.returncode != 0:
return None
m = _UPDATE_SLUG_RE.search(r.stdout)
return m.group(1) if m else None
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
return None
# Tags the community-scripts installers stamp on the CT config so we
# can recognise a CT as a helper-scripts install even when /usr/bin/
# update has been deleted or was never created (very old installs).
_HELPER_SCRIPTS_TAGS = frozenset({"proxmox-helper-scripts", "community-scripts"})
def _probe_lxc_tags(vmid: str) -> set:
"""Return the set of tags configured on the CT (from ``pct config``).
Returns empty set on any failure never raises.
"""
try:
r = subprocess.run(
[_PCT_BIN, "config", str(vmid)],
capture_output=True, text=True, timeout=5,
)
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
return set()
if r.returncode != 0:
return set()
for line in r.stdout.splitlines():
if line.startswith("tags:"):
raw = line.split(":", 1)[1].strip()
return {t.strip().lower() for t in raw.split(";") if t.strip()}
return set()
def _normalize_for_fuzzy(s: str) -> str:
"""Lowercase + strip non-alphanumeric, for hostname↔slug matching."""
return "".join(ch for ch in (s or "").lower() if ch.isalnum())
def _guess_helper_slug_from_hostname(hostname: str) -> Optional[str]:
"""Fuzzy-match a CT hostname against community-scripts catalog slugs.
Tried in this order:
1. Exact-normalized match the safest and only unambiguous case
2. Prefix match (hostname is a proper prefix of the slug
e.g. `nginxproxy` `nginxproxymanager`) only accepted when
there is EXACTLY ONE candidate. A hostname like `paperless`
matching all of {paperless-ai, paperless-gpt, paperless-ngx}
returns None: the guess would be wrong more often than right.
3. Contains match same "unique or bust" rule.
Ambiguity None. The user then goes through the catalog picker
or types the app name themselves accurate manual choice beats
silently-wrong auto-suggestion.
"""
norm_host = _normalize_for_fuzzy(hostname)
if not norm_host:
return None
cache = _fetch_helpers_cache() or {}
if not cache:
return None
norm_slugs = {slug: _normalize_for_fuzzy(slug) for slug in cache}
for slug, ns in norm_slugs.items():
if ns == norm_host:
return slug
prefix = [slug for slug, ns in norm_slugs.items() if ns.startswith(norm_host)]
if len(prefix) == 1:
return prefix[0]
if prefix:
return None # ambiguous — refuse to guess
contains = [slug for slug, ns in norm_slugs.items() if norm_host in ns]
if len(contains) == 1:
return contains[0]
return None
def _infer_helper_slug(vmid: str, hostname: str) -> Optional[str]:
"""Best-effort identification of the community-scripts slug for a CT.
Primary: extract from /usr/bin/update (present on installs from a
reasonably modern community-scripts installer). Fallback: if the
CT carries a helper-scripts tag but /usr/bin/update is missing
(very old installs, or the file was removed), guess by
fuzzy-matching the hostname against the helpers_cache slug list.
"""
slug = _probe_helper_scripts_slug(vmid)
if slug:
return slug
tags = _probe_lxc_tags(vmid)
if not (tags & _HELPER_SCRIPTS_TAGS):
return None
return _guess_helper_slug_from_hostname(hostname)
def _probe_lxc_os(vmid: str) -> Optional[str]: def _probe_lxc_os(vmid: str) -> Optional[str]:
"""Return a normalized family identifier (``debian`` / ``ubuntu`` / """Return a normalized family identifier (``debian`` / ``ubuntu`` /
@@ -531,6 +801,12 @@ def _detect_lxc_containers() -> list[dict]:
} }
cts = _list_pve_lxcs() cts = _list_pve_lxcs()
# Set of CTs currently managed by oci_manager (Secure Gateway etc).
# Their update path is the OCI app's own updater — we mark them so
# the LXC row in the UI redirects the user there instead of running
# our generic apt/apk flow.
oci_managed = _get_oci_managed_vmids()
out: list[dict] = [] out: list[dict] = []
for ct in cts: for ct in cts:
if ct["status"] != "running": if ct["status"] != "running":
@@ -538,15 +814,56 @@ def _detect_lxc_containers() -> list[dict]:
vmid = ct["vmid"] vmid = ct["vmid"]
cid = f"lxc:{vmid}" cid = f"lxc:{vmid}"
prior = existing_by_id.get(cid) or {} prior = existing_by_id.get(cid) or {}
# OCI-image marker is cached — the CT origin doesn't change
# over its lifetime, and reading the pct config file is cheap
# enough that we don't gain much from skipping the re-probe.
is_oci = _probe_lxc_is_oci(vmid)
# Managed OCI-app membership (Secure Gateway / Tailscale / any
# future ProxMenux-shipped OCI app).
managed_oci_app = oci_managed.get(str(vmid))
# OS family is only meaningful for non-OCI CTs. We still cache
# it for OCI (some images ARE Ubuntu/Debian underneath and
# future features might use it), but we don't require it.
os_family = prior.get("_os_family") os_family = prior.get("_os_family")
if not os_family: if not os_family:
os_family = _probe_lxc_os(vmid) os_family = _probe_lxc_os(vmid)
if os_family not in _SUPPORTED_OS_FAMILIES: if not is_oci and os_family not in _SUPPORTED_OS_FAMILIES:
# Distribution we don't yet have a package-manager # Non-OCI, non-supported family — the framework has
# parser for. Skip silently. The framework marks any # no way to check its updates. Skip silently.
# existing entry as removed_at if it stops appearing
# in the detector output.
continue continue
# Helper-scripts updater detection — only meaningful for
# non-OCI, non-managed CTs. Managed OCI apps have their own
# updater; OCI-image CTs almost never carry /usr/bin/update
# since apps are baked into the image at build time.
#
# `_has_app_updater` gates whether the "Apply application
# update" button appears in the modal. It's only True when
# BOTH:
# (a) /usr/bin/update exists AND we can extract the
# community-scripts slug from it, and
# (b) that slug is marked `updateable: true` in the
# helpers_cache — 47/733 entries are false, and running
# their updaters is a known-broken action.
# `_helper_slug` and `_helper_app_name` are surfaced to the UI
# so users see which app they'd be updating (e.g. "Update
# Jellyfin" rather than a generic "Update").
has_app_updater = False
helper_slug: Optional[str] = None
helper_app_name: Optional[str] = None
helper_updateable_known = False # True when we found the slug in the cache
if not is_oci and not managed_oci_app:
helper_slug = _infer_helper_slug(vmid, ct.get("name") or "")
if helper_slug:
entry = _fetch_helpers_cache().get(helper_slug)
if entry:
helper_updateable_known = True
helper_app_name = entry.get("name") or helper_slug
has_app_updater = bool(entry.get("updateable"))
out.append({ out.append({
"id": cid, "id": cid,
"type": "lxc", "type": "lxc",
@@ -556,8 +873,12 @@ def _detect_lxc_containers() -> list[dict]:
"menu_script": None, "menu_script": None,
"_vmid": vmid, "_vmid": vmid,
"_os_family": os_family, "_os_family": os_family,
# Phase 2 hook: populate `_helper_script_app` here once we "_is_oci": is_oci,
# learn how to read the community-scripts marker. "_managed_oci_app": managed_oci_app,
"_has_app_updater": has_app_updater,
"_helper_slug": helper_slug,
"_helper_app_name": helper_app_name,
"_helper_updateable_known": helper_updateable_known,
}) })
return out return out
@@ -1113,6 +1434,24 @@ def _check_lxc_updates(entry: dict) -> dict:
"last_check": _now_iso(), "error": "no vmid in entry", "last_check": _now_iso(), "error": "no vmid in entry",
} }
# OCI-image CTs are immutable by design — apt/apk upgrade inside
# them is the wrong workflow (update = rebuild from a newer image
# tag). Skip the package-manager probe entirely so the UI doesn't
# surface a misleading "N packages pending" badge that would nudge
# users toward the anti-pattern. The Updates modal renders a
# dedicated OCI-container panel using the flag propagated below.
#
# Same treatment for CTs managed by oci_manager (Secure Gateway
# etc.) — those have their own dashboard-driven updater with
# app-specific hooks; running our generic apt/apk in parallel
# would race and could restart the wrong services.
if entry.get("_is_oci") or entry.get("_managed_oci_app"):
return {
"available": False, "latest": None,
"last_check": _now_iso(), "error": None,
"_count": 0, "_security_count": 0, "_packages": [],
}
refresh_diag = _refresh_lxc_pkg_cache_if_stale(vmid, family) refresh_diag = _refresh_lxc_pkg_cache_if_stale(vmid, family)
if family in ("debian", "ubuntu"): if family in ("debian", "ubuntu"):
+11
View File
@@ -3508,6 +3508,17 @@ class PollingCollector:
print(f"[PollingCollector] managed_installs update run failed: {e}") print(f"[PollingCollector] managed_installs update run failed: {e}")
return return
# Piggy-back on the same 24 h cycle to refresh every
# user-registered app watch. Keeps the header badge accurate
# in the VMs list without needing a dedicated timer. Errors
# are absorbed inside refresh_all_apps — one broken CT never
# blocks the others.
try:
import lxc_apps
lxc_apps.refresh_all_apps(force=False)
except Exception as e:
print(f"[PollingCollector] lxc_apps refresh failed: {e}")
# Split LXC updates out of the per-item event stream — they get # Split LXC updates out of the per-item event stream — they get
# one grouped notification per cycle instead of one per CT, to # one grouped notification per cycle instead of one per CT, to
# avoid spamming the user when 15 CTs have pending updates the # avoid spamming the user when 15 CTs have pending updates the
@@ -510,6 +510,27 @@ TEMPLATES = {
'group': 'vm_ct', 'group': 'vm_ct',
'default_enabled': False, 'default_enabled': False,
}, },
'lxc_update_applied': {
'title': '{hostname}: LXC {ct_name} ({vmid}) update {result}',
'body': (
'Container {ct_name} (CT {vmid}) — update {result}.\n'
'Target: {target} Duration: {duration}'
),
'label': 'LXC update applied',
'group': 'vm_ct',
'default_enabled': True,
},
'app_update_available': {
'title': '{hostname}: {app_name} update available on CT {vmid}',
'body': (
'{app_name} on CT {vmid} ({ct_name}) has a new version:\n'
' {installed}{latest}\n'
'Registered via ProxMenux App Watch.'
),
'label': 'App update available (App Watch)',
'group': 'vm_ct',
'default_enabled': False,
},
'vm_start': { 'vm_start': {
'title': '{hostname}: VM {vmname} ({vmid}) started', 'title': '{hostname}: VM {vmname} ({vmid}) started',
'body': 'Virtual machine {vmname} (ID: {vmid}) is now running.', 'body': 'Virtual machine {vmname} (ID: {vmid}) is now running.',
@@ -1696,6 +1717,8 @@ CATEGORY_EMOJI = {
EVENT_EMOJI = { EVENT_EMOJI = {
# VM / CT # VM / CT
'lxc_updates_available': '\U0001F4E6', # \uD83D\uDCE6 package \u2014 pending CT updates 'lxc_updates_available': '\U0001F4E6', # \uD83D\uDCE6 package \u2014 pending CT updates
'lxc_update_applied': '\u2705', # \u2705 check \u2014 update applied
'app_update_available': '\U0001F195', # \ud83c\udd95 NEW \u2014 upstream app release
'vm_start': '\u25B6\uFE0F', # play button 'vm_start': '\u25B6\uFE0F', # play button
'vm_start_warning': '\u26A0\uFE0F', # warning sign - started with warnings 'vm_start_warning': '\u26A0\uFE0F', # warning sign - started with warnings
'vm_stop': '\u23F9\uFE0F', # stop button 'vm_stop': '\u23F9\uFE0F', # stop button
+751
View File
@@ -0,0 +1,751 @@
{
"adguard": {
"binary_path": "/opt/AdGuardHome/AdGuardHome",
"default_ports": [
80
],
"github_source": "releases",
"installed_via": "binary",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/adguard-home.webp",
"repo": "AdguardTeam/AdGuardHome",
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)",
"website": "https://adguard.com/en/adguard-home/overview.html"
},
"agentdvr": {
"default_ports": [
8090
],
"file_path": "/root/.agentdvr",
"file_regex": "Agent_[^/]+_([0-9]+(?:_[0-9]+){3})\\.zip",
"github_source": "releases",
"installed_via": "file",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/agent-dvr.webp",
"repo": "ispysoftware/agent-install-scripts",
"tag_regex": "v?(\\d+(?:\\.\\d+){3})",
"website": "https://www.ispyconnect.com/"
},
"audiobookshelf": {
"default_ports": [
13378
],
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/audiobookshelf.webp",
"package": "audiobookshelf",
"repo": "advplyr/audiobookshelf",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
"website": "https://www.audiobookshelf.org/"
},
"cloudflared": {
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/cloudflare.webp",
"package": "cloudflared",
"repo": "cloudflare/cloudflared",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
"website": "https://www.cloudflare.com/"
},
"cockpit": {
"default_ports": [
9090
],
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/cockpit.webp",
"package": "cockpit",
"repo": "cockpit-project/cockpit",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
"website": "https://cockpit-project.org/"
},
"ddclient": {
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/ddclient.webp",
"package": "ddclient",
"repo": "ddclient/ddclient",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
"website": "https://ddclient.net/"
},
"docker": {
"binary_path": "/usr/bin/docker",
"installed_via": "binary",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/docker.webp",
"repo": "moby/moby",
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)",
"website": "https://docs.docker.com/engine/"
},
"docmost": {
"default_ports": [
3000
],
"file_fallbacks": [
{
"path": "/root/.docmost",
"regex": "v?(\\d+\\.\\d+\\.\\d+)"
}
],
"file_path": "/opt/docmost/package.json",
"file_regex": "\"version\"\\s*:\\s*\"(\\d+\\.\\d+\\.\\d+)\"",
"github_source": "releases",
"installed_via": "file",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/docmost.webp",
"repo": "docmost/docmost",
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)",
"website": "https://docmost.com/"
},
"emby": {
"binary_path": "/opt/emby-server/bin/emby-server",
"default_ports": [
8096
],
"github_source": "releases",
"installed_via": "binary",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/emby.webp",
"repo": "MediaBrowser/Emby.Releases",
"tag_regex": "(\\d+\\.\\d+\\.\\d+\\.\\d+)",
"website": "https://emby.media/"
},
"evcc": {
"default_ports": [
7070
],
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/evcc.webp",
"package": "evcc",
"repo": "evcc-io/evcc",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
"website": "https://evcc.io/en/"
},
"globaleaks": {
"default_ports": [
443
],
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/globaleaks.webp",
"package": "globaleaks",
"repo": "globaleaks/globaleaks-whistleblowing-software",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
"website": "https://www.globaleaks.org/"
},
"grafana": {
"alt_detectors": [
{
"binary_args": [
"server",
"-v"
],
"binary_path": "grafana",
"container_name": "grafana",
"installed_via": "docker_exec"
}
],
"default_ports": [
3000
],
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/grafana.webp",
"package": "grafana",
"repo": "grafana/grafana",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
"website": "https://grafana.com/"
},
"homebridge": {
"default_ports": [
8581
],
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/homebridge.webp",
"package": "homebridge",
"repo": "homebridge/homebridge",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
"website": "https://homebridge.io/"
},
"hyperhdr": {
"default_ports": [
8090
],
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/hyperhdr.webp",
"package": "hyperhdr",
"repo": "awawa-dev/HyperHDR",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
"website": "https://github.com/awawa-dev/HyperHDR"
},
"hyperion": {
"default_ports": [
8090
],
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/hyperion.webp",
"package": "hyperion",
"repo": "hyperion-project/hyperion.ng",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
"website": "https://hyperion-project.org/forum/"
},
"infisical": {
"default_ports": [
8080
],
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/infisical.webp",
"package": "infisical-core",
"repo": "Infisical/infisical",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
"website": "https://infisical.com/"
},
"influxdb": {
"default_ports": [
8086
],
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/influxdb.webp",
"package": "influxdb",
"repo": "influxdata/influxdb",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
"website": "https://www.influxdata.com/"
},
"inventree": {
"default_ports": [
80
],
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/inventree.webp",
"package": "inventree",
"repo": "inventree/InvenTree",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
"website": "https://inventree.org"
},
"jellyfin": {
"default_ports": [
8096
],
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/jellyfin.webp",
"package": "jellyfin",
"repo": "jellyfin/jellyfin",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
"website": "https://jellyfin.org/"
},
"jenkins": {
"default_ports": [
8080
],
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/jenkins.webp",
"package": "jenkins",
"repo": "jenkinsci/jenkins",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
"website": "https://www.jenkins.io/"
},
"kiwix": {
"default_ports": [
8080
],
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/kiwix.webp",
"package": "kiwix-tools",
"repo": "kiwix/kiwix-tools",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
"website": "https://www.kiwix.org"
},
"lldap": {
"default_ports": [
17170
],
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/lldap.webp",
"package": "lldap",
"repo": "lldap/lldap",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
"website": "https://github.com/lldap/lldap"
},
"loki": {
"default_ports": [
3100
],
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/loki.webp",
"package": "loki",
"repo": "grafana/loki",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
"website": "https://github.com/grafana/loki"
},
"mattermost": {
"default_ports": [
8065
],
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/mattermost.webp",
"package": "mattermost",
"repo": "mattermost/mattermost",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
"website": "https://mattermost.com/"
},
"neo4j": {
"default_ports": [
7474
],
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/neo4j.webp",
"package": "neo4j",
"repo": "neo4j/neo4j",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
"website": "https://neo4j.com/product/neo4j-graph-database/"
},
"netbird": {
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/netbird.webp",
"package": "netbird",
"repo": "netbirdio/netbird",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
"website": "https://netbird.io/"
},
"nginxproxymanager": {
"default_ports": [
81
],
"file_fallbacks": [
{
"path": "/app/package.json",
"regex": "\"version\"\\s*:\\s*\"(\\d+\\.\\d+\\.\\d+)\""
},
{
"path": "/root/.nginxproxymanager",
"regex": "v?(\\d+\\.\\d+\\.\\d+)"
}
],
"file_path": "/opt/nginxproxymanager/backend/package.json",
"file_regex": "\"version\"\\s*:\\s*\"(\\d+\\.\\d+\\.\\d+)\"",
"github_source": "releases",
"installed_via": "file",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/nginx-proxy-manager.webp",
"repo": "NginxProxyManager/nginx-proxy-manager",
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)",
"website": "https://nginxproxymanager.com/"
},
"notifiarr": {
"default_ports": [
5454
],
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/notifiarr.webp",
"package": "notifiarr",
"repo": "Notifiarr/notifiarr",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
"website": "https://notifiarr.com/"
},
"ntfy": {
"default_ports": [
80
],
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/ntfy.webp",
"package": "ntfy",
"repo": "binwiederhier/ntfy",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
"website": "https://ntfy.sh/"
},
"nzbget": {
"default_ports": [
6789
],
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/nzbget.webp",
"package": "nzbget",
"repo": "nzbget/nzbget",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
"website": "https://nzbget.com/"
},
"odoo": {
"alt_detectors": [
{
"binary_path": "/usr/bin/odoo",
"installed_via": "binary"
}
],
"default_ports": [
8069
],
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/odoo.webp",
"package": "odoo",
"tag_regex": "(\\d+\\.\\d+(?:\\.\\d+)?)",
"website": "https://www.odoo.com/"
},
"onlyoffice": {
"default_ports": [
80
],
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/onlyoffice.webp",
"package": "onlyoffice-documentserver",
"repo": "ONLYOFFICE/DocumentServer",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
"website": "https://onlyoffice.com/"
},
"openproject": {
"default_ports": [
80
],
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/openproject.webp",
"package": "openproject",
"repo": "opf/openproject",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
"website": "https://www.openproject.org"
},
"openwebui": {
"default_ports": [
8080
],
"distribution": "open-webui",
"github_source": "releases",
"installed_via": "python_dist",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/open-webui.webp",
"python_path": "/root/.local/share/uv/tools/open-webui/bin/python",
"repo": "open-webui/open-webui",
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)",
"website": "https://openwebui.com/"
},
"openziti-controller": {
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/openziti.webp",
"package": "openziti-controller",
"repo": "openziti/ziti",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
"website": "https://www.openziti.io/"
},
"pairdrop": {
"default_ports": [
3000
],
"file_fallbacks": [
{
"path": "/root/.pairdrop",
"regex": "v?(\\d+\\.\\d+\\.\\d+)"
}
],
"file_path": "/opt/pairdrop/package.json",
"file_regex": "\"version\"\\s*:\\s*\"(\\d+\\.\\d+\\.\\d+)\"",
"github_source": "releases",
"installed_via": "file",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/pairdrop.webp",
"repo": "schlagmichdoch/PairDrop",
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)",
"website": "https://github.com/schlagmichdoch/PairDrop"
},
"paperless-ngx": {
"alt_detectors": [
{
"file_path": "/opt/paperless/src/paperless/version.py",
"file_regex": "__version__[^\\n=]*=\\s*\\((\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\)",
"installed_via": "file"
}
],
"container_name": "paperless-webserver-1",
"default_ports": [
8000
],
"github_source": "releases",
"installed_via": "docker_label",
"label": "org.opencontainers.image.version",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/paperless-ngx.webp",
"repo": "paperless-ngx/paperless-ngx",
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)",
"website": "https://docs.paperless-ngx.com/"
},
"photoprism": {
"binary_path": "/opt/photoprism/bin/photoprism",
"default_ports": [
2342
],
"github_source": "releases",
"installed_via": "binary",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/photoprism.webp",
"repo": "photoprism/photoprism",
"tag_regex": "(\\d{6})",
"website": "https://photoprism.app/"
},
"pihole": {
"binary_path": "/usr/local/bin/pihole",
"default_ports": [
80
],
"installed_via": "binary",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/pi-hole.webp",
"repo": "pi-hole/pi-hole",
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)",
"website": "https://pi-hole.net/"
},
"plex": {
"default_ports": [
32400
],
"installed_via": "dpkg",
"package": "plexmediaserver",
"installed_regex": "(\\d+\\.\\d+\\.\\d+\\.\\d+)",
"upstream_type": "http_json",
"upstream_url": "https://plex.tv/api/downloads/5.json?channel=8",
"upstream_json_path": "computer.Linux.version",
"tag_regex": "(\\d+\\.\\d+\\.\\d+\\.\\d+)",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/plex.webp",
"website": "https://www.plex.tv/"
},
"podman": {
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/podman.webp",
"package": "podman",
"repo": "containers/podman",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
"website": "https://podman.io/"
},
"prometheus": {
"alt_detectors": [
{
"binary_path": "/usr/local/bin/prometheus",
"installed_via": "binary"
}
],
"binary_args": [
"--version"
],
"binary_path": "/bin/prometheus",
"container_name": "prometheus",
"default_ports": [
9090
],
"github_source": "releases",
"installed_via": "docker_exec",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/prometheus.webp",
"repo": "prometheus/prometheus",
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)",
"website": "https://prometheus.io/"
},
"proxmox-backup-server": {
"default_ports": [
8007
],
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/proxmox.webp",
"package": "proxmox-backup-server",
"tag_regex": "(\\d+\\.\\d+\\.\\d+)",
"website": "https://www.proxmox.com/en/proxmox-backup-server/overview"
},
"qbittorrent": {
"binary_path": "/opt/qbittorrent/qbittorrent-nox",
"default_ports": [
8090
],
"github_source": "releases",
"installed_via": "binary",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/qbittorrent.webp",
"repo": "userdocs/qbittorrent-nox-static",
"tag_regex": "(?i)(?:release-)?(\\d+\\.\\d+\\.\\d+)",
"website": "https://www.qbittorrent.org/"
},
"rabbitmq": {
"default_ports": [
15672
],
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/rabbitmq.webp",
"package": "rabbitmq-server",
"repo": "rabbitmq/rabbitmq-server",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
"website": "https://www.rabbitmq.com/"
},
"redis": {
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/redis.webp",
"package": "redis",
"repo": "redis/redis",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
"website": "https://redis.io/"
},
"sftpgo": {
"default_ports": [
8080
],
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/sftpgo.webp",
"package": "sftpgo",
"repo": "drakkan/sftpgo",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
"website": "https://github.com/drakkan/sftpgo"
},
"smokeping": {
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/smokeping.webp",
"package": "smokeping",
"repo": "oetiker/SmokePing",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
"website": "https://oss.oetiker.ch/smokeping/"
},
"squid": {
"default_ports": [
3128
],
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/squid.webp",
"package": "squid",
"repo": "squid-cache/squid",
"tag_regex": "(?i)(?:SQUID_)?(\\d+(?:[._]\\d+){1,3})",
"website": "https://www.squid-cache.org/"
},
"step-ca": {
"default_ports": [
443
],
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/step-ca.webp",
"package": "step-ca",
"repo": "smallstep/certificates",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
"website": "https://github.com/smallstep/certificates"
},
"syncthing": {
"default_ports": [
8384
],
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/syncthing.webp",
"package": "syncthing",
"repo": "syncthing/syncthing",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
"website": "https://syncthing.net/"
},
"tandoor": {
"default_ports": [
8002
],
"file_fallbacks": [
{
"path": "/root/.tandoor",
"regex": "v?(\\d+\\.\\d+\\.\\d+)"
}
],
"file_path": "/opt/tandoor/cookbook/version_info.py",
"file_regex": "TANDOOR_VERSION\\s*=\\s*[\"']v?(\\d+\\.\\d+\\.\\d+)",
"github_source": "releases",
"installed_via": "file",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/tandoor-recipes.webp",
"repo": "TandoorRecipes/recipes",
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)",
"website": "https://tandoor.dev/"
},
"telegraf": {
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/telegraf.webp",
"package": "telegraf",
"repo": "influxdata/telegraf",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
"website": "https://github.com/influxdata/telegraf"
},
"teleport": {
"default_ports": [
3080
],
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/teleport.webp",
"package": "teleport",
"repo": "gravitational/teleport",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
"website": "https://goteleport.com/"
},
"unbound": {
"default_ports": [
5335
],
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/unbound.webp",
"package": "unbound",
"repo": "NLnetLabs/unbound",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
"website": "https://www.nlnetlabs.nl/projects/unbound/about/"
},
"urbackupserver": {
"default_ports": [
55414
],
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/urbackup.webp",
"package": "urbackup-server",
"repo": "uroni/urbackup_backend",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
"website": "https://www.urbackup.org/"
},
"valkey": {
"default_ports": [
6379
],
"github_source": "releases",
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/valkey.webp",
"package": "valkey",
"repo": "valkey-io/valkey",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
"website": "https://valkey.io/"
},
"vaultwarden": {
"alt_detectors": [
{
"file_path": "/root/.vaultwarden",
"file_regex": "v?(\\d+\\.\\d+\\.\\d+)",
"installed_via": "file"
}
],
"binary_path": "/opt/vaultwarden/bin/vaultwarden",
"default_ports": [
8000
],
"github_source": "releases",
"installed_via": "binary",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/vaultwarden.webp",
"repo": "dani-garcia/vaultwarden",
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)",
"website": "https://github.com/dani-garcia/vaultwarden/"
},
"wireguard": {
"installed_via": "dpkg",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/wireguard.webp",
"package": "wireguard-tools",
"repo": "WireGuard/wireguard-tools",
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)",
"website": "https://www.wireguard.com/"
}
}
+210
View File
@@ -0,0 +1,210 @@
{
"schema_version": 1,
"observed_at": "2026-08-06",
"notes": "Runtime-verified detectors for the LXC app catalog. Every entry is a detector proven to work on a real container. Contribution rules \u2014 accept ONLY: `detector` (required, with installed_via + method-specific fields + repo/tag_regex), optional `alt_detectors` (cross-method fallbacks), optional `file_fallbacks` (same-method secondary paths). REJECT anything that identifies a host: no IP addresses, no VMIDs, no hostnames, no `evidence` blocks with those fields. Report reproduction context in the PR description instead \u2014 the committed JSON must stay generic.",
"apps": {
"qbittorrent": {
"detector": {
"installed_via": "binary",
"binary_path": "/opt/qbittorrent/qbittorrent-nox",
"repo": "userdocs/qbittorrent-nox-static",
"github_source": "releases",
"tag_regex": "(?i)(?:release-)?(\\d+\\.\\d+\\.\\d+)"
}
},
"tandoor": {
"detector": {
"installed_via": "file",
"file_path": "/opt/tandoor/cookbook/version_info.py",
"file_regex": "TANDOOR_VERSION\\s*=\\s*[\"']v?(\\d+\\.\\d+\\.\\d+)",
"repo": "TandoorRecipes/recipes",
"github_source": "releases",
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)"
},
"file_fallbacks": [
{
"path": "/root/.tandoor",
"regex": "v?(\\d+\\.\\d+\\.\\d+)"
}
]
},
"openwebui": {
"operational": false,
"remove_from_v1": true,
"detector": {
"installed_via": "python_dist",
"python_path": "/root/.local/share/uv/tools/open-webui/bin/python",
"distribution": "open-webui",
"repo": "open-webui/open-webui",
"github_source": "releases",
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)"
}
},
"proxmox-backup-server": {
"detector": {
"installed_via": "dpkg",
"package": "proxmox-backup-server",
"tag_regex": "(\\d+\\.\\d+\\.\\d+)"
}
},
"adguard": {
"detector": {
"installed_via": "binary",
"binary_path": "/opt/AdGuardHome/AdGuardHome",
"repo": "AdguardTeam/AdGuardHome",
"github_source": "releases",
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)"
}
},
"nginxproxymanager": {
"detector": {
"installed_via": "file",
"file_path": "/opt/nginxproxymanager/backend/package.json",
"file_regex": "\"version\"\\s*:\\s*\"(\\d+\\.\\d+\\.\\d+)\"",
"repo": "NginxProxyManager/nginx-proxy-manager",
"github_source": "releases",
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)"
},
"file_fallbacks": [
{
"path": "/app/package.json",
"regex": "\"version\"\\s*:\\s*\"(\\d+\\.\\d+\\.\\d+)\""
},
{
"path": "/root/.nginxproxymanager",
"regex": "v?(\\d+\\.\\d+\\.\\d+)"
}
]
},
"pairdrop": {
"detector": {
"installed_via": "file",
"file_path": "/opt/pairdrop/package.json",
"file_regex": "\"version\"\\s*:\\s*\"(\\d+\\.\\d+\\.\\d+)\"",
"repo": "schlagmichdoch/PairDrop",
"github_source": "releases",
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)"
},
"file_fallbacks": [
{
"path": "/root/.pairdrop",
"regex": "v?(\\d+\\.\\d+\\.\\d+)"
}
]
},
"vaultwarden": {
"detector": {
"installed_via": "binary",
"binary_path": "/opt/vaultwarden/bin/vaultwarden",
"repo": "dani-garcia/vaultwarden",
"github_source": "releases",
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)"
},
"alt_detectors": [
{
"installed_via": "file",
"file_path": "/root/.vaultwarden",
"file_regex": "v?(\\d+\\.\\d+\\.\\d+)"
}
]
},
"docmost": {
"detector": {
"installed_via": "file",
"file_path": "/opt/docmost/package.json",
"file_regex": "\"version\"\\s*:\\s*\"(\\d+\\.\\d+\\.\\d+)\"",
"repo": "docmost/docmost",
"github_source": "releases",
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)"
},
"file_fallbacks": [
{
"path": "/root/.docmost",
"regex": "v?(\\d+\\.\\d+\\.\\d+)"
}
]
},
"agentdvr": {
"detector": {
"installed_via": "file",
"file_path": "/root/.agentdvr",
"file_regex": "Agent_[^/]+_([0-9]+(?:_[0-9]+){3})\\.zip",
"repo": "ispysoftware/agent-install-scripts",
"github_source": "releases",
"tag_regex": "v?(\\d+(?:\\.\\d+){3})"
}
},
"odoo": {
"detector": {
"installed_via": "dpkg",
"package": "odoo",
"tag_regex": "(\\d+\\.\\d+(?:\\.\\d+)?)"
},
"alt_detectors": [
{
"installed_via": "binary",
"binary_path": "/usr/bin/odoo"
}
]
},
"paperless-ngx": {
"operational": false,
"detector": {
"installed_via": "docker_label",
"container_name": "paperless-webserver-1",
"label": "org.opencontainers.image.version",
"repo": "paperless-ngx/paperless-ngx",
"github_source": "releases",
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)"
},
"alt_detectors": [
{
"installed_via": "file",
"file_path": "/opt/paperless/src/paperless/version.py",
"file_regex": "__version__[^\\n=]*=\\s*\\((\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\)"
}
]
},
"plex": {
"operational": true,
"detector": {
"installed_via": "dpkg",
"package": "plexmediaserver",
"installed_regex": "(\\d+\\.\\d+\\.\\d+\\.\\d+)",
"upstream_type": "http_json",
"upstream_url": "https://plex.tv/api/downloads/5.json?channel=8",
"upstream_json_path": "computer.Linux.version",
"tag_regex": "(\\d+\\.\\d+\\.\\d+\\.\\d+)"
}
},
"prometheus": {
"operational": false,
"detector": {
"installed_via": "docker_exec",
"container_name": "prometheus",
"binary_path": "/bin/prometheus",
"binary_args": [
"--version"
],
"repo": "prometheus/prometheus",
"github_source": "releases",
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)"
}
},
"grafana": {
"operational": false,
"detector": {
"installed_via": "docker_exec",
"container_name": "grafana",
"binary_path": "grafana",
"binary_args": [
"server",
"-v"
],
"repo": "grafana/grafana",
"github_source": "releases",
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)"
}
}
}
}
+232
View File
@@ -0,0 +1,232 @@
#!/bin/bash
# ==========================================================
# ProxMenux — Apply Updates to an LXC container
# ==========================================================
# Runs inside the Monitor's terminal streamer (PTY over WS).
# Input via env vars (all set by the frontend before launch):
#
# VMID — target container id (required)
# TARGET — "os" | "app" | "both" (required)
# BACKUP — "1" to snapshot with vzdump first, "0" to skip
# BACKUP_STORAGE — PVE storage name for vzdump (required when BACKUP=1)
# RESTART — "1" to `pct reboot` after update, "0" to skip
# UPDATE_COMMAND — optional; user-defined bash string. When set
# and TARGET is "app" or "both", the script
# runs this VIA sh -c inside the CT instead of
# /usr/bin/update. This IS the one place we
# intentionally use sh -c with a variable
# payload — the threat model matches "user
# typed it via pct exec themselves"; ProxMenux
# does not compose or interpret the command.
#
# Exit codes:
# 0 everything requested completed OK
# 1 CT not found on this node
# 2 CT could not be started
# 3 pre-update backup failed (abort so the user still has a rollback)
# 4 OS update failed OR OS family not supported for automated updates
# 5 TARGET=app requested but no update method (neither UPDATE_COMMAND
# nor /usr/bin/update) available in the CT
# 6 post-update restart failed
#
# The frontend surfaces exit code + duration in a follow-up POST to
# /api/lxc-updates/<vmid>/applied so the notification event fires with
# the correct result field.
# ==========================================================
set -o pipefail
: "${VMID:?VMID is required}"
: "${TARGET:?TARGET is required}"
BACKUP="${BACKUP:-0}"
RESTART="${RESTART:-0}"
STARTED_AT=$(date -Iseconds)
NODE=$(hostname)
echo "=== ProxMenux LXC update — CT $VMID on $NODE ==="
echo "Started: $STARTED_AT"
echo "Target: $TARGET"
echo "Backup: $BACKUP${BACKUP_STORAGE:+ (storage: $BACKUP_STORAGE)}"
echo "Restart: $RESTART"
echo
# 1) CT must exist on this node.
if ! pct list | awk 'NR>1 {print $1}' | grep -qE "^${VMID}$"; then
echo "ERROR: CT $VMID is not on this node." >&2
exit 1
fi
# 2) CT must be running for pct exec. Auto-start stopped CTs.
STATE=$(pct status "$VMID" | awk '{print $2}')
if [[ "$STATE" != "running" ]]; then
echo "CT is $STATE. Starting it before applying updates…"
if ! pct start "$VMID"; then
echo "ERROR: failed to start CT $VMID." >&2
exit 2
fi
# give the CT a moment for services to come up
sleep 3
fi
# 3) Optional pre-update snapshot. Uses vzdump (not `pct snapshot`)
# because most homelab storages support vzdump snapshots (including
# directory + PBS) whereas `pct snapshot` requires the underlying
# storage type to expose it. Abort on backup failure so the user
# always has a rollback point when they asked for one.
if [[ "$BACKUP" == "1" ]]; then
: "${BACKUP_STORAGE:?BACKUP_STORAGE is required when BACKUP=1}"
echo "--- Creating vzdump snapshot on '$BACKUP_STORAGE' ---"
if ! vzdump "$VMID" --mode snapshot --storage "$BACKUP_STORAGE" --compress zstd --notes-template "pre-update {{guestname}} {{node}}"; then
echo "ERROR: pre-update backup failed. Aborting so you keep a rollback point." >&2
exit 3
fi
echo
fi
# 4) Detect OS family for the OS-update branch.
OS_FAMILY="unknown"
if OS_LINE=$(pct exec "$VMID" -- sh -c 'grep -E "^ID=" /etc/os-release 2>/dev/null | head -1' 2>/dev/null); then
OS_FAMILY=$(echo "$OS_LINE" | sed -e 's/^ID=//' -e 's/^"//' -e 's/"$//')
fi
echo "OS family: $OS_FAMILY"
echo
OS_FAILED=0
APP_FAILED=0
# 5) OS package updates.
if [[ "$TARGET" == "os" || "$TARGET" == "both" ]]; then
echo "--- Applying OS package updates ---"
case "$OS_FAMILY" in
debian|ubuntu)
# `apt-get` (not `apt`) for machine-friendly output; the
# DEBIAN_FRONTEND avoids interactive dpkg prompts on config
# conflicts (dpkg keeps the local version by default with
# --force-confold).
if ! pct exec "$VMID" -- env DEBIAN_FRONTEND=noninteractive \
apt-get -y -o Dpkg::Options::="--force-confold" upgrade; then
echo "ERROR: apt-get upgrade failed inside CT $VMID." >&2
OS_FAILED=1
fi
;;
alpine)
if ! pct exec "$VMID" -- apk upgrade --no-cache; then
echo "ERROR: apk upgrade failed inside CT $VMID." >&2
OS_FAILED=1
fi
;;
*)
echo "OS family '$OS_FAMILY' isn't supported for automated OS updates." >&2
OS_FAILED=1
;;
esac
echo
fi
# 6) Application update. Precedence:
# a) /usr/bin/update present (community-scripts convention) →
# runs the community-scripts helper FROM THE HOST with CTID
# env var. Their build.func framework requires CTID + host-only
# `pveversion`, so `pct exec ... /usr/bin/update` inside the CT
# always fails ("You need to set 'CTID' variable"). We parse
# the ct/<slug>.sh URL from /usr/bin/update and re-fetch it
# here with CTID set. PHS_SILENT=1 keeps it non-interactive.
# b) UPDATE_COMMAND env var set → run it verbatim via `sh -c`
# inside the CT. The one intentional shell-exec-with-variable
# in ProxMenux — see header comment for threat-model rationale.
# Both can run in the same invocation: the helper first (if
# present), then the per-app custom commands.
if [[ "$TARGET" == "app" || "$TARGET" == "both" ]]; then
APP_METHOD_RAN=0
UPDATE_URL=""
RESOLVED_SLUG=""
if pct exec "$VMID" -- test -f /usr/bin/update 2>/dev/null; then
UPDATE_URL=$(pct exec "$VMID" -- cat /usr/bin/update 2>/dev/null | grep -oE 'https?://[^"'"'"' ]+ct/[a-zA-Z0-9._-]+\.sh' | head -1)
RESOLVED_SLUG=$(echo "$UPDATE_URL" | sed -nE 's|.*/ct/([a-zA-Z0-9._-]+)\.sh$|\1|p')
fi
# HELPER_SLUG env is a passthrough from the backend when the CT no
# longer carries /usr/bin/update (older installs where the file was
# removed) but the community-scripts slug is known via hostname
# match against the helpers_cache. Lets us run the same host-side
# updater without requiring the on-CT marker file.
if [[ -z "$RESOLVED_SLUG" && -n "$HELPER_SLUG" ]]; then
if [[ "$HELPER_SLUG" =~ ^[a-zA-Z0-9._-]+$ ]]; then
RESOLVED_SLUG="$HELPER_SLUG"
UPDATE_URL="https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/${RESOLVED_SLUG}.sh"
else
echo "WARN: HELPER_SLUG contains invalid characters — ignored." >&2
fi
fi
if [[ -n "$UPDATE_URL" && -n "$RESOLVED_SLUG" ]]; then
echo "--- Running community-scripts helper (slug: $RESOLVED_SLUG) ---"
# Community-scripts' build.func in start() dispatches on
# `command -v pveversion`: present → install_script (whiptail
# "Default Install / Advanced / Settings" menu); absent → the
# PHS_SILENT=1 branch runs update_script silently. Since
# pveversion only exists on the Proxmox host, we run the script
# INSIDE the CT so the framework picks the silent update path.
# The CT must have wget or curl — every modern helper install
# ships one of them.
IN_CT_FETCH=""
if pct exec "$VMID" -- sh -c 'command -v wget >/dev/null 2>&1'; then
IN_CT_FETCH="wget -qLO - '$UPDATE_URL'"
elif pct exec "$VMID" -- sh -c 'command -v curl >/dev/null 2>&1'; then
IN_CT_FETCH="curl -fsSL '$UPDATE_URL'"
fi
if [[ -z "$IN_CT_FETCH" ]]; then
echo "ERROR: CT $VMID has neither wget nor curl — cannot fetch the helper." >&2
APP_FAILED=1
else
if ! pct exec "$VMID" -- bash -c "PHS_SILENT=1 bash -c \"\$($IN_CT_FETCH)\""; then
echo "ERROR: community-scripts helper returned non-zero." >&2
APP_FAILED=1
fi
fi
APP_METHOD_RAN=1
echo
fi
if [[ -n "$UPDATE_COMMAND" ]]; then
echo "--- Running user-defined update command ---"
echo "\$ $UPDATE_COMMAND"
if ! pct exec "$VMID" -- sh -c "$UPDATE_COMMAND"; then
echo "ERROR: user-defined update command returned non-zero." >&2
APP_FAILED=1
fi
APP_METHOD_RAN=1
echo
fi
if [[ "$APP_METHOD_RAN" -eq 0 ]]; then
if [[ "$TARGET" == "app" ]]; then
echo "ERROR: TARGET=app but no update method (UPDATE_COMMAND unset AND /usr/bin/update missing) in CT $VMID." >&2
exit 5
else
echo "No app update method available in this CT — skipping app update step."
echo
fi
fi
fi
# 7) If either branch failed, abort here BEFORE the optional reboot so
# the CT stays in the pre-update state and the user can inspect it.
if (( OS_FAILED || APP_FAILED )); then
echo "=== Update FAILED — CT left running for inspection. ==="
exit 4
fi
# 8) Optional post-update reboot. Handy for kernel/library upgrades and
# OCI-style CTs whose PID 1 is a user entrypoint (a plain `apt
# upgrade` doesn't restart the app; a CT reboot does).
if [[ "$RESTART" == "1" ]]; then
echo "--- Rebooting CT $VMID ---"
if ! pct reboot "$VMID"; then
echo "ERROR: pct reboot failed." >&2
exit 6
fi
fi
FINISHED_AT=$(date -Iseconds)
echo
echo "=== Update complete — CT $VMID ==="
echo "Started: $STARTED_AT"
echo "Finished: $FINISHED_AT"
exit 0
+1 -1
View File
@@ -1093,7 +1093,7 @@ enable_zfs_autotrim() {
setup_persistent_network() { setup_persistent_network() {
local FUNC_VERSION="1.1" local FUNC_VERSION="1.2"
# description: Pin NIC names to MAC addresses via systemd .link files so kernel updates don't shuffle interface names. # description: Pin NIC names to MAC addresses via systemd .link files so kernel updates don't shuffle interface names.
local pve_version local pve_version
pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1) pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1)
@@ -2836,7 +2836,7 @@ EOF
setup_persistent_network() { setup_persistent_network() {
local FUNC_VERSION="1.1" local FUNC_VERSION="1.2"
# description: Pin NIC names to MAC addresses via systemd .link files so kernel updates don't shuffle interface names. # description: Pin NIC names to MAC addresses via systemd .link files so kernel updates don't shuffle interface names.
local pve_version local pve_version
pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1) pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1)