From de9f5ae1107106d62a71fd3577807e7daa31c517 Mon Sep 17 00:00:00 2001 From: nanxun Date: Sun, 2 Aug 2026 17:18:02 +0800 Subject: [PATCH] fix: rebuild native fnOS package with fnpack --- .gitignore | 1 + README.md | 6 +- fnos/{ => app}/ui/config | 0 fnos/cmd/main | 48 +++++-- fnos/manifest | 5 +- scripts/build-fnos-package.sh | 227 ++++++++++++++++++++++++--------- scripts/smoke-fnos-package.sh | 66 ++++++---- scripts/verify-fnos-package.sh | 85 ++++++++++-- 8 files changed, 333 insertions(+), 105 deletions(-) rename fnos/{ => app}/ui/config (100%) diff --git a/.gitignore b/.gitignore index 1951ad1..727b9fa 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ frontend/node_modules/ frontend/dist/ .codex-temp/ +.tools/ build.log webapi-build.log webapi-build-no-restore.log diff --git a/README.md b/README.md index 4a2c8b3..0525564 100644 --- a/README.md +++ b/README.md @@ -256,10 +256,12 @@ docker compose up -d ```bash ./scripts/build-fnos-package.sh -./scripts/smoke-fnos-package.sh artifacts/fnos/liverecorder-1.0.0-x86_64.fpk +./scripts/smoke-fnos-package.sh artifacts/fnos/liverecorder-1.0.1-x86_64.fpk ``` -- x86_64 原生自包含包,不依赖 Docker、系统 .NET、PostgreSQL、Node.js 或 FFmpeg +- 使用 fnOS 开发者平台提供的官方 `fnpack` 构建;可通过 `FNPACK=/path/to/fnpack` 指定工具路径 +- x86_64 原生包,不依赖 Docker;离线内置 .NET、PostgreSQL、官方 Node.js 22 与 curl +- 依赖 fnOS 系统环境同时提供 `ffmpeg` 和 `ffprobe`,安装前请先确认二者可执行 - 安装向导会要求设置 `admin` 管理员密码 - Web 管理界面默认使用端口 `18080` - 数据库与日志保存在 fnOS 应用持久化目录 diff --git a/fnos/ui/config b/fnos/app/ui/config similarity index 100% rename from fnos/ui/config rename to fnos/app/ui/config diff --git a/fnos/cmd/main b/fnos/cmd/main index 2b65491..81c6633 100755 --- a/fnos/cmd/main +++ b/fnos/cmd/main @@ -2,7 +2,7 @@ set -u PACKAGE_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) -APP_ROOT="${TRIM_APPDEST:-$PACKAGE_ROOT}" +APP_ROOT="${TRIM_APPDEST:-$PACKAGE_ROOT/app}" DATA_ROOT="${TRIM_PKGVAR:-$PACKAGE_ROOT/var}" VOLUME_ROOT="${TRIM_APPDEST_VOL:-$DATA_ROOT/volume}" RECORD_ROOT="${LIVE_RECORDER_RECORD_ROOT:-$VOLUME_ROOT/@appshare/liverecorder/records}" @@ -10,6 +10,10 @@ RUNTIME_ROOT="$APP_ROOT/runtime" SERVER="$APP_ROOT/server/LiveRecorder.WebApi" PG_BIN="$RUNTIME_ROOT/usr/lib/postgresql/15/bin" PG_SHARE="$RUNTIME_ROOT/usr/share/postgresql/15" +PG_LIB="$RUNTIME_ROOT/usr/lib/postgresql/15/lib" +NODE_BIN="$RUNTIME_ROOT/bin/node" +CURL_BIN="$RUNTIME_ROOT/bin/curl" +CA_BUNDLE="$RUNTIME_ROOT/etc/ssl/certs/ca-certificates.crt" PG_DATA="$DATA_ROOT/postgres" RUN_ROOT="$DATA_ROOT/run" LOG_ROOT="$DATA_ROOT/log" @@ -19,7 +23,9 @@ PG_LOG="$LOG_ROOT/postgresql.log" ADMIN_PASSWORD_FILE="$DATA_ROOT/admin-password.seed" PG_PORT="${LIVE_RECORDER_POSTGRES_PORT:-54329}" SERVICE_PORT="${TRIM_SERVICE_PORT:-18080}" -RUNTIME_LIBRARY_PATH="$RUNTIME_ROOT/lib/x86_64-linux-gnu:$RUNTIME_ROOT/usr/lib/x86_64-linux-gnu:$RUNTIME_ROOT/usr/lib/x86_64-linux-gnu/pulseaudio:$RUNTIME_ROOT/usr/lib/x86_64-linux-gnu/blas:$RUNTIME_ROOT/usr/lib/x86_64-linux-gnu/lapack:$RUNTIME_ROOT/usr/lib/postgresql/15/lib" +SYSTEM_PATH="${PATH:-/usr/local/bin:/usr/bin:/bin}" +RUNTIME_PATH="$RUNTIME_ROOT/bin:$SYSTEM_PATH" +RUNTIME_LIBRARY_PATH="$RUNTIME_ROOT/lib:$PG_LIB" log_message() { mkdir -p "$LOG_ROOT" @@ -38,11 +44,26 @@ app_pid() { } run_pg() { - env LD_LIBRARY_PATH="$RUNTIME_LIBRARY_PATH" PATH="$PG_BIN:$RUNTIME_ROOT/usr/bin:/usr/bin:/bin" "$@" + env LD_LIBRARY_PATH="$RUNTIME_LIBRARY_PATH" PATH="$PG_BIN:$RUNTIME_PATH" "$@" } run_native() { - env LD_LIBRARY_PATH="$RUNTIME_LIBRARY_PATH" PATH="$RUNTIME_ROOT/usr/bin:/usr/bin:/bin" "$@" + env LD_LIBRARY_PATH="$RUNTIME_LIBRARY_PATH" PATH="$RUNTIME_PATH" \ + SSL_CERT_FILE="$CA_BUNDLE" CURL_CA_BUNDLE="$CA_BUNDLE" "$@" +} + +system_media_tools_available() { + missing_tools="" + for tool_name in ffmpeg ffprobe; do + if ! PATH="$SYSTEM_PATH" command -v "$tool_name" >/dev/null 2>&1; then + missing_tools="$missing_tools $tool_name" + fi + done + if [ -n "$missing_tools" ]; then + log_message "缺少 fnOS 系统媒体工具:${missing_tools# }。请先在系统环境中安装 FFmpeg(必须同时提供 ffmpeg 与 ffprobe)。" + return 1 + fi + return 0 } postgres_running() { @@ -107,7 +128,13 @@ start_postgres() { stop_postgres() { if postgres_running; then - run_pg "$PG_BIN/pg_ctl" -D "$PG_DATA" -m fast -w stop >>"$PG_LOG" 2>&1 || true + # A first-run migration can dirty enough pages that a NAS needs more + # than pg_ctl's 60-second default to finish the shutdown checkpoint. + # Do not let restart race a database that is still shutting down. + if ! run_pg "$PG_BIN/pg_ctl" -D "$PG_DATA" -m fast -t 180 -w stop >>"$PG_LOG" 2>&1; then + log_message "PostgreSQL 未能在 180 秒内安全停止,已取消后续重启。" + return 1 + fi fi } @@ -126,7 +153,9 @@ launch_app_process() { export XDG_CACHE_HOME="$DATA_ROOT/cache" export TMPDIR="$DATA_ROOT/tmp" export LD_LIBRARY_PATH="$RUNTIME_LIBRARY_PATH" - export PATH="$RUNTIME_ROOT/usr/bin:/usr/bin:/bin" + export PATH="$RUNTIME_PATH" + export SSL_CERT_FILE="$CA_BUNDLE" + export CURL_CA_BUNDLE="$CA_BUNDLE" mkdir -p "$XDG_CACHE_HOME" "$TMPDIR" cd "$APP_ROOT/server" || exit 1 exec "$SERVER" @@ -143,10 +172,11 @@ start_app() { log_message "应用程序不存在或不可执行:$SERVER" return 1 fi - if [ ! -x "$PG_BIN/postgres" ] || [ ! -x "$RUNTIME_ROOT/usr/bin/ffmpeg" ] || [ ! -x "$RUNTIME_ROOT/usr/bin/node" ]; then + if [ ! -x "$PG_BIN/postgres" ] || [ ! -x "$NODE_BIN" ] || [ ! -x "$CURL_BIN" ] || [ ! -s "$CA_BUNDLE" ]; then log_message "FPK 原生运行环境不完整。" return 1 fi + system_media_tools_available || return 1 if pid=$(app_pid); then log_message "应用已运行,PID $pid。" return 0 @@ -160,7 +190,7 @@ start_app() { attempt=0 while [ "$attempt" -lt 90 ]; do - if run_native "$RUNTIME_ROOT/usr/bin/curl" -fsS "http://127.0.0.1:$SERVICE_PORT/health/ready" >/dev/null 2>&1; then + if run_native "$CURL_BIN" -fsS "http://127.0.0.1:$SERVICE_PORT/health/ready" >/dev/null 2>&1; then rm -f "$ADMIN_PASSWORD_FILE" log_message "应用启动成功,PID $pid,端口 $SERVICE_PORT。" return 0 @@ -203,7 +233,7 @@ stop_app() { case "${1:-status}" in start) start_app ;; stop) stop_app ;; - restart) stop_app; start_app ;; + restart) stop_app && start_app ;; status) if app_pid >/dev/null && postgres_running; then exit 0 diff --git a/fnos/manifest b/fnos/manifest index 66f80e2..0ece77d 100644 --- a/fnos/manifest +++ b/fnos/manifest @@ -1,13 +1,12 @@ appname=liverecorder -version=1.0.0 +version=1.0.1 display_name=Live Recorder -desc=原生自包含直播录制系统,内置 PostgreSQL、FFmpeg、Node.js 和 Web 管理界面,支持分片录制、弹幕采集与 OpenList 自动上传 +desc=原生直播录制系统,离线内置 PostgreSQL、Node.js 和 Web 管理界面,支持分片录制、弹幕采集与 OpenList 自动上传 platform=x86 source=thirdparty maintainer=Live Recorder Contributors os_min_version=1.2.0 desktop_uidir=ui desktop_applaunchname=liverecorder.Application -checksum=@CHECKSUM@ checkport=true ctl_stop=true diff --git a/scripts/build-fnos-package.sh b/scripts/build-fnos-package.sh index c4429c1..62fc10e 100755 --- a/scripts/build-fnos-package.sh +++ b/scripts/build-fnos-package.sh @@ -2,7 +2,9 @@ set -euo pipefail ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) -VERSION=1.0.0 +VERSION=1.0.1 +NODE_VERSION=22.18.0 +NODE_ARCHIVE_SHA256=c1bfeecf1d7404fa74728f9db72e697decbd8119ccc6f5a294d795756dfcfca7 OUTPUT="${1:-$ROOT_DIR/artifacts/fnos/liverecorder-${VERSION}-x86_64.fpk}" WORKSPACE_CACHE=$(CDPATH= cd -- "$ROOT_DIR/.." && pwd) DOTNET_BIN="${DOTNET:-$WORKSPACE_CACHE/.dotnet8/dotnet}" @@ -10,13 +12,21 @@ NUGET_FEED="${LIVERECORDER_NUGET_FEED:-$WORKSPACE_CACHE/.nuget-feed}" NUGET_PACKAGES="${NUGET_PACKAGES:-$WORKSPACE_CACHE/.nuget-packages}" DOTNET_CLI_HOME="${DOTNET_CLI_HOME:-$WORKSPACE_CACHE/.dotnet-cli-home}" BUILD_TMP_ROOT="${LIVERECORDER_BUILD_TMPDIR:-$WORKSPACE_CACHE/.fnos-build-tmp}" -SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-$(git -C "$ROOT_DIR" show -s --format=%ct HEAD)}" -mkdir -p "$BUILD_TMP_ROOT" "$(dirname -- "$OUTPUT")" -WORK_DIR=$(mktemp -d "${BUILD_TMP_ROOT%/}/liverecorder-fnos-build.XXXXXX") -trap 'rm -rf -- "$WORK_DIR"' EXIT +if [ -n "${FNPACK:-}" ]; then + FNPACK_BIN="$FNPACK" +elif [ -x "$ROOT_DIR/.tools/fnpack" ]; then + FNPACK_BIN="$ROOT_DIR/.tools/fnpack" +else + FNPACK_BIN=fnpack +fi -for command_name in npm apt-get dpkg-deb tar md5sum sha256sum node; do +FNPACK_BIN=$(command -v "$FNPACK_BIN") || { + printf 'fnpack is required; install it from the fnOS developer portal or set FNPACK.\n' >&2 + exit 1 +} + +for command_name in npm apt-get curl dpkg-deb readelf realpath find install sha256sum tar xz node; do command -v "$command_name" >/dev/null 2>&1 || { printf 'required build command is missing: %s\n' "$command_name" >&2 exit 1 @@ -25,6 +35,21 @@ done test -x "$DOTNET_BIN" || { printf 'missing .NET SDK: %s\n' "$DOTNET_BIN" >&2; exit 1; } test -d "$NUGET_FEED" || { printf 'missing offline NuGet feed: %s\n' "$NUGET_FEED" >&2; exit 1; } +mkdir -p "$BUILD_TMP_ROOT" "$(dirname -- "$OUTPUT")" +WORK_DIR=$(mktemp -d "${BUILD_TMP_ROOT%/}/liverecorder-fnos-build.XXXXXX") +trap 'rm -rf -- "$WORK_DIR"' EXIT +STAGE="$WORK_DIR/stage" +PACKED_ROOT="$WORK_DIR/packed" +FNPACK_TMP_ROOT="$WORK_DIR/fnpack-tmp" +EXTRACT_ROOT="$WORK_DIR/debian-root" +RUNTIME_ROOT="$STAGE/app/runtime" + +mkdir -p "$STAGE/app/server" "$RUNTIME_ROOT/bin" "$RUNTIME_ROOT/lib" \ + "$RUNTIME_ROOT/usr/lib/postgresql/15/bin" "$RUNTIME_ROOT/usr/lib/postgresql/15/lib" \ + "$RUNTIME_ROOT/usr/share/postgresql/15" "$RUNTIME_ROOT/etc/ssl/certs" \ + "$PACKED_ROOT" "$FNPACK_TMP_ROOT" "$EXTRACT_ROOT" +cp -a "$ROOT_DIR/fnos/." "$STAGE/" + printf 'Building frontend...\n' npm run build --prefix "$ROOT_DIR/frontend" @@ -44,11 +69,11 @@ export NUGET_PACKAGES DOTNET_CLI_HOME -p:DebugSymbols=false \ -p:PublishSingleFile=false \ -p:PublishReadyToRun=false \ - -o "$WORK_DIR/payload/server" \ + -o "$STAGE/app/server" \ /maxcpucount:1 -rm -f "$WORK_DIR/payload/server/"*.pdb -mkdir -p "$WORK_DIR/payload/server/wwwroot" -cp -a "$ROOT_DIR/frontend/dist/." "$WORK_DIR/payload/server/wwwroot/" +rm -f "$STAGE/app/server/"*.pdb +mkdir -p "$STAGE/app/server/wwwroot" +cp -a "$ROOT_DIR/frontend/dist/." "$STAGE/app/server/wwwroot/" printf 'Downloading pinned Debian Bookworm native runtime packages...\n' APT_ROOT="$WORK_DIR/apt" @@ -65,6 +90,7 @@ APT_OPTIONS=( -o "Dir::Etc::sourceparts=-" -o "Dir::State::status=$APT_ROOT/var/lib/dpkg/status" -o "Dir::State::lists=$APT_ROOT/var/lib/apt/lists" + -o "Dir::Cache=$APT_ROOT/var/cache/apt" -o "Dir::Cache::archives=$APT_ROOT/var/cache/apt/archives" -o "Debug::NoLocking=1" -o "APT::Architecture=amd64" @@ -78,75 +104,160 @@ apt-get "${APT_OPTIONS[@]}" \ install \ postgresql-15 \ postgresql-client-15 \ - nodejs \ - ffmpeg \ curl \ ca-certificates -RUNTIME_ROOT="$WORK_DIR/payload/runtime" -mkdir -p "$RUNTIME_ROOT" shopt -s nullglob packages=("$APT_ROOT"/var/cache/apt/archives/*.deb) test "${#packages[@]}" -gt 0 || { printf 'APT did not download runtime packages\n' >&2; exit 1; } for package_file in "${packages[@]}"; do - case "$(basename -- "$package_file")" in - libc6_*|libc-bin_*) - # Native programs must use the fnOS glibc/loader as one matched - # pair. Bundling Debian's libc while an executable still starts - # through the host loader can crash before main() on newer fnOS - # releases. All other runtime libraries remain private to the app. - continue - ;; - esac - dpkg-deb -x "$package_file" "$RUNTIME_ROOT" + dpkg-deb -x "$package_file" "$EXTRACT_ROOT" done shopt -u nullglob -for forbidden_glibc_file in \ - "$RUNTIME_ROOT/lib/x86_64-linux-gnu/libc.so.6" \ - "$RUNTIME_ROOT/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2"; do - test ! -e "$forbidden_glibc_file" || { - printf 'host glibc must not be shadowed: %s\n' "$forbidden_glibc_file" >&2 - exit 1 - } +resolve_extracted_file() { + local current=$1 target normalized depth=0 + while [ -L "$current" ]; do + depth=$((depth + 1)) + [ "$depth" -le 32 ] || { printf 'too many symlink levels: %s\n' "$1" >&2; return 1; } + target=$(readlink -- "$current") + case "$target" in + /*) current="$EXTRACT_ROOT$target" ;; + *) current="$(dirname -- "$current")/$target" ;; + esac + normalized=$(realpath -m -- "$current") + case "$normalized" in + "$EXTRACT_ROOT"/*) current="$normalized" ;; + *) printf 'Debian package link escapes extraction root: %s\n' "$1" >&2; return 1 ;; + esac + done + [ -f "$current" ] || { printf 'missing extracted runtime file: %s\n' "$1" >&2; return 1; } + printf '%s\n' "$current" +} + +copy_extracted_file() { + local source resolved destination=$2 mode=${3:-0644} + source=$1 + resolved=$(resolve_extracted_file "$source") + mkdir -p "$(dirname -- "$destination")" + install -m "$mode" "$resolved" "$destination" +} + +copy_dereferenced_tree() { + local source_root=$1 destination_root=$2 relative source mode + [ -d "$source_root" ] || { printf 'missing extracted runtime directory: %s\n' "$source_root" >&2; return 1; } + while IFS= read -r -d '' relative; do + mkdir -p "$destination_root/${relative#./}" + done < <(cd "$source_root" && find . -type d -print0) + while IFS= read -r -d '' relative; do + source="$source_root/${relative#./}" + mode=0644 + [ -x "$source" ] && mode=0755 + copy_extracted_file "$source" "$destination_root/${relative#./}" "$mode" + done < <(cd "$source_root" && find . \( -type f -o -type l \) -print0) +} + +printf 'Assembling minimal relocatable runtime...\n' +for source in "$EXTRACT_ROOT/usr/lib/postgresql/15/bin/"*; do + [ -f "$source" ] || [ -L "$source" ] || continue + copy_extracted_file "$source" "$RUNTIME_ROOT/usr/lib/postgresql/15/bin/$(basename -- "$source")" 0755 +done +copy_dereferenced_tree \ + "$EXTRACT_ROOT/usr/share/postgresql/15" \ + "$RUNTIME_ROOT/usr/share/postgresql/15" + +for source in "$EXTRACT_ROOT/usr/lib/postgresql/15/lib/"*.so*; do + [ -f "$source" ] || [ -L "$source" ] || continue + copy_extracted_file "$source" "$RUNTIME_ROOT/usr/lib/postgresql/15/lib/$(basename -- "$source")" 0755 done -rm -rf \ - "$RUNTIME_ROOT/usr/share/doc" \ - "$RUNTIME_ROOT/usr/share/man" \ - "$RUNTIME_ROOT/usr/share/lintian" \ - "$RUNTIME_ROOT/usr/share/locale" -if [ ! -e "$RUNTIME_ROOT/usr/bin/node" ] && [ -x "$RUNTIME_ROOT/usr/bin/nodejs" ]; then - ln -s nodejs "$RUNTIME_ROOT/usr/bin/node" +NODE_ARCHIVE="$WORK_DIR/node-v${NODE_VERSION}-linux-x64.tar.xz" +NODE_DIST_ROOT="$WORK_DIR/node-dist" +printf 'Downloading pinned official Node.js %s runtime...\n' "$NODE_VERSION" +curl --fail --location --retry 3 \ + "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" \ + --output "$NODE_ARCHIVE" +printf '%s %s\n' "$NODE_ARCHIVE_SHA256" "$NODE_ARCHIVE" | sha256sum --check --status +mkdir -p "$NODE_DIST_ROOT" +tar -xJf "$NODE_ARCHIVE" -C "$NODE_DIST_ROOT" --strip-components=1 +install -m 0755 "$NODE_DIST_ROOT/bin/node" "$RUNTIME_ROOT/bin/node" +copy_extracted_file "$EXTRACT_ROOT/usr/bin/curl" "$RUNTIME_ROOT/bin/curl" 0755 + +CA_CONFIG="$EXTRACT_ROOT/etc/ca-certificates.conf" +CA_SOURCE_ROOT="$EXTRACT_ROOT/usr/share/ca-certificates" +CA_BUNDLE="$RUNTIME_ROOT/etc/ssl/certs/ca-certificates.crt" +: >"$CA_BUNDLE" +if [ -s "$CA_CONFIG" ]; then + while IFS= read -r certificate; do + case "$certificate" in + ''|'#'*|'!'*) continue ;; + esac + [ -f "$CA_SOURCE_ROOT/$certificate" ] || continue + sed -e '$a\' "$CA_SOURCE_ROOT/$certificate" >>"$CA_BUNDLE" + done <"$CA_CONFIG" +else + while IFS= read -r -d '' certificate; do + sed -e '$a\' "$certificate" >>"$CA_BUNDLE" + done < <(find "$CA_SOURCE_ROOT" -type f -name '*.crt' -print0 | sort -z) fi +[ -s "$CA_BUNDLE" ] || { printf 'unable to assemble CA certificate bundle\n' >&2; exit 1; } +chmod 0644 "$CA_BUNDLE" + +declare -A COPIED_LIBRARIES=() +ELF_QUEUE=() +while IFS= read -r -d '' elf_file; do + if readelf -h "$elf_file" >/dev/null 2>&1; then + ELF_QUEUE+=("$elf_file") + fi +done < <(find "$RUNTIME_ROOT/bin" "$RUNTIME_ROOT/usr/lib/postgresql/15/bin" "$RUNTIME_ROOT/usr/lib/postgresql/15/lib" -type f -print0) + +is_system_glibc_library() { + case "$1" in + ld-linux-*.so.*|libc.so.*|libBrokenLocale.so.*|libanl.so.*|libdl.so.*|libm.so.*|libmvec.so.*|libnss_compat.so.*|libnss_dns.so.*|libnss_files.so.*|libnss_hesiod.so.*|libpthread.so.*|libresolv.so.*|librt.so.*|libthread_db.so.*|libutil.so.*) + return 0 + ;; + *) return 1 ;; + esac +} + +queue_index=0 +while [ "$queue_index" -lt "${#ELF_QUEUE[@]}" ]; do + elf_file=${ELF_QUEUE[$queue_index]} + queue_index=$((queue_index + 1)) + while IFS= read -r needed; do + [ -n "$needed" ] || continue + is_system_glibc_library "$needed" && continue + [ -z "${COPIED_LIBRARIES[$needed]:-}" ] || continue + source=$(find "$EXTRACT_ROOT" \( -type f -o -type l \) -name "$needed" -print -quit) + [ -n "$source" ] || { + printf 'unable to resolve native dependency %s required by %s\n' "$needed" "$elf_file" >&2 + exit 1 + } + destination="$RUNTIME_ROOT/lib/$needed" + copy_extracted_file "$source" "$destination" 0755 + COPIED_LIBRARIES[$needed]=1 + ELF_QUEUE+=("$destination") + done < <(readelf -d "$elf_file" 2>/dev/null | sed -n 's/.*Shared library: \[\([^]]*\)\].*/\1/p') +done for required_file in \ "$RUNTIME_ROOT/usr/lib/postgresql/15/bin/postgres" \ "$RUNTIME_ROOT/usr/lib/postgresql/15/bin/initdb" \ - "$RUNTIME_ROOT/usr/bin/node" \ - "$RUNTIME_ROOT/usr/bin/ffmpeg" \ - "$RUNTIME_ROOT/usr/bin/curl"; do + "$RUNTIME_ROOT/usr/lib/postgresql/15/bin/pg_ctl" \ + "$RUNTIME_ROOT/bin/node" \ + "$RUNTIME_ROOT/bin/curl"; do test -x "$required_file" || { printf 'native runtime file is missing: %s\n' "$required_file" >&2; exit 1; } done -mkdir -p "$WORK_DIR/payload/ui" "$WORK_DIR/package" -cp "$ROOT_DIR/fnos/ui/config" "$WORK_DIR/payload/ui/config" -node "$ROOT_DIR/scripts/generate-fnos-icons.mjs" "$WORK_DIR/package" "$WORK_DIR/payload/ui/images" +node "$ROOT_DIR/scripts/generate-fnos-icons.mjs" "$STAGE" "$STAGE/app/ui/images" +chmod 0755 "$STAGE/cmd/"* -printf 'Packing fnOS payload...\n' -tar --sort=name --mtime="@$SOURCE_DATE_EPOCH" --owner=0 --group=0 --numeric-owner \ - -czf "$WORK_DIR/package/app.tgz" \ - -C "$WORK_DIR/payload" \ - server runtime ui -cp -a "$ROOT_DIR/fnos/cmd" "$ROOT_DIR/fnos/config" "$ROOT_DIR/fnos/wizard" "$WORK_DIR/package/" -chmod 0755 "$WORK_DIR/package/cmd/"* -checksum=$(md5sum "$WORK_DIR/package/app.tgz" | cut -d' ' -f1) -sed "s/@CHECKSUM@/$checksum/" "$ROOT_DIR/fnos/manifest" >"$WORK_DIR/package/manifest" - -tar --sort=name --mtime="@$SOURCE_DATE_EPOCH" --owner=0 --group=0 --numeric-owner \ - -czf "$OUTPUT" \ - -C "$WORK_DIR/package" \ - app.tgz cmd config wizard ICON.PNG ICON_256.PNG manifest +printf 'Packing with official fnOS fnpack...\n' +( + cd "$PACKED_ROOT" + TMPDIR="$FNPACK_TMP_ROOT" "$FNPACK_BIN" build --directory "$STAGE" +) +mv "$PACKED_ROOT/liverecorder.fpk" "$OUTPUT" ( cd "$(dirname -- "$OUTPUT")" sha256sum "$(basename -- "$OUTPUT")" >"$(basename -- "$OUTPUT").sha256" diff --git a/scripts/smoke-fnos-package.sh b/scripts/smoke-fnos-package.sh index f216188..b5b8038 100755 --- a/scripts/smoke-fnos-package.sh +++ b/scripts/smoke-fnos-package.sh @@ -6,20 +6,25 @@ SMOKE_TMP_ROOT="${2:-${LIVERECORDER_SMOKE_TMPDIR:-${TMPDIR:-/tmp}}}" mkdir -p "$SMOKE_TMP_ROOT" SMOKE_TMP_ROOT=$(CDPATH= cd -- "$SMOKE_TMP_ROOT" && pwd) WORK_DIR=$(mktemp -d "${SMOKE_TMP_ROOT%/}/liverecorder-fnos-smoke.XXXXXX") +PACKAGE_ROOT="$WORK_DIR/package" +APP_ROOT="$WORK_DIR/app" +DATA_ROOT="$WORK_DIR/var" +VOLUME_ROOT="$WORK_DIR/volume" PORT=${LIVERECORDER_SMOKE_PORT:-19180} +CONTROL="$PACKAGE_ROOT/cmd/main" cleanup() { status=$? - if [ -x "$WORK_DIR/cmd/main" ]; then - TRIM_APPDEST="$WORK_DIR" \ - TRIM_PKGVAR="$WORK_DIR/var" \ - TRIM_APPDEST_VOL="$WORK_DIR/volume" \ + if [ -x "$CONTROL" ]; then + TRIM_APPDEST="$APP_ROOT" \ + TRIM_PKGVAR="$DATA_ROOT" \ + TRIM_APPDEST_VOL="$VOLUME_ROOT" \ TRIM_SERVICE_PORT="$PORT" \ - "$WORK_DIR/cmd/main" stop >/dev/null 2>&1 || true + "$CONTROL" stop >/dev/null 2>&1 || true fi if [ "$status" -ne 0 ]; then printf '%s\n' 'fnOS smoke test failed; application logs follow:' >&2 - for log_file in "$WORK_DIR/var/log/postgresql.log" "$WORK_DIR/var/log/liverecorder.log"; do + for log_file in "$DATA_ROOT/log/postgresql.log" "$DATA_ROOT/log/liverecorder.log"; do if [ -f "$log_file" ]; then printf '%s\n' "--- $log_file ---" >&2 tail -n 120 "$log_file" >&2 || true @@ -31,21 +36,24 @@ cleanup() { } trap cleanup EXIT HUP INT TERM -tar -xzf "$PACKAGE" -C "$WORK_DIR" -tar -xzf "$WORK_DIR/app.tgz" -C "$WORK_DIR" -mkdir -p "$WORK_DIR/volume" +command -v ffmpeg >/dev/null 2>&1 || { printf 'system ffmpeg is required for the fnOS smoke test\n' >&2; exit 1; } +command -v ffprobe >/dev/null 2>&1 || { printf 'system ffprobe is required for the fnOS smoke test\n' >&2; exit 1; } -export TRIM_APPDEST="$WORK_DIR" -export TRIM_PKGVAR="$WORK_DIR/var" -export TRIM_APPDEST_VOL="$WORK_DIR/volume" +mkdir -p "$PACKAGE_ROOT" "$APP_ROOT" "$VOLUME_ROOT" +tar -xzf "$PACKAGE" -C "$PACKAGE_ROOT" +tar -xzf "$PACKAGE_ROOT/app.tgz" -C "$APP_ROOT" + +export TRIM_APPDEST="$APP_ROOT" +export TRIM_PKGVAR="$DATA_ROOT" +export TRIM_APPDEST_VOL="$VOLUME_ROOT" export TRIM_SERVICE_PORT="$PORT" SMOKE_PASSWORD='LiveRecorder-Smoke-2026!' wizard_admin_password="$SMOKE_PASSWORD" \ wizard_admin_password_confirm="$SMOKE_PASSWORD" \ - "$WORK_DIR/cmd/install_callback" -"$WORK_DIR/cmd/main" start -"$WORK_DIR/cmd/main" status + "$PACKAGE_ROOT/cmd/install_callback" +"$CONTROL" start +"$CONTROL" status BASE_URL="http://127.0.0.1:$PORT" curl -fsS "$BASE_URL/" >"$WORK_DIR/index.html" @@ -60,16 +68,28 @@ token=$(printf '%s' "$login_response" | sed -n 's/.*"token":"\([^"]*\)".*/\1/p') test -n "$token" settings_response=$(curl -fsS -H "Authorization: Bearer $token" "$BASE_URL/api/settings") -expected_record_root="$WORK_DIR/volume/@appshare/liverecorder/records" +expected_record_root="$VOLUME_ROOT/@appshare/liverecorder/records" printf '%s' "$settings_response" | grep -Fq "\"outputRoot\":\"$expected_record_root\"" -runtime_libs="$WORK_DIR/runtime/lib/x86_64-linux-gnu:$WORK_DIR/runtime/usr/lib/x86_64-linux-gnu:$WORK_DIR/runtime/usr/lib/x86_64-linux-gnu/pulseaudio:$WORK_DIR/runtime/usr/lib/x86_64-linux-gnu/blas:$WORK_DIR/runtime/usr/lib/x86_64-linux-gnu/lapack:$WORK_DIR/runtime/usr/lib/postgresql/15/lib" -LD_LIBRARY_PATH="$runtime_libs" "$WORK_DIR/runtime/usr/bin/node" --version >/dev/null -LD_LIBRARY_PATH="$runtime_libs" "$WORK_DIR/runtime/usr/bin/ffmpeg" -version >/dev/null 2>&1 -LD_LIBRARY_PATH="$runtime_libs" "$WORK_DIR/runtime/usr/bin/curl" -fsS "$BASE_URL/health/ready" >/dev/null +runtime_libs="$APP_ROOT/runtime/lib:$APP_ROOT/runtime/usr/lib/postgresql/15/lib" +ca_bundle="$APP_ROOT/runtime/etc/ssl/certs/ca-certificates.crt" +node_bin="$APP_ROOT/runtime/bin/node" +curl_bin="$APP_ROOT/runtime/bin/curl" +signer="$APP_ROOT/server/Platforms/Douyin/Signing/sign-xbogus.js" -"$WORK_DIR/cmd/main" stop -"$WORK_DIR/cmd/main" start +LD_LIBRARY_PATH="$runtime_libs" "$node_bin" --version | grep -q '^v22\.18\.0$' +signature=$(LD_LIBRARY_PATH="$runtime_libs" "$node_bin" "$signer" \ + 'aid=6383&device_platform=web&room_id=1' \ + 'Mozilla/5.0 LiveRecorder fnOS package smoke test') +test -n "$signature" +LD_LIBRARY_PATH="$runtime_libs" SSL_CERT_FILE="$ca_bundle" CURL_CA_BUNDLE="$ca_bundle" \ + "$curl_bin" -fsS "$BASE_URL/health/ready" >/dev/null +ffmpeg -version >/dev/null 2>&1 +ffprobe -version >/dev/null 2>&1 + +"$CONTROL" stop +"$CONTROL" start +"$CONTROL" status curl -fsS "$BASE_URL/health/ready" | grep -q '"status":"ready"' -printf 'fnOS native smoke test passed: frontend, API, PostgreSQL, Node.js and FFmpeg are ready\n' +printf 'fnOS native smoke test passed: frontend, API, PostgreSQL, packaged Node/curl and system FFmpeg/FFprobe are ready\n' diff --git a/scripts/verify-fnos-package.sh b/scripts/verify-fnos-package.sh index 39a3891..fe6d7ff 100755 --- a/scripts/verify-fnos-package.sh +++ b/scripts/verify-fnos-package.sh @@ -3,14 +3,19 @@ set -euo pipefail PACKAGE=${1:?usage: verify-fnos-package.sh package.fpk} VERIFY_TMP_ROOT="${LIVERECORDER_VERIFY_TMPDIR:-${TMPDIR:-/tmp}}" +MAX_APP_UNCOMPRESSED_BYTES=$((512 * 1024 * 1024)) mkdir -p "$VERIFY_TMP_ROOT" WORK_DIR=$(mktemp -d "${VERIFY_TMP_ROOT%/}/liverecorder-fnos-verify.XXXXXX") trap 'rm -rf -- "$WORK_DIR"' EXIT +manifest_value() { + sed -n "s/^$1[[:space:]]*=[[:space:]]*//p" "$WORK_DIR/manifest" | head -n 1 | tr -d '\r' +} + tar -xzf "$PACKAGE" -C "$WORK_DIR" -grep -q '^appname=liverecorder$' "$WORK_DIR/manifest" -grep -q '^version=1.0.0$' "$WORK_DIR/manifest" -grep -q '^platform=x86$' "$WORK_DIR/manifest" +test "$(manifest_value appname)" = "liverecorder" +test "$(manifest_value version)" = "1.0.1" +test "$(manifest_value platform)" = "x86" test -x "$WORK_DIR/cmd/main" test -x "$WORK_DIR/cmd/install_callback" test -s "$WORK_DIR/wizard/install" @@ -18,23 +23,69 @@ test -s "$WORK_DIR/wizard/upgrade" test -s "$WORK_DIR/ICON.PNG" test -s "$WORK_DIR/ICON_256.PNG" -expected=$(sed -n 's/^checksum=//p' "$WORK_DIR/manifest") +expected=$(manifest_value checksum) actual=$(md5sum "$WORK_DIR/app.tgz" | cut -d' ' -f1) test -n "$expected" test "$expected" = "$actual" +gzip -t "$WORK_DIR/app.tgz" + +gzip -dc "$WORK_DIR/app.tgz" >"$WORK_DIR/app.tar" +uncompressed_bytes=$(wc -c <"$WORK_DIR/app.tar") +if [ "$uncompressed_bytes" -gt "$MAX_APP_UNCOMPRESSED_BYTES" ]; then + printf 'app.tgz expands to %s bytes; limit is %s bytes\n' \ + "$uncompressed_bytes" "$MAX_APP_UNCOMPRESSED_BYTES" >&2 + exit 1 +fi + +tar -tf "$WORK_DIR/app.tar" >"$WORK_DIR/app-files.txt" +if awk '/^\// || /(^|\/)\.\.($|\/)/ { unsafe = 1; exit } END { exit unsafe ? 0 : 1 }' "$WORK_DIR/app-files.txt"; then + printf 'app.tgz contains an unsafe member path\n' >&2 + exit 1 +fi + +tar -tvf "$WORK_DIR/app.tar" >"$WORK_DIR/app-metadata.txt" +if awk ' + /^l/ { + marker = " -> " + offset = index($0, marker) + if (offset > 0) { + target = substr($0, offset + length(marker)) + if (target ~ /^\// || target ~ /(^|\/)\.\.($|\/)/) { unsafe = 1; exit } + } + } + /^h/ { + marker = " link to " + offset = index($0, marker) + if (offset > 0) { + target = substr($0, offset + length(marker)) + if (target ~ /^\// || target ~ /(^|\/)\.\.($|\/)/) { unsafe = 1; exit } + } + } + END { exit unsafe ? 0 : 1 } +' "$WORK_DIR/app-metadata.txt"; then + printf 'app.tgz contains an unsafe symbolic or hard link\n' >&2 + exit 1 +fi -tar -tzf "$WORK_DIR/app.tgz" >"$WORK_DIR/app-files.txt" grep -q '^server/LiveRecorder.WebApi$' "$WORK_DIR/app-files.txt" grep -q '^server/wwwroot/index.html$' "$WORK_DIR/app-files.txt" +grep -q '^server/Platforms/Douyin/Signing/sign-xbogus.js$' "$WORK_DIR/app-files.txt" grep -q '^runtime/usr/lib/postgresql/15/bin/postgres$' "$WORK_DIR/app-files.txt" grep -q '^runtime/usr/lib/postgresql/15/bin/initdb$' "$WORK_DIR/app-files.txt" -grep -q '^runtime/usr/bin/ffmpeg$' "$WORK_DIR/app-files.txt" -grep -q '^runtime/usr/bin/node$' "$WORK_DIR/app-files.txt" -grep -q '^runtime/usr/bin/curl$' "$WORK_DIR/app-files.txt" +grep -q '^runtime/usr/lib/postgresql/15/bin/pg_ctl$' "$WORK_DIR/app-files.txt" +grep -q '^runtime/usr/share/postgresql/15/postgresql.conf.sample$' "$WORK_DIR/app-files.txt" +grep -q '^runtime/bin/node$' "$WORK_DIR/app-files.txt" +grep -q '^runtime/bin/curl$' "$WORK_DIR/app-files.txt" +grep -q '^runtime/etc/ssl/certs/ca-certificates.crt$' "$WORK_DIR/app-files.txt" grep -q '^ui/config$' "$WORK_DIR/app-files.txt" grep -q '^ui/images/icon_64.png$' "$WORK_DIR/app-files.txt" -if grep -Eq '^runtime/(usr/)?lib/x86_64-linux-gnu/(libc\.so\.6|ld-linux-x86-64\.so\.2)$' "$WORK_DIR/app-files.txt"; then +if grep -Eq '^runtime/.*/(ffmpeg|ffprobe)$' "$WORK_DIR/app-files.txt"; then + printf 'ffmpeg and ffprobe must come from the fnOS system environment\n' >&2 + exit 1 +fi + +if grep -Eq '^runtime/lib/(ld-linux-.*|libc\.so\..*|libBrokenLocale\.so\..*|libanl\.so\..*|libdl\.so\..*|libm(vec)?\.so\..*|libnss_(compat|dns|files|hesiod)\.so\..*|libpthread\.so\..*|libresolv\.so\..*|librt\.so\..*|libthread_db\.so\..*|libutil\.so\..*)$' "$WORK_DIR/app-files.txt"; then printf 'the FPK must use the fnOS glibc and matching system loader\n' >&2 exit 1 fi @@ -44,4 +95,18 @@ if grep -Eq '^(data|records|postgres|log|var)/' "$WORK_DIR/app-files.txt"; then exit 1 fi -printf 'fnOS package verified: %s\n' "$PACKAGE" +mkdir -p "$WORK_DIR/app" +tar -xf "$WORK_DIR/app.tar" -C "$WORK_DIR/app" +while IFS= read -r -d '' link_path; do + target=$(readlink -- "$link_path") + case "$target" in + /*) printf 'absolute symbolic link in app.tgz: %s -> %s\n' "$link_path" "$target" >&2; exit 1 ;; + esac + resolved=$(realpath -m -- "$(dirname -- "$link_path")/$target") + case "$resolved" in + "$WORK_DIR/app"/*) ;; + *) printf 'escaping symbolic link in app.tgz: %s -> %s\n' "$link_path" "$target" >&2; exit 1 ;; + esac +done < <(find "$WORK_DIR/app" -type l -print0) + +printf 'fnOS package verified: %s (%s bytes uncompressed)\n' "$PACKAGE" "$uncompressed_bytes"