feat: add secure on-demand HLS playback plugin

This commit is contained in:
root
2026-09-04 20:23:10 +02:00
commit 6fae6a3a43
35 changed files with 2283 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Plugin\AzuraCastOnDemandHls\Tests;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Plugin\AzuraCastOnDemandHls\Config;
#[CoversClass(Config::class)]
final class ConfigTest extends TestCase
{
public function testDefaultsAreProductionSafe(): void
{
$config = new Config();
self::assertTrue($config->enabled);
self::assertSame(1800, $config->sessionTtl);
self::assertSame(6, $config->segmentDuration);
self::assertGreaterThanOrEqual($config->sessionTtl, $config->cacheTtl);
}
public function testTraversalCannotBeUsedAsCacheDirectory(): void
{
$this->expectException(InvalidArgumentException::class);
new Config(cacheDirectory: '../outside');
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Plugin\AzuraCastOnDemandHls\Tests\Security;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Plugin\AzuraCastOnDemandHls\Security\OpaqueToken;
#[CoversClass(OpaqueToken::class)]
final class OpaqueTokenTest extends TestCase
{
public function testGeneratedTokensAreOpaqueValidAndUnique(): void
{
$tokens = [];
for ($i = 0; $i < 100; ++$i) {
$token = OpaqueToken::generate();
self::assertTrue(OpaqueToken::isValid($token));
self::assertSame(43, strlen($token));
$tokens[] = $token;
}
self::assertCount(100, array_unique($tokens));
}
public function testMalformedTokensAreRejected(): void
{
foreach (['', 'short', str_repeat('a', 42), str_repeat('a', 44), str_repeat('.', 43), '../' . str_repeat('a', 40), str_repeat('a', 42) . '='] as $token) {
self::assertFalse(OpaqueToken::isValid($token), $token);
}
}
public function testMutationChangesStoredIdentifier(): void
{
$token = OpaqueToken::generate();
$replacement = 'A' === $token[0] ? 'B' : 'A';
$mutated = $replacement . substr($token, 1);
self::assertNotSame(OpaqueToken::id($token), OpaqueToken::id($mutated));
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Plugin\AzuraCastOnDemandHls\Tests\Security;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use Plugin\AzuraCastOnDemandHls\Security\ResourcePath;
use Plugin\AzuraCastOnDemandHls\Tests\Support\TemporaryDirectoryTestCase;
#[CoversClass(ResourcePath::class)]
final class ResourcePathTest extends TemporaryDirectoryTestCase
{
public function testOnlyExpectedHlsNamesAreAllowed(): void
{
self::assertTrue(ResourcePath::isAllowed('master.m3u8'));
self::assertTrue(ResourcePath::isAllowed('media.m3u8'));
self::assertTrue(ResourcePath::isAllowed('segment00000.ts'));
self::assertTrue(ResourcePath::isAllowed('segment99999.ts'));
}
#[DataProvider('traversalProvider')]
public function testTraversalAndUnexpectedPathsAreRejected(string $path): void
{
self::assertFalse(ResourcePath::isAllowed($path));
}
/** @return iterable<string, array{string}> */
public static function traversalProvider(): iterable
{
yield 'dot-dot slash' => ['../master.m3u8'];
yield 'dot-dot backslash' => ['..\\master.m3u8'];
yield 'absolute Unix' => ['/etc/passwd'];
yield 'absolute Windows' => ['C:\\Windows\\win.ini'];
yield 'URL encoded' => ['%2e%2e%2fmaster.m3u8'];
yield 'double encoded' => ['%252e%252e%252fmaster.m3u8'];
yield 'null byte' => ["master.m3u8\0.ts"];
yield 'nested segment' => ['folder/segment00000.ts'];
yield 'bad extension' => ['segment00000.php'];
yield 'unbounded digits' => ['segment1.ts'];
yield 'query text' => ['media.m3u8?x=1'];
}
public function testLongMediaSegmentNumbersRemainValid(): void
{
self::assertTrue(ResourcePath::isAllowed('segment100000.ts'));
self::assertTrue(ResourcePath::isAllowed('segment999999999.ts'));
self::assertFalse(ResourcePath::isAllowed('segment1000000000.ts'));
}
public function testResolveRejectsSymlinksAndMissingFiles(): void
{
file_put_contents($this->temporaryDirectory . '/master.m3u8', '#EXTM3U');
self::assertSame(
realpath($this->temporaryDirectory . '/master.m3u8'),
ResourcePath::resolve($this->temporaryDirectory, 'master.m3u8')
);
self::assertNull(ResourcePath::resolve($this->temporaryDirectory, 'media.m3u8'));
file_put_contents($this->temporaryDirectory . '/outside', 'secret');
symlink($this->temporaryDirectory . '/outside', $this->temporaryDirectory . '/media.m3u8');
self::assertNull(ResourcePath::resolve($this->temporaryDirectory, 'media.m3u8'));
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Plugin\AzuraCastOnDemandHls\Tests\Service;
use PHPUnit\Framework\Attributes\CoversClass;
use Plugin\AzuraCastOnDemandHls\Config;
use Plugin\AzuraCastOnDemandHls\Security\ResourcePath;
use Plugin\AzuraCastOnDemandHls\Service\AssetAuthorizer;
use Plugin\AzuraCastOnDemandHls\Service\CachePaths;
use Plugin\AzuraCastOnDemandHls\Service\SessionRepository;
use Plugin\AzuraCastOnDemandHls\Tests\Support\TemporaryDirectoryTestCase;
#[CoversClass(AssetAuthorizer::class)]
final class AssetAuthorizerTest extends TemporaryDirectoryTestCase
{
public function testPlaylistAndSegmentRequireValidUnexpiredStationBoundSession(): void
{
$config = new Config(sessionTtl: 60, cacheTtl: 60);
$paths = new CachePaths($config);
$sessions = new SessionRepository($config, $paths);
$cacheKey = str_repeat('a', 64);
$assetDirectory = $paths->asset($this->temporaryDirectory, $cacheKey);
mkdir($assetDirectory, 0755, true);
file_put_contents($assetDirectory . '/master.m3u8', '#EXTM3U');
file_put_contents($assetDirectory . '/media.m3u8', '#EXTM3U');
file_put_contents($assetDirectory . '/segment00000.ts', 'segment');
$created = $sessions->create(
$this->temporaryDirectory,
7,
'abcdef123456abcdef123456',
$cacheKey,
str_repeat('c', 64),
1000,
);
$authorizer = new AssetAuthorizer($sessions, $paths);
self::assertNotNull($authorizer->authorize($this->temporaryDirectory, 7, $created['token'], 'master.m3u8', 1059));
self::assertNotNull($authorizer->authorize($this->temporaryDirectory, 7, $created['token'], 'segment00000.ts', 1059));
self::assertNull($authorizer->authorize($this->temporaryDirectory, 8, $created['token'], 'master.m3u8', 1059));
self::assertNull($authorizer->authorize($this->temporaryDirectory, 7, $created['token'], 'master.m3u8', 1060));
self::assertNull($authorizer->authorize($this->temporaryDirectory, 7, 'x' . substr($created['token'], 1), 'master.m3u8', 1059));
self::assertNull($authorizer->authorize($this->temporaryDirectory, 7, $created['token'], '../master.m3u8', 1059));
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Plugin\AzuraCastOnDemandHls\Tests\Service;
use PHPUnit\Framework\Attributes\CoversClass;
use Plugin\AzuraCastOnDemandHls\Config;
use Plugin\AzuraCastOnDemandHls\Service\CachePaths;
use Plugin\AzuraCastOnDemandHls\Service\CleanupService;
use Plugin\AzuraCastOnDemandHls\Service\SessionRepository;
use Plugin\AzuraCastOnDemandHls\Tests\Support\TemporaryDirectoryTestCase;
use Psr\Log\NullLogger;
#[CoversClass(CleanupService::class)]
final class CleanupServiceTest extends TemporaryDirectoryTestCase
{
public function testExpiredSessionsAndUnreferencedAssetsAreRemoved(): void
{
$config = new Config(sessionTtl: 60, cacheTtl: 60);
$paths = new CachePaths($config);
$sessions = new SessionRepository($config, $paths);
$cacheKey = str_repeat('a', 64);
$created = $sessions->create(
$this->temporaryDirectory,
7,
'abcdef123456abcdef123456',
$cacheKey,
str_repeat('c', 64),
1000,
);
$asset = $paths->asset($this->temporaryDirectory, $cacheKey);
mkdir($asset, 0755, true);
file_put_contents($asset . '/master.m3u8', '#EXTM3U');
touch($asset, 1000);
$cleanup = new CleanupService($config, $paths, new NullLogger());
$result = $cleanup->run($this->temporaryDirectory, 1061);
self::assertSame(['sessions' => 1, 'assets' => 1], $result);
self::assertNull($sessions->find($this->temporaryDirectory, $created['token']));
self::assertDirectoryDoesNotExist($asset);
}
public function testActiveSessionPreventsCacheDeletion(): void
{
$config = new Config(sessionTtl: 120, cacheTtl: 120);
$paths = new CachePaths($config);
$sessions = new SessionRepository($config, $paths);
$cacheKey = str_repeat('a', 64);
$sessions->create(
$this->temporaryDirectory,
7,
'abcdef123456abcdef123456',
$cacheKey,
str_repeat('c', 64),
1000,
);
$asset = $paths->asset($this->temporaryDirectory, $cacheKey);
mkdir($asset, 0755, true);
touch($asset, 1);
$cleanup = new CleanupService($config, $paths, new NullLogger());
self::assertSame(['sessions' => 0, 'assets' => 0], $cleanup->run($this->temporaryDirectory, 1050));
self::assertDirectoryExists($asset);
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace Plugin\AzuraCastOnDemandHls\Tests\Service;
use App\Entity\Station;
use App\Entity\StationMedia;
use App\Flysystem\StationFilesystems;
use PHPUnit\Framework\Attributes\CoversClass;
use Plugin\AzuraCastOnDemandHls\Config;
use Plugin\AzuraCastOnDemandHls\Exception\TranscodeException;
use Plugin\AzuraCastOnDemandHls\Service\CachePaths;
use Plugin\AzuraCastOnDemandHls\Service\HlsCache;
use Plugin\AzuraCastOnDemandHls\Tests\Support\TemporaryDirectoryTestCase;
use Psr\Log\NullLogger;
#[CoversClass(HlsCache::class)]
final class HlsCacheTest extends TemporaryDirectoryTestCase
{
public function testGeneratesAValidatedPackageAndReusesIt(): void
{
$source = $this->temporaryDirectory . '/source.mp3';
$counter = $this->temporaryDirectory . '/ffmpeg-calls';
file_put_contents($source, 'input');
$script = $this->makeFfmpegScript($counter, false);
$cache = $this->cache($source, $script);
$station = new Station(7, $this->temporaryDirectory);
$media = new StationMedia(2, 'abcdefghijklmnopqrstuvwx', 123, 'source.mp3');
$key = $cache->ensure($station, $media);
self::assertSame($key, $cache->ensure($station, $media));
self::assertSame("#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-STREAM-INF:BANDWIDTH=140800,CODECS=\"mp4a.40.2\"\nmedia.m3u8\n", file_get_contents($this->temporaryDirectory . '/ondemand-hls/assets/' . $key . '/master.m3u8'));
self::assertFileExists($this->temporaryDirectory . '/ondemand-hls/assets/' . $key . '/media.m3u8');
self::assertFileExists($this->temporaryDirectory . '/ondemand-hls/assets/' . $key . '/segment00000.ts');
self::assertSame("1\n", file_get_contents($counter));
}
public function testRejectsMalformedFfmpegOutputWithoutPublishingIt(): void
{
$source = $this->temporaryDirectory . '/source.mp3';
file_put_contents($source, 'input');
$script = $this->makeFfmpegScript($this->temporaryDirectory . '/ffmpeg-calls', true);
$cache = $this->cache($source, $script);
$station = new Station(7, $this->temporaryDirectory);
$media = new StationMedia(2, 'abcdefghijklmnopqrstuvwx', 123, 'source.mp3');
$this->expectException(TranscodeException::class);
try {
$cache->ensure($station, $media);
} finally {
self::assertDirectoryDoesNotExist($this->temporaryDirectory . '/ondemand-hls/assets/' . $cache->key($station, $media));
}
}
private function cache(string $source, string $ffmpeg): HlsCache
{
$config = new Config(cacheTtl: 1800, ffmpegBinary: $ffmpeg);
$filesystem = new class($source) {
public function __construct(private string $source)
{
}
public function fileExists(string $path): bool
{
return 'source.mp3' === $path;
}
public function withLocalFile(string $path, callable $callback): mixed
{
return $callback($this->source);
}
};
return new HlsCache(
$config,
new CachePaths($config),
new StationFilesystems($filesystem),
new NullLogger(),
);
}
private function makeFfmpegScript(string $counter, bool $malformed): string
{
$script = $this->temporaryDirectory . '/fake-ffmpeg-' . bin2hex(random_bytes(4)) . '.sh';
$segment = $malformed
? "printf '#EXTM3U\\n../outside.ts\\n' > \"\$output\"\n"
: "printf '#EXTM3U\\n#EXT-X-ENDLIST\\nsegment00000.ts\\n' > \"\$output\"\nsegment_file=\$(printf \"\$segment\" 0)\nprintf segment > \"\$segment_file\"\n";
file_put_contents($script, "#!/bin/sh\nset -eu\noutput=''\nsegment=''\nprevious=''\nfor argument in \"\$@\"; do\n if [ \"\$previous\" = '-hls_segment_filename' ]; then segment=\"\$argument\"; fi\n previous=\"\$argument\"\n output=\"\$argument\"\ndone\nprintf '1\\n' >> " . escapeshellarg($counter) . "\n" . $segment);
chmod($script, 0700);
return $script;
}
}
+162
View File
@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace Plugin\AzuraCastOnDemandHls\Tests\Service;
use PHPUnit\Framework\Attributes\CoversClass;
use Plugin\AzuraCastOnDemandHls\Config;
use Plugin\AzuraCastOnDemandHls\Domain\PlaybackSession;
use Plugin\AzuraCastOnDemandHls\Security\OpaqueToken;
use Plugin\AzuraCastOnDemandHls\Service\CachePaths;
use Plugin\AzuraCastOnDemandHls\Service\SessionRepository;
use Plugin\AzuraCastOnDemandHls\Tests\Support\TemporaryDirectoryTestCase;
use RuntimeException;
use Symfony\Component\Process\Process;
#[CoversClass(SessionRepository::class)]
#[CoversClass(PlaybackSession::class)]
final class SessionRepositoryTest extends TemporaryDirectoryTestCase
{
public function testValidSessionCreationAndLookup(): void
{
$repository = $this->repository();
$created = $repository->create(
$this->temporaryDirectory,
7,
'abcdef123456abcdef123456',
str_repeat('b', 64),
str_repeat('c', 64),
1000,
);
self::assertTrue(OpaqueToken::isValid($created['token']));
self::assertSame(1060, $created['session']->expiresAt);
self::assertEquals($created['session'], $repository->find($this->temporaryDirectory, $created['token']));
}
public function testInvalidAndModifiedTokensCannotLoadSession(): void
{
$repository = $this->repository();
$created = $repository->create(
$this->temporaryDirectory,
7,
'abcdef123456abcdef123456',
str_repeat('b', 64),
str_repeat('c', 64),
1000,
);
$token = $created['token'];
$mutated = ('A' === $token[0] ? 'B' : 'A') . substr($token, 1);
self::assertNull($repository->find($this->temporaryDirectory, 'invalid'));
self::assertNull($repository->find($this->temporaryDirectory, $mutated));
}
public function testConcurrentSessionLimitIsEnforced(): void
{
$repository = $this->repository(maxSessions: 2);
for ($i = 0; $i < 2; ++$i) {
$repository->create(
$this->temporaryDirectory,
7,
'abcdef123456abcdef123456',
hash('sha256', 'cache-' . $i),
str_repeat('c', 64),
1000,
);
}
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Maximum concurrent playback sessions reached.');
$repository->create(
$this->temporaryDirectory,
7,
'abcdef123456abcdef123456',
str_repeat('d', 64),
str_repeat('c', 64),
1000,
);
}
public function testParallelCreatorsCannotExceedSessionLimit(): void
{
$worker = $this->temporaryDirectory . '/session-worker.php';
$autoload = dirname(__DIR__, 2) . '/vendor/autoload.php';
file_put_contents($worker, <<<'PHP'
<?php
declare(strict_types=1);
require $argv[1];
use Plugin\AzuraCastOnDemandHls\Config;
use Plugin\AzuraCastOnDemandHls\Exception\SessionLimitException;
use Plugin\AzuraCastOnDemandHls\Service\CachePaths;
use Plugin\AzuraCastOnDemandHls\Service\SessionRepository;
$config = new Config(sessionTtl: 60, cacheTtl: 60, maxSessionsPerPrincipal: 1);
$repository = new SessionRepository($config, new CachePaths($config));
touch($argv[5] . '/ready-' . $argv[6]);
while (!is_file($argv[4])) {
usleep(1000);
}
try {
$repository->create(
$argv[2],
7,
'abcdef123456abcdef123456',
hash('sha256', 'cache-' . $argv[6]),
str_repeat('c', 64),
1000,
);
echo 'created';
} catch (SessionLimitException) {
echo 'limited';
}
PHP);
$marker = $this->temporaryDirectory . '/start';
$processes = [];
foreach (['one', 'two'] as $id) {
$process = new Process([
PHP_BINARY,
$worker,
$autoload,
$this->temporaryDirectory,
$marker,
$this->temporaryDirectory,
$id,
]);
$process->start();
$processes[] = $process;
}
$deadline = microtime(true) + 5;
while ((!is_file($this->temporaryDirectory . '/ready-one')
|| !is_file($this->temporaryDirectory . '/ready-two'))
&& microtime(true) < $deadline) {
usleep(1000);
}
self::assertFileExists($this->temporaryDirectory . '/ready-one');
self::assertFileExists($this->temporaryDirectory . '/ready-two');
touch($marker);
$results = [];
foreach ($processes as $process) {
self::assertSame(0, $process->wait(), $process->getErrorOutput());
$results[] = trim($process->getOutput());
}
sort($results);
self::assertSame(['created', 'limited'], $results);
$sessionFiles = glob($this->temporaryDirectory . '/ondemand-hls/sessions/*.json') ?: [];
self::assertCount(1, $sessionFiles);
}
private function repository(int $maxSessions = 10): SessionRepository
{
$config = new Config(sessionTtl: 60, cacheTtl: 60, maxSessionsPerPrincipal: $maxSessions);
return new SessionRepository($config, new CachePaths($config));
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Entity;
final class Station
{
public function __construct(public int $id, private string $radioTempDirectory)
{
}
public function getRadioTempDir(): string
{
return $this->radioTempDirectory;
}
}
final class StationMedia
{
public function __construct(
public int $storage_location_id,
public string $unique_id,
public int $mtime,
public string $path,
) {
}
}
namespace App\Flysystem;
use App\Entity\Station;
final class StationFilesystems
{
public function __construct(private object $filesystem)
{
}
public function getMediaFilesystem(Station $station): object
{
return $this->filesystem;
}
}
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Plugin\AzuraCastOnDemandHls\Tests\Support;
use PHPUnit\Framework\TestCase;
abstract class TemporaryDirectoryTestCase extends TestCase
{
protected string $temporaryDirectory;
protected function setUp(): void
{
parent::setUp();
$this->temporaryDirectory = sys_get_temp_dir() . '/ondemand-hls-test-' . bin2hex(random_bytes(8));
mkdir($this->temporaryDirectory, 0700, true);
}
protected function tearDown(): void
{
$this->removeTree($this->temporaryDirectory);
parent::tearDown();
}
private function removeTree(string $path): void
{
if (is_link($path) || is_file($path)) {
@unlink($path);
return;
}
if (!is_dir($path)) {
return;
}
foreach (new \FilesystemIterator($path, \FilesystemIterator::SKIP_DOTS) as $item) {
$this->removeTree($item->getPathname());
}
@rmdir($path);
}
}
+6
View File
@@ -0,0 +1,6 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
require __DIR__ . '/Support/AzuraCastStubs.php';