Files
ProxMenux/AppImage/scripts/build_appimage.sh
T
MacRimiandClaude Opus 5 da8a480eff Add audit and reports page, and a change journal
ProxMenux modifies the host: it rewrites configuration files, installs packages, enables services. Until now nobody could say afterwards what had changed, and showing the script does not answer that question — a four-hundred-line function may alter two values, and the reader has no way to know which two. This adds the two halves of an answer.

The change journal records what ProxMenux does as it does it. Eleven bash primitives capture the previous state, apply the change and record it in the same step, writing to a spool that the Monitor reads back. One hundred and thirteen functions across twenty-five scripts are instrumented, covering post-install, shared storage, security tooling, container conversions, disk operations and the PVE 8 to 9 upgrade path. The page shows the difference — rotate 7 becoming rotate 14 — and never the script. Restore and backup scripts are deliberately left out: a restore puts the host back to a state some other script already recorded.

The Audit and reports page answers the other half: what state is this host in, regardless of who put it there. Forty-three checks across seven areas read the host and classify each result as critical, warning, observation, conformant, unverified or not applicable, with the evidence they read attached to each one. A declared policy lets the reader say what this particular host is expected to do — which guests must have a backup, which storages are essential — so the report judges the host against its own intent rather than a generic template. An inventory records the hardware, network and guest topology behind those readings, a comparison shows what moved between two runs, and six report profiles produce a printable document scoped to what the reader needs. Everything is available in the eight supported languages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 21:06:04 +02:00

589 lines
26 KiB
Bash
Executable File

#!/bin/bash
# ProxMenux Monitor AppImage Builder
# This script creates a single AppImage with Flask server, Next.js dashboard, and translation support
set -e
WORK_DIR="/tmp/proxmenux_build"
APP_DIR="$WORK_DIR/ProxMenux.AppDir"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DIST_DIR="$SCRIPT_DIR/../dist"
APPIMAGE_ROOT="$SCRIPT_DIR/.."
# Native Python dependencies are selected explicitly for the two Python ABIs
# used by supported Proxmox VE hosts. GitHub Actions currently builds on
# Ubuntu 22.04 (CPython 3.10), while PVE 8 and PVE 9 execute the AppImage with
# the host's CPython 3.11 and 3.13 respectively. Installing gevent with the
# builder's interpreter therefore creates an AppImage that cannot import its
# C extensions on either supported host (issue #331).
GEVENT_VERSION="26.8.0"
GREENLET_VERSION="3.5.5"
ZOPE_INTERFACE_VERSION="8.6"
ZOPE_EVENT_VERSION="6.2"
GEVENT_WEBSOCKET_VERSION="0.10.1"
VERSION=$(node -p "require('$APPIMAGE_ROOT/package.json').version")
APPIMAGE_NAME="ProxMenux-${VERSION}.AppImage"
echo "🚀 Building ProxMenux Monitor AppImage v${VERSION} with hardware monitoring tools..."
# Cache the downloaded appimagetool across builds. Prefer the XDG user
# cache so this works the same way under root (local .50 build) and
# under the non-root GitHub Actions runner — previously hardcoded to
# /var/cache/, which the CI user can't write to (mkdir Permission
# denied → build aborted before doing any actual work).
APPIMAGETOOL_CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/proxmenux-build/appimagetool"
# Preserve a cached copy of appimagetool across builds. wget -q has bitten
# us repeatedly when GitHub momentarily rate-limits or the runner has no
# network — the result is a 0-byte file that passes the `[ -f ]` check on
# the next run and breaks the build silently.
if [ -f "$WORK_DIR/appimagetool" ] && [ -s "$WORK_DIR/appimagetool" ]; then
mkdir -p "$(dirname "$APPIMAGETOOL_CACHE")"
cp -f "$WORK_DIR/appimagetool" "$APPIMAGETOOL_CACHE"
fi
# Clean and create work directory
rm -rf "$WORK_DIR"
mkdir -p "$APP_DIR"
mkdir -p "$DIST_DIR"
# Restore appimagetool from cache if available, otherwise download.
if [ -s "$APPIMAGETOOL_CACHE" ]; then
echo "📦 Reusing cached appimagetool"
cp "$APPIMAGETOOL_CACHE" "$WORK_DIR/appimagetool"
chmod +x "$WORK_DIR/appimagetool"
fi
if [ ! -s "$WORK_DIR/appimagetool" ]; then
echo "📥 Downloading appimagetool..."
wget --tries=3 --timeout=60 "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" -O "$WORK_DIR/appimagetool" || true
if [ ! -s "$WORK_DIR/appimagetool" ]; then
echo "❌ Failed to download appimagetool" >&2
exit 1
fi
chmod +x "$WORK_DIR/appimagetool"
mkdir -p "$(dirname "$APPIMAGETOOL_CACHE")"
cp -f "$WORK_DIR/appimagetool" "$APPIMAGETOOL_CACHE"
fi
# Create directory structure
mkdir -p "$APP_DIR/usr/bin"
mkdir -p "$APP_DIR/usr/lib/python3/dist-packages"
mkdir -p "$APP_DIR/usr/lib/python3/vendor"
mkdir -p "$APP_DIR/usr/share/applications"
mkdir -p "$APP_DIR/usr/share/icons/hicolor/256x256/apps"
mkdir -p "$APP_DIR/web"
echo "🔨 Building Next.js application..."
cd "$APPIMAGE_ROOT"
if [ ! -f "package.json" ]; then
echo "❌ Error: package.json not found in AppImage directory"
exit 1
fi
# Always reconcile node_modules against the lockfile. The previous
# guard (`if [ ! -d "node_modules" ]`) skipped install when an older
# tree existed on disk — so a bump in package.json silently shipped
# with the cached version. We hit this when bumping Next.js
# 15.1.6 -> 15.1.9 for CVE-2025-55182: the build succeeded with the
# stale node_modules and the AppImage still carried 15.1.6. `npm install`
# is idempotent: when package.json + lockfile + node_modules already
# agree it returns in under a second. `--legacy-peer-deps` is required
# because vaul@0.9.9 (and a few others) still declare peer-deps for
# React ≤18 while we are on React 19; npm 7+ refuses by default.
# The actual runtime works fine with React 19.
echo "📦 Reconciling dependencies against the lockfile..."
npm install --legacy-peer-deps
echo "🏗️ Building Next.js static export..."
npm run export
echo "🔍 Checking export results..."
if [ -d "out" ]; then
echo "✅ Export directory found"
echo "📁 Contents of out directory:"
ls -la out/
if [ -f "out/index.html" ]; then
echo "✅ index.html found in out directory"
else
echo "❌ index.html NOT found in out directory"
echo "📁 Looking for HTML files:"
find out/ -name "*.html" -type f || echo "No HTML files found"
fi
else
echo "❌ Error: Next.js export failed - out directory not found"
echo "📁 Current directory contents:"
ls -la
echo "📁 Looking for any build outputs:"
find . -name "*.html" -type f 2>/dev/null || echo "No HTML files found anywhere"
exit 1
fi
# Return to script directory
cd "$SCRIPT_DIR"
# Copy Flask server
echo "📋 Copying Flask server..."
cp "$SCRIPT_DIR/flask_server.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/flask_auth_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_auth_routes.py not found"
cp "$SCRIPT_DIR/auth_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ auth_manager.py not found"
cp "$SCRIPT_DIR/jwt_middleware.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ jwt_middleware.py not found"
cp "$SCRIPT_DIR/health_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ health_monitor.py not found"
cp "$SCRIPT_DIR/health_persistence.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ health_persistence.py not found"
cp "$SCRIPT_DIR/flask_health_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_health_routes.py not found"
cp "$SCRIPT_DIR/flask_proxmenux_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_proxmenux_routes.py not found"
cp "$SCRIPT_DIR/post_install_versions.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ post_install_versions.py not found"
cp "$SCRIPT_DIR/mount_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ mount_monitor.py not found"
cp "$SCRIPT_DIR/lxc_mount_points.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ lxc_mount_points.py not found"
cp "$SCRIPT_DIR/disk_temperature_history.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ disk_temperature_history.py not found"
cp "$SCRIPT_DIR/smartctl_resolver.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ smartctl_resolver.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 "$SCRIPT_DIR/custom_links.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ custom_links.py not found"
cp "$SCRIPT_DIR/recreate_docker_container.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ recreate_docker_container.py not found"
chmod +x "$APP_DIR/usr/bin/recreate_docker_container.py" 2>/dev/null || true
cp "$SCRIPT_DIR/update_docker_engine.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ update_docker_engine.py not found"
chmod +x "$APP_DIR/usr/bin/update_docker_engine.py" 2>/dev/null || true
cp "$APPIMAGE_ROOT/../json/app_tracking_hints.json" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ app_tracking_hints.json not found"
cp "$APPIMAGE_ROOT/../json/runtime_verified_overrides.json" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ runtime_verified_overrides.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/temperature_sensor_resolver.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ temperature_sensor_resolver.py not found"
cp "$SCRIPT_DIR/proxmox_storage_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ proxmox_storage_monitor.py not found"
cp "$SCRIPT_DIR/flask_script_runner.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_script_runner.py not found"
cp "$SCRIPT_DIR/security_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ security_manager.py not found"
cp "$SCRIPT_DIR/flask_security_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_security_routes.py not found"
cp "$SCRIPT_DIR/notification_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ notification_manager.py not found"
cp "$SCRIPT_DIR/notification_channels.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ notification_channels.py not found"
cp "$SCRIPT_DIR/notification_templates.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ notification_templates.py not found"
cp "$SCRIPT_DIR/notification_events.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ notification_events.py not found"
cp "$SCRIPT_DIR/proxmox_known_errors.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ proxmox_known_errors.py not found"
cp "$SCRIPT_DIR/ai_context_enrichment.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ ai_context_enrichment.py not found"
cp "$SCRIPT_DIR/startup_grace.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ startup_grace.py not found"
cp "$SCRIPT_DIR/flask_notification_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_notification_routes.py not found"
cp "$SCRIPT_DIR/oci_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ oci_manager.py not found"
cp "$SCRIPT_DIR/flask_oci_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_oci_routes.py not found"
cp "$SCRIPT_DIR/flask_audit_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_audit_routes.py not found"
cp "$SCRIPT_DIR/audit_store.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ audit_store.py not found"
cp "$SCRIPT_DIR/audit_checks.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ audit_checks.py not found"
cp "$SCRIPT_DIR/audit_profiles.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ audit_profiles.py not found"
cp "$SCRIPT_DIR/audit_policy.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ audit_policy.py not found"
cp "$SCRIPT_DIR/changes_journal.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ changes_journal.py not found"
cp "$SCRIPT_DIR/audit_inventory.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ audit_inventory.py not found"
cp "$SCRIPT_DIR/audit_checks_pve.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ audit_checks_pve.py not found"
# Preserve the existing build version as assessment provenance; no version bump.
cp "$APPIMAGE_ROOT/package.json" "$APP_DIR/package.json"
cp "$SCRIPT_DIR/oci/description_templates.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ description_templates.py not found"
# Copy AI providers module for notification enhancement
echo "📋 Copying AI providers module..."
if [ -d "$SCRIPT_DIR/ai_providers" ]; then
mkdir -p "$APP_DIR/usr/bin/ai_providers"
cp "$SCRIPT_DIR/ai_providers/"*.py "$APP_DIR/usr/bin/ai_providers/"
echo "✅ AI providers module copied"
else
echo "⚠️ ai_providers directory not found"
fi
# Copy config files (verified AI models, prompts, etc.)
echo "📋 Copying config files..."
CONFIG_DIR="$APPIMAGE_ROOT/config"
if [ -d "$CONFIG_DIR" ]; then
mkdir -p "$APP_DIR/usr/bin/config"
cp "$CONFIG_DIR/"*.json "$APP_DIR/usr/bin/config/" 2>/dev/null || true
cp "$CONFIG_DIR/"*.txt "$APP_DIR/usr/bin/config/" 2>/dev/null || true
echo "✅ Config files copied"
else
echo "⚠️ config directory not found"
fi
# Translation handling lives in scripts/utils.sh now. It reads
# /usr/local/share/proxmenux/lang/<lang>.json (pre-built by the
# build_translation_cache.py CI job) and falls back to the English
# source string on miss. The Monitor AppImage no longer ships the
# runtime translate_cli.py — the JSON files belong to the host install,
# not to the Flask dashboard.
# ── Borg standalone binary ─────────────────────────────────────────
# Ship the official borg standalone binary inside the AppImage so the
# host-backup / restore workflows can run without an internet round-trip
# at install time. Pinned to the same version that proxmenux's
# hb_ensure_borg used to download on demand — kept in lockstep so both
# code paths see the same version semantics. SHA256 is the upstream
# release checksum; bump both together.
BORG_VERSION="1.2.8"
BORG_URL="https://github.com/borgbackup/borg/releases/download/${BORG_VERSION}/borg-linux64"
BORG_SHA256="cfa50fb704a93d3a4fa258120966345fddb394f960dca7c47fcb774d0172f40b"
echo "📦 Downloading borg ${BORG_VERSION} into AppImage..."
BORG_TARGET="$APP_DIR/usr/bin/borg"
# GitHub releases serve borg-linux64 via a 302 redirect to a signed
# release-assets.githubusercontent.com URL. wget -qO silently dropped
# the redirect once during the 2026-06-15 build, killing the AppImage
# pipeline. curl -L --retry 3 is more robust and falls back to wget
# only when curl is missing.
if command -v curl >/dev/null 2>&1; then
DOWNLOAD_OK=0
if curl -sSL --retry 3 --retry-delay 2 --max-time 120 -o "$BORG_TARGET" "$BORG_URL"; then
DOWNLOAD_OK=1
fi
else
DOWNLOAD_OK=0
if wget -qO "$BORG_TARGET" "$BORG_URL"; then
DOWNLOAD_OK=1
fi
fi
if [ "$DOWNLOAD_OK" = "1" ]; then
if echo "${BORG_SHA256} ${BORG_TARGET}" | sha256sum -c - >/dev/null 2>&1; then
chmod +x "$BORG_TARGET"
echo "✅ borg ${BORG_VERSION} bundled (sha256 verified)"
else
echo "❌ borg sha256 verification failed — removing"
rm -f "$BORG_TARGET"
exit 1
fi
else
echo "❌ borg download failed from $BORG_URL"
exit 1
fi
# Copy Next.js build
echo "📋 Copying web dashboard..."
if [ -d "$APPIMAGE_ROOT/out" ]; then
mkdir -p "$APP_DIR/web"
echo "📁 Copying from $APPIMAGE_ROOT/out to $APP_DIR/web"
cp -r "$APPIMAGE_ROOT/out"/* "$APP_DIR/web/"
if [ -f "$APP_DIR/web/index.html" ]; then
echo "✅ index.html copied successfully to $APP_DIR/web/"
else
echo "❌ index.html NOT found after copying"
echo "📁 Contents of $APP_DIR/web:"
ls -la "$APP_DIR/web/" || echo "Directory is empty or doesn't exist"
fi
if [ -d "$APPIMAGE_ROOT/public" ]; then
cp -r "$APPIMAGE_ROOT/public"/* "$APP_DIR/web/" 2>/dev/null || true
fi
cp "$APPIMAGE_ROOT/package.json" "$APP_DIR/web/"
echo "✅ Next.js static export copied successfully"
else
echo "❌ Error: Next.js export not found even after building"
exit 1
fi
# Copy AppRun script
echo "📋 Copying AppRun script..."
if [ -f "$SCRIPT_DIR/AppRun" ]; then
cp "$SCRIPT_DIR/AppRun" "$APP_DIR/AppRun"
chmod +x "$APP_DIR/AppRun"
echo "✅ AppRun script copied successfully"
else
echo "❌ Error: AppRun script not found at $SCRIPT_DIR/AppRun"
exit 1
fi
# Create desktop file
cat > "$APP_DIR/proxmenux-monitor.desktop" << EOF
[Desktop Entry]
Type=Application
Name=ProxMenux Monitor
Comment=Proxmox System Monitoring Dashboard
Exec=AppRun
Icon=proxmenux-monitor
Categories=System;Monitor;
Terminal=false
StartupNotify=true
EOF
# Copy desktop file to applications directory
cp "$APP_DIR/proxmenux-monitor.desktop" "$APP_DIR/usr/share/applications/"
# Download and set icon
echo "🎨 Setting up icon..."
if [ -f "$APPIMAGE_ROOT/public/images/proxmenux-logo.png" ]; then
cp "$APPIMAGE_ROOT/public/images/proxmenux-logo.png" "$APP_DIR/proxmenux-monitor.png"
else
wget -q "https://raw.githubusercontent.com/MacRimi/ProxMenux/main/images/logo.png" -O "$APP_DIR/proxmenux-monitor.png" || {
echo "⚠️ Could not download logo, creating placeholder..."
convert -size 256x256 xc:blue -fill white -gravity center -pointsize 24 -annotate +0+0 "PM" "$APP_DIR/proxmenux-monitor.png" 2>/dev/null || {
echo "⚠️ ImageMagick not available, skipping icon creation"
}
}
fi
if [ -f "$APP_DIR/proxmenux-monitor.png" ]; then
cp "$APP_DIR/proxmenux-monitor.png" "$APP_DIR/usr/share/icons/hicolor/256x256/apps/"
fi
echo "📦 Installing Python dependencies..."
# Flask/WebSocket dependencies for the Monitor dashboard. The previous
# Phase-1 (googletrans==4.0.0-rc1 + httpx 0.13.3 + httpcore 0.9.1 +
# h11 0.9.0) is gone — translation is now a static-lookup feature on
# the host, so the AppImage no longer needs any runtime translator.
# Removing those pins also unblocks the h11>=0.14.0 family without the
# conflict workaround we used to ship.
# Note: cryptography removed due to Python version compatibility issues (PyO3 modules)
pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" --upgrade --no-deps \
flask \
flask-cors \
psutil \
requests \
PyJWT \
pyotp \
segno \
beautifulsoup4
# WebSocket with modern h11 (no need for the legacy pin anymore)
pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" --upgrade \
h11>=0.14.0 \
wsproto>=1.2.0 \
simple-websocket>=0.10.0 \
flask-sock>=0.6.0
# Phase 3b: Install the complete gevent/WSS runtime separately for every
# supported host ABI. Keeping each dependency tree isolated avoids mixing
# CPython-specific modules from the build runner with those loaded by PVE.
# The two --platform values allow gevent's manylinux_2_28 wheels and the
# backwards-compatible manylinux2014 zope.interface wheels to be resolved in
# one deterministic installation.
install_gevent_runtime() {
local python_version="$1"
local abi="$2"
local target="$APP_DIR/usr/lib/python3/vendor/$abi"
echo "📦 Installing gevent runtime for ${abi} (Python ${python_version})..."
mkdir -p "$target"
python3 -m pip install \
--disable-pip-version-check \
--target "$target" \
--upgrade \
--only-binary=:all: \
--platform manylinux_2_28_x86_64 \
--platform manylinux2014_x86_64 \
--implementation cp \
--python-version "$python_version" \
--abi "$abi" \
--no-deps \
"gevent==${GEVENT_VERSION}" \
"greenlet==${GREENLET_VERSION}" \
"zope.interface==${ZOPE_INTERFACE_VERSION}" \
"zope.event==${ZOPE_EVENT_VERSION}" \
"gevent-websocket==${GEVENT_WEBSOCKET_VERSION}"
}
install_gevent_runtime "3.11" "cp311"
install_gevent_runtime "3.13" "cp313"
echo "🔍 Verifying bundled Python ABI extensions..."
for abi in cp311 cp313; do
abi_digits="${abi#cp}"
abi_dir="$APP_DIR/usr/lib/python3/vendor/$abi"
for extension in \
"gevent/_gevent_c_hub_local.cpython-${abi_digits}-*.so" \
"greenlet/_greenlet.cpython-${abi_digits}-*.so" \
"zope/interface/_zope_interface_coptimizations.cpython-${abi_digits}-*.so"; do
if ! compgen -G "$abi_dir/$extension" >/dev/null; then
echo "❌ Missing ${abi} extension: ${extension}" >&2
exit 1
fi
done
if [ ! -f "$abi_dir/geventwebsocket/__init__.py" ]; then
echo "❌ Missing gevent-websocket runtime for ${abi}" >&2
exit 1
fi
echo " ✅ ${abi} runtime complete"
done
# Phase 3c: Apprise notification hub (issue #207). One library handles
# ~80 notification services behind a single URL scheme (`tgram://`,
# `discord://`, `ntfy://`, `matrix://`, etc.). Used by the optional
# `apprise` channel in notification_channels.py for operators who want
# to reach a service we don't support natively.
pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" --upgrade \
apprise>=1.7.0
cat > "$APP_DIR/usr/lib/python3/dist-packages/cgi.py" << 'PYEOF'
from typing import Tuple, Dict
try:
from html import escape as _html_escape
except Exception:
def _html_escape(s, quote=True): return s
__all__ = ["parse_header", "escape"]
def escape(s, quote=True):
return _html_escape(s, quote=quote)
def parse_header(value: str) -> Tuple[str, Dict[str, str]]:
if not isinstance(value, str):
value = str(value or "")
parts = [p.strip() for p in value.split(";")]
if not parts:
return "", {}
key = parts[0].lower()
params: Dict[str, str] = {}
for item in parts[1:]:
if not item:
continue
if "=" in item:
k, v = item.split("=", 1)
k = k.strip().lower()
v = v.strip().strip('"').strip("'")
params[k] = v
else:
params[item.strip().lower()] = ""
return key, params
PYEOF
echo "🔧 Installing hardware monitoring tools..."
mkdir -p "$WORK_DIR/debs"
cd "$WORK_DIR/debs"
echo "📥 Downloading hardware monitoring tools (dynamic via APT)..."
dl_pkg() {
local out="$1"; shift
local pkg deb_file
for pkg in "$@"; do
echo " - trying: $pkg"
if apt-get download -y "$pkg" >/dev/null 2>&1; then
deb_file="$(ls -1 ${pkg}_*.deb 2>/dev/null | head -n1)"
if [ -n "$deb_file" ] && [ -f "$deb_file" ]; then
mv "$deb_file" "$out"
echo " ✅ downloaded: $pkg -> $out"
return 0
fi
fi
done
if command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then
echo " ↻ retry with sudo apt-get update && download"
sudo apt-get update -qq || true
for pkg in "$@"; do
echo " - trying (sudo): $pkg"
if sudo apt-get download -y "$pkg" >/dev/null 2>&1; then
deb_file="$(ls -1 ${pkg}_*.deb 2>/dev/null | head -n1)"
if [ -n "$deb_file" ] && [ -f "$deb_file" ]; then
mv "$deb_file" "$out"
echo " ✅ downloaded (sudo): $pkg -> $out"
return 0
fi
fi
done
fi
echo " ⚠️ none of the candidates could be downloaded for $out"
return 1
}
dl_pkg "ipmitool.deb" "ipmitool" || true
dl_pkg "libfreeipmi17.deb" "libfreeipmi17" || true
dl_pkg "lm-sensors.deb" "lm-sensors" || true
dl_pkg "nut-client.deb" "nut-client" || true
dl_pkg "libupsclient.deb" "libupsclient6t64" "libupsclient6" "libupsclient5" "libupsclient4" || true
echo "📦 Extracting .deb packages into AppDir..."
extracted_count=0
shopt -s nullglob
for deb in *.deb; do
echo " -> $deb"
if file "$deb" | grep -q "Debian binary package"; then
dpkg-deb -x "$deb" "$APP_DIR" && extracted_count=$((extracted_count + 1))
else
echo " ⚠️ $deb is not a valid .deb, skipping"
fi
done
shopt -u nullglob
if [ $extracted_count -eq 0 ]; then
echo "⚠️ No packages extracted; hardware/GPU monitoring may be unavailable"
else
echo "✅ Extracted $extracted_count package(s)"
fi
if [ -d "$APP_DIR/bin" ]; then
echo "📋 Normalizing /bin -> /usr/bin"
mkdir -p "$APP_DIR/usr/bin"
cp -r "$APP_DIR/bin/"* "$APP_DIR/usr/bin/" 2>/dev/null || true
rm -rf "$APP_DIR/bin"
fi
echo "🔍 Sanity check (ldd + presence of libfreeipmi)"
export LD_LIBRARY_PATH="$APP_DIR/lib:$APP_DIR/lib/x86_64-linux-gnu:$APP_DIR/usr/lib:$APP_DIR/usr/lib/x86_64-linux-gnu"
if ! find "$APP_DIR/usr/lib" "$APP_DIR/lib" -maxdepth 3 -name 'libfreeipmi.so.17*' | grep -q .; then
echo "❌ libfreeipmi.so.17 not found inside AppDir (ipmitool will fail)"
exit 1
fi
if [ -x "$APP_DIR/usr/bin/ipmitool" ] && ldd "$APP_DIR/usr/bin/ipmitool" | grep -q 'not found'; then
echo "❌ ipmitool has unresolved libs:"
ldd "$APP_DIR/usr/bin/ipmitool" | grep 'not found' || true
exit 1
fi
if [ -x "$APP_DIR/usr/bin/upsc" ] && ldd "$APP_DIR/usr/bin/upsc" | grep -q 'not found'; then
echo "⚠️ upsc has unresolved libs, trying to auto-fix..."
missing="$(ldd "$APP_DIR/usr/bin/upsc" | awk '/not found/{print $1}' | tr -d ' ')"
echo " missing: $missing"
case "$missing" in
# Debian 13+ ships the t64 transitional package — try it first.
libupsclient.so.6) need_pkgs="libupsclient6t64 libupsclient6" ;;
libupsclient.so.5) need_pkgs="libupsclient5" ;;
libupsclient.so.4) need_pkgs="libupsclient4" ;;
*) need_pkgs="" ;;
esac
if [ -n "$need_pkgs" ]; then
echo " downloading: $need_pkgs"
dl_pkg "libupsclient_autofix.deb" $need_pkgs || true
if [ -f "libupsclient_autofix.deb" ]; then
dpkg-deb -x "libupsclient_autofix.deb" "$APP_DIR"
echo " re-checking ldd for upsc..."
if ldd "$APP_DIR/usr/bin/upsc" | grep -q 'not found'; then
echo "❌ upsc still has unresolved libs:"
ldd "$APP_DIR/usr/bin/upsc" | grep 'not found' || true
exit 1
fi
else
echo "❌ could not download any of: $need_pkgs"
exit 1
fi
else
echo "❌ unknown missing library for upsc: $missing"
exit 1
fi
fi
echo "✅ Sanity check OK (ipmitool/upsc ready; libfreeipmi present)"
# Info rápida
[ -x "$APP_DIR/usr/bin/sensors" ] && echo " • sensors: OK" || echo " • sensors: missing"
[ -x "$APP_DIR/usr/bin/ipmitool" ] && echo " • ipmitool: OK" || echo " • ipmitool: missing"
[ -x "$APP_DIR/usr/bin/upsc" ] && echo " • upsc: OK" || echo " • upsc: missing"
[ -x "$APP_DIR/usr/bin/nvidia-smi" ] && echo " • nvidia-smi: OK" || echo " • nvidia-smi: missing"
[ -x "$APP_DIR/usr/bin/intel_gpu_top" ] && echo " • intel-gpu-tools: OK" || echo " • intel-gpu-tools: missing"
[ -x "$APP_DIR/usr/bin/radeontop" ] && echo " • radeontop: OK" || echo " • radeontop: missing"
# Build AppImage
echo "🔨 Building unified AppImage v${VERSION}..."
cd "$WORK_DIR"
export NO_CLEANUP=1
export APPIMAGE_EXTRACT_AND_RUN=1
ARCH=x86_64 ./appimagetool --no-appstream --verbose "$APP_DIR" "$APPIMAGE_NAME"
# Move to dist directory
mv "$APPIMAGE_NAME" "$DIST_DIR/"
echo "✅ Unified AppImage created: $DIST_DIR/$APPIMAGE_NAME"
echo ""
echo "📋 Usage:"
echo " Dashboard: ./$APPIMAGE_NAME"
echo " Translation: ./$APPIMAGE_NAME --translate"
echo ""
echo "🚀 Installation:"
echo " sudo cp $DIST_DIR/$APPIMAGE_NAME /usr/local/bin/proxmenux-monitor"
echo " sudo chmod +x /usr/local/bin/proxmenux-monitor"