mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-08-09 01:06:22 +00:00
feat(lxc-updates): revamp Updates + Options card + curated hints
Updates tab - Unified OS + Application update card with per-section Apply buttons and a combined "Apply OS + <app>" footer button - Helper-scripts install detection: uses helper_slug from managed_installs cache (hostname fuzzy-match against helpers_cache) so the button surfaces even when /usr/bin/update was removed - Runs the community-scripts helper INSIDE the CT via pct exec so build.func picks the silent update path (PHS_SILENT=1) instead of the install menu — works with and without /usr/bin/update - HELPER_SLUG env passthrough from backend to apply_updates.sh: falls back to constructing the ct/<slug>.sh URL when the CT no longer carries the marker file - Post-apply state refresh via managed_installs.check_for_updates(force) in the /applied hook so the badge updates without a manual reload Options card - Rewrote as view / edit mode split with a single Edit button - Unified apply defaults (snapshot + storage + restart) shared by manual and scheduled runs - Scheduled updates (M5): cron picker + preset dropdown + What-to-update target + Delete schedule button, wired to a background scheduler thread that fires apply_updates.sh headless with the schedule's env vars - External host cron detection with variant + scope reporting (tteck-legacy / community-scripts / custom, OS-only), shown as an informational chip only in edit mode App tab editor - Multi-app registration with per-app upstream tracking method (github / http_json / docker_hub) - Card-contrast pattern in edit mode (bg-card + bg-background inputs) - Auto-heal for missing installed_version via alt_detectors + file_fallbacks Curated tracking hints (M6) - Add http_json upstream for Plex (plex.tv API) - Add binary+github hints for Emby (MediaBrowser/Emby.Releases) and PhotoPrism (photoprism/photoprism) - Extend CI merge whitelist with upstream_type / upstream_url / upstream_json_path / docker_image Tab reorder - LXC modal tabs: Status | App | Updates | Mounts | Backups | Firewall apply_updates.sh - New helper execution path: parse ct/<slug>.sh URL, run inside CT with PHS_SILENT=1, respecting HELPER_SLUG fallback when /usr/bin/update is missing
This commit is contained in:
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
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
FileText,
|
||||
RefreshCw,
|
||||
Shield,
|
||||
Download,
|
||||
ArrowUpCircle,
|
||||
X,
|
||||
Clock,
|
||||
BellOff,
|
||||
@@ -777,7 +777,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
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"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-1.5" />
|
||||
<ArrowUpCircle className="h-4 w-4 mr-1.5" />
|
||||
{t("healthStatus.updateNow")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
// same visual weight as the colored card selectors used elsewhere
|
||||
// (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,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -10,7 +10,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
return (
|
||||
<textarea
|
||||
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
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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/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/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/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"
|
||||
|
||||
@@ -5882,15 +5882,115 @@ def _get_lxc_update_status_map() -> dict:
|
||||
'error': update.get('error'),
|
||||
# Cap packages list shipped to UI — modal uses first 30 max
|
||||
'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
|
||||
|
||||
|
||||
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():
|
||||
"""Get Proxmox VM and LXC information (requires pvesh command) - only from local node"""
|
||||
try:
|
||||
all_vms = []
|
||||
lxc_updates_map = _get_lxc_update_status_map()
|
||||
lxc_app_map = _get_lxc_app_watch_map()
|
||||
|
||||
try:
|
||||
# local_node = socket.gethostname()
|
||||
@@ -5931,6 +6031,13 @@ def get_proxmox_vms():
|
||||
upd = lxc_updates_map.get(str(resource.get('vmid')))
|
||||
if upd is not None:
|
||||
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
|
||||
# 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
|
||||
|
||||
|
||||
# ─── 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'])
|
||||
@require_auth
|
||||
def api_health_thresholds_get():
|
||||
@@ -19257,6 +19631,154 @@ def api_proxmenux_self_update_status():
|
||||
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__':
|
||||
import sys
|
||||
import logging
|
||||
@@ -19461,6 +19983,18 @@ if __name__ == '__main__':
|
||||
except Exception as 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
|
||||
ssl_ctx = None
|
||||
ssl_cert = None
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -297,10 +297,40 @@ def _detect_oci_apps() -> list[dict]:
|
||||
# Stash the raw app_id so the checker can find it without
|
||||
# parsing the prefixed registry 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
|
||||
|
||||
|
||||
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) ────────────
|
||||
#
|
||||
# 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")
|
||||
|
||||
# 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]:
|
||||
"""Return a normalized family identifier (``debian`` / ``ubuntu`` /
|
||||
@@ -531,6 +801,12 @@ def _detect_lxc_containers() -> list[dict]:
|
||||
}
|
||||
|
||||
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] = []
|
||||
for ct in cts:
|
||||
if ct["status"] != "running":
|
||||
@@ -538,15 +814,56 @@ def _detect_lxc_containers() -> list[dict]:
|
||||
vmid = ct["vmid"]
|
||||
cid = f"lxc:{vmid}"
|
||||
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")
|
||||
if not os_family:
|
||||
os_family = _probe_lxc_os(vmid)
|
||||
if os_family not in _SUPPORTED_OS_FAMILIES:
|
||||
# Distribution we don't yet have a package-manager
|
||||
# parser for. Skip silently. The framework marks any
|
||||
# existing entry as removed_at if it stops appearing
|
||||
# in the detector output.
|
||||
if not is_oci and os_family not in _SUPPORTED_OS_FAMILIES:
|
||||
# Non-OCI, non-supported family — the framework has
|
||||
# no way to check its updates. Skip silently.
|
||||
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({
|
||||
"id": cid,
|
||||
"type": "lxc",
|
||||
@@ -556,8 +873,12 @@ def _detect_lxc_containers() -> list[dict]:
|
||||
"menu_script": None,
|
||||
"_vmid": vmid,
|
||||
"_os_family": os_family,
|
||||
# Phase 2 hook: populate `_helper_script_app` here once we
|
||||
# learn how to read the community-scripts marker.
|
||||
"_is_oci": is_oci,
|
||||
"_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
|
||||
|
||||
@@ -1113,6 +1434,24 @@ def _check_lxc_updates(entry: dict) -> dict:
|
||||
"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)
|
||||
|
||||
if family in ("debian", "ubuntu"):
|
||||
|
||||
@@ -3508,6 +3508,17 @@ class PollingCollector:
|
||||
print(f"[PollingCollector] managed_installs update run failed: {e}")
|
||||
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
|
||||
# one grouped notification per cycle instead of one per CT, to
|
||||
# avoid spamming the user when 15 CTs have pending updates the
|
||||
|
||||
@@ -510,6 +510,27 @@ TEMPLATES = {
|
||||
'group': 'vm_ct',
|
||||
'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': {
|
||||
'title': '{hostname}: VM {vmname} ({vmid}) started',
|
||||
'body': 'Virtual machine {vmname} (ID: {vmid}) is now running.',
|
||||
@@ -1696,6 +1717,8 @@ CATEGORY_EMOJI = {
|
||||
EVENT_EMOJI = {
|
||||
# VM / CT
|
||||
'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_warning': '\u26A0\uFE0F', # warning sign - started with warnings
|
||||
'vm_stop': '\u23F9\uFE0F', # stop button
|
||||
|
||||
@@ -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/"
|
||||
}
|
||||
}
|
||||
@@ -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+)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+232
@@ -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
|
||||
Reference in New Issue
Block a user