diff --git a/.github/scripts/check_appimage_elf.py b/.github/scripts/check_appimage_elf.py new file mode 100644 index 00000000..4f131dcf --- /dev/null +++ b/.github/scripts/check_appimage_elf.py @@ -0,0 +1,33 @@ +"""Reject foreign ELF files in a native AppDir without executing any binary.""" +import argparse +from pathlib import Path +import struct + + +def check(directory, architecture): + expected = {'x86_64': 62, 'aarch64': 183}[architecture] + checked = 0 + for path in Path(directory).rglob('*'): + if path.is_symlink() or not path.is_file(): + continue + with path.open('rb') as stream: + header = stream.read(20) + if not header.startswith(b'\x7fELF'): + continue + if len(header) != 20 or header[5] not in (1, 2): + raise ValueError(f'Invalid ELF header: {path}') + machine = struct.unpack('H', header[18:20])[0] + if machine != expected: + raise ValueError(f'Foreign ELF machine {machine} in {architecture} AppDir: {path}') + checked += 1 + if not checked: + raise ValueError('No native ELF files found in AppDir') + return checked + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('directory', type=Path) + parser.add_argument('architecture', choices=('x86_64', 'aarch64')) + args = parser.parse_args() + print(f'PASS: {check(args.directory, args.architecture)} native ELF files') diff --git a/.github/scripts/smoke_native_apprun.sh b/.github/scripts/smoke_native_apprun.sh new file mode 100644 index 00000000..cb84203c --- /dev/null +++ b/.github/scripts/smoke_native_apprun.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Exercise the extracted launcher and its real native Python environment, +# intercepting only the final server exec so no host-monitoring server starts. +set -eu +appdir="$1" +export PMX_SMOKE_PYTHON +PMX_SMOKE_PYTHON="$(command -v python)" +fixture_bin="$(mktemp -d)" +trap 'rm -rf "$fixture_bin"' EXIT +cat > "$fixture_bin/python3" <<'WRAPPER' +#!/bin/sh +case "$1" in + */flask_server.py) + exec "$PMX_SMOKE_PYTHON" -c 'import gevent, greenlet, geventwebsocket, zope.interface; from PIL import Image; from gevent import pywsgi; print("PASS: extracted AppRun native runtime")' + ;; + *) exec "$PMX_SMOKE_PYTHON" "$@" ;; +esac +WRAPPER +chmod +x "$fixture_bin/python3" +PATH="$fixture_bin:$PATH" bash "$appdir/AppRun" diff --git a/.github/workflows/test-appimage-native.yml b/.github/workflows/test-appimage-native.yml index 8f80cee4..163709e0 100644 --- a/.github/workflows/test-appimage-native.yml +++ b/.github/workflows/test-appimage-native.yml @@ -6,6 +6,9 @@ on: - '.github/workflows/test-appimage-native.yml' - 'AppImage/scripts/build_appimage.sh' - 'AppImage/scripts/appimage_arch.sh' + - 'AppImage/scripts/borg_native_arm.sh' + - '.github/scripts/smoke_native_apprun.sh' + - '.github/scripts/check_appimage_elf.py' - 'AppImage/scripts/AppRun' - 'tests/test_appimage_arch.py' workflow_dispatch: @@ -45,6 +48,8 @@ jobs: run: sudo apt-get update - name: Build native AppImage without publishing it run: bash AppImage/scripts/build_appimage.sh + - name: Reject foreign ELF payloads + run: python .github/scripts/check_appimage_elf.py /tmp/proxmenux_build/ProxMenux.AppDir '${{ matrix.arch }}' - name: Extract native AppImage without FUSE run: | mkdir /tmp/proxmenux-native-extract @@ -59,11 +64,15 @@ jobs: - name: Import bundled Python 3.11 native WebSocket runtime env: PYTHONPATH: /tmp/proxmenux-native-extract/squashfs-root/usr/lib/python3/vendor/cp311:/tmp/proxmenux-native-extract/squashfs-root/usr/lib/python3/dist-packages - run: python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from PIL import Image; from gevent import pywsgi; print(gevent.__version__)' + run: | + python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from PIL import Image; from gevent import pywsgi; print(gevent.__version__)' + bash .github/scripts/smoke_native_apprun.sh /tmp/proxmenux-native-extract/squashfs-root - uses: actions/setup-python@v5 with: python-version: '3.13' - name: Import bundled Python 3.13 native WebSocket runtime env: PYTHONPATH: /tmp/proxmenux-native-extract/squashfs-root/usr/lib/python3/vendor/cp313:/tmp/proxmenux-native-extract/squashfs-root/usr/lib/python3/dist-packages - run: python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from PIL import Image; from gevent import pywsgi; print(gevent.__version__)' + run: | + python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from PIL import Image; from gevent import pywsgi; print(gevent.__version__)' + bash .github/scripts/smoke_native_apprun.sh /tmp/proxmenux-native-extract/squashfs-root diff --git a/AppImage/scripts/borg_native_arm.sh b/AppImage/scripts/borg_native_arm.sh new file mode 100644 index 00000000..2c01ee23 --- /dev/null +++ b/AppImage/scripts/borg_native_arm.sh @@ -0,0 +1,8 @@ +#!/bin/sh +# Borg 1.2.8 has no official ARM64 standalone release. Never ship or run +# its AMD64 executable on ARM; use the host's native distro package instead. +if [ -x /usr/bin/borg ]; then + exec /usr/bin/borg "$@" +fi +echo 'Native Borg is unavailable. Install the native borgbackup package on this host to use Borg backups.' >&2 +exit 127 diff --git a/AppImage/scripts/build_appimage.sh b/AppImage/scripts/build_appimage.sh index ecc7ae0c..1b28e223 100755 --- a/AppImage/scripts/build_appimage.sh +++ b/AppImage/scripts/build_appimage.sh @@ -37,6 +37,14 @@ echo "🚀 Building ProxMenux Monitor AppImage v${VERSION} with hardware monitor # denied → build aborted before doing any actual work). APPIMAGETOOL_CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/proxmenux-build/appimagetool-${PMX_APPIMAGE_ARCH}" +APPIMAGETOOL_URL="https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-${PMX_APPIMAGE_ARCH}.AppImage" +if [ "$PMX_APPIMAGE_ARCH" = aarch64 ]; then + # The old AppImageKit ARM build does not accept ARCH=aarch64 reliably. + # Use a versioned tool; leave the existing AMD64 tool unchanged. + APPIMAGETOOL_URL="https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-aarch64.AppImage" + APPIMAGETOOL_CACHE="${APPIMAGETOOL_CACHE}-1.9.1" +fi + # Architecture-specific caches never reuse an executable from a different CPU. # Clean and create work directory @@ -52,7 +60,7 @@ if [ -s "$APPIMAGETOOL_CACHE" ]; then fi if [ ! -s "$WORK_DIR/appimagetool" ]; then echo "📥 Downloading appimagetool..." - wget --tries=3 --timeout=60 "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-${PMX_APPIMAGE_ARCH}.AppImage" -O "$WORK_DIR/appimagetool" || true + wget --tries=3 --timeout=60 "$APPIMAGETOOL_URL" -O "$WORK_DIR/appimagetool" || true if [ ! -s "$WORK_DIR/appimagetool" ]; then echo "❌ Failed to download appimagetool" >&2 exit 1 @@ -62,6 +70,10 @@ if [ ! -s "$WORK_DIR/appimagetool" ]; then cp -f "$WORK_DIR/appimagetool" "$APPIMAGETOOL_CACHE" fi +if [ "$PMX_APPIMAGE_ARCH" = aarch64 ]; then + echo "f0837e7448a0c1e4e650a93bb3e85802546e60654ef287576f46c71c126a9158 $WORK_DIR/appimagetool" | sha256sum -c - +fi + # Create directory structure mkdir -p "$APP_DIR/usr/bin" mkdir -p "$APP_DIR/usr/lib/python3/dist-packages" @@ -240,6 +252,7 @@ fi # 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. +if [ "$PMX_APPIMAGE_ARCH" = x86_64 ]; then BORG_VERSION="1.2.8" BORG_URL="https://github.com/borgbackup/borg/releases/download/${BORG_VERSION}/borg-linux64" BORG_SHA256="cfa50fb704a93d3a4fa258120966345fddb394f960dca7c47fcb774d0172f40b" @@ -275,6 +288,12 @@ else exit 1 fi +else + echo "⚠️ No official Borg 1.2.8 ARM64 standalone binary; using native-host adapter" + cp "$SCRIPT_DIR/borg_native_arm.sh" "$APP_DIR/usr/bin/borg" + chmod +x "$APP_DIR/usr/bin/borg" +fi + # Copy Next.js build echo "📋 Copying web dashboard..." if [ -d "$APPIMAGE_ROOT/out" ]; then diff --git a/docs/monitor-native-builds.md b/docs/monitor-native-builds.md index feade22e..b578bcc2 100644 --- a/docs/monitor-native-builds.md +++ b/docs/monitor-native-builds.md @@ -13,7 +13,8 @@ Python/pip, APT and the usual AppImage build dependencies. AMD64 keeps the exist The native validation workflow builds both architectures on GitHub's native Ubuntu 22.04 runners, extracts each AppImage without FUSE, and imports its bundled -CPython 3.11 and 3.13 WebSocket/image runtimes. It does not upload artifacts, +CPython 3.11 and 3.13 WebSocket/image runtimes, including the extracted launcher +with only its final server execution intercepted. It does not upload artifacts, change release assets, or modify an installed Monitor. This build lane is a prerequisite for ARM distribution, not a declaration of @@ -21,3 +22,16 @@ PXVIRT or Raspberry Pi hardware compatibility. ARM release/checksum publication, installer selection and validation on the maintainer's PXVIRT lab remain separate steps before ARM installations can use a published image. The current release and installer distribution remain unchanged. + +## Borg host backups on ARM64 + +Borg 1.2.8 publishes an AMD64 standalone executable but no ARM64 equivalent. +ARM64 builds therefore include a small adapter that uses `/usr/bin/borg` from +the host's native `borgbackup` package. Without that package it exits with a +clear error; it never runs the AMD64 binary. The host-backup helper likewise +refuses AMD64 cached/downloaded binaries on non-AMD64 hosts. The existing AMD64 +standalone binary and checksum remain unchanged. Operators must install their +native distro package separately; this build does not install it on the host. + +ARM64 uses the official versioned appimagetool 1.9.1 with SHA256 verification; +the existing AMD64 packaging tool remains unchanged. diff --git a/lang/de.json b/lang/de.json index 08d6ad53..b79b2c6b 100644 --- a/lang/de.json +++ b/lang/de.json @@ -4396,6 +4396,7 @@ "Name or part of the option or of the application": "Name oder Teil der Option oder der Anwendung", "Name:": "Name:", "Named accounts need private mode. Stop the container, set public: false in /config/config.js, start it again and create each user with: pct exec -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add ": "Benannte Konten benötigen einen privaten Modus. Stoppen Sie den Container, setzen Sie public: false in /config/config.js, starten Sie ihn erneut und erstellen Sie jeden Benutzer mit: pct exec -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add ", + "Native Borg is unavailable. Install the native borgbackup package on this host to use Borg backups.": "Eine native Borg-Version ist nicht verfügbar. Installiere das native Paket borgbackup auf diesem Host, um Borg-Sicherungen zu verwenden.", "Needed:": "Erforderlich:", "Needs": "Benötigt", "Neither /etc/kernel/cmdline nor /etc/default/grub found.": "Weder /etc/kernel/cmdline noch /etc/default/grub gefunden.", diff --git a/lang/es.json b/lang/es.json index 4823976e..b42537b2 100644 --- a/lang/es.json +++ b/lang/es.json @@ -4391,6 +4391,7 @@ "Name or part of the option or of the application": "Nombre o parte de la opción o de la aplicación", "Name:": "Nombre:", "Named accounts need private mode. Stop the container, set public: false in /config/config.js, start it again and create each user with: pct exec -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add ": "Las cuentas con nombre necesitan el modo privado. Detén el contenedor, pon public: false en /config/config.js, vuelve a arrancarlo y crea cada usuario con: pct exec -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add ", + "Native Borg is unavailable. Install the native borgbackup package on this host to use Borg backups.": "Borg nativo no está disponible. Instala el paquete borgbackup nativo en este host para usar las copias de seguridad de Borg.", "Needed:": "Necesario:", "Needs": "Necesita", "Neither /etc/kernel/cmdline nor /etc/default/grub found.": "No se encontraron /etc/kernel/cmdline ni /etc/default/grub.", diff --git a/lang/fr.json b/lang/fr.json index 660c173d..30d2b831 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -4396,6 +4396,7 @@ "Name or part of the option or of the application": "Nom ou partie de l'option ou de l'application", "Name:": "Nom:", "Named accounts need private mode. Stop the container, set public: false in /config/config.js, start it again and create each user with: pct exec -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add ": "Les comptes nommés nécessitent un mode privé. Arrêter le conteneur, définir public: false in /config/config.js, recommencer et créer chaque utilisateur avec: pct exec -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge ajouter ", + "Native Borg is unavailable. Install the native borgbackup package on this host to use Borg backups.": "La version native de Borg n’est pas disponible. Installez le paquet natif borgbackup sur cet hôte pour utiliser les sauvegardes Borg.", "Needed:": "Nécessaire :", "Needs": "Nécessite", "Neither /etc/kernel/cmdline nor /etc/default/grub found.": "Ni /etc/kernel/cmdline ni /etc/default/grub n'ont été trouvés.", diff --git a/lang/it.json b/lang/it.json index be964a45..57823795 100644 --- a/lang/it.json +++ b/lang/it.json @@ -4396,6 +4396,7 @@ "Name or part of the option or of the application": "Nome dell'opzione o dell'applicazione, anche parziale", "Name:": "Nome:", "Named accounts need private mode. Stop the container, set public: false in /config/config.js, start it again and create each user with: pct exec -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add ": "Gli account nominativi richiedono la modalità privata. Arresta il container, imposta public: false in /config/config.js, riavvialo e crea ogni utente con: pct exec -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add ", + "Native Borg is unavailable. Install the native borgbackup package on this host to use Borg backups.": "La versione nativa di Borg non è disponibile. Installa il pacchetto borgbackup nativo su questo host per utilizzare i backup Borg.", "Needed:": "Spazio richiesto:", "Needs": "Richiede", "Neither /etc/kernel/cmdline nor /etc/default/grub found.": "Non sono stati trovati né /etc/kernel/cmdline né /etc/default/grub.", diff --git a/lang/pt.json b/lang/pt.json index a21f5998..e25beb80 100644 --- a/lang/pt.json +++ b/lang/pt.json @@ -4396,6 +4396,7 @@ "Name or part of the option or of the application": "Nome ou parte da opção ou do aplicativo", "Name:": "Nome:", "Named accounts need private mode. Stop the container, set public: false in /config/config.js, start it again and create each user with: pct exec -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add ": "Contas nomeadas precisam de modo privado. Pare o contêiner, defina público: false in /config/config.js, inicie-o novamente e crie cada usuário com: pct exec -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add ", + "Native Borg is unavailable. Install the native borgbackup package on this host to use Borg backups.": "A versão nativa do Borg não está disponível. Instale o pacote borgbackup nativo neste host para utilizar as cópias de segurança do Borg.", "Needed:": "Necessário:", "Needs": "Requer", "Neither /etc/kernel/cmdline nor /etc/default/grub found.": "Nem /etc/kernel/cmdline nem /etc/default/grub foram encontrados.", diff --git a/lang/sk.json b/lang/sk.json index 5920f1e8..4f37a588 100644 --- a/lang/sk.json +++ b/lang/sk.json @@ -4396,6 +4396,7 @@ "Name or part of the option or of the application": "Názov alebo časť možnosti alebo aplikácie", "Name:": "Názov:", "Named accounts need private mode. Stop the container, set public: false in /config/config.js, start it again and create each user with: pct exec -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add ": "Používateľské účty vyžadujú súkromný režim. Zastavte kontajner, v /config/config.js nastavte public: false, znova ho spustite a každého používateľa vytvorte príkazom: pct exec -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add ", + "Native Borg is unavailable. Install the native borgbackup package on this host to use Borg backups.": "Natívna verzia Borg nie je dostupná. Ak chcete používať zálohy Borg, nainštalujte na tento hostiteľ natívny balík borgbackup.", "Needed:": "Potrebné:", "Needs": "Vyžaduje", "Neither /etc/kernel/cmdline nor /etc/default/grub found.": "Nenašiel sa /etc/kernel/cmdline ani /etc/default/grub.", diff --git a/lang/sv.json b/lang/sv.json index 5efd8fdf..77f8253e 100644 --- a/lang/sv.json +++ b/lang/sv.json @@ -4396,6 +4396,7 @@ "Name or part of the option or of the application": "Namn eller del av alternativet eller applikationen", "Name:": "Namn:", "Named accounts need private mode. Stop the container, set public: false in /config/config.js, start it again and create each user with: pct exec -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add ": "Namngivna konton behöver privat läge. Stoppa behållaren, ställ in offentligt: falskt i /config/config.js, starta den igen och skapa varje användare med: pct exec - env THELOUNGE_HOME=/config s6-setuidgid abc thelounge addera ", + "Native Borg is unavailable. Install the native borgbackup package on this host to use Borg backups.": "En inbyggd version av Borg är inte tillgänglig. Installera paketet borgbackup för värdens arkitektur för att använda Borg-säkerhetskopior.", "Needed:": "Behövs:", "Needs": "Kräver", "Neither /etc/kernel/cmdline nor /etc/default/grub found.": "Varken /etc/kernel/cmdline eller /etc/default/grub hittades.", diff --git a/scripts/backup_restore/lib_host_backup_common.sh b/scripts/backup_restore/lib_host_backup_common.sh index 1c465b19..b7938053 100755 --- a/scripts/backup_restore/lib_host_backup_common.sh +++ b/scripts/backup_restore/lib_host_backup_common.sh @@ -2021,6 +2021,13 @@ hb_ensure_borg() { # without the new AppImage) command -v borg >/dev/null 2>&1 && { echo "borg"; return 0; } + # Cached/bundled/downloaded borg-linux64 below is AMD64 only. A native + # distro Borg may be used above, but never try a foreign cached executable. + if [[ "$(uname -m)" != x86_64 ]]; then + msg_error "$(hb_translate "Native Borg is unavailable. Install the native borgbackup package on this host to use Borg backups.")" + return 1 + fi + local appimage_cache="$HB_STATE_DIR/borg" [[ -x "$appimage_cache" ]] && { echo "$appimage_cache"; return 0; } diff --git a/tests/test_appimage_arch.py b/tests/test_appimage_arch.py index 0678b3a5..d7b0384a 100644 --- a/tests/test_appimage_arch.py +++ b/tests/test_appimage_arch.py @@ -1,5 +1,6 @@ """Exercise native architecture wiring without building or changing the host.""" -import json +import importlib.util +import struct import os from pathlib import Path import shutil @@ -18,6 +19,23 @@ class AppImageArchitectureTests(unittest.TestCase): 'printf "%s|%s|%s" "$PMX_APPIMAGE_ARCH" "$PMX_APPIMAGE_TRIPLET" "$PMX_APPIMAGE_SUFFIX"', 'fixture', str(SCRIPTS / 'appimage_arch.sh'), machine], capture_output=True, text=True) + def test_elf_audit_rejects_foreign_binary_without_executing_it(self): + spec = importlib.util.spec_from_file_location('appimage_elf_check', ROOT / '.github/scripts/check_appimage_elf.py') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + with tempfile.TemporaryDirectory() as folder: + binary = Path(folder) / 'fixture-elf' + header = bytearray(20) + header[:6] = b'\x7fELF\x02\x01' + header[18:20] = struct.pack('&2; }; ' + 'wget() { echo UNEXPECTED_DOWNLOAD >&2; return 99; }; ' + 'HB_STATE_DIR="$1"; hb_ensure_borg') + result = subprocess.run(['bash', '-c', script, 'fixture', folder], capture_output=True, text=True) + self.assertEqual(result.returncode, 1) + self.assertIn('Native Borg is unavailable.', result.stderr) + self.assertNotIn('UNEXPECTED_DOWNLOAD', result.stderr) + self.assertEqual(result.stdout, '') + + if __name__ == '__main__': unittest.main()