#5028 -- Update LS to use proper system HTTP requests.

This commit is contained in:
Buster "Silver Eagle" Neece 2022-01-25 01:59:16 -06:00
parent 04b2072259
commit d2ecc4664f
No known key found for this signature in database
GPG Key ID: 9FC8B9E008872109
9 changed files with 114 additions and 400 deletions

View File

@ -7,13 +7,8 @@ return function (App\Event\BuildConsoleCommands $event) {
'azuracast:backup' => Command\Backup\BackupCommand::class,
'azuracast:restore' => Command\Backup\RestoreCommand::class,
'azuracast:debug:optimize-tables' => Command\Debug\OptimizeTablesCommand::class,
'azuracast:internal:auth' => Command\Internal\DjAuthCommand::class,
'azuracast:internal:djoff' => Command\Internal\DjOffCommand::class,
'azuracast:internal:djon' => Command\Internal\DjOnCommand::class,
'azuracast:internal:feedback' => Command\Internal\FeedbackCommand::class,
'azuracast:internal:sftp-event' => Command\Internal\SftpEventCommand::class,
'azuracast:internal:sftp-auth' => Command\Internal\SftpAuthCommand::class,
'azuracast:internal:nextsong' => Command\Internal\NextSongCommand::class,
'azuracast:internal:on-ssl-renewal' => Command\Internal\OnSslRenewal::class,
'azuracast:internal:ip' => Command\Internal\GetIpCommand::class,
'azuracast:locale:generate' => Command\Locale\GenerateCommand::class,

View File

@ -1,67 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Command\Internal;
use App\Console\Command\CommandAbstract;
use App\Entity;
use App\Entity\Repository\SettingsRepository;
use App\Radio\Adapters;
use App\Radio\Backend\Liquidsoap;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'azuracast:internal:auth',
description: 'Authorize a streamer to connect as a source for the radio service.',
)]
class DjAuthCommand extends CommandAbstract
{
public function __construct(
protected Adapters $adapters,
protected EntityManagerInterface $em,
protected SettingsRepository $settingsRepo,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('station-id', InputArgument::REQUIRED)
->addOption('dj-user', null, InputOption::VALUE_REQUIRED, '', '')
->addOption('dj-password', null, InputOption::VALUE_REQUIRED, '', '');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$stationId = (int)$input->getArgument('station-id');
$djUser = $input->getOption('dj-user');
$djPassword = $input->getOption('dj-password');
$station = $this->em->getRepository(Entity\Station::class)->find($stationId);
if (!($station instanceof Entity\Station) || !$station->getEnableStreamers()) {
$io->write('false');
return 0;
}
$adapter = $this->adapters->getBackendAdapter($station);
if ($adapter instanceof Liquidsoap) {
$response = $adapter->authenticateStreamer($station, $djUser, $djPassword);
$io->write($response);
return 0;
}
$io->write('false');
return 0;
}
}

View File

@ -1,61 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Command\Internal;
use App\Console\Command\CommandAbstract;
use App\Entity;
use App\Radio\Adapters;
use App\Radio\Backend\Liquidsoap;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'azuracast:internal:djoff',
description: 'Indicate that a DJ has finished streaming to a station.',
)]
class DjOffCommand extends CommandAbstract
{
public function __construct(
protected Adapters $adapters,
protected EntityManagerInterface $em
) {
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('station-id', InputArgument::REQUIRED)
->addOption('dj-user', null, InputOption::VALUE_REQUIRED, '', '');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$stationId = (int)$input->getArgument('station-id');
$djUser = $input->getOption('dj-user');
$station = $this->em->find(Entity\Station::class, $stationId);
if (!($station instanceof Entity\Station) || !$station->getEnableStreamers()) {
return 1;
}
$adapter = $this->adapters->getBackendAdapter($station);
if ($adapter instanceof Liquidsoap) {
$io->write($adapter->onDisconnect($station, $djUser));
return 0;
}
$io->write('received');
return 0;
}
}

View File

@ -1,61 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Command\Internal;
use App\Console\Command\CommandAbstract;
use App\Entity;
use App\Radio\Adapters;
use App\Radio\Backend\Liquidsoap;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'azuracast:internal:djon',
description: 'Indicate that a DJ has begun streaming to a station.',
)]
class DjOnCommand extends CommandAbstract
{
public function __construct(
protected Adapters $adapters,
protected EntityManagerInterface $em
) {
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('station-id', InputArgument::REQUIRED)
->addOption('dj-user', null, InputOption::VALUE_REQUIRED, '', '');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$stationId = (int)$input->getArgument('station-id');
$djUser = $input->getOption('dj-user');
$station = $this->em->find(Entity\Station::class, $stationId);
if (!($station instanceof Entity\Station) || !$station->getEnableStreamers()) {
return 1;
}
$adapter = $this->adapters->getBackendAdapter($station);
if ($adapter instanceof Liquidsoap) {
$io->write($adapter->onConnect($station, $djUser));
return 0;
}
$io->write('received');
return 0;
}
}

View File

@ -1,54 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Command\Internal;
use App\Console\Command\CommandAbstract;
use App\Entity;
use App\Radio\AutoDJ;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'azuracast:internal:nextsong',
description: 'Return the next song to the AutoDJ.',
)]
class NextSongCommand extends CommandAbstract
{
public function __construct(
protected EntityManagerInterface $em,
protected AutoDJ\Annotations $annotations,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('station-id', InputArgument::REQUIRED)
->addOption('as-autodj', null, InputOption::VALUE_NONE);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$stationId = (int)$input->getArgument('station-id');
$asAutodj = (bool)$input->getOption('as-autodj');
$station = $this->em->find(Entity\Station::class, $stationId);
if (!($station instanceof Entity\Station)) {
$io->write('false');
return 0;
}
$io->write($this->annotations->annotateNextSong($station, $asAutodj));
return 0;
}
}

View File

@ -34,65 +34,40 @@ class InternalController
$this->checkStationAuth($request);
$station = $request->getStation();
if (!$station->getEnableStreamers()) {
if ($station->getEnableStreamers()) {
$params = (array)$request->getParsedBody();
$user = $params['user'] ?? '';
$pass = $params['password'] ?? '';
$adapter = $request->getStationBackend();
if (($adapter instanceof Liquidsoap) && $adapter->authenticateStreamer($station, $user, $pass)) {
$response->getBody()->write('true');
return $response->withStatus(200);
}
} else {
$this->logger->error(
'Attempted DJ authentication when streamers are disabled on this station.',
[
'station_id' => $station->getId(),
'station_id' => $station->getId(),
'station_name' => $station->getName(),
]
);
$response->getBody()->write('false');
return $response;
}
$params = $request->getParams();
$user = $params['dj-user'] ?? '';
$pass = $params['dj-password'] ?? '';
$adapter = $request->getStationBackend();
if ($adapter instanceof Liquidsoap) {
$response->getBody()->write($adapter->authenticateStreamer($station, $user, $pass));
return $response;
}
$response->getBody()->write('false');
return $response;
}
protected function checkStationAuth(ServerRequest $request): void
{
$station = $request->getStation();
$acl = $request->getAcl();
if ($acl->isAllowed(StationPermissions::View, $station->getId())) {
return;
}
$params = $request->getParams();
$auth_key = $params['api_auth'];
if (!$station->validateAdapterApiKey($auth_key)) {
$this->logger->error(
'Invalid API key supplied for internal API call.',
[
'station_id' => $station->getId(),
'station_name' => $station->getName(),
]
);
throw new PermissionDeniedException();
}
return $response->withStatus(403);
}
public function nextsongAction(ServerRequest $request, Response $response): ResponseInterface
{
$this->checkStationAuth($request);
$params = $request->getParams();
$as_autodj = isset($params['api_auth']);
$response->getBody()->write($this->annotations->annotateNextSong($request->getStation(), $as_autodj));
$response->getBody()->write(
$this->annotations->annotateNextSong(
$request->getStation(),
$request->hasHeader('X-Liquidsoap-Api-Key')
)
);
return $response;
}
@ -103,7 +78,7 @@ class InternalController
$adapter = $request->getStationBackend();
if ($adapter instanceof Liquidsoap) {
$station = $request->getStation();
$user = $request->getParam('dj-user', '');
$user = $request->getParsedBodyParam('user', '');
$this->logger->notice(
'Received "DJ connected" ping from Liquidsoap.',
@ -129,7 +104,7 @@ class InternalController
$adapter = $request->getStationBackend();
if ($adapter instanceof Liquidsoap) {
$station = $request->getStation();
$user = $request->getParam('dj-user', '');
$user = $request->getParsedBodyParam('user', '');
$this->logger->notice(
'Received "DJ disconnected" ping from Liquidsoap.',
@ -152,17 +127,9 @@ class InternalController
{
$this->checkStationAuth($request);
$station = $request->getStation();
$body = $request->getParams();
($this->feedback)(
$station,
[
'song_id' => $body['song'] ?? null,
'media_id' => $body['media'] ?? null,
'playlist_id' => $body['playlist'] ?? null,
]
$request->getStation(),
(array)$request->getParsedBody()
);
$response->getBody()->write('OK');
@ -238,4 +205,27 @@ class InternalController
->withHeader('icecast-auth-user', '0')
->withHeader('icecast-auth-message', 'geo-blocked');
}
protected function checkStationAuth(ServerRequest $request): void
{
$station = $request->getStation();
$acl = $request->getAcl();
if ($acl->isAllowed(StationPermissions::View, $station->getId())) {
return;
}
$authKey = $request->getHeaderLine('X-Liquidsoap-Api-Key');
if (!$station->validateAdapterApiKey($authKey)) {
$this->logger->error(
'Invalid API key supplied for internal API call.',
[
'station_id' => $station->getId(),
'station_name' => $station->getName(),
]
);
throw new PermissionDeniedException();
}
}
}

View File

@ -121,7 +121,7 @@ class ErrorHandler extends \Slim\Handlers\ErrorHandler
// Special handling for cURL requests.
$ua = $this->request->getHeaderLine('User-Agent');
if (false !== stripos($ua, 'curl')) {
if (false !== stripos($ua, 'curl') || false !== stripos($ua, 'Liquidsoap')) {
$response = $this->responseFactory->createResponse($this->statusCode);
$response->getBody()->write(

View File

@ -316,16 +316,14 @@ class Liquidsoap extends AbstractBackend
Entity\Station $station,
string $user = '',
string $pass = ''
): string {
): bool {
// Allow connections using the exact broadcast source password.
$sourcePw = $station->getFrontendConfig()->getSourcePassword();
if (!empty($sourcePw) && strcmp($sourcePw, $pass) === 0) {
return 'true';
return true;
}
return $this->streamerRepo->authenticate($station, $user, $pass)
? 'true'
: 'false';
return $this->streamerRepo->authenticate($station, $user, $pass);
}
public function onConnect(

View File

@ -129,6 +129,15 @@ class ConfigWriter implements EventSubscriberInterface
$stationTz = self::cleanUpString($station->getTimezone());
$stationApiAuth = self::cleanUpString($station->getAdapterApiKey());
if ($this->environment->isDocker()) {
$apiServiceUrl = ($this->environment->isDockerRevisionAtLeast(5))
? 'web'
: 'nginx';
} else {
$apiServiceUrl = 'localhost';
}
$stationApiUrl = self::cleanUpString('http://' . $apiServiceUrl . '/api/internal/' . $station->getId());
$event->appendBlock(
<<<EOF
init.daemon.set(false)
@ -146,9 +155,6 @@ class ConfigWriter implements EventSubscriberInterface
setenv("TZ", "${stationTz}")
azuracast_api_auth = ref("${stationApiAuth}")
ignore(azuracast_api_auth)
autodj_is_loading = ref(true)
ignore(autodj_is_loading)
@ -158,6 +164,26 @@ class ConfigWriter implements EventSubscriberInterface
# Track live-enabled status script-wide for fades.
live_enabled = ref(false)
ignore(live_enabled)
azuracast_api_url = "${stationApiUrl}"
azuracast_api_key = "${stationApiAuth}"
def azuracast_api_call(~timeout=2, url, payload) =
full_url = "#{azuracast_api_url}/#{url}"
log("API #{url} - Sending POST request to '#{full_url}' with body: #{payload}")
response = http.post(full_url,
headers=[
("Content-Type", "application/json"),
("User-Agent", "Liquidsoap AzuraCast"),
("X-Liquidsoap-Api-Key", "#{azuracast_api_key}")
],
timeout=timeout,
data=payload
)
log("API #{url} - Response (#{response.status_code}): #{response}")
response
end
EOF
);
}
@ -409,20 +435,18 @@ class ConfigWriter implements EventSubscriberInterface
}
if (!$station->useManualAutoDJ()) {
$nextsongCommand = $this->getApiUrlCommand($station, 'nextsong');
$event->appendBlock(
<<< EOF
# AutoDJ Next Song Script
def autodj_next_song() =
log("autodj_next_song: Sending AzuraCast API Call...")
uri = {$nextsongCommand}
log("autodj_next_song: AzuraCast API Response: #{uri}")
if uri == "" or string.match(pattern="Error", uri) then
response = azuracast_api_call(
"nextsong",
""
)
if (response.status_code != 200) or (response == "") or (string.match(pattern="Error", response)) then
null()
else
r = request.create(uri)
r = request.create(response)
if request.resolve(r) then
r
else
@ -637,55 +661,6 @@ class ConfigWriter implements EventSubscriberInterface
return $play_time;
}
/**
* Returns the URL that LiquidSoap should call when attempting to execute AzuraCast API commands.
*
* @param Entity\Station $station
* @param string $endpoint
* @param array $params
*/
protected function getApiUrlCommand(Entity\Station $station, string $endpoint, array $params = []): string
{
// Docker cURL-based API URL call with API authentication.
if ($this->environment->isDocker()) {
$params = (array)$params;
$params['api_auth'] = '!azuracast_api_auth';
$service_uri = ($this->environment->isDockerRevisionAtLeast(5)) ? 'web' : 'nginx';
/** @noinspection HttpUrlsUsage */
$api_url = 'http://' . $service_uri . '/api/internal/' . $station->getId() . '/' . $endpoint;
$command = 'curl -s --request POST --url ' . $api_url;
foreach ($params as $paramKey => $paramVal) {
$envVarKey = strtoupper(str_replace('-', '_', $paramKey));
$command .= ' --form ' . $paramKey . '="$' . $envVarKey . '"';
}
} else {
// Ansible shell-script call.
$shell_path = '/usr/bin/php ' . $this->environment->getBaseDirectory() . '/bin/console';
$shell_args = [];
$shell_args[] = 'azuracast:internal:' . $endpoint;
$shell_args[] = $station->getId();
foreach ((array)$params as $paramKey => $paramVal) {
$envVarKey = strtoupper(str_replace('-', '_', $paramKey));
$shell_args [] = '--' . $paramKey . '="$' . $envVarKey . '"';
}
$command = $shell_path . ' ' . implode(' ', $shell_args);
}
$envVarsParts = [];
foreach ($params as $envVarName => $envVarValue) {
$envVarKey = strtoupper(str_replace('-', '_', $envVarName));
$envVarsParts[] = '("' . $envVarKey . '", ' . $envVarValue . ')';
}
$envVarsStr = 'env=[' . implode(', ', $envVarsParts) . ']';
return 'list.hd(process.read.lines(' . $envVarsStr . ', \'' . $command . '\', timeout=2.), default="")';
}
public function writeCrossfadeConfiguration(WriteLiquidsoapConfiguration $event): void
{
$settings = $event->getStation()->getBackendConfig();
@ -726,14 +701,6 @@ class ConfigWriter implements EventSubscriberInterface
$dj_mount = $settings->getDjMountPoint();
$recordLiveStreams = $settings->recordStreams();
$authCommand = $this->getApiUrlCommand(
$station,
'auth',
['dj-user' => 'auth_info.user', 'dj-password' => 'auth_info.password']
);
$djonCommand = $this->getApiUrlCommand($station, 'djon', ['dj-user' => 'dj']);
$djoffCommand = $this->getApiUrlCommand($station, 'djoff', ['dj-user' => 'dj']);
$event->appendBlock(
<<< EOF
# DJ Authentication
@ -754,10 +721,11 @@ class ConfigWriter implements EventSubscriberInterface
log("dj_auth: Sending AzuraCast API DJ Auth command for user: #{auth_info.user}")
ret = {$authCommand}
log("dj_auth: AzuraCast API Response: #{ret}")
if bool_of_string(ret) then
response = azuracast_api_call(
"auth",
json.stringify(auth_info)
)
if response.status_code == 200 then
last_authenticated_dj := auth_info.user
true
else
@ -772,23 +740,28 @@ class ConfigWriter implements EventSubscriberInterface
live_enabled := true
live_dj := dj
log("live_connected: Sending AzuraCast API DJ onConnect command...")
ret = {$djonCommand}
log("live_connected: AzuraCast API Response: #{ret}")
if (string.contains(prefix="/", ret)) then
live_record_path := ret
j = json()
j.add("user", dj)
response = azuracast_api_call(
"djon",
json.stringify(j)
)
if response.status_code == 200 and string.contains(prefix="/", response) then
live_record_path := response
end
end
def live_disconnected() =
dj = !live_dj
log("DJ Source disconnected! Current live DJ: #{dj}")
j = json()
j.add("user", dj)
log("live_disconnected: Sending AzuraCast API DJ onDisconnect command...")
ret = {$djoffCommand}
log("live_disconnected: AzuraCast API Response: #{ret}")
_ = azuracast_api_call(
"djoff",
json.stringify(j)
)
live_enabled := false
last_authenticated_dj := ""
@ -926,20 +899,21 @@ class ConfigWriter implements EventSubscriberInterface
// Custom configuration
$this->writeCustomConfigurationSection($event, self::CUSTOM_PRE_BROADCAST);
$feedbackCommand = $this->getApiUrlCommand(
$station,
'feedback',
['song' => 'm["song_id"]', 'media' => 'm["media_id"]', 'playlist' => 'm["playlist_id"]']
);
$event->appendBlock(
<<<EOF
# Send metadata changes back to AzuraCast
def metadata_updated(m) =
def f() =
if (m["song_id"] != "") then
ret = {$feedbackCommand}
log("AzuraCast Feedback Response: #{ret}")
j = json()
j.add("song_id", m["song_id"])
j.add("media_id", m["media_id"])
j.add("playlist_id", m["playlist_id"])
_ = azuracast_api_call(
"feedback",
json.stringify(j)
)
end
end