diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c0785266..948ae1bd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,7 +99,13 @@ jobs: - name: Install media privacy tools run: | sudo apt-get update - sudo apt-get install -y --no-install-recommends ffmpeg libimage-exiftool-perl + sudo apt-get install -y --no-install-recommends \ + build-essential ffmpeg libimage-exiftool-perl libopenh264-dev \ + nasm pkg-config xz-utils zlib1g-dev + FFMPEG_PREFIX="$RUNNER_TEMP/buzz-ffmpeg" ./scripts/build-ffmpeg-lgpl.sh + echo "BUZZ_FFMPEG_PATH=$RUNNER_TEMP/buzz-ffmpeg/bin/ffmpeg" >> "$GITHUB_ENV" + echo "BUZZ_FFPROBE_PATH=$RUNNER_TEMP/buzz-ffmpeg/bin/ffprobe" >> "$GITHUB_ENV" + echo "BUZZ_FIXTURE_FFMPEG_PATH=/usr/bin/ffmpeg" >> "$GITHUB_ENV" - name: Unit tests run: just test-unit - name: Media compliance tests diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 855bb72a9..136150bc2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,7 +44,7 @@ unacceptable behavior to **conduct@buzz-relay.org**. | pnpm | 10+ | Required for desktop app commands and `just ci` | | Flutter | 3.41+ | Required for mobile app — install via [flutter.dev](https://docs.flutter.dev/get-started/install) | | Docker | 24+ | For Postgres, Redis, MinIO | -| FFmpeg + ffprobe | 6+ | Media sanitization; pinned by Hermit | +| FFmpeg + ffprobe | 6+ | Media sanitization; install with your OS package manager | | ExifTool | 12.70+ | Image metadata removal and verification; install with your OS package manager | | `just` | latest | Task runner — `cargo install just` | | `lefthook` | latest | Optional; run `lefthook install` for local Git hooks | @@ -92,11 +92,15 @@ Adminer on `:8082`, Keycloak on `:8180` for local OAuth/OIDC testing, MinIO on pending database migrations. The relay validates its privacy toolchain during startup and fails closed when -FFmpeg, ffprobe, ExifTool, or a required codec is unavailable. Hermit supplies -FFmpeg and ffprobe. Install ExifTool separately (for example, -`brew install exiftool` on macOS or `apt install libimage-exiftool-perl` on -Debian/Ubuntu). Override binary locations with `BUZZ_EXIFTOOL_PATH`, -`BUZZ_FFMPEG_PATH`, and `BUZZ_FFPROBE_PATH` when needed. +FFmpeg, ffprobe, ExifTool, or a required codec is unavailable. Install the +media tools separately for local development (for example, +`brew install ffmpeg exiftool` on macOS or +`apt install ffmpeg libimage-exiftool-perl` on Debian/Ubuntu). Buzz rejects +FFmpeg builds configured with `--enable-nonfree`. The published relay image +instead builds an LGPL FFmpeg/ffprobe toolchain with BSD-licensed OpenH264 from +source and includes the corresponding source archive and build configuration. +Override binary locations with `BUZZ_EXIFTOOL_PATH`, `BUZZ_FFMPEG_PATH`, and +`BUZZ_FFPROBE_PATH` when needed. ### Running the Relay and Desktop App diff --git a/Dockerfile b/Dockerfile index ac62d72d5..d4f845491 100644 --- a/Dockerfile +++ b/Dockerfile @@ -109,7 +109,31 @@ RUN pnpm install --frozen-lockfile --filter buzz-web COPY web/ web/ RUN pnpm -C web build -# ─── Stage 5: runtime ─────────────────────────────────────────────────────── +# ─── Stage 5: LGPL media toolchain ───────────────────────────────────────── +# Debian's ffmpeg package enables GPL components such as libx264. Build the two +# standalone tools used by the relay from upstream source instead, with GPL and +# non-redistributable components disabled. OpenH264 is BSD-licensed and keeps +# the relay's canonical H.264/AAC MP4 output without pulling libx264 into the +# published image. The exact corresponding source and build configuration are +# retained under /opt/ffmpeg/share/source in the runtime image. +FROM debian:${DEBIAN_VERSION}-slim AS media-tools-builder + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + ca-certificates \ + curl \ + libopenh264-dev \ + nasm \ + pkg-config \ + xz-utils \ + zlib1g-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY scripts/build-ffmpeg-lgpl.sh /usr/local/bin/build-ffmpeg-lgpl +RUN /usr/local/bin/build-ffmpeg-lgpl + +# ─── Stage 6: runtime ─────────────────────────────────────────────────────── FROM debian:${DEBIAN_VERSION}-slim AS runtime # OCI annotations: required for GHCR to auto-link the image to this repo and @@ -126,9 +150,9 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends \ ca-certificates \ curl \ - ffmpeg \ git \ libimage-exiftool-perl \ + libopenh264-7 \ openssl \ && rm -rf /var/lib/apt/lists/* \ && groupadd --system --gid 1000 buzz \ @@ -139,6 +163,9 @@ COPY --from=builder /build/target/release/buzz-relay /usr/local/bin/buzz-rela COPY --from=builder /build/target/release/buzz-admin /usr/local/bin/buzz-admin COPY --from=builder /build/target/release/buzz-pair-relay /usr/local/bin/buzz-pair-relay COPY --from=web-builder /build/web/dist /srv/buzz/web +COPY --from=media-tools-builder /opt/ffmpeg /opt/ffmpeg + +ENV PATH="/opt/ffmpeg/bin:${PATH}" # The invite landing page is always served from the bundled web UI. Repository # browser routes require the separate BUZZ_SERVE_GIT_WEB_GUI=true opt-in. diff --git a/bin/.ffmpeg-6.1.1.pkg b/bin/.ffmpeg-6.1.1.pkg deleted file mode 120000 index 383f4511d..000000000 --- a/bin/.ffmpeg-6.1.1.pkg +++ /dev/null @@ -1 +0,0 @@ -hermit \ No newline at end of file diff --git a/bin/.ffprobe-6.1.1.pkg b/bin/.ffprobe-6.1.1.pkg deleted file mode 120000 index 383f4511d..000000000 --- a/bin/.ffprobe-6.1.1.pkg +++ /dev/null @@ -1 +0,0 @@ -hermit \ No newline at end of file diff --git a/bin/ffmpeg b/bin/ffmpeg deleted file mode 120000 index 4f2f621a9..000000000 --- a/bin/ffmpeg +++ /dev/null @@ -1 +0,0 @@ -.ffmpeg-6.1.1.pkg \ No newline at end of file diff --git a/bin/ffprobe b/bin/ffprobe deleted file mode 120000 index dfdf7c92b..000000000 --- a/bin/ffprobe +++ /dev/null @@ -1 +0,0 @@ -.ffprobe-6.1.1.pkg \ No newline at end of file diff --git a/crates/buzz-media/src/sanitize.rs b/crates/buzz-media/src/sanitize.rs index e0d79ec77..7e0f11916 100644 --- a/crates/buzz-media/src/sanitize.rs +++ b/crates/buzz-media/src/sanitize.rs @@ -36,6 +36,9 @@ pub struct ToolVersions { pub ffmpeg: String, /// Full first version line reported by ffprobe. pub ffprobe: String, + /// H.264 encoder selected from the verified FFmpeg capabilities. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub video_encoder: Option, } /// Return the startup-verified sanitizer versions for private audit records. @@ -89,6 +92,8 @@ pub async fn validate_toolchain(config: &MediaConfig) -> Result<(), MediaError> let exiftool = successful_version(&config.exiftool_path, "-ver").await?; let ffmpeg = successful_version(&config.ffmpeg_path, "-version").await?; let ffprobe = successful_version(&config.ffprobe_path, "-version").await?; + reject_nonredistributable_build(&config.ffmpeg_path).await?; + reject_nonredistributable_build(&config.ffprobe_path).await?; let encoders = run_tool( &config.ffmpeg_path, &["-hide_banner", "-encoders"], @@ -96,14 +101,8 @@ pub async fn validate_toolchain(config: &MediaConfig) -> Result<(), MediaError> ) .await?; let encoders = String::from_utf8_lossy(&encoders.stdout); - for required in [ - "libx264", - "aac", - "libaom-av1", - "libvpx-vp9", - "libopus", - "libvorbis", - ] { + let video_encoder = select_video_encoder(&encoders).ok_or(MediaError::ToolUnavailable)?; + for required in ["aac"] { if !encoders.contains(required) { return Err(MediaError::ToolUnavailable); } @@ -136,10 +135,50 @@ pub async fn validate_toolchain(config: &MediaConfig) -> Result<(), MediaError> exiftool, ffmpeg, ffprobe, + video_encoder: Some(video_encoder.to_string()), }); Ok(()) } +async fn reject_nonredistributable_build(program: &str) -> Result<(), MediaError> { + let output = run_tool( + program, + &["-hide_banner", "-buildconf"], + Duration::from_secs(15), + ) + .await?; + if !output.status.success() { + return Err(MediaError::ToolUnavailable); + } + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + if build_is_nonredistributable(&stdout) || build_is_nonredistributable(&stderr) { + return Err(MediaError::ToolUnavailable); + } + Ok(()) +} + +fn build_is_nonredistributable(configuration: &str) -> bool { + configuration + .split_ascii_whitespace() + .any(|argument| argument == "--enable-nonfree") +} + +fn select_video_encoder(encoders: &str) -> Option<&'static str> { + ["libopenh264", "libx264"].into_iter().find(|encoder| { + encoders + .split_ascii_whitespace() + .any(|word| word == *encoder) + }) +} + +fn video_encoder() -> Result<&'static str, MediaError> { + TOOL_VERSIONS + .get() + .and_then(|versions| versions.video_encoder.as_deref()) + .ok_or(MediaError::ToolUnavailable) +} + async fn successful_version(program: &str, arg: &str) -> Result { let output = run_tool(program, &[arg], Duration::from_secs(15)).await?; if !output.status.success() { @@ -467,9 +506,25 @@ async fn sanitize_video( if can_copy { args.extend(strings(&["-c:v", "copy", "-c:a", "copy"])); } else { - args.extend(strings(&[ - "-c:v", "libx264", "-preset", "medium", "-crf", "20", "-c:a", "aac", "-b:a", "192k", - ])); + match video_encoder()? { + "libopenh264" => args.extend(strings(&[ + "-c:v", + "libopenh264", + "-b:v", + "4M", + "-maxrate", + "4M", + "-bufsize", + "8M", + "-pix_fmt", + "yuv420p", + ])), + "libx264" => args.extend(strings(&[ + "-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p", + ])), + _ => return Err(MediaError::ToolUnavailable), + } + args.extend(strings(&["-c:a", "aac", "-b:a", "192k"])); } args.extend(strings(&[ "-movflags", @@ -1036,6 +1091,27 @@ mod tests { assert!(!is_structural_tag("title")); } + #[test] + fn nonredistributable_ffmpeg_builds_are_rejected() { + assert!(build_is_nonredistributable( + "configuration: --enable-gpl --enable-nonfree --enable-libx264" + )); + assert!(!build_is_nonredistributable( + "configuration: --disable-autodetect --enable-libopenh264 --enable-shared" + )); + } + + #[test] + fn openh264_is_preferred_without_requiring_it_from_operators() { + let both = " V....D libx264 H.264 / AVC\n V....D libopenh264 OpenH264 H.264"; + assert_eq!(select_video_encoder(both), Some("libopenh264")); + assert_eq!( + select_video_encoder(" V....D libx264 H.264 / AVC"), + Some("libx264") + ); + assert_eq!(select_video_encoder(" A..... aac AAC"), None); + } + #[test] fn video_and_animation_limits_fail_closed() { let mut probe = MediaProbe { diff --git a/crates/buzz-media/tests/fixtures/README.md b/crates/buzz-media/tests/fixtures/README.md index 307a1e01d..42ed2ceed 100644 --- a/crates/buzz-media/tests/fixtures/README.md +++ b/crates/buzz-media/tests/fixtures/README.md @@ -1,9 +1,11 @@ # Media compliance fixtures The compliance test builds deterministic, synthetic fixtures at runtime so -codec/container coverage follows the exact FFmpeg and ExifTool binaries shipped -with the relay. Coordinates are fictional test data (`41.8781, -87.6298`) and -no fixture contains a real person or device identifier. +codec/container coverage follows the configured FFmpeg and ExifTool binaries. +The published relay image builds its FFmpeg/ffprobe executables from the pinned +source and configuration in `scripts/build-ffmpeg-lgpl.sh`. Coordinates are +fictional test data (`41.8781, -87.6298`) and no fixture contains a real person +or device identifier. `tiny.heic.b64` is the sole prebuilt input because FFmpeg does not provide a portable HEIC muxer. It is a 64×64 synthetic application icon converted by diff --git a/crates/buzz-media/tests/media_compliance.rs b/crates/buzz-media/tests/media_compliance.rs index c7a8a0bad..817e3b9f5 100644 --- a/crates/buzz-media/tests/media_compliance.rs +++ b/crates/buzz-media/tests/media_compliance.rs @@ -1,4 +1,4 @@ -use std::path::{Path, PathBuf}; +use std::path::Path; use std::process::Command; use base64::Engine; @@ -7,15 +7,6 @@ use buzz_media::MediaConfig; use sha2::{Digest, Sha256}; fn config() -> MediaConfig { - let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); - let hermit = |name: &str| { - let path = root.join("bin").join(name); - if path.exists() { - path.to_string_lossy().into_owned() - } else { - name.to_string() - } - }; MediaConfig { s3_endpoint: String::new(), s3_access_key: String::new(), @@ -30,8 +21,8 @@ fn config() -> MediaConfig { public_base_url: "http://localhost:3000/media".to_string(), exiftool_path: std::env::var("BUZZ_EXIFTOOL_PATH") .unwrap_or_else(|_| "exiftool".to_string()), - ffmpeg_path: std::env::var("BUZZ_FFMPEG_PATH").unwrap_or_else(|_| hermit("ffmpeg")), - ffprobe_path: std::env::var("BUZZ_FFPROBE_PATH").unwrap_or_else(|_| hermit("ffprobe")), + ffmpeg_path: std::env::var("BUZZ_FFMPEG_PATH").unwrap_or_else(|_| "ffmpeg".to_string()), + ffprobe_path: std::env::var("BUZZ_FFPROBE_PATH").unwrap_or_else(|_| "ffprobe".to_string()), image_process_timeout_secs: 120, av_process_timeout_secs: 600, upload_records_enabled: false, @@ -59,7 +50,9 @@ fn path(path: &Path) -> &str { fn ffmpeg(config: &MediaConfig, args: &[&str]) { let mut full = vec!["-nostdin", "-v", "error", "-y"]; full.extend_from_slice(args); - run(&config.ffmpeg_path, &full); + let generator = + std::env::var("BUZZ_FIXTURE_FFMPEG_PATH").unwrap_or_else(|_| config.ffmpeg_path.clone()); + run(&generator, &full); } fn add_private_metadata(config: &MediaConfig, fixture: &Path) { diff --git a/scripts/build-ffmpeg-lgpl.sh b/scripts/build-ffmpeg-lgpl.sh new file mode 100755 index 000000000..4b638295a --- /dev/null +++ b/scripts/build-ffmpeg-lgpl.sh @@ -0,0 +1,58 @@ +#!/bin/sh + +# Build the relay's FFmpeg/ffprobe toolchain without GPL or non-redistributable +# components. The only external codec enabled here is BSD-licensed OpenH264; +# all other media support comes from FFmpeg's LGPL implementation. + +set -eu + +: "${FFMPEG_VERSION:=7.1.5}" +: "${FFMPEG_SHA256:=de668509caf9e35e3cd162473441fdb29538c6d96ed080292b3cf9e6fc5d558f}" +: "${FFMPEG_PREFIX:=/opt/ffmpeg}" + +archive="/tmp/ffmpeg-${FFMPEG_VERSION}.tar.xz" +source_dir="/tmp/ffmpeg-${FFMPEG_VERSION}" + +curl --fail --location --silent --show-error \ + "https://ffmpeg.org/releases/ffmpeg-${FFMPEG_VERSION}.tar.xz" \ + --output "${archive}" +echo "${FFMPEG_SHA256} ${archive}" | sha256sum --check --strict +tar --extract --file "${archive}" --directory /tmp + +cd "${source_dir}" +./configure \ + --prefix="${FFMPEG_PREFIX}" \ + --disable-autodetect \ + --disable-debug \ + --disable-doc \ + --disable-ffplay \ + --disable-network \ + --disable-static \ + --enable-libopenh264 \ + --enable-shared \ + --extra-ldflags="-Wl,-rpath,${FFMPEG_PREFIX}/lib" \ + --enable-zlib + +make --jobs="$(getconf _NPROCESSORS_ONLN)" +make install + +license_dir="${FFMPEG_PREFIX}/share/licenses/ffmpeg" +openh264_license_dir="${FFMPEG_PREFIX}/share/licenses/openh264" +source_archive_dir="${FFMPEG_PREFIX}/share/source/ffmpeg" +mkdir -p "${license_dir}" "${openh264_license_dir}" "${source_archive_dir}" +cp COPYING.LGPLv2.1 LICENSE.md "${license_dir}/" +cp /usr/share/doc/libopenh264-dev/copyright "${openh264_license_dir}/" +cp "${archive}" "${source_archive_dir}/" + +"${FFMPEG_PREFIX}/bin/ffmpeg" -hide_banner -buildconf \ + > "${source_archive_dir}/build-configuration.txt" 2>&1 + +if grep -q -- '--enable-gpl\|--enable-nonfree' \ + "${source_archive_dir}/build-configuration.txt"; then + echo "Refusing to package a GPL or non-redistributable FFmpeg build" >&2 + exit 1 +fi + +"${FFMPEG_PREFIX}/bin/ffmpeg" -hide_banner -encoders 2>&1 \ + | grep -q 'libopenh264' +"${FFMPEG_PREFIX}/bin/ffprobe" -hide_banner -version >/dev/null