Merge commit 'aab4a0c7b5edab1ca261a528e3407ff2f1b59297'
This commit is contained in:
parent
18fe4060e7
commit
1e3ccd93d5
44 changed files with 673 additions and 555 deletions
|
@ -5,7 +5,9 @@ release channel, you can take advantage of these new features and fixes.
|
|||
|
||||
## New Features/Changes
|
||||
|
||||
There have been no new features/changes since the last stable release.
|
||||
- **LetsEncrypt via the Web**: We now support configuring LetsEncrypt via the web interface. If you had previously set
|
||||
up LetsEncrypt via the command line, your settings will be imported automatically. This update also adds LetsEncrypt
|
||||
support for Ansible installations.
|
||||
|
||||
## Code Quality/Technical Changes
|
||||
|
||||
|
|
1
backups/backup_Thursday_0.json
Normal file
1
backups/backup_Thursday_0.json
Normal file
|
@ -0,0 +1 @@
|
|||
{"users":[],"groups":[],"folders":[],"admins":[],"api_keys":[],"shares":[],"version":12}
|
1
backups/backup_Wednesday_0.json
Normal file
1
backups/backup_Wednesday_0.json
Normal file
|
@ -0,0 +1 @@
|
|||
{"users":[{"id":1,"status":1,"username":"test","expiration_date":0,"password":"$2a$10$1ULW1yY/Vwxzbl77gCoyGu.rzPROEv6Z07n9/D.FcLneJJ35Z8o52","home_dir":"/var/azuracast/stations/azuratest_radio/media","uid":0,"gid":0,"max_sessions":0,"quota_size":1000000000,"quota_files":0,"permissions":{"/":["*"]},"upload_data_transfer":0,"download_data_transfer":0,"total_data_transfer":0,"last_login":1654581296256,"created_at":1654581296198,"updated_at":1654581296198,"filters":{"hooks":{"external_auth_disabled":false,"pre_login_disabled":false,"check_password_disabled":false},"totp_config":{"secret":{}}},"filesystem":{"provider":0,"s3config":{"access_secret":{}},"gcsconfig":{"credentials":{}},"azblobconfig":{"account_key":{},"sas_url":{}},"cryptconfig":{"passphrase":{}},"sftpconfig":{"password":{},"private_key":{},"key_passphrase":{}}}}],"groups":[],"folders":[],"admins":[],"api_keys":[],"shares":[],"version":12}
|
|
@ -70,6 +70,7 @@
|
|||
"psr/simple-cache": ">1",
|
||||
"ramsey/uuid": "^4.0",
|
||||
"rlanvin/php-ip": "dev-master",
|
||||
"skoerfgen/acmecert": "^3.2",
|
||||
"slim/http": "^1.1",
|
||||
"slim/slim": "^4.2",
|
||||
"spatie/flysystem-dropbox": "^2",
|
||||
|
|
46
composer.lock
generated
46
composer.lock
generated
|
@ -4,7 +4,7 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "d0f53f37fc280407f5070076e53e3bc2",
|
||||
"content-hash": "13899e60907b126bd3c5b7ea5cbf1278",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
|
@ -6242,6 +6242,50 @@
|
|||
},
|
||||
"time": "2022-03-02T08:51:37+00:00"
|
||||
},
|
||||
{
|
||||
"name": "skoerfgen/acmecert",
|
||||
"version": "3.2.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/skoerfgen/ACMECert.git",
|
||||
"reference": "706564824eed25896b2e02d80095bd82c051fc2d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/skoerfgen/ACMECert/zipball/706564824eed25896b2e02d80095bd82c051fc2d",
|
||||
"reference": "706564824eed25896b2e02d80095bd82c051fc2d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-openssl": "*",
|
||||
"php": ">=5.6.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-curl": "Optional for better http performance"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"skoerfgen\\ACMECert\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Stefan Körfgen",
|
||||
"homepage": "https://github.com/skoerfgen"
|
||||
}
|
||||
],
|
||||
"description": "PHP client library for Let's Encrypt and other ACME v2 - RFC 8555 compatible Certificate Authorities",
|
||||
"support": {
|
||||
"issues": "https://github.com/skoerfgen/ACMECert/issues",
|
||||
"source": "https://github.com/skoerfgen/ACMECert"
|
||||
},
|
||||
"time": "2022-04-22T23:10:20+00:00"
|
||||
},
|
||||
{
|
||||
"name": "slim/http",
|
||||
"version": "1.2.0",
|
||||
|
|
|
@ -4,6 +4,7 @@ use App\Console\Command;
|
|||
|
||||
return function (App\Event\BuildConsoleCommands $event) {
|
||||
$event->addAliases([
|
||||
'azuracast:acme:get-certificate' => Command\Acme\GetCertificateCommand::class,
|
||||
'azuracast:backup' => Command\Backup\BackupCommand::class,
|
||||
'azuracast:restore' => Command\Backup\RestoreCommand::class,
|
||||
'azuracast:debug:optimize-tables' => Command\Debug\OptimizeTablesCommand::class,
|
||||
|
@ -37,5 +38,6 @@ return function (App\Event\BuildConsoleCommands $event) {
|
|||
'queue:process' => Command\MessageQueue\ProcessCommand::class,
|
||||
'queue:clear' => Command\MessageQueue\ClearCommand::class,
|
||||
'cache:clear' => Command\ClearCacheCommand::class,
|
||||
'acme:cert' => Command\Acme\GetCertificateCommand::class,
|
||||
]);
|
||||
};
|
||||
|
|
|
@ -138,6 +138,7 @@ return function (CallableEventDispatcherInterface $dispatcher) {
|
|||
App\Sync\Task\MoveBroadcastsTask::class,
|
||||
App\Sync\Task\QueueInterruptingTracks::class,
|
||||
App\Sync\Task\ReactivateStreamerTask::class,
|
||||
App\Sync\Task\RenewAcmeCertTask::class,
|
||||
App\Sync\Task\RotateLogsTask::class,
|
||||
App\Sync\Task\RunAnalyticsTask::class,
|
||||
App\Sync\Task\RunAutomatedAssignmentTask::class,
|
||||
|
|
|
@ -13,6 +13,8 @@ return [
|
|||
|
||||
Message\BackupMessage::class => Task\RunBackupTask::class,
|
||||
|
||||
Message\GenerateAcmeCertificate::class => App\Service\Acme::class,
|
||||
|
||||
Message\DispatchWebhookMessage::class => App\Webhook\Dispatcher::class,
|
||||
Message\TestWebhookMessage::class => App\Webhook\Dispatcher::class,
|
||||
|
||||
|
|
|
@ -89,6 +89,16 @@ return static function (RouteCollectorProxy $group) {
|
|||
Controller\Api\Admin\SendTestMessageAction::class
|
||||
)->setName('api:admin:send-test-message');
|
||||
|
||||
$group->put(
|
||||
'/acme',
|
||||
Controller\Api\Admin\Acme\GenerateCertificateAction::class
|
||||
)->setName('api:admin:acme');
|
||||
|
||||
$group->get(
|
||||
'/acme-log/{path}',
|
||||
Controller\Api\Admin\Acme\CertificateLogAction::class
|
||||
)->setName('api:admin:acme-log');
|
||||
|
||||
$group->get(
|
||||
'/custom_assets/{type}',
|
||||
Controller\Api\Admin\CustomAssets\GetCustomAssetAction::class
|
||||
|
|
|
@ -10,7 +10,8 @@ services:
|
|||
- "127.0.0.1:3306:3306"
|
||||
- "127.0.0.1:6379:6379"
|
||||
volumes:
|
||||
- $PWD/util/local_ssl:/etc/nginx/certs
|
||||
- $PWD/util/local_ssl/default.crt:/var/azuracast/acme/ssl.crt
|
||||
- $PWD/util/local_ssl/default.key:/var/azuracast/acme/ssl.key
|
||||
- $PWD/vendor:/var/azuracast/www/vendor
|
||||
- $PWD:/var/azuracast/www
|
||||
extra_hosts:
|
||||
|
|
|
@ -183,8 +183,6 @@ services:
|
|||
PUID: ${AZURACAST_PUID:-1000}
|
||||
PGID: ${AZURACAST_PGID:-1000}
|
||||
volumes:
|
||||
- letsencrypt:/etc/nginx/certs
|
||||
- letsencrypt_acme:/etc/acme.sh
|
||||
- www_uploads:/var/azuracast/uploads
|
||||
- station_data:/var/azuracast/stations
|
||||
- shoutcast2_install:/var/azuracast/servers/shoutcast2
|
||||
|
@ -192,6 +190,7 @@ services:
|
|||
- geolite_install:/var/azuracast/geoip
|
||||
- sftpgo_data:/var/azuracast/sftpgo/persist
|
||||
- backups:/var/azuracast/backups
|
||||
- acme:/var/azuracast/acme
|
||||
- db_data:/var/lib/mysql
|
||||
restart: unless-stopped
|
||||
ulimits: &default-ulimits
|
||||
|
@ -205,8 +204,7 @@ services:
|
|||
|
||||
volumes:
|
||||
db_data: { }
|
||||
letsencrypt: { }
|
||||
letsencrypt_acme: { }
|
||||
acme: { }
|
||||
shoutcast2_install: { }
|
||||
stereo_tool_install: { }
|
||||
geolite_install: { }
|
||||
|
|
17
docker.sh
17
docker.sh
|
@ -200,14 +200,6 @@ setup-ports() {
|
|||
envfile-set "AZURACAST_SFTP_PORT" "2022" "Port to use for SFTP connections"
|
||||
}
|
||||
|
||||
#
|
||||
# Configure the settings used by LetsEncrypt.
|
||||
#
|
||||
setup-letsencrypt() {
|
||||
envfile-set "LETSENCRYPT_HOST" "" "Domain name (example.com) or names (example.com,foo.bar) to use with LetsEncrypt"
|
||||
envfile-set "LETSENCRYPT_EMAIL" "" "Optional e-mail address for expiration updates"
|
||||
}
|
||||
|
||||
#
|
||||
# Configure release mode settings.
|
||||
#
|
||||
|
@ -792,13 +784,14 @@ uninstall() {
|
|||
}
|
||||
|
||||
#
|
||||
# Create and link a LetsEncrypt SSL certificate.
|
||||
# Usage: ./docker.sh letsencrypt-create
|
||||
# LetsEncrypt: Now managed via the Web UI.
|
||||
#
|
||||
setup-letsencrypt() {
|
||||
echo "LetsEncrypt is now managed from within the web interface."
|
||||
}
|
||||
|
||||
letsencrypt-create() {
|
||||
setup-letsencrypt
|
||||
docker-compose down
|
||||
docker-compose up -d
|
||||
exit
|
||||
}
|
||||
|
||||
|
|
|
@ -23,7 +23,8 @@
|
|||
:tab-class="getTabClass($v.securityPrivacyTab)"></settings-security-privacy-tab>
|
||||
<settings-services-tab :form="$v.form" :tab-class="getTabClass($v.servicesTab)"
|
||||
:release-channel="releaseChannel"
|
||||
:test-message-url="testMessageUrl"></settings-services-tab>
|
||||
:test-message-url="testMessageUrl"
|
||||
:acme-url="acmeUrl"></settings-services-tab>
|
||||
</b-tabs>
|
||||
</b-overlay>
|
||||
|
||||
|
@ -52,6 +53,7 @@ export default {
|
|||
props: {
|
||||
apiUrl: String,
|
||||
testMessageUrl: String,
|
||||
acmeUrl: String,
|
||||
releaseChannel: {
|
||||
type: String,
|
||||
default: 'rolling',
|
||||
|
@ -84,6 +86,8 @@ export default {
|
|||
api_access_control: {},
|
||||
|
||||
check_for_updates: {},
|
||||
acme_email: {},
|
||||
acme_domains: {},
|
||||
mail_enabled: {},
|
||||
mail_sender_name: {},
|
||||
mail_sender_email: {},
|
||||
|
@ -106,7 +110,9 @@ export default {
|
|||
'form.analytics', 'form.always_use_ssl', 'form.api_access_control'
|
||||
],
|
||||
servicesTab: [
|
||||
'form.check_for_updates', 'form.mail_enabled', 'form.mail_sender_name', 'form.mail_sender_email',
|
||||
'form.check_for_updates',
|
||||
'form.acme_email', 'form.acme_domains',
|
||||
'form.mail_enabled', 'form.mail_sender_name', 'form.mail_sender_email',
|
||||
'form.mail_smtp_host', 'form.mail_smtp_port', 'form.mail_smtp_secure', 'form.mail_smtp_username',
|
||||
'form.mail_smtp_password', 'form.avatar_service', 'form.avatar_default_url',
|
||||
'form.use_external_album_art_in_apis', 'form.use_external_album_art_when_processing_media',
|
||||
|
@ -148,6 +154,8 @@ export default {
|
|||
api_access_control: data.api_access_control,
|
||||
|
||||
check_for_updates: data.check_for_updates,
|
||||
acme_email: data.acme_email,
|
||||
acme_domains: data.acme_domains,
|
||||
mail_enabled: data.mail_enabled,
|
||||
mail_sender_name: data.mail_sender_name,
|
||||
mail_sender_email: data.mail_sender_email,
|
||||
|
|
|
@ -35,6 +35,49 @@
|
|||
</b-form-row>
|
||||
</b-form-fieldset>
|
||||
|
||||
<b-form-fieldset>
|
||||
<template #label>
|
||||
<translate key="lang_section_letsencrypt">LetsEncrypt</translate>
|
||||
</template>
|
||||
<template #description>
|
||||
<translate key="lang_section_letsencrypt_desc">LetsEncrypt provides simple, free SSL certificates allowing you to secure traffic through your control panel and radio streams.</translate>
|
||||
</template>
|
||||
|
||||
<b-form-row>
|
||||
<b-wrapped-form-group class="col-md-6" id="edit_form_acme_email"
|
||||
:field="form.acme_email" input-type="email">
|
||||
<template #label="{lang}">
|
||||
<translate :key="lang">E-mail Address</translate>
|
||||
</template>
|
||||
<template #description="{lang}">
|
||||
<translate
|
||||
:key="lang">Enter your e-mail address to receive updates about your certificate.</translate>
|
||||
</template>
|
||||
</b-wrapped-form-group>
|
||||
|
||||
<b-wrapped-form-group class="col-md-6" id="edit_form_acme_domains"
|
||||
:field="form.acme_domains">
|
||||
<template #label="{lang}">
|
||||
<translate :key="lang">Domain Name(s)</translate>
|
||||
</template>
|
||||
<template #description="{lang}">
|
||||
<translate
|
||||
:key="lang">All listed domain names should point to this AzuraCast installation. Separate multiple domain names with commas.</translate>
|
||||
</template>
|
||||
</b-wrapped-form-group>
|
||||
|
||||
<div class="form-group col">
|
||||
<b-button size="sm" variant="primary" :disabled="form.$anyDirty" @click="generateAcmeCert">
|
||||
<icon icon="badge"></icon>
|
||||
<translate key="lang_btn_acme_cert">Generate/Renew Certificate</translate>
|
||||
<span v-if="form.$anyDirty">
|
||||
(<translate key="lang_btn_acme_cert_save_changes">Save Changes first</translate>)
|
||||
</span>
|
||||
</b-button>
|
||||
</div>
|
||||
</b-form-row>
|
||||
</b-form-fieldset>
|
||||
|
||||
<b-form-fieldset>
|
||||
<template #label>
|
||||
<translate key="lang_section_email_delivery">E-mail Delivery Service</translate>
|
||||
|
@ -177,6 +220,8 @@
|
|||
</b-form-row>
|
||||
</b-form-fieldset>
|
||||
|
||||
<streaming-log-modal ref="acmeModal"></streaming-log-modal>
|
||||
|
||||
<admin-settings-test-message-modal :test-message-url="testMessageUrl"></admin-settings-test-message-modal>
|
||||
</b-tab>
|
||||
</template>
|
||||
|
@ -188,18 +233,25 @@ import BFormFieldset from "~/components/Form/BFormFieldset";
|
|||
import BWrappedFormCheckbox from "~/components/Form/BWrappedFormCheckbox";
|
||||
import AdminSettingsTestMessageModal from "~/components/Admin/Settings/TestMessageModal";
|
||||
import Icon from "~/components/Common/Icon";
|
||||
import StreamingLogModal from "~/components/Common/StreamingLogModal";
|
||||
|
||||
export default {
|
||||
name: 'SettingsServicesTab',
|
||||
components: {
|
||||
StreamingLogModal,
|
||||
Icon,
|
||||
AdminSettingsTestMessageModal, BWrappedFormCheckbox, BFormFieldset, BWrappedFormGroup, BFormMarkup
|
||||
AdminSettingsTestMessageModal,
|
||||
BWrappedFormCheckbox,
|
||||
BFormFieldset,
|
||||
BWrappedFormGroup,
|
||||
BFormMarkup
|
||||
},
|
||||
props: {
|
||||
form: Object,
|
||||
tabClass: {},
|
||||
releaseChannel: String,
|
||||
testMessageUrl: String,
|
||||
acmeUrl: String,
|
||||
},
|
||||
computed: {
|
||||
langTabTitle() {
|
||||
|
@ -226,6 +278,15 @@ export default {
|
|||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
generateAcmeCert() {
|
||||
this.$wrapWithLoading(
|
||||
this.axios.put(this.acmeUrl)
|
||||
).then((resp) => {
|
||||
this.$refs.acmeModal.show(resp.data.links.log);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
40
src/Console/Command/Acme/GetCertificateCommand.php
Normal file
40
src/Console/Command/Acme/GetCertificateCommand.php
Normal file
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Command\Acme;
|
||||
|
||||
use App\Console\Command\CommandAbstract;
|
||||
use App\Service\Acme;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'azuracast:acme:get-certificate',
|
||||
description: 'Get a new or updated ACME (LetsEncrypt) certificate.',
|
||||
aliases: ['acme:cert']
|
||||
)]
|
||||
final class GetCertificateCommand extends CommandAbstract
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Acme $acme
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
try {
|
||||
$this->acme->getCertificate();
|
||||
} catch (\Exception $e) {
|
||||
$io->error($e->getMessage());
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
|
@ -6,6 +6,7 @@ namespace App\Console\Command;
|
|||
|
||||
use App\Entity;
|
||||
use App\Environment;
|
||||
use App\Service\Acme;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
@ -20,6 +21,7 @@ class InitializeCommand extends CommandAbstract
|
|||
public function __construct(
|
||||
protected Environment $environment,
|
||||
protected Entity\Repository\StorageLocationRepository $storageLocationRepo,
|
||||
protected Acme $acme,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
@ -68,6 +70,13 @@ class InitializeCommand extends CommandAbstract
|
|||
// Ensure default storage locations exist.
|
||||
$this->storageLocationRepo->createDefaultStorageLocations();
|
||||
|
||||
// Pull Acme certificates if necessary.
|
||||
try {
|
||||
$this->acme->getCertificate();
|
||||
} catch (\Exception) {
|
||||
// Noop
|
||||
}
|
||||
|
||||
$io->newLine();
|
||||
$io->success(
|
||||
[
|
||||
|
|
|
@ -33,6 +33,7 @@ final class SettingsAction
|
|||
'group' => Settings::GROUP_GENERAL,
|
||||
]),
|
||||
'testMessageUrl' => (string)$router->named('api:admin:send-test-message'),
|
||||
'acmeUrl' => (string)$router->named('api:admin:acme'),
|
||||
'releaseChannel' => $this->version->getReleaseChannelEnum()->value,
|
||||
],
|
||||
);
|
||||
|
|
30
src/Controller/Api/Admin/Acme/CertificateLogAction.php
Normal file
30
src/Controller/Api/Admin/Acme/CertificateLogAction.php
Normal file
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Api\Admin\Acme;
|
||||
|
||||
use App\Controller\Api\Traits\HasLogViewer;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
use App\Utilities\File;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
final class CertificateLogAction
|
||||
{
|
||||
use HasLogViewer;
|
||||
|
||||
public function __invoke(
|
||||
ServerRequest $request,
|
||||
Response $response,
|
||||
string $path
|
||||
): ResponseInterface {
|
||||
$tempPath = File::validateTempPath($path);
|
||||
|
||||
return $this->streamLogToResponse(
|
||||
$request,
|
||||
$response,
|
||||
$tempPath
|
||||
);
|
||||
}
|
||||
}
|
44
src/Controller/Api/Admin/Acme/GenerateCertificateAction.php
Normal file
44
src/Controller/Api/Admin/Acme/GenerateCertificateAction.php
Normal file
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Api\Admin\Acme;
|
||||
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
use App\Message\GenerateAcmeCertificate;
|
||||
use App\Utilities\File;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Symfony\Component\Messenger\MessageBus;
|
||||
|
||||
final class GenerateCertificateAction
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MessageBus $messageBus
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(
|
||||
ServerRequest $request,
|
||||
Response $response
|
||||
): ResponseInterface {
|
||||
$tempFile = File::generateTempPath('acme_test.log');
|
||||
|
||||
$message = new GenerateAcmeCertificate();
|
||||
$message->outputPath = $tempFile;
|
||||
|
||||
$this->messageBus->dispatch($message);
|
||||
|
||||
$router = $request->getRouter();
|
||||
return $response->withJson(
|
||||
[
|
||||
'success' => true,
|
||||
'links' => [
|
||||
'log' => (string)$router->fromHere('api:admin:acme-log', [
|
||||
'path' => basename($tempFile),
|
||||
]),
|
||||
],
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
28
src/Entity/Migration/Version20220608113502.php
Normal file
28
src/Entity/Migration/Version20220608113502.php
Normal file
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity\Migration;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20220608113502 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add ACME settings to Settings table.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(
|
||||
'ALTER TABLE settings ADD acme_email VARCHAR(255) DEFAULT NULL, ADD acme_domains VARCHAR(255) DEFAULT NULL'
|
||||
);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE settings DROP acme_email, DROP acme_domains');
|
||||
}
|
||||
}
|
|
@ -75,6 +75,18 @@ final class StationRepository extends Repository
|
|||
return $select;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<Entity\Station>
|
||||
*/
|
||||
public function iterateEnabledStations(): iterable
|
||||
{
|
||||
return $this->em->createQuery(
|
||||
<<<DQL
|
||||
SELECT s FROM App\Entity\Station s WHERE s.is_enabled = 1
|
||||
DQL
|
||||
)->toIterable();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $short_code
|
||||
*/
|
||||
|
|
|
@ -1006,6 +1006,40 @@ class Settings implements Stringable
|
|||
$this->avatar_default_url = $avatarDefaultUrl;
|
||||
}
|
||||
|
||||
#[
|
||||
OA\Property(description: "ACME (LetsEncrypt) e-mail address.", example: ""),
|
||||
ORM\Column(length: 255, nullable: true),
|
||||
Groups(self::GROUP_GENERAL)
|
||||
]
|
||||
protected ?string $acme_email = null;
|
||||
|
||||
public function getAcmeEmail(): ?string
|
||||
{
|
||||
return $this->acme_email;
|
||||
}
|
||||
|
||||
public function setAcmeEmail(?string $acme_email): void
|
||||
{
|
||||
$this->acme_email = $acme_email;
|
||||
}
|
||||
|
||||
#[
|
||||
OA\Property(description: "ACME (LetsEncrypt) domain name(s).", example: ""),
|
||||
ORM\Column(length: 255, nullable: true),
|
||||
Groups(self::GROUP_GENERAL)
|
||||
]
|
||||
protected ?string $acme_domains = null;
|
||||
|
||||
public function getAcmeDomains(): ?string
|
||||
{
|
||||
return $this->acme_domains;
|
||||
}
|
||||
|
||||
public function setAcmeDomains(?string $acme_domains): void
|
||||
{
|
||||
$this->acme_domains = $acme_domains;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return 'Settings';
|
||||
|
|
|
@ -241,23 +241,6 @@ class InstallCommand extends Command
|
|||
$env['AZURACAST_STATION_PORTS'] = implode(',', $stationPorts);
|
||||
}
|
||||
|
||||
$customizeLetsEncrypt = $io->confirm(
|
||||
__('Set up LetsEncrypt?'),
|
||||
false
|
||||
);
|
||||
|
||||
if ($customizeLetsEncrypt) {
|
||||
$env['LETSENCRYPT_HOST'] = $io->ask(
|
||||
$envConfig['LETSENCRYPT_HOST']['description'],
|
||||
$env['LETSENCRYPT_HOST'] ?? ''
|
||||
);
|
||||
|
||||
$env['LETSENCRYPT_EMAIL'] = $io->ask(
|
||||
$envConfig['LETSENCRYPT_EMAIL']['description'],
|
||||
$env['LETSENCRYPT_EMAIL'] ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
$azuracastEnv['COMPOSER_PLUGIN_MODE'] = $io->confirm(
|
||||
$azuracastEnvConfig['COMPOSER_PLUGIN_MODE']['name'],
|
||||
$azuracastEnv->getAsBool('COMPOSER_PLUGIN_MODE', false)
|
||||
|
|
|
@ -84,20 +84,6 @@ class EnvFile extends AbstractEnvFile
|
|||
'name' => __('Advanced: Use Privileged Docker Settings'),
|
||||
'default' => true,
|
||||
],
|
||||
'LETSENCRYPT_HOST' => [
|
||||
'name' => __('LetsEncrypt Domain Name(s)'),
|
||||
'default' => '',
|
||||
'description' => __(
|
||||
'Domain name (example.com) or names (example.com,foo.bar) to use with LetsEncrypt.'
|
||||
),
|
||||
],
|
||||
'LETSENCRYPT_EMAIL' => [
|
||||
'name' => __('LetsEncrypt E-mail Address'),
|
||||
'default' => '',
|
||||
'description' => __(
|
||||
'Optionally provide an e-mail address for updates from LetsEncrypt.',
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
11
src/Message/GenerateAcmeCertificate.php
Normal file
11
src/Message/GenerateAcmeCertificate.php
Normal file
|
@ -0,0 +1,11 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Message;
|
||||
|
||||
class GenerateAcmeCertificate extends AbstractMessage
|
||||
{
|
||||
/** @var string|null The path to log output of the Backup command to. */
|
||||
public ?string $outputPath = null;
|
||||
}
|
|
@ -1,24 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Radio;
|
||||
|
||||
class Certificate
|
||||
{
|
||||
public function __construct(
|
||||
protected string $keyPath,
|
||||
protected string $certPath
|
||||
) {
|
||||
}
|
||||
|
||||
public function getKeyPath(): string
|
||||
{
|
||||
return $this->keyPath;
|
||||
}
|
||||
|
||||
public function getCertPath(): string
|
||||
{
|
||||
return $this->certPath;
|
||||
}
|
||||
}
|
|
@ -1,53 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Radio;
|
||||
|
||||
use App\Environment;
|
||||
|
||||
class CertificateLocator
|
||||
{
|
||||
public static function findCertificate(): Certificate
|
||||
{
|
||||
// Check environment variable for a virtual host.
|
||||
$certBase = '/etc/nginx/certs';
|
||||
|
||||
if (is_dir($certBase)) {
|
||||
if (!empty($_ENV['VIRTUAL_HOST'])) {
|
||||
$vhost = $_ENV['VIRTUAL_HOST'];
|
||||
$domainKey = $certBase . '/' . $vhost . '.key';
|
||||
$domainCert = $certBase . '/' . $vhost . '.crt';
|
||||
|
||||
if (file_exists($domainKey) && file_exists($domainCert)) {
|
||||
return new Certificate($domainKey, $domainCert);
|
||||
}
|
||||
}
|
||||
|
||||
$generatedKey = $certBase . '/ssl.key';
|
||||
$generatedCert = $certBase . '/ssl.crt';
|
||||
if (file_exists($generatedKey) && file_exists($generatedCert)) {
|
||||
return new Certificate($generatedKey, $generatedCert);
|
||||
}
|
||||
|
||||
$defaultKey = $certBase . '/default.key';
|
||||
$defaultCert = $certBase . '/default.crt';
|
||||
if (file_exists($defaultKey) && file_exists($defaultCert)) {
|
||||
return new Certificate($defaultKey, $defaultCert);
|
||||
}
|
||||
}
|
||||
|
||||
return self::getDefaultCertificates();
|
||||
}
|
||||
|
||||
public static function getDefaultCertificates(): Certificate
|
||||
{
|
||||
$environment = Environment::getInstance();
|
||||
|
||||
if ($environment->isDocker()) {
|
||||
return new Certificate('/etc/nginx/ssl.key', '/etc/nginx/ssl.crt');
|
||||
}
|
||||
|
||||
return new Certificate('/etc/nginx/ssl/server.key', '/etc/nginx/ssl/server.crt');
|
||||
}
|
||||
}
|
|
@ -5,8 +5,8 @@ declare(strict_types=1);
|
|||
namespace App\Radio\Frontend;
|
||||
|
||||
use App\Entity;
|
||||
use App\Radio\CertificateLocator;
|
||||
use App\Radio\Enums\StreamFormats;
|
||||
use App\Service\Acme;
|
||||
use App\Utilities;
|
||||
use App\Xml\Writer;
|
||||
use Exception;
|
||||
|
@ -114,7 +114,7 @@ class Icecast extends AbstractFrontend
|
|||
$settingsBaseUrl = $settings->getBaseUrl() ?: '';
|
||||
$baseUrl = Utilities\Urls::getUri($settingsBaseUrl) ?? new Uri('http://localhost');
|
||||
|
||||
$certPaths = CertificateLocator::findCertificate();
|
||||
[$certPath, $certKey] = Acme::getCertificatePaths();
|
||||
|
||||
$config = [
|
||||
'location' => 'AzuraCast',
|
||||
|
@ -154,8 +154,8 @@ class Icecast extends AbstractFrontend
|
|||
'@dest' => '/status.xsl',
|
||||
],
|
||||
],
|
||||
'ssl-private-key' => $certPaths->getKeyPath(),
|
||||
'ssl-certificate' => $certPaths->getCertPath(),
|
||||
'ssl-private-key' => $certKey,
|
||||
'ssl-certificate' => $certPath,
|
||||
// phpcs:disable Generic.Files.LineLength
|
||||
'ssl-allowed-ciphers' => 'ECDH+AESGCM:DH+AESGCM:ECDH+AES256:DH+AES256:ECDH+AES128:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!MD5:!DSS',
|
||||
// phpcs:enable
|
||||
|
|
|
@ -5,7 +5,7 @@ declare(strict_types=1);
|
|||
namespace App\Radio\Frontend;
|
||||
|
||||
use App\Entity;
|
||||
use App\Radio\CertificateLocator;
|
||||
use App\Service\Acme;
|
||||
use Exception;
|
||||
use NowPlaying\Result\Result;
|
||||
use Psr\Http\Message\UriInterface;
|
||||
|
@ -111,7 +111,7 @@ class Shoutcast extends AbstractFrontend
|
|||
$configPath = $station->getRadioConfigDir();
|
||||
$frontendConfig = $station->getFrontendConfig();
|
||||
|
||||
$certPaths = CertificateLocator::findCertificate();
|
||||
[$certPath, $certKey] = Acme::getCertificatePaths();
|
||||
|
||||
$config = [
|
||||
'password' => $frontendConfig->getSourcePassword(),
|
||||
|
@ -128,8 +128,8 @@ class Shoutcast extends AbstractFrontend
|
|||
'saveagentlistonexit' => '0',
|
||||
'licenceid' => $frontendConfig->getScLicenseId(),
|
||||
'userid' => $frontendConfig->getScUserId(),
|
||||
'sslCertificateFile' => $certPaths->getCertPath(),
|
||||
'sslCertificateKeyFile' => $certPaths->getKeyPath(),
|
||||
'sslCertificateFile' => $certPath,
|
||||
'sslCertificateKeyFile' => $certKey,
|
||||
];
|
||||
|
||||
$customConfig = trim($frontendConfig->getCustomConfiguration() ?? '');
|
||||
|
@ -168,7 +168,7 @@ class Shoutcast extends AbstractFrontend
|
|||
|
||||
$configFileOutput = '';
|
||||
foreach ($config as $config_key => $config_value) {
|
||||
$configFileOutput .= $config_key . '=' . str_replace("\n", '', (string) $config_value) . "\n";
|
||||
$configFileOutput .= $config_key . '=' . str_replace("\n", '', (string)$config_value) . "\n";
|
||||
}
|
||||
|
||||
return $configFileOutput;
|
||||
|
|
206
src/Service/Acme.php
Normal file
206
src/Service/Acme.php
Normal file
|
@ -0,0 +1,206 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Repository\SettingsRepository;
|
||||
use App\Entity\Repository\StationRepository;
|
||||
use App\Environment;
|
||||
use App\Message\AbstractMessage;
|
||||
use App\Message\GenerateAcmeCertificate;
|
||||
use App\Nginx\Nginx;
|
||||
use App\Radio\Adapters;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Logger;
|
||||
use Psr\Log\LogLevel;
|
||||
use skoerfgen\ACMECert\ACMECert;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
|
||||
final class Acme
|
||||
{
|
||||
public const LETSENCRYPT_PROD = 'https://acme-v02.api.letsencrypt.org/directory';
|
||||
public const LETSENCRYPT_DEV = 'https://acme-staging-v02.api.letsencrypt.org/directory';
|
||||
public const THRESHOLD_DAYS = 14;
|
||||
|
||||
public function __construct(
|
||||
private readonly SettingsRepository $settingsRepo,
|
||||
private readonly StationRepository $stationRepo,
|
||||
private readonly Environment $environment,
|
||||
private readonly Logger $logger,
|
||||
private readonly Nginx $nginx,
|
||||
private readonly Adapters $adapters,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(AbstractMessage $message): void
|
||||
{
|
||||
if ($message instanceof GenerateAcmeCertificate) {
|
||||
$outputPath = $message->outputPath;
|
||||
|
||||
if (null !== $outputPath) {
|
||||
$logHandler = new StreamHandler($outputPath, LogLevel::DEBUG, true);
|
||||
$this->logger->pushHandler($logHandler);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->getCertificate();
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error(
|
||||
sprintf('ACME Error: %s', $e->getMessage()),
|
||||
[
|
||||
'exception' => $e,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
if (null !== $outputPath) {
|
||||
$this->logger->popHandler();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function getCertificate(bool $force = false): void
|
||||
{
|
||||
// Check folder permissions.
|
||||
$acmeDir = self::getAcmeDirectory();
|
||||
$fs = new Filesystem();
|
||||
|
||||
// Build ACME Cert class.
|
||||
$directoryUrl = $this->environment->isProduction() ? self::LETSENCRYPT_PROD : self::LETSENCRYPT_DEV;
|
||||
|
||||
$this->logger->debug(
|
||||
sprintf('ACME: Using directory URL: %s', $directoryUrl)
|
||||
);
|
||||
|
||||
$acme = new ACMECert($directoryUrl);
|
||||
|
||||
// Build LetsEncrypt settings.
|
||||
$settings = $this->settingsRepo->readSettings();
|
||||
|
||||
$acmeEmail = $settings->getAcmeEmail();
|
||||
$acmeDomain = $settings->getAcmeDomains();
|
||||
|
||||
if (empty($acmeEmail)) {
|
||||
$acmeEmail = getenv('LETSENCRYPT_EMAIL');
|
||||
}
|
||||
if (empty($acmeDomain)) {
|
||||
$acmeDomain = getenv('LETSENCRYPT_HOST');
|
||||
}
|
||||
if (empty($acmeDomain)) {
|
||||
$acmeDomain = $settings->getBaseUrlAsUri()?->getHost();
|
||||
}
|
||||
|
||||
if (empty($acmeEmail) || empty($acmeDomain)) {
|
||||
throw new \RuntimeException('Missing e-mail address or domain(s).');
|
||||
}
|
||||
|
||||
$settings->setAcmeEmail($acmeEmail);
|
||||
$settings->setAcmeDomains($acmeDomain);
|
||||
$this->settingsRepo->writeSettings($settings);
|
||||
|
||||
// Account certificate registration.
|
||||
if (file_exists($acmeDir . '/account_key.pem')) {
|
||||
$acme->loadAccountKey('file://' . $acmeDir . '/account_key.pem');
|
||||
} else {
|
||||
$accountKey = $acme->generateECKey('P-384');
|
||||
$fs->dumpFile($acmeDir . '/account_key.pem', $accountKey);
|
||||
$acme->loadAccountKey($accountKey);
|
||||
|
||||
$acme->register(true, $acmeEmail);
|
||||
}
|
||||
|
||||
// Renewal check.
|
||||
if (
|
||||
!$force
|
||||
&& file_exists($acmeDir . '/acme.crt')
|
||||
&& $acme->getRemainingDays('file://' . $acmeDir . '/acme.crt') > self::THRESHOLD_DAYS
|
||||
) {
|
||||
throw new \RuntimeException('Certificate does not need renewal.');
|
||||
}
|
||||
|
||||
$fs->mkdir($acmeDir . '/challenges');
|
||||
|
||||
$domainConfig = [];
|
||||
foreach (explode(',', $acmeDomain) as $domain) {
|
||||
$domain = trim($domain);
|
||||
$domainConfig[$domain] = ['challenge' => 'http-01'];
|
||||
}
|
||||
|
||||
$handler = function ($opts) use ($acmeDir, $fs) {
|
||||
$fs->dumpFile(
|
||||
$acmeDir . '/challenges/' . basename($opts['key']),
|
||||
$opts['value']
|
||||
);
|
||||
|
||||
return function ($opts) use ($acmeDir, $fs) {
|
||||
$fs->remove($acmeDir . '/challenges/' . $opts['key']);
|
||||
};
|
||||
};
|
||||
|
||||
if (!file_exists($acmeDir . '/acme.key')) {
|
||||
$acmeKey = $acme->generateECKey('P-384');
|
||||
$fs->dumpFile($acmeDir . '/acme.key', $acmeKey);
|
||||
}
|
||||
|
||||
$fullchain = $acme->getCertificateChain(
|
||||
'file://' . $acmeDir . '/acme.key',
|
||||
$domainConfig,
|
||||
$handler
|
||||
);
|
||||
$fs->dumpFile($acmeDir . '/acme.crt', $fullchain);
|
||||
|
||||
// Symlink to the shared SSL cert.
|
||||
$fs->remove([
|
||||
$acmeDir . '/ssl.crt',
|
||||
$acmeDir . '/ssl.key',
|
||||
]);
|
||||
|
||||
$fs->symlink($acmeDir . '/acme.crt', $acmeDir . '/ssl.crt');
|
||||
$fs->symlink($acmeDir . '/acme.key', $acmeDir . '/ssl.key');
|
||||
|
||||
$this->reloadServices();
|
||||
|
||||
$this->logger->notice('ACME certificate process successful.');
|
||||
}
|
||||
|
||||
private function reloadServices(): void
|
||||
{
|
||||
try {
|
||||
$this->nginx->reload();
|
||||
|
||||
foreach ($this->stationRepo->iterateEnabledStations() as $station) {
|
||||
if (!$station->getHasStarted()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$frontend = $this->adapters->getFrontendAdapter($station);
|
||||
|
||||
if ($frontend->supportsReload() && $frontend->isRunning($station)) {
|
||||
$frontend->reload($station);
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error(
|
||||
sprintf('ACME: Could not reload all adapters: %s', $e->getMessage()),
|
||||
[
|
||||
'exception' => $e,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static function getAcmeDirectory(): string
|
||||
{
|
||||
return Environment::getInstance()->getParentDirectory() . '/acme';
|
||||
}
|
||||
|
||||
public static function getCertificatePaths(): array
|
||||
{
|
||||
$acmeDir = self::getAcmeDirectory();
|
||||
return [
|
||||
$acmeDir . '/ssl.crt',
|
||||
$acmeDir . '/ssl.key',
|
||||
];
|
||||
}
|
||||
}
|
39
src/Sync/Task/RenewAcmeCertTask.php
Normal file
39
src/Sync/Task/RenewAcmeCertTask.php
Normal file
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Sync\Task;
|
||||
|
||||
use App\Doctrine\ReloadableEntityManagerInterface;
|
||||
use App\Service\Acme;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
final class RenewAcmeCertTask extends AbstractTask
|
||||
{
|
||||
public function __construct(
|
||||
ReloadableEntityManagerInterface $em,
|
||||
LoggerInterface $logger,
|
||||
private readonly Acme $acme
|
||||
) {
|
||||
parent::__construct($em, $logger);
|
||||
}
|
||||
|
||||
public static function getSchedulePattern(): string
|
||||
{
|
||||
return '3 */6 * * *';
|
||||
}
|
||||
|
||||
public function run(bool $force = false): void
|
||||
{
|
||||
try {
|
||||
$this->acme->getCertificate();
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning(
|
||||
sprintf('ACME Failed: %s', $e->getMessage()),
|
||||
[
|
||||
'exception' => $e,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -34,7 +34,7 @@ else
|
|||
fi
|
||||
|
||||
APP_ENV="${APP_ENV:-production}"
|
||||
UPDATE_REVISION="${UPDATE_REVISION:-87}"
|
||||
UPDATE_REVISION="${UPDATE_REVISION:-88}"
|
||||
|
||||
echo "Updating AzuraCast (Environment: $APP_ENV, Update revision: $UPDATE_REVISION)"
|
||||
|
||||
|
|
|
@ -27,11 +27,11 @@
|
|||
- beanstalkd
|
||||
- sftpgo
|
||||
- mariadb
|
||||
- azuracast-db-install
|
||||
- ufw
|
||||
- dbip
|
||||
- composer
|
||||
- services
|
||||
- azuracast-db-install
|
||||
- azuracast-build
|
||||
- azuracast-setup
|
||||
- azuracast-cron
|
||||
|
|
|
@ -48,5 +48,6 @@
|
|||
- "{{ app_base }}/servers/icecast2"
|
||||
- "{{ app_base }}/servers/stereo_tool"
|
||||
- "{{ app_base }}/uploads"
|
||||
- "{{ app_base }}/acme/challenges"
|
||||
loop_control:
|
||||
loop_var: azuracast_config_sys_directory
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
become_user: azuracast
|
||||
shell: >-
|
||||
php {{ www_base }}/bin/console azuracast:setup
|
||||
when: update_mode|bool
|
||||
when: !update_mode|bool
|
||||
|
||||
- name: Migrate Legacy Configuration (Update Mode)
|
||||
become: true
|
||||
|
|
|
@ -33,21 +33,6 @@
|
|||
- nginx-common
|
||||
- libnginx-mod-nchan
|
||||
|
||||
- name: Create nginx ssl directory
|
||||
file:
|
||||
path: "/etc/nginx/ssl"
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: 0744
|
||||
|
||||
- name: Create self-signed SSL cert
|
||||
command: >-
|
||||
openssl req -new -nodes -x509 -subj "/C=US/ST=Texas/L=Austin/O=IT/CN=${ansible_fqdn}" -days 3650
|
||||
-keyout /etc/nginx/ssl/server.key -out /etc/nginx/ssl/server.crt -extensions v3_ca
|
||||
args:
|
||||
creates: /etc/nginx/ssl/server.crt
|
||||
|
||||
- name: Remove default nginx site symlink
|
||||
file:
|
||||
path: "/etc/nginx/sites-enabled/default"
|
||||
|
@ -81,6 +66,25 @@
|
|||
replace: 'sendfile off;'
|
||||
when: app_env == "development"
|
||||
|
||||
- name: Create self-signed SSL cert
|
||||
command: >-
|
||||
openssl req -new -nodes -x509 -subj "/C=US/ST=Texas/L=Austin/O=IT/CN=${ansible_fqdn}" -days 3650
|
||||
-keyout {{ app_base }}/acme/default.key -out {{ app_base }}/acme/default.crt -extensions v3_ca
|
||||
args:
|
||||
creates: "{{ app_base }}/acme/default.crt"
|
||||
|
||||
- name: Link self-signed SSL key if applicable.
|
||||
file:
|
||||
path: "{{ app_base }}/acme/ssl.key"
|
||||
state: link
|
||||
src: "{{ app_base }}/acme/default.key"
|
||||
|
||||
- name: Link self-signed SSL cert if applicable.
|
||||
file:
|
||||
path: "{{ app_base }}/acme/ssl.crt"
|
||||
state: link
|
||||
src: "{{ app_base }}/acme/default.crt"
|
||||
|
||||
- name: Install Nginx Supervisord conf
|
||||
template:
|
||||
src: supervisor.conf.j2
|
||||
|
|
|
@ -58,8 +58,8 @@ server {
|
|||
listen [::]:80;
|
||||
listen [::]:443 default_server ssl;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/server.crt;
|
||||
ssl_certificate_key /etc/nginx/ssl/server.key;
|
||||
ssl_certificate {{ app_base }}/acme/ssl.crt;
|
||||
ssl_certificate_key {{ app_base }}/acme/ssl.key;
|
||||
|
||||
root {{ app_base }}/www/web;
|
||||
index index.php;
|
||||
|
@ -73,6 +73,12 @@ server {
|
|||
access_log {{ app_base }}/www_tmp/access.log;
|
||||
error_log {{ app_base }}/www_tmp/error.log;
|
||||
|
||||
# LetsEncrypt handling
|
||||
location /.well-known/acme-challenge {
|
||||
alias {{ app_base }}/acme/challenges;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Serve a static version of the nowplaying data for non-PHP-blocking delivery.
|
||||
location /api/nowplaying_static {
|
||||
expires 10s;
|
||||
|
|
|
@ -28,7 +28,7 @@
|
|||
when: update_revision|int < 87
|
||||
|
||||
- role: "nginx"
|
||||
when: update_revision|int < 87
|
||||
when: update_revision|int < 88
|
||||
|
||||
- role: "redis"
|
||||
when: update_revision|int < 87
|
||||
|
|
|
@ -13,7 +13,8 @@ usermod -aG www-data azuracast
|
|||
|
||||
mkdir -p /var/azuracast/www /var/azuracast/stations /var/azuracast/servers/shoutcast2 \
|
||||
/var/azuracast/servers/stereo_tool /var/azuracast/backups /var/azuracast/www_tmp \
|
||||
/var/azuracast/uploads /var/azuracast/geoip /var/azuracast/dbip
|
||||
/var/azuracast/uploads /var/azuracast/geoip /var/azuracast/dbip \
|
||||
/var/azuracast/acme
|
||||
|
||||
chown -R azuracast:azuracast /var/azuracast
|
||||
chmod -R 777 /var/azuracast/www_tmp
|
||||
|
|
|
@ -53,13 +53,8 @@ server {
|
|||
listen 80;
|
||||
listen 443 default_server http2 ssl;
|
||||
|
||||
{{if exists "/etc/nginx/certs/ssl.crt"}}
|
||||
ssl_certificate /etc/nginx/certs/ssl.crt;
|
||||
ssl_certificate_key /etc/nginx/certs/ssl.key;
|
||||
{{else}}
|
||||
ssl_certificate /etc/nginx/certs/default.crt;
|
||||
ssl_certificate_key /etc/nginx/certs/default.key;
|
||||
{{end}}
|
||||
ssl_certificate /var/azuracast/acme/ssl.crt;
|
||||
ssl_certificate_key /var/azuracast/acme/ssl.key;
|
||||
|
||||
ssl_protocols TLSv1.3 TLSv1.2;
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
@ -79,8 +74,8 @@ server {
|
|||
add_header Referrer-Policy no-referrer-when-downgrade;
|
||||
|
||||
# LetsEncrypt handling
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /usr/share/nginx/html;
|
||||
location /.well-known/acme-challenge {
|
||||
alias /var/azuracast/acme/challenges;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,337 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Acme loading script
|
||||
# Uses code from:
|
||||
# https://github.com/nginx-proxy/acme-companion/blob/main/app/letsencrypt_service
|
||||
|
||||
# We set a "LOG_LEVEL" that is incompatible with acme.sh. Overwrite it.
|
||||
export LOG_LEVEL=1
|
||||
export DEBUG=1
|
||||
|
||||
shopt -s expand_aliases
|
||||
. /usr/local/acme.sh/acme.sh.env
|
||||
|
||||
function set_ownership_and_permissions {
|
||||
local path="${1:?}"
|
||||
# The default ownership is root:root, with 755 permissions for folders and 644 for files.
|
||||
local user="azuracast"
|
||||
local group="azuracast"
|
||||
local f_perms="644"
|
||||
local d_perms="755"
|
||||
|
||||
[[ "$DEBUG" == 1 ]] && echo "Debug: checking $path ownership and permissions."
|
||||
|
||||
# Find the user numeric ID if the FILES_UID environment variable isn't numeric.
|
||||
if [[ "$user" =~ ^[0-9]+$ ]]; then
|
||||
user_num="$user"
|
||||
# Check if this user exist inside the container
|
||||
elif id -u "$user" > /dev/null 2>&1; then
|
||||
# Convert the user name to numeric ID
|
||||
local user_num; user_num="$(id -u "$user")"
|
||||
[[ "$DEBUG" == 1 ]] && echo "Debug: numeric ID of user $user is $user_num."
|
||||
else
|
||||
echo "Warning: user $user not found in the container, please use a numeric user ID instead of a user name. Skipping ownership and permissions check."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Find the group numeric ID if the FILES_GID environment variable isn't numeric.
|
||||
if [[ "$group" =~ ^[0-9]+$ ]]; then
|
||||
group_num="$group"
|
||||
# Check if this group exist inside the container
|
||||
elif getent group "$group" > /dev/null 2>&1; then
|
||||
# Convert the group name to numeric ID
|
||||
local group_num; group_num="$(getent group "$group" | awk -F ':' '{print $3}')"
|
||||
[[ "$DEBUG" == 1 ]] && echo "Debug: numeric ID of group $group is $group_num."
|
||||
else
|
||||
echo "Warning: group $group not found in the container, please use a numeric group ID instead of a group name. Skipping ownership and permissions check."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check and modify ownership if required.
|
||||
if [[ -e "$path" ]]; then
|
||||
if [[ "$(stat -c %u:%g "$path" )" != "$user_num:$group_num" ]]; then
|
||||
[[ "$DEBUG" == 1 ]] && echo "Debug: setting $path ownership to $user:$group."
|
||||
if [[ -L "$path" ]]; then
|
||||
chown -h "$user_num:$group_num" "$path"
|
||||
else
|
||||
chown "$user_num:$group_num" "$path"
|
||||
fi
|
||||
fi
|
||||
# If the path is a folder, check and modify permissions if required.
|
||||
if [[ -d "$path" ]]; then
|
||||
if [[ "$(stat -c %a "$path")" != "$d_perms" ]]; then
|
||||
[[ "$DEBUG" == 1 ]] && echo "Debug: setting $path permissions to $d_perms."
|
||||
chmod "$d_perms" "$path"
|
||||
fi
|
||||
# If the path is a file, check and modify permissions if required.
|
||||
elif [[ -f "$path" ]]; then
|
||||
# Use different permissions for private files (private keys and ACME account files) ...
|
||||
if [[ "$path" =~ ^.*(default\.key|key\.pem|\.json)$ ]]; then
|
||||
if [[ "$(stat -c %a "$path")" != "$f_perms" ]]; then
|
||||
[[ "$DEBUG" == 1 ]] && echo "Debug: setting $path permissions to $f_perms."
|
||||
chmod "$f_perms" "$path"
|
||||
fi
|
||||
# ... and for public files (certificates, chains, fullchains, DH parameters).
|
||||
else
|
||||
if [[ "$(stat -c %a "$path")" != "644" ]]; then
|
||||
[[ "$DEBUG" == 1 ]] && echo "Debug: setting $path permissions to 644."
|
||||
chmod "644" "$path"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "Warning: $path does not exist. Skipping ownership and permissions check."
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Convert argument to lowercase (bash 4 only)
|
||||
function lc() {
|
||||
echo "${@,,}"
|
||||
}
|
||||
|
||||
function create_link {
|
||||
local -r source=${1?missing source argument}
|
||||
local -r target=${2?missing target argument}
|
||||
|
||||
if [[ -f "$target" ]] && [[ "$(readlink "$target")" == "$source" ]]; then
|
||||
set_ownership_and_permissions "$target"
|
||||
[[ "$DEBUG" == 1 ]] && echo "$target already linked to $source"
|
||||
return 1
|
||||
else
|
||||
ln -sf "$source" "$target" \
|
||||
&& set_ownership_and_permissions "$target"
|
||||
fi
|
||||
}
|
||||
|
||||
function create_links {
|
||||
local -r base_domain=${1?missing base_domain argument}
|
||||
|
||||
if [[ ! -f "/etc/nginx/certs/$base_domain/fullchain.pem" || \
|
||||
! -f "/etc/nginx/certs/$base_domain/key.pem" ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
local return_code=1
|
||||
|
||||
create_link "./$base_domain/fullchain.pem" "/etc/nginx/certs/ssl.crt"
|
||||
return_code=$(( return_code & $? ))
|
||||
|
||||
create_link "./$base_domain/key.pem" "/etc/nginx/certs/ssl.key"
|
||||
return_code=$(( return_code & $? ))
|
||||
|
||||
if [[ -f "/etc/nginx/certs/dhparam.pem" ]]; then
|
||||
create_link ./dhparam.pem "/etc/nginx/certs/ssl.dhparam.pem"
|
||||
return_code=$(( return_code & $? ))
|
||||
fi
|
||||
|
||||
if [[ -f "/etc/nginx/certs/$base_domain/chain.pem" ]]; then
|
||||
create_link "./$base_domain/chain.pem" "/etc/nginx/certs/ssl.chain.pem"
|
||||
return_code=$(( return_code & $? ))
|
||||
fi
|
||||
|
||||
return $return_code
|
||||
}
|
||||
|
||||
CERTS_UPDATE_INTERVAL="${CERTS_UPDATE_INTERVAL:-3600}"
|
||||
ACME_CA_URI="${ACME_CA_URI:-"https://acme-v02.api.letsencrypt.org/directory"}"
|
||||
ACME_CA_TEST_URI="https://acme-staging-v02.api.letsencrypt.org/directory"
|
||||
DEFAULT_KEY_SIZE="${DEFAULT_KEY_SIZE:-4096}"
|
||||
RENEW_PRIVATE_KEYS="$(lc "${RENEW_PRIVATE_KEYS:-true}")"
|
||||
|
||||
# Backward compatibility environment variable
|
||||
REUSE_PRIVATE_KEYS="$(lc "${REUSE_PRIVATE_KEYS:-false}")"
|
||||
|
||||
function update_cert {
|
||||
local hosts_array
|
||||
IFS=',' read -ra hosts_array <<< "$LETSENCRYPT_HOST"
|
||||
|
||||
local base_domain="${hosts_array[0]}"
|
||||
|
||||
# Base CLI parameters array, used for both --register-account and --issue
|
||||
local -a params_base_arr
|
||||
|
||||
params_base_arr+=(--log /dev/null)
|
||||
[[ "$DEBUG" == 1 ]] && params_base_arr+=(--debug 2)
|
||||
|
||||
# Alternative trusted root CA path, used for test with Pebble
|
||||
if [[ -n "${CA_BUNDLE// }" ]]; then
|
||||
if [[ -f "$CA_BUNDLE" ]]; then
|
||||
params_base_arr+=(--ca-bundle "$CA_BUNDLE")
|
||||
[[ "$DEBUG" == 1 ]] && echo "Debug: acme.sh will use $CA_BUNDLE as trusted root CA."
|
||||
else
|
||||
echo "Warning: the path to the alternate CA bundle ($CA_BUNDLE) is not valid, using default Alpine trust store."
|
||||
fi
|
||||
fi
|
||||
|
||||
# CLI parameters array used for --register-account
|
||||
local -a params_register_arr
|
||||
|
||||
# CLI parameters array used for --issue
|
||||
local -a params_issue_arr
|
||||
params_issue_arr+=(--webroot /usr/share/nginx/html)
|
||||
|
||||
local -n cert_keysize="LETSENCRYPT_KEYSIZE"
|
||||
if [[ -z "$cert_keysize" ]] || \
|
||||
[[ ! "$cert_keysize" =~ ^(2048|3072|4096|ec-256|ec-384)$ ]]; then
|
||||
cert_keysize=$DEFAULT_KEY_SIZE
|
||||
fi
|
||||
params_issue_arr+=(--keylength "$cert_keysize")
|
||||
|
||||
# OCSP-Must-Staple extension
|
||||
local -n ocsp="ACME_OCSP"
|
||||
if [[ $(lc "$ocsp") == true ]]; then
|
||||
params_issue_arr+=(--ocsp-must-staple)
|
||||
fi
|
||||
|
||||
local -n accountemail="LETSENCRYPT_EMAIL"
|
||||
local config_home
|
||||
# If we don't have a LETSENCRYPT_EMAIL from the proxied container
|
||||
# and DEFAULT_EMAIL is set to a non empty value, use the latter.
|
||||
if [[ -z "$accountemail" ]]; then
|
||||
if [[ -n "${DEFAULT_EMAIL// }" ]]; then
|
||||
accountemail="$DEFAULT_EMAIL"
|
||||
else
|
||||
unset accountemail
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "${accountemail// }" ]]; then
|
||||
# If we got an email, use it with the corresponding config home
|
||||
config_home="/etc/acme.sh/$accountemail"
|
||||
else
|
||||
# If we did not get any email at all, use the default (empty mail) config
|
||||
config_home="/etc/acme.sh/default"
|
||||
fi
|
||||
|
||||
local -n acme_ca_uri="ACME_CA_URI"
|
||||
if [[ -z "$acme_ca_uri" ]]; then
|
||||
# Use default or user provided ACME end point
|
||||
acme_ca_uri="$ACME_CA_URI"
|
||||
fi
|
||||
|
||||
# LETSENCRYPT_TEST overrides LETSENCRYPT_ACME_CA_URI
|
||||
local -n test_certificate="LETSENCRYPT_TEST"
|
||||
if [[ $(lc "$test_certificate") == true ]]; then
|
||||
# Use Let's Encrypt ACME V2 staging end point
|
||||
acme_ca_uri="$ACME_CA_TEST_URI"
|
||||
fi
|
||||
|
||||
# Set relevant --server parameter and ca folder name
|
||||
params_base_arr+=(--server "$acme_ca_uri")
|
||||
local ca_dir="${acme_ca_uri##*://}" \
|
||||
&& ca_dir="${ca_dir%%:*}"
|
||||
|
||||
local certificate_dir
|
||||
# If we're going to use one of LE stating endpoints ...
|
||||
if [[ "$acme_ca_uri" =~ ^https://acme-staging.* ]]; then
|
||||
# Unset accountemail
|
||||
# force config dir to 'staging'
|
||||
unset accountemail
|
||||
config_home="/etc/acme.sh/staging"
|
||||
# Prefix test certificate directory with _test_
|
||||
certificate_dir="/etc/nginx/certs/_test_$base_domain"
|
||||
else
|
||||
certificate_dir="/etc/nginx/certs/$base_domain"
|
||||
fi
|
||||
|
||||
params_issue_arr+=( \
|
||||
--cert-file "${certificate_dir}/cert.pem" \
|
||||
--key-file "${certificate_dir}/key.pem" \
|
||||
--ca-file "${certificate_dir}/chain.pem" \
|
||||
--fullchain-file "${certificate_dir}/fullchain.pem" \
|
||||
)
|
||||
|
||||
[[ ! -d "$config_home" ]] && mkdir -p "$config_home"
|
||||
|
||||
params_base_arr+=(--config-home "$config_home")
|
||||
local account_file="${config_home}/ca/${ca_dir}/account.json"
|
||||
|
||||
if [[ -n "${accountemail// }" ]]; then
|
||||
# We're not using Zero SSL, register the ACME account using the provided email.
|
||||
params_register_arr+=(--accountemail "$accountemail")
|
||||
fi
|
||||
|
||||
# Account registration and update if required
|
||||
if [[ ! -f "$account_file" ]]; then
|
||||
params_register_arr=("${params_base_arr[@]}" "${params_register_arr[@]}")
|
||||
[[ "$DEBUG" == 1 ]] && echo "Calling acme.sh --register-account with the following parameters : ${params_register_arr[*]}"
|
||||
acme.sh --register-account "${params_register_arr[@]}"
|
||||
fi
|
||||
|
||||
if [[ -n "${accountemail// }" ]] && ! grep -q "mailto:$accountemail" "$account_file"; then
|
||||
local -a params_update_arr=("${params_base_arr[@]}" --accountemail "$accountemail")
|
||||
[[ "$DEBUG" == 1 ]] && echo "Calling acme.sh --update-account with the following parameters : ${params_update_arr[*]}"
|
||||
acme.sh --update-account "${params_update_arr[@]}"
|
||||
fi
|
||||
|
||||
# If we still don't have an account.json file by this point, we've got an issue
|
||||
if [[ ! -f "$account_file" ]]; then
|
||||
echo "Error: no ACME account was found or registered for $accountemail and $acme_ca_uri, certificate creation aborted."
|
||||
return 1
|
||||
fi
|
||||
|
||||
local -n acme_preferred_chain="ACME_PREFERRED_CHAIN"
|
||||
if [[ -n "${acme_preferred_chain}" ]]; then
|
||||
# Using amce.sh --preferred-chain to select alternate chain.
|
||||
params_issue_arr+=(--preferred-chain "$acme_preferred_chain")
|
||||
fi
|
||||
if [[ "$RENEW_PRIVATE_KEYS" != 'false' && "$REUSE_PRIVATE_KEYS" != 'true' ]]; then
|
||||
params_issue_arr+=(--always-force-new-domain-key)
|
||||
fi
|
||||
|
||||
[[ "${2:-}" == "--force-renew" ]] && params_issue_arr+=(--force)
|
||||
|
||||
# Create directory for the first domain
|
||||
mkdir -p "$certificate_dir"
|
||||
set_ownership_and_permissions "$certificate_dir"
|
||||
|
||||
for domain in "${hosts_array[@]}"; do
|
||||
# Add all the domains to certificate
|
||||
params_issue_arr+=(--domain "$domain")
|
||||
done
|
||||
|
||||
params_issue_arr=("${params_base_arr[@]}" "${params_issue_arr[@]}")
|
||||
[[ "$DEBUG" == 1 ]] && echo "Calling acme.sh --issue with the following parameters : ${params_issue_arr[*]}"
|
||||
echo "Creating/renewal $base_domain certificates... (${hosts_array[*]})"
|
||||
acme.sh --issue "${params_issue_arr[@]}"
|
||||
|
||||
local acmesh_return=$?
|
||||
local should_reload_nginx='false'
|
||||
|
||||
# 0 = success, 2 = RENEW_SKIP
|
||||
if [[ $acmesh_return == 0 || $acmesh_return == 2 ]]; then
|
||||
if [[ $acme_ca_uri =~ ^https://acme-staging.* ]]; then
|
||||
create_links "_test_$base_domain" \
|
||||
&& should_reload_nginx='true'
|
||||
else
|
||||
create_links "$base_domain" \
|
||||
&& should_reload_nginx='true'
|
||||
fi
|
||||
|
||||
# Make private key root readable only
|
||||
for file in cert.pem key.pem chain.pem fullchain.pem; do
|
||||
local file_path="${certificate_dir}/${file}"
|
||||
[[ -e "$file_path" ]] && set_ownership_and_permissions "$file_path"
|
||||
done
|
||||
|
||||
[[ $acmesh_return -eq 0 ]] \
|
||||
&& should_reload_nginx='true'
|
||||
fi
|
||||
|
||||
if [[ "$should_reload_nginx" == 'true' ]]; then
|
||||
echo "Reloading nginx..."
|
||||
on_ssl_renewal
|
||||
fi
|
||||
}
|
||||
|
||||
if [ ! -z "$VIRTUAL_HOST" ]; then
|
||||
echo "Multi-site configuration detected; skipping local ACME setup."
|
||||
elif [ ! -z "$LETSENCRYPT_HOST" -a "$LETSENCRYPT_HOST" != " " ]; then
|
||||
update_cert "$@"
|
||||
fi
|
||||
|
||||
# Wait some amount of time
|
||||
echo "Sleep for ${CERTS_UPDATE_INTERVAL}s"
|
||||
sleep $CERTS_UPDATE_INTERVAL
|
||||
exit
|
|
@ -1,11 +0,0 @@
|
|||
[program:acme]
|
||||
command=run_acme_sh
|
||||
priority=200
|
||||
numprocs=1
|
||||
autostart=true
|
||||
autorestart=true
|
||||
|
||||
stdout_logfile=/proc/1/fd/1
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/proc/1/fd/2
|
||||
stderr_logfile_maxbytes=0
|
|
@ -1,19 +0,0 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
set -x
|
||||
|
||||
# Get acme.sh ACME client source
|
||||
mkdir /src
|
||||
git -C /src clone https://github.com/acmesh-official/acme.sh.git
|
||||
cd /src/acme.sh
|
||||
|
||||
# Install acme.sh in /app
|
||||
./acme.sh --install \
|
||||
--nocron \
|
||||
--auto-upgrade 0 \
|
||||
--home /usr/local/acme.sh \
|
||||
--config-home /etc/acme.sh/default
|
||||
|
||||
# Make house cleaning
|
||||
cd /
|
||||
rm -rf /src
|
|
@ -1,19 +1,26 @@
|
|||
#!/bin/bash
|
||||
|
||||
if [ -f /etc/nginx/certs/default.crt ]; then
|
||||
rm -rf /etc/nginx/certs/default.key || true
|
||||
rm -rf /etc/nginx/certs/default.crt || true
|
||||
mkdir -p /var/azuracast/acme/challenges || true
|
||||
|
||||
if [ -f /var/azuracast/acme/default.crt ]; then
|
||||
rm -rf /var/azuracast/acme/default.key || true
|
||||
rm -rf /var/azuracast/acme/default.crt || true
|
||||
fi
|
||||
|
||||
# Generate a self-signed certificate if one doesn't exist in the certs path.
|
||||
if [ ! -f /etc/nginx/certs/default.crt ]; then
|
||||
if [ ! -f /var/azuracast/acme/default.crt ]; then
|
||||
echo "Generating self-signed certificate..."
|
||||
|
||||
openssl req -new -nodes -x509 -subj "/C=US/ST=Texas/L=Austin/O=IT/CN=localhost" \
|
||||
-days 365 -extensions v3_ca \
|
||||
-keyout /etc/nginx/certs/default.key \
|
||||
-out /etc/nginx/certs/default.crt
|
||||
-keyout /var/azuracast/acme/default.key \
|
||||
-out /var/azuracast/acme/default.crt
|
||||
fi
|
||||
|
||||
chown azuracast:azuracast /etc/nginx/certs/default.* || true
|
||||
chmod 644 /etc/nginx/certs/default.* || true
|
||||
if [ ! -f /var/azuracast/acme/ssl.crt ]; then
|
||||
ln -s /var/azuracast/acme/default.key /var/azuracast/acme/ssl.key
|
||||
ln -s /var/azuracast/acme/default.crt /var/azuracast/acme/ssl.crt
|
||||
fi
|
||||
|
||||
chown -R azuracast:azuracast /var/azuracast/acme || true
|
||||
chmod -R u=rwX,go=rX /var/azuracast/acme || true
|
||||
|
|
Loading…
Reference in a new issue