diff --git a/CHANGELOG.md b/CHANGELOG.md index ffcc3ab72..68cd5f2ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/backups/backup_Thursday_0.json b/backups/backup_Thursday_0.json new file mode 100644 index 000000000..f1aa1d3f7 --- /dev/null +++ b/backups/backup_Thursday_0.json @@ -0,0 +1 @@ +{"users":[],"groups":[],"folders":[],"admins":[],"api_keys":[],"shares":[],"version":12} \ No newline at end of file diff --git a/backups/backup_Wednesday_0.json b/backups/backup_Wednesday_0.json new file mode 100644 index 000000000..10293385a --- /dev/null +++ b/backups/backup_Wednesday_0.json @@ -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} \ No newline at end of file diff --git a/composer.json b/composer.json index 127c02b7c..08991e97b 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/composer.lock b/composer.lock index b395e7734..53b3211db 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/config/cli.php b/config/cli.php index 16181ebdb..431e3e99f 100644 --- a/config/cli.php +++ b/config/cli.php @@ -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, ]); }; diff --git a/config/events.php b/config/events.php index 8e7c1277a..70201ed5a 100644 --- a/config/events.php +++ b/config/events.php @@ -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, diff --git a/config/messagequeue.php b/config/messagequeue.php index 4c3a69aaa..e64a3cb77 100644 --- a/config/messagequeue.php +++ b/config/messagequeue.php @@ -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, diff --git a/config/routes/api_admin.php b/config/routes/api_admin.php index ca14a6909..c6fe9471d 100644 --- a/config/routes/api_admin.php +++ b/config/routes/api_admin.php @@ -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 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 849154a8f..0265d1e85 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -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: diff --git a/docker-compose.sample.yml b/docker-compose.sample.yml index f37f21527..3c3759957 100644 --- a/docker-compose.sample.yml +++ b/docker-compose.sample.yml @@ -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: { } diff --git a/docker.sh b/docker.sh index 7204860c4..c41bb3db5 100755 --- a/docker.sh +++ b/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 } diff --git a/frontend/vue/components/Admin/Settings.vue b/frontend/vue/components/Admin/Settings.vue index 492b97f43..5ee747af2 100644 --- a/frontend/vue/components/Admin/Settings.vue +++ b/frontend/vue/components/Admin/Settings.vue @@ -23,7 +23,8 @@ :tab-class="getTabClass($v.securityPrivacyTab)"> + :test-message-url="testMessageUrl" + :acme-url="acmeUrl"> @@ -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, diff --git a/frontend/vue/components/Admin/Settings/ServicesTab.vue b/frontend/vue/components/Admin/Settings/ServicesTab.vue index fbfa0037c..7d2b4413b 100644 --- a/frontend/vue/components/Admin/Settings/ServicesTab.vue +++ b/frontend/vue/components/Admin/Settings/ServicesTab.vue @@ -35,6 +35,49 @@ + + + + + + + + + + + + + + + +
+ + + Generate/Renew Certificate + + (Save Changes first) + + +
+
+
+ @@ -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); + }); + } } } diff --git a/src/Console/Command/Acme/GetCertificateCommand.php b/src/Console/Command/Acme/GetCertificateCommand.php new file mode 100644 index 000000000..d5a0ee673 --- /dev/null +++ b/src/Console/Command/Acme/GetCertificateCommand.php @@ -0,0 +1,40 @@ +acme->getCertificate(); + } catch (\Exception $e) { + $io->error($e->getMessage()); + return 1; + } + + return 0; + } +} diff --git a/src/Console/Command/InitializeCommand.php b/src/Console/Command/InitializeCommand.php index baaca15ed..bb1ed4c0e 100644 --- a/src/Console/Command/InitializeCommand.php +++ b/src/Console/Command/InitializeCommand.php @@ -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( [ diff --git a/src/Controller/Admin/SettingsAction.php b/src/Controller/Admin/SettingsAction.php index b170d92e3..5b7ba859b 100644 --- a/src/Controller/Admin/SettingsAction.php +++ b/src/Controller/Admin/SettingsAction.php @@ -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, ], ); diff --git a/src/Controller/Api/Admin/Acme/CertificateLogAction.php b/src/Controller/Api/Admin/Acme/CertificateLogAction.php new file mode 100644 index 000000000..9d7aa7535 --- /dev/null +++ b/src/Controller/Api/Admin/Acme/CertificateLogAction.php @@ -0,0 +1,30 @@ +streamLogToResponse( + $request, + $response, + $tempPath + ); + } +} diff --git a/src/Controller/Api/Admin/Acme/GenerateCertificateAction.php b/src/Controller/Api/Admin/Acme/GenerateCertificateAction.php new file mode 100644 index 000000000..b343bb5ba --- /dev/null +++ b/src/Controller/Api/Admin/Acme/GenerateCertificateAction.php @@ -0,0 +1,44 @@ +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), + ]), + ], + ] + ); + } +} diff --git a/src/Entity/Migration/Version20220608113502.php b/src/Entity/Migration/Version20220608113502.php new file mode 100644 index 000000000..dab0b2d2a --- /dev/null +++ b/src/Entity/Migration/Version20220608113502.php @@ -0,0 +1,28 @@ +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'); + } +} diff --git a/src/Entity/Repository/StationRepository.php b/src/Entity/Repository/StationRepository.php index 9ec113056..8dcf4acaf 100644 --- a/src/Entity/Repository/StationRepository.php +++ b/src/Entity/Repository/StationRepository.php @@ -75,6 +75,18 @@ final class StationRepository extends Repository return $select; } + /** + * @return iterable + */ + public function iterateEnabledStations(): iterable + { + return $this->em->createQuery( + <<toIterable(); + } + /** * @param string $short_code */ diff --git a/src/Entity/Settings.php b/src/Entity/Settings.php index 568242876..596f95d3a 100644 --- a/src/Entity/Settings.php +++ b/src/Entity/Settings.php @@ -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'; diff --git a/src/Installer/Command/InstallCommand.php b/src/Installer/Command/InstallCommand.php index bc55bb563..3b6c717da 100644 --- a/src/Installer/Command/InstallCommand.php +++ b/src/Installer/Command/InstallCommand.php @@ -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) diff --git a/src/Installer/EnvFiles/EnvFile.php b/src/Installer/EnvFiles/EnvFile.php index 0e03fe6bb..dd285e279 100644 --- a/src/Installer/EnvFiles/EnvFile.php +++ b/src/Installer/EnvFiles/EnvFile.php @@ -33,9 +33,9 @@ class EnvFile extends AbstractEnvFile 'required' => true, ], 'AZURACAST_VERSION' => [ - 'name' => __('Release Channel'), - 'options' => ['latest', 'stable'], - 'default' => 'latest', + 'name' => __('Release Channel'), + 'options' => ['latest', 'stable'], + 'default' => 'latest', 'required' => true, ], 'AZURACAST_HTTP_PORT' => [ @@ -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.', - ), - ], ]; } diff --git a/src/Message/GenerateAcmeCertificate.php b/src/Message/GenerateAcmeCertificate.php new file mode 100644 index 000000000..c6b5c1aeb --- /dev/null +++ b/src/Message/GenerateAcmeCertificate.php @@ -0,0 +1,11 @@ +keyPath; - } - - public function getCertPath(): string - { - return $this->certPath; - } -} diff --git a/src/Radio/CertificateLocator.php b/src/Radio/CertificateLocator.php deleted file mode 100644 index 58783ec22..000000000 --- a/src/Radio/CertificateLocator.php +++ /dev/null @@ -1,53 +0,0 @@ -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'); - } -} diff --git a/src/Radio/Frontend/Icecast.php b/src/Radio/Frontend/Icecast.php index 0a16b77e0..54bda114b 100644 --- a/src/Radio/Frontend/Icecast.php +++ b/src/Radio/Frontend/Icecast.php @@ -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', @@ -127,13 +127,13 @@ class Icecast extends AbstractFrontend 'client-timeout' => 30, 'header-timeout' => 15, 'source-timeout' => 10, - 'burst-size' => 65535, + 'burst-size' => 65535, ], 'authentication' => [ 'source-password' => $frontendConfig->getSourcePassword(), - 'relay-password' => $frontendConfig->getRelayPassword(), - 'admin-user' => 'admin', - 'admin-password' => $frontendConfig->getAdminPassword(), + 'relay-password' => $frontendConfig->getRelayPassword(), + 'admin-user' => 'admin', + 'admin-password' => $frontendConfig->getAdminPassword(), ], 'listen-socket' => [ @@ -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 @@ -232,9 +232,9 @@ class Icecast extends AbstractFrontend $mountRelayUri = $mount_row->getRelayUrlAsUri(); if (null !== $mountRelayUri) { $config['relay'][] = [ - 'server' => $mountRelayUri->getHost(), - 'port' => $mountRelayUri->getPort(), - 'mount' => $mountRelayUri->getPath(), + 'server' => $mountRelayUri->getHost(), + 'port' => $mountRelayUri->getPort(), + 'mount' => $mountRelayUri->getPath(), 'local-mount' => $mount_row->getName(), ]; } diff --git a/src/Radio/Frontend/Shoutcast.php b/src/Radio/Frontend/Shoutcast.php index 793b07233..f89ee5025 100644 --- a/src/Radio/Frontend/Shoutcast.php +++ b/src/Radio/Frontend/Shoutcast.php @@ -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; diff --git a/src/Service/Acme.php b/src/Service/Acme.php new file mode 100644 index 000000000..e012fc4a0 --- /dev/null +++ b/src/Service/Acme.php @@ -0,0 +1,206 @@ +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', + ]; + } +} diff --git a/src/Sync/Task/RenewAcmeCertTask.php b/src/Sync/Task/RenewAcmeCertTask.php new file mode 100644 index 000000000..86636c046 --- /dev/null +++ b/src/Sync/Task/RenewAcmeCertTask.php @@ -0,0 +1,39 @@ +acme->getCertificate(); + } catch (\Exception $e) { + $this->logger->warning( + sprintf('ACME Failed: %s', $e->getMessage()), + [ + 'exception' => $e, + ] + ); + } + } +} diff --git a/update.sh b/update.sh index 2737af5b0..f80a88899 100755 --- a/update.sh +++ b/update.sh @@ -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)" diff --git a/util/ansible/deploy.yml b/util/ansible/deploy.yml index 365a5774c..f03c8929c 100644 --- a/util/ansible/deploy.yml +++ b/util/ansible/deploy.yml @@ -27,11 +27,11 @@ - beanstalkd - sftpgo - mariadb - - azuracast-db-install - ufw - dbip - composer - services + - azuracast-db-install - azuracast-build - azuracast-setup - azuracast-cron diff --git a/util/ansible/roles/azuracast-config/tasks/main.yml b/util/ansible/roles/azuracast-config/tasks/main.yml index d82ae8656..f525f72bd 100644 --- a/util/ansible/roles/azuracast-config/tasks/main.yml +++ b/util/ansible/roles/azuracast-config/tasks/main.yml @@ -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 diff --git a/util/ansible/roles/azuracast-setup/tasks/main.yml b/util/ansible/roles/azuracast-setup/tasks/main.yml index c8dc8c0d7..c957dfa96 100644 --- a/util/ansible/roles/azuracast-setup/tasks/main.yml +++ b/util/ansible/roles/azuracast-setup/tasks/main.yml @@ -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 diff --git a/util/ansible/roles/nginx/tasks/main.yml b/util/ansible/roles/nginx/tasks/main.yml index 8247e85a9..535f4f7e8 100644 --- a/util/ansible/roles/nginx/tasks/main.yml +++ b/util/ansible/roles/nginx/tasks/main.yml @@ -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 diff --git a/util/ansible/roles/nginx/templates/default.j2 b/util/ansible/roles/nginx/templates/default.j2 index 61ef9a906..42f65652c 100644 --- a/util/ansible/roles/nginx/templates/default.j2 +++ b/util/ansible/roles/nginx/templates/default.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; diff --git a/util/ansible/update.yml b/util/ansible/update.yml index 02b48f75e..ca8b00f3e 100644 --- a/util/ansible/update.yml +++ b/util/ansible/update.yml @@ -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 diff --git a/util/docker/common/add_user.sh b/util/docker/common/add_user.sh index 51de8e134..5851cb4df 100644 --- a/util/docker/common/add_user.sh +++ b/util/docker/common/add_user.sh @@ -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 diff --git a/util/docker/web/nginx/azuracast.conf.tmpl b/util/docker/web/nginx/azuracast.conf.tmpl index 47be1d617..f38042cf7 100644 --- a/util/docker/web/nginx/azuracast.conf.tmpl +++ b/util/docker/web/nginx/azuracast.conf.tmpl @@ -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; } diff --git a/util/docker/web/scripts/run_acme_sh b/util/docker/web/scripts/run_acme_sh deleted file mode 100644 index c84960118..000000000 --- a/util/docker/web/scripts/run_acme_sh +++ /dev/null @@ -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 diff --git a/util/docker/web/service.full/acme.conf b/util/docker/web/service.full/acme.conf deleted file mode 100644 index ae212d5cb..000000000 --- a/util/docker/web/service.full/acme.conf +++ /dev/null @@ -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 diff --git a/util/docker/web/setup/acme_sh.sh b/util/docker/web/setup/acme_sh.sh deleted file mode 100644 index 65644f992..000000000 --- a/util/docker/web/setup/acme_sh.sh +++ /dev/null @@ -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 diff --git a/util/docker/web/startup_scripts/01_self_signed_ssl.sh b/util/docker/web/startup_scripts/01_self_signed_ssl.sh index bfeef303a..55e6dbd5d 100644 --- a/util/docker/web/startup_scripts/01_self_signed_ssl.sh +++ b/util/docker/web/startup_scripts/01_self_signed_ssl.sh @@ -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