diff --git a/apps/media-server/Dockerfile b/apps/media-server/Dockerfile index f3b8963e60..f9a8bae617 100644 --- a/apps/media-server/Dockerfile +++ b/apps/media-server/Dockerfile @@ -37,7 +37,7 @@ RUN bun test src/__tests__/lib/audio-quality-policy.test.ts src/__tests__/lib/au && bun test src/__tests__/lib/live-transcribe.test.ts src/__tests__/routes/live-transcribe.test.ts \ && bun test src/__tests__/lib/media-routes-real-world.integration.test.ts \ && bun test src/__tests__/lib/media-video.integration.test.ts \ - && bun test src/__tests__/lib/isolated-worker-pool.test.ts src/__tests__/lib/media-probe-hardening.test.ts src/__tests__/lib/process-hardening.test.ts \ + && bun test src/__tests__/lib/isolated-worker-pool.test.ts src/__tests__/lib/media-probe-hardening.test.ts src/__tests__/lib/process-hardening.test.ts src/__tests__/lib/legacy-codecs.integration.test.ts \ && bun test src/__tests__/routes/thumbnail-remote.test.ts src/__tests__/routes/health.test.ts RUN MEDIA_SERVER_RECORDING_PERFORMANCE_TESTS=1 bun test \ diff --git a/apps/media-server/Dockerfile.standalone b/apps/media-server/Dockerfile.standalone index e68b4309b6..b7ca301d22 100644 --- a/apps/media-server/Dockerfile.standalone +++ b/apps/media-server/Dockerfile.standalone @@ -18,6 +18,7 @@ COPY src ./src COPY capcodec.md ./capcodec.md # Files whose tables claim far more than they hold must probe at once, or the image is not built. RUN bun test src/__tests__/lib/media-probe-sample-counts.test.ts +RUN bun test src/__tests__/lib/legacy-codecs.integration.test.ts ENV PORT=3456 # Self-hosted storage usually sits on a private network next to this container. diff --git a/apps/media-server/docker-entrypoint.sh b/apps/media-server/docker-entrypoint.sh index 3df38c7c5b..cd95a3ef3e 100755 --- a/apps/media-server/docker-entrypoint.sh +++ b/apps/media-server/docker-entrypoint.sh @@ -40,6 +40,14 @@ groups="$gid" for device in /dev/nvidia* /dev/dri/*; do [ -c "$device" ] || continue device_gid="$(stat -c %g "$device")" + # A device anyone may read and write needs no group, and the root group + # is never handed out for one. NVIDIA nodes usually own their devices + # as root:root with mode 666. + if [ "$device_gid" = 0 ]; then + case "$(stat -c %a "$device")" in + *[67]) continue ;; + esac + fi case ",$groups," in *",$device_gid,"*) ;; *) groups="$groups,$device_gid" ;; diff --git a/apps/media-server/src/__tests__/lib/legacy-codecs.integration.test.ts b/apps/media-server/src/__tests__/lib/legacy-codecs.integration.test.ts new file mode 100644 index 0000000000..87594a3e0d --- /dev/null +++ b/apps/media-server/src/__tests__/lib/legacy-codecs.integration.test.ts @@ -0,0 +1,219 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { probeVideoFile } from "../../lib/media-probe"; +import { stopProbeWorkers } from "../../lib/media-probe-pool"; +import { spawn } from "../../lib/media-spawn"; + +// Older codecs a regular Cap import can carry, in the containers the probe +// reads (Matroska, MP4 and 3GP). Each fixture is made with the local FFmpeg, +// then probed and transcoded to H.264 and AAC through the same hardened spawn +// the server uses, so the codec and decoder lists are what decides. A family +// whose encoder this FFmpeg lacks is skipped here. +type Fixture = { + name: string; + file: string; + video: string[]; + audio: string[] | null; + encoders: string[]; +}; + +const FIXTURES: Fixture[] = [ + { + name: "MPEG-1 video with MPEG audio layer 2", + file: "mpeg1.mkv", + video: ["-c:v", "mpeg1video"], + audio: ["-c:a", "mp2"], + encoders: ["mpeg1video", "mp2"], + }, + { + name: "MPEG-2 video with MPEG audio layer 2", + file: "mpeg2.mkv", + video: ["-c:v", "mpeg2video"], + audio: ["-c:a", "mp2"], + encoders: ["mpeg2video", "mp2"], + }, + { + name: "Windows Media video 7 with Windows Media audio 1", + file: "wmv1.mkv", + video: ["-c:v", "wmv1"], + audio: ["-c:a", "wmav1"], + encoders: ["wmv1", "wmav1"], + }, + { + name: "Windows Media video 8 with Windows Media audio 2", + file: "wmv2.mkv", + video: ["-c:v", "wmv2"], + audio: ["-c:a", "wmav2"], + encoders: ["wmv2", "wmav2"], + }, + { + name: "early MPEG-4 (DivX 3) with Microsoft ADPCM", + file: "msmpeg4v3.mkv", + video: ["-c:v", "msmpeg4"], + audio: ["-c:a", "adpcm_ms"], + encoders: ["msmpeg4", "adpcm_ms"], + }, + { + name: "early MPEG-4 version 2 with IMA ADPCM", + file: "msmpeg4v2.mkv", + video: ["-c:v", "msmpeg4v2"], + audio: ["-c:a", "adpcm_ima_wav"], + encoders: ["msmpeg4v2", "adpcm_ima_wav"], + }, + { + name: "Flash video", + file: "flv1.mkv", + video: ["-c:v", "flv"], + audio: ["-c:a", "aac"], + encoders: ["flv", "aac"], + }, + { + name: "H.263 from a phone", + file: "phone.3gp", + video: ["-c:v", "h263"], + audio: ["-c:a", "aac", "-ar", "8000", "-ac", "1"], + encoders: ["h263", "aac"], + }, + { + name: "H.263 with AMR narrowband audio", + file: "amrnb.3gp", + video: ["-c:v", "h263"], + audio: ["-c:a", "libopencore_amrnb", "-ar", "8000", "-ac", "1"], + encoders: ["h263", "libopencore_amrnb"], + }, + { + name: "H.263 with AMR wideband audio", + file: "amrwb.3gp", + video: ["-c:v", "h263"], + audio: ["-c:a", "libvo_amrwbenc", "-ar", "16000", "-ac", "1"], + encoders: ["h263", "libvo_amrwbenc"], + }, + { + name: "Theora with Vorbis", + file: "theora.mkv", + video: ["-c:v", "libtheora"], + audio: ["-c:a", "libvorbis"], + encoders: ["libtheora", "libvorbis"], + }, + { + name: "H.264 with MPEG audio layer 2", + file: "h264-mp2.mkv", + video: ["-c:v", "libx264"], + audio: ["-c:a", "mp2"], + encoders: ["libx264", "mp2"], + }, + { + name: "H.264 with Windows Media audio 2", + file: "h264-wma.mkv", + video: ["-c:v", "libx264"], + audio: ["-c:a", "wmav2"], + encoders: ["libx264", "wmav2"], + }, +]; + +let dir = ""; +// Read when the file loads: skipIf decides before beforeAll runs. +const encoders = new Set( + Bun.spawnSync(["ffmpeg", "-hide_banner", "-encoders"]) + .stdout.toString() + .split("\n") + .map((line) => line.trim().split(/\s+/)[1] ?? ""), +); + +async function run(cmd: string[]): Promise<{ code: number; stderr: string }> { + const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" }); + const [code, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + return { code, stderr: stderr || stdout }; +} + +const available = (fixture: Fixture) => + fixture.encoders.every((name) => encoders.has(name)); + +beforeAll(async () => { + dir = await mkdtemp(join(tmpdir(), "legacy-codecs-")); + for (const fixture of FIXTURES) { + if (!available(fixture)) continue; + const made = await run([ + "ffmpeg", + "-v", + "error", + "-y", + "-f", + "lavfi", + "-i", + "testsrc=d=1:s=176x144:r=25", + ...(fixture.audio ? ["-f", "lavfi", "-i", "sine=d=1:r=44100"] : []), + ...fixture.video, + ...(fixture.audio ?? []), + join(dir, fixture.file), + ]); + if (made.code !== 0) + throw new Error(`could not make ${fixture.file}: ${made.stderr}`); + } +}, 120_000); + +afterAll(async () => { + stopProbeWorkers(); + await rm(dir, { recursive: true, force: true }); +}); + +describe("older formats a regular import can carry", () => { + for (const fixture of FIXTURES) { + test.skipIf(!available(fixture))( + `${fixture.name} probes and transcodes`, + async () => { + const input = join(dir, fixture.file); + const metadata = await probeVideoFile(input); + expect(metadata.width).toBe(176); + expect(metadata.height).toBe(144); + + const output = join(dir, `${fixture.file}.mp4`); + const transcode = spawn({ + cmd: [ + "ffmpeg", + "-v", + "error", + "-y", + "-i", + input, + "-c:v", + "libx264", + "-preset", + "ultrafast", + "-c:a", + "aac", + output, + ], + stdout: "ignore", + stderr: "pipe", + }); + const [code, stderr] = await Promise.all([ + transcode.exited, + new Response(transcode.stderr).text(), + ]); + expect(stderr).not.toContain("not on whitelist"); + expect(code).toBe(0); + + const streams = await run([ + "ffprobe", + "-v", + "error", + "-show_entries", + "stream=codec_type,codec_name", + "-of", + "csv=p=0", + output, + ]); + expect(streams.stderr).toContain("h264,video"); + if (fixture.audio) expect(streams.stderr).toContain("aac,audio"); + }, + 60_000, + ); + } +}); diff --git a/apps/media-server/src/__tests__/lib/media-input-policy.test.ts b/apps/media-server/src/__tests__/lib/media-input-policy.test.ts index 9c6aa38030..64042a4ce9 100644 --- a/apps/media-server/src/__tests__/lib/media-input-policy.test.ts +++ b/apps/media-server/src/__tests__/lib/media-input-policy.test.ts @@ -102,15 +102,21 @@ describe("decoder whitelist", () => { "prores", "mpeg4", "mjpeg", - ]) - expect(isAllowedVideoCodec(codec)).toBe(true); - for (const codec of [ + "mpeg1video", "mpeg2video", + "wmv1", + "wmv2", + "wmv3", + "vc1", + "msmpeg4v1", + "msmpeg4v2", + "msmpeg4v3", + "flv1", + "h263", "theora", - "dvvideo", - "cinepak", - "unknown", ]) + expect(isAllowedVideoCodec(codec)).toBe(true); + for (const codec of ["dvvideo", "cinepak", "unknown"]) expect(isAllowedVideoCodec(codec)).toBe(false); const decoders = new Set(DECODER_WHITELIST.split(",")); for (const name of [ @@ -123,16 +129,23 @@ describe("decoder whitelist", () => { "mov_text", "h264_cuvid", "libdav1d", + "mpeg2video", + "msmpeg4", + "flv", + "mp2", + "wmav2", + "amrnb", + "adpcm_ms", ]) expect(decoders.has(name)).toBe(true); - for (const name of ["mpeg2video", "wmav2", "cinepak", "rawvideo"]) + for (const name of ["cinepak", "rawvideo", "dvvideo", "indeo5"]) expect(decoders.has(name)).toBe(false); }); test("FFmpeg refuses to decode a stream outside the list", async () => { const dir = await mkdtemp(join(tmpdir(), "codec-whitelist-")); try { - const path = join(dir, "mpeg2.mkv"); + const path = join(dir, "cinepak.avi"); const make = spawn({ cmd: [ "ffmpeg", @@ -143,7 +156,7 @@ describe("decoder whitelist", () => { "-i", "testsrc=d=0.2:s=64x64", "-c:v", - "mpeg2video", + "cinepak", "-y", path, ], diff --git a/apps/media-server/src/__tests__/lib/media-probe-hardening.test.ts b/apps/media-server/src/__tests__/lib/media-probe-hardening.test.ts index 5b8546062f..b532004741 100644 --- a/apps/media-server/src/__tests__/lib/media-probe-hardening.test.ts +++ b/apps/media-server/src/__tests__/lib/media-probe-hardening.test.ts @@ -64,7 +64,7 @@ beforeAll(async () => { dir = await mkdtemp(join(tmpdir(), "probe-hardening-")); const source = ["-f", "lavfi", "-i", "testsrc=d=1:s=128x96:r=10"]; await ffmpeg([...source, "-c:v", "mpeg4", join(dir, "part2.mp4")]); - await ffmpeg([...source, "-c:v", "mpeg2video", join(dir, "mpeg2.mkv")]); + await ffmpeg([...source, "-c:v", "cinepak", join(dir, "cinepak.mkv")]); await ffmpeg([ ...source, "-f", @@ -175,7 +175,7 @@ describe("probe input checks", () => { }, 30_000); test("a video codec outside the allowed list is refused", async () => { - const error = await probeError("mpeg2.mkv"); + const error = await probeError("cinepak.mkv"); expect(error).toBeInstanceOf(UnreadableMediaInputError); expect((error as Error).message).toContain("not supported"); }, 30_000); diff --git a/apps/media-server/src/lib/media-input-policy.ts b/apps/media-server/src/lib/media-input-policy.ts index 31cf8f3f27..83647b64a5 100644 --- a/apps/media-server/src/lib/media-input-policy.ts +++ b/apps/media-server/src/lib/media-input-policy.ts @@ -122,6 +122,42 @@ export function getSafeDemuxerWhitelist(): string { return safeDemuxerWhitelist; } +// Regular Cap imports still carry these, and FFmpeg's decoders for them are +// mature. Exotic codecs (Cinepak, DV, Indeo, raw video) stay refused. +const LEGACY_VIDEO_CODECS = [ + "mpeg1video", + "mpeg2video", + "wmv1", + "wmv2", + "wmv3", + "vc1", + "msmpeg4v1", + "msmpeg4v2", + "msmpeg4v3", + "flv1", + "h263", + "h263i", + "h263p", + "theora", +]; + +// FFmpeg opens these codecs with decoders of another name, and the decoder +// list is matched by decoder name. +const LEGACY_VIDEO_DECODERS = ["msmpeg4", "flv"]; + +const LEGACY_AUDIO_DECODERS = [ + "mp2", + "mp2float", + "wmav1", + "wmav2", + "amrnb", + "amrwb", + "libopencore_amrnb", + "libopencore_amrwb", + "adpcm_ima_wav", + "adpcm_ms", +]; + // Video codecs this server accepts in an upload, as ffprobe names them. export const ALLOWED_VIDEO_CODECS: readonly string[] = [ "h264", @@ -132,6 +168,7 @@ export const ALLOWED_VIDEO_CODECS: readonly string[] = [ "prores", "mpeg4", "mjpeg", + ...LEGACY_VIDEO_CODECS, ]; export function isAllowedVideoCodec(codec: string): boolean { @@ -206,6 +243,8 @@ export const ALLOWED_DECODERS: readonly string[] = [ "ac3_fixed", "eac3", "dca", + ...LEGACY_VIDEO_DECODERS, + ...LEGACY_AUDIO_DECODERS, ...PCM_DECODERS, "mov_text", "subrip",