fix(monitor): avoid foreign Borg binary in ARM builds and smoke-test launcher

This commit is contained in:
Jonatan Castro
2026-11-04 02:52:05 +01:00
parent 2bcc0c1e61
commit fd061054d4
15 changed files with 174 additions and 5 deletions
+33
View File
@@ -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' if header[5] == 1 else '>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')
+20
View File
@@ -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"
+11 -2
View File
@@ -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
+8
View File
@@ -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
+20 -1
View File
@@ -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
+15 -1
View File
@@ -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.
+1
View File
@@ -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 <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>": "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 <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>",
"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.",
+1
View File
@@ -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 <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>": "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 <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>",
"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.",
+1
View File
@@ -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 <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>": "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 <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge ajouter <user>",
"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.",
+1
View File
@@ -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 <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>": "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 <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>",
"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.",
+1
View File
@@ -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 <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>": "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 <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>",
"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.",
+1
View File
@@ -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 <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>": "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 <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>",
"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.",
+1
View File
@@ -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 <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>": "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 <CTID> - env THELOUNGE_HOME=/config s6-setuidgid abc thelounge addera <user>",
"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.",
@@ -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; }
+53 -1
View File
@@ -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('<H', 183)
binary.write_bytes(header)
self.assertEqual(module.check(folder, 'aarch64'), 1)
with self.assertRaisesRegex(ValueError, 'Foreign ELF machine 183'):
module.check(folder, 'x86_64')
binary.write_text('#!/bin/sh\nexit 127\n')
with self.assertRaisesRegex(ValueError, 'No native ELF files'):
module.check(folder, 'aarch64')
def test_supported_native_architectures_and_aliases(self):
for machine in ['x86_64', 'amd64']:
self.assertEqual(self.configure(machine).stdout, 'x86_64|x86_64-linux-gnu|')
@@ -73,5 +91,39 @@ class AppImageArchitectureTests(unittest.TestCase):
self.assertIn(str(app / 'usr/lib/python3/vendor/cp311'), result.stdout)
def test_arm_borg_adapter_reports_unavailable_or_uses_native_host_package(self):
source = (SCRIPTS / 'borg_native_arm.sh').read_text()
with tempfile.TemporaryDirectory() as folder:
native = Path(folder) / 'borg'
adapter = Path(folder) / 'adapter'
adapter.write_text(source.replace('/usr/bin/borg', str(native)))
missing = subprocess.run(['sh', str(adapter), '--version'], capture_output=True, text=True)
self.assertEqual(missing.returncode, 127)
self.assertIn('Install the native borgbackup package', missing.stderr)
native.write_text('#!/bin/sh\nprintf "native %s" "$*"\n')
native.chmod(0o755)
installed = subprocess.run(['sh', str(adapter), 'list', 'fixture-repo'], capture_output=True, text=True, check=True)
self.assertEqual(installed.stdout, 'native list fixture-repo')
def test_arm_host_backup_never_selects_cached_amd64_borg_or_downloads_it(self):
source = (ROOT / 'scripts/backup_restore/lib_host_backup_common.sh').read_text()
function = source[source.index('hb_ensure_borg() {'):source.index('\nhb_borg_init_if_needed()')]
with tempfile.TemporaryDirectory() as folder:
cached = Path(folder) / 'borg'
cached.write_text('#!/bin/sh\nexit 0\n')
cached.chmod(0o755)
script = (function + '\n'
'command() { if [ "$*" = "-v borg" ]; then return 1; fi; builtin command "$@"; }; '
'uname() { echo aarch64; }; hb_translate() { printf "%s" "$1"; }; '
'msg_error() { printf "%s" "$1" >&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()