diff --git a/AcmePhp/Cli/Exception/AcmeCliActionException.php b/AcmePhp/Cli/Exception/AcmeCliActionException.php new file mode 100644 index 00000000..a18f3e6f --- /dev/null +++ b/AcmePhp/Cli/Exception/AcmeCliActionException.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AcmePhp\Cli\Exception; + +/** + * @author Titouan Galopin + */ +class AcmeCliActionException extends AcmeCliException +{ + public function __construct($actionName, \Exception $previous = null) + { + parent::__construct(sprintf('An exception was thrown during action "%s"', $actionName), $previous); + } +} diff --git a/AcmePhp/Cli/Exception/AcmeCliException.php b/AcmePhp/Cli/Exception/AcmeCliException.php new file mode 100644 index 00000000..b5f4c0c7 --- /dev/null +++ b/AcmePhp/Cli/Exception/AcmeCliException.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AcmePhp\Cli\Exception; + +/** + * @author Titouan Galopin + */ +class AcmeCliException extends \RuntimeException +{ + public function __construct($message, \Exception $previous = null) + { + parent::__construct($message, 0, $previous); + } +} diff --git a/AcmePhp/Cli/Exception/AcmeDnsResolutionException.php b/AcmePhp/Cli/Exception/AcmeDnsResolutionException.php new file mode 100644 index 00000000..a1137999 --- /dev/null +++ b/AcmePhp/Cli/Exception/AcmeDnsResolutionException.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AcmePhp\Cli\Exception; + +/** + * @author Jérémy Derussé + */ +class AcmeDnsResolutionException extends AcmeCliException +{ + public function __construct($message, \Exception $previous = null) + { + parent::__construct(null === $message ? 'An exception was thrown during resolution of DNS' : $message, $previous); + } +} diff --git a/AcmePhp/Cli/Exception/CommandFlowException.php b/AcmePhp/Cli/Exception/CommandFlowException.php new file mode 100644 index 00000000..f03b5b29 --- /dev/null +++ b/AcmePhp/Cli/Exception/CommandFlowException.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AcmePhp\Cli\Exception; + +/** + * @author Jérémy Derussé + */ +class CommandFlowException extends AcmeCliException +{ + /** + * @var string + */ + private $missing; + /** + * @var string + */ + private $command; + /** + * @var array + */ + private $arguments; + + /** + * @param string $missing Missing requirement to fix the flow + * @param string $command Name of the command to run in order to fix the flow + * @param array $arguments Optional list of missing arguments + * @param \Exception|null $previous + */ + public function __construct($missing, $command, array $arguments = [], \Exception $previous = null) + { + $this->missing = $missing; + $this->command = $command; + $this->arguments = $arguments; + + $message = trim(sprintf( + 'You have to %s first. Run the command%sphp %s %s %s', + $missing, + PHP_EOL.PHP_EOL, + $_SERVER['PHP_SELF'], + $command, + implode(' ', $arguments) + )); + + parent::__construct($message, $previous); + } +} diff --git a/AcmePhp/Cli/Repository/Repository.php b/AcmePhp/Cli/Repository/Repository.php new file mode 100644 index 00000000..29a54c18 --- /dev/null +++ b/AcmePhp/Cli/Repository/Repository.php @@ -0,0 +1,439 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AcmePhp\Cli\Repository; + +use AcmePhp\Cli\Exception\AcmeCliException; +use AcmePhp\Cli\Serializer\PemEncoder; +use AcmePhp\Core\Protocol\AuthorizationChallenge; +use AcmePhp\Core\Protocol\CertificateOrder; +use AcmePhp\Ssl\Certificate; +use AcmePhp\Ssl\CertificateResponse; +use AcmePhp\Ssl\DistinguishedName; +use AcmePhp\Ssl\KeyPair; +use AcmePhp\Ssl\PrivateKey; +use AcmePhp\Ssl\PublicKey; +use League\Flysystem\FilesystemInterface; +use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\SerializerInterface; + +/** + * @author Titouan Galopin + */ +class Repository implements RepositoryV2Interface +{ + const PATH_ACCOUNT_KEY_PRIVATE = 'account/key.private.pem'; + const PATH_ACCOUNT_KEY_PUBLIC = 'account/key.public.pem'; + + const PATH_DOMAIN_KEY_PUBLIC = 'certs/{domain}/private/key.public.pem'; + const PATH_DOMAIN_KEY_PRIVATE = 'certs/{domain}/private/key.private.pem'; + const PATH_DOMAIN_CERT_CERT = 'certs/{domain}/public/cert.pem'; + const PATH_DOMAIN_CERT_CHAIN = 'certs/{domain}/public/chain.pem'; + const PATH_DOMAIN_CERT_FULLCHAIN = 'certs/{domain}/public/fullchain.pem'; + const PATH_DOMAIN_CERT_COMBINED = 'certs/{domain}/private/combined.pem'; + + const PATH_CACHE_AUTHORIZATION_CHALLENGE = 'var/{domain}/authorization_challenge.json'; + const PATH_CACHE_DISTINGUISHED_NAME = 'var/{domain}/distinguished_name.json'; + const PATH_CACHE_CERTIFICATE_ORDER = 'var/{domains}/certificate_order.json'; + + /** + * @var SerializerInterface + */ + private $serializer; + + /** + * @var FilesystemInterface + */ + private $master; + + /** + * @var FilesystemInterface + */ + private $backup; + + /** + * @var bool + */ + private $enableBackup; + + /** + * @param SerializerInterface $serializer + * @param FilesystemInterface $master + * @param FilesystemInterface $backup + * @param bool $enableBackup + */ + public function __construct(SerializerInterface $serializer, FilesystemInterface $master, FilesystemInterface $backup, $enableBackup) + { + $this->serializer = $serializer; + $this->master = $master; + $this->backup = $backup; + $this->enableBackup = $enableBackup; + } + + /** + * {@inheritdoc} + */ + public function storeCertificateResponse(CertificateResponse $certificateResponse) + { + $distinguishedName = $certificateResponse->getCertificateRequest()->getDistinguishedName(); + $domain = $distinguishedName->getCommonName(); + + $this->storeDomainKeyPair($domain, $certificateResponse->getCertificateRequest()->getKeyPair()); + $this->storeDomainDistinguishedName($domain, $distinguishedName); + $this->storeDomainCertificate($domain, $certificateResponse->getCertificate()); + } + + /** + * {@inheritdoc} + */ + public function storeAccountKeyPair(KeyPair $keyPair) + { + try { + $this->save( + self::PATH_ACCOUNT_KEY_PUBLIC, + $this->serializer->serialize($keyPair->getPublicKey(), PemEncoder::FORMAT) + ); + + $this->save( + self::PATH_ACCOUNT_KEY_PRIVATE, + $this->serializer->serialize($keyPair->getPrivateKey(), PemEncoder::FORMAT) + ); + } catch (\Exception $e) { + throw new AcmeCliException('Storing of account key pair failed', $e); + } + } + + private function getPathForDomain($path, $domain) + { + return strtr($path, ['{domain}' => $this->normalizeDomain($domain)]); + } + + private function getPathForDomainList($path, array $domains) + { + return strtr($path, ['{domains}' => $this->normalizeDomainList($domains)]); + } + + /** + * {@inheritdoc} + */ + public function hasAccountKeyPair() + { + return $this->master->has(self::PATH_ACCOUNT_KEY_PRIVATE); + } + + /** + * {@inheritdoc} + */ + public function loadAccountKeyPair() + { + try { + $publicKeyPem = $this->master->read(self::PATH_ACCOUNT_KEY_PUBLIC); + $privateKeyPem = $this->master->read(self::PATH_ACCOUNT_KEY_PRIVATE); + + return new KeyPair( + $this->serializer->deserialize($publicKeyPem, PublicKey::class, PemEncoder::FORMAT), + $this->serializer->deserialize($privateKeyPem, PrivateKey::class, PemEncoder::FORMAT) + ); + } catch (\Exception $e) { + throw new AcmeCliException('Loading of account key pair failed', $e); + } + } + + /** + * {@inheritdoc} + */ + public function storeDomainKeyPair($domain, KeyPair $keyPair) + { + try { + $this->save( + $this->getPathForDomain(self::PATH_DOMAIN_KEY_PUBLIC, $domain), + $this->serializer->serialize($keyPair->getPublicKey(), PemEncoder::FORMAT) + ); + + $this->save( + $this->getPathForDomain(self::PATH_DOMAIN_KEY_PRIVATE, $domain), + $this->serializer->serialize($keyPair->getPrivateKey(), PemEncoder::FORMAT) + ); + } catch (\Exception $e) { + throw new AcmeCliException(sprintf('Storing of domain %s key pair failed', $domain), $e); + } + } + + /** + * {@inheritdoc} + */ + public function hasDomainKeyPair($domain) + { + return $this->master->has($this->getPathForDomain(self::PATH_DOMAIN_KEY_PRIVATE, $domain)); + } + + /** + * {@inheritdoc} + */ + public function loadDomainKeyPair($domain) + { + try { + $publicKeyPem = $this->master->read($this->getPathForDomain(self::PATH_DOMAIN_KEY_PUBLIC, $domain)); + $privateKeyPem = $this->master->read($this->getPathForDomain(self::PATH_DOMAIN_KEY_PRIVATE, $domain)); + + return new KeyPair( + $this->serializer->deserialize($publicKeyPem, PublicKey::class, PemEncoder::FORMAT), + $this->serializer->deserialize($privateKeyPem, PrivateKey::class, PemEncoder::FORMAT) + ); + } catch (\Exception $e) { + throw new AcmeCliException(sprintf('Loading of domain %s key pair failed', $domain), $e); + } + } + + /** + * {@inheritdoc} + */ + public function storeDomainAuthorizationChallenge($domain, AuthorizationChallenge $authorizationChallenge) + { + try { + $this->save( + $this->getPathForDomain(self::PATH_CACHE_AUTHORIZATION_CHALLENGE, $domain), + $this->serializer->serialize($authorizationChallenge, JsonEncoder::FORMAT) + ); + } catch (\Exception $e) { + throw new AcmeCliException(sprintf('Storing of domain %s authorization challenge failed', $domain), $e); + } + } + + /** + * {@inheritdoc} + */ + public function hasDomainAuthorizationChallenge($domain) + { + return $this->master->has($this->getPathForDomain(self::PATH_CACHE_AUTHORIZATION_CHALLENGE, $domain)); + } + + /** + * {@inheritdoc} + */ + public function loadDomainAuthorizationChallenge($domain) + { + try { + $json = $this->master->read($this->getPathForDomain(self::PATH_CACHE_AUTHORIZATION_CHALLENGE, $domain)); + + return $this->serializer->deserialize($json, AuthorizationChallenge::class, JsonEncoder::FORMAT); + } catch (\Exception $e) { + throw new AcmeCliException(sprintf('Loading of domain %s authorization challenge failed', $domain), $e); + } + } + + /** + * {@inheritdoc} + */ + public function storeDomainDistinguishedName($domain, DistinguishedName $distinguishedName) + { + try { + $this->save( + $this->getPathForDomain(self::PATH_CACHE_DISTINGUISHED_NAME, $domain), + $this->serializer->serialize($distinguishedName, JsonEncoder::FORMAT) + ); + } catch (\Exception $e) { + throw new AcmeCliException(sprintf('Storing of domain %s distinguished name failed', $domain), $e); + } + } + + /** + * {@inheritdoc} + */ + public function hasDomainDistinguishedName($domain) + { + return $this->master->has($this->getPathForDomain(self::PATH_CACHE_DISTINGUISHED_NAME, $domain)); + } + + /** + * {@inheritdoc} + */ + public function loadDomainDistinguishedName($domain) + { + try { + $json = $this->master->read($this->getPathForDomain(self::PATH_CACHE_DISTINGUISHED_NAME, $domain)); + + return $this->serializer->deserialize($json, DistinguishedName::class, JsonEncoder::FORMAT); + } catch (\Exception $e) { + throw new AcmeCliException(sprintf('Loading of domain %s distinguished name failed', $domain), $e); + } + } + + /** + * {@inheritdoc} + */ + public function storeDomainCertificate($domain, Certificate $certificate) + { + // Simple certificate + $certPem = $this->serializer->serialize($certificate, PemEncoder::FORMAT); + + // Issuer chain + $issuerChain = []; + $issuerCertificate = $certificate->getIssuerCertificate(); + + while (null !== $issuerCertificate) { + $issuerChain[] = $this->serializer->serialize($issuerCertificate, PemEncoder::FORMAT); + $issuerCertificate = $issuerCertificate->getIssuerCertificate(); + } + + $chainPem = implode("\n", $issuerChain); + + // Full chain + $fullChainPem = $certPem.$chainPem; + + // Combined + $keyPair = $this->loadDomainKeyPair($domain); + $combinedPem = $fullChainPem.$this->serializer->serialize($keyPair->getPrivateKey(), PemEncoder::FORMAT); + + // Save + $this->save($this->getPathForDomain(self::PATH_DOMAIN_CERT_CERT, $domain), $certPem); + $this->save($this->getPathForDomain(self::PATH_DOMAIN_CERT_CHAIN, $domain), $chainPem); + $this->save($this->getPathForDomain(self::PATH_DOMAIN_CERT_FULLCHAIN, $domain), $fullChainPem); + $this->save($this->getPathForDomain(self::PATH_DOMAIN_CERT_COMBINED, $domain), $combinedPem); + } + + /** + * {@inheritdoc} + */ + public function hasDomainCertificate($domain) + { + return $this->master->has($this->getPathForDomain(self::PATH_DOMAIN_CERT_FULLCHAIN, $domain)); + } + + /** + * {@inheritdoc} + */ + public function loadDomainCertificate($domain) + { + try { + $pems = explode('-----BEGIN CERTIFICATE-----', $this->master->read($this->getPathForDomain(self::PATH_DOMAIN_CERT_FULLCHAIN, $domain))); + } catch (\Exception $e) { + throw new AcmeCliException(sprintf('Loading of domain %s certificate failed', $domain), $e); + } + + $pems = array_map(function ($item) { + return trim(str_replace('-----END CERTIFICATE-----', '', $item)); + }, $pems); + array_shift($pems); + $pems = array_reverse($pems); + + $certificate = null; + + foreach ($pems as $pem) { + $certificate = new Certificate( + "-----BEGIN CERTIFICATE-----\n".$pem."\n-----END CERTIFICATE-----", + $certificate + ); + } + + return $certificate; + } + + /** + * {@inheritdoc} + */ + public function storeCertificateOrder(array $domains, CertificateOrder $order) + { + try { + $this->save( + $this->getPathForDomainList(self::PATH_CACHE_CERTIFICATE_ORDER, $domains), + $this->serializer->serialize($order, JsonEncoder::FORMAT) + ); + } catch (\Exception $e) { + throw new AcmeCliException(sprintf('Storing of domains %s certificate order failed', implode(', ', $domains)), $e); + } + } + + /** + * {@inheritdoc} + */ + public function hasCertificateOrder(array $domains) + { + return $this->master->has($this->getPathForDomainList(self::PATH_CACHE_CERTIFICATE_ORDER, $domains)); + } + + /** + * {@inheritdoc} + */ + public function loadCertificateOrder(array $domains) + { + try { + $json = $this->master->read($this->getPathForDomainList(self::PATH_CACHE_CERTIFICATE_ORDER, $domains)); + + return $this->serializer->deserialize($json, CertificateOrder::class, JsonEncoder::FORMAT); + } catch (\Exception $e) { + throw new AcmeCliException(sprintf('Loading of domains %s certificate order failed', implode(', ', $domains)), $e); + } + } + + /** + * {@inheritdoc} + */ + public function save($path, $content, $visibility = self::VISIBILITY_PRIVATE) + { + if (!$this->master->has($path)) { + // File creation: remove from backup if it existed and warm-up both master and backup + $this->createAndBackup($path, $content); + } else { + // File update: backup before writing + $this->backupAndUpdate($path, $content); + } + + if ($this->enableBackup) { + $this->backup->setVisibility($path, $visibility); + } + + $this->master->setVisibility($path, $visibility); + } + + private function createAndBackup($path, $content) + { + if ($this->enableBackup) { + if ($this->backup->has($path)) { + $this->backup->delete($path); + } + + $this->backup->write($path, $content); + } + + $this->master->write($path, $content); + } + + private function backupAndUpdate($path, $content) + { + if ($this->enableBackup) { + $oldContent = $this->master->read($path); + + if (false !== $oldContent) { + if ($this->backup->has($path)) { + $this->backup->update($path, $oldContent); + } else { + $this->backup->write($path, $oldContent); + } + } + } + + $this->master->update($path, $content); + } + + private function normalizeDomain($domain) + { + return $domain; + } + + private function normalizeDomainList(array $domains) + { + $normalizedDomains = array_unique(array_map([$this, 'normalizeDomain'], $domains)); + sort($normalizedDomains); + + return (isset($domains[0]) ? $this->normalizeDomain($domains[0]) : '-').'/'.sha1(json_encode($normalizedDomains)); + } +} diff --git a/AcmePhp/Cli/Repository/RepositoryInterface.php b/AcmePhp/Cli/Repository/RepositoryInterface.php new file mode 100644 index 00000000..f42c5277 --- /dev/null +++ b/AcmePhp/Cli/Repository/RepositoryInterface.php @@ -0,0 +1,198 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AcmePhp\Cli\Repository; + +use AcmePhp\Cli\Exception\AcmeCliException; +use AcmePhp\Core\Protocol\AuthorizationChallenge; +use AcmePhp\Ssl\Certificate; +use AcmePhp\Ssl\CertificateResponse; +use AcmePhp\Ssl\DistinguishedName; +use AcmePhp\Ssl\KeyPair; + +/** + * @author Titouan Galopin + */ +interface RepositoryInterface +{ + const VISIBILITY_PUBLIC = 'public'; + const VISIBILITY_PRIVATE = 'private'; + + /** + * Extract important elements from the given certificate response and store them + * in the repository. + * + * This method will use the distinguished name common name as a domain to store: + * - the key pair + * - the certificate request + * - the certificate + * + * @param CertificateResponse $certificateResponse + * + * @throws AcmeCliException + */ + public function storeCertificateResponse(CertificateResponse $certificateResponse); + + /** + * Store a given key pair as the account key pair (the global key pair used to + * interact with the ACME server). + * + * @param KeyPair $keyPair + * + * @throws AcmeCliException + */ + public function storeAccountKeyPair(KeyPair $keyPair); + + /** + * Check if there is an account key pair in the repository. + * + * @return bool + */ + public function hasAccountKeyPair(); + + /** + * Load the account key pair. + * + * @throws AcmeCliException + * + * @return KeyPair + */ + public function loadAccountKeyPair(); + + /** + * Store a given key pair as associated to a given domain. + * + * @param string $domain + * @param KeyPair $keyPair + * + * @throws AcmeCliException + */ + public function storeDomainKeyPair($domain, KeyPair $keyPair); + + /** + * Check if there is a key pair associated to the given domain in the repository. + * + * @param string $domain + * + * @return bool + */ + public function hasDomainKeyPair($domain); + + /** + * Load the key pair associated to a given domain. + * + * @param string $domain + * + * @throws AcmeCliException + * + * @return KeyPair + */ + public function loadDomainKeyPair($domain); + + /** + * Store a given authorization challenge as associated to a given domain. + * + * @param string $domain + * @param AuthorizationChallenge $authorizationChallenge + * + * @throws AcmeCliException + */ + public function storeDomainAuthorizationChallenge($domain, AuthorizationChallenge $authorizationChallenge); + + /** + * Check if there is an authorization challenge associated to the given domain in the repository. + * + * @param string $domain + * + * @return bool + */ + public function hasDomainAuthorizationChallenge($domain); + + /** + * Load the authorization challenge associated to a given domain. + * + * @param string $domain + * + * @throws AcmeCliException + * + * @return AuthorizationChallenge + */ + public function loadDomainAuthorizationChallenge($domain); + + /** + * Store a given distinguished name as associated to a given domain. + * + * @param string $domain + * @param DistinguishedName $distinguishedName + * + * @throws AcmeCliException + */ + public function storeDomainDistinguishedName($domain, DistinguishedName $distinguishedName); + + /** + * Check if there is a distinguished name associated to the given domain in the repository. + * + * @param string $domain + * + * @return bool + */ + public function hasDomainDistinguishedName($domain); + + /** + * Load the distinguished name associated to a given domain. + * + * @param string $domain + * + * @throws AcmeCliException + * + * @return DistinguishedName + */ + public function loadDomainDistinguishedName($domain); + + /** + * Store a given certificate as associated to a given domain. + * + * @param string $domain + * @param Certificate $certificate + * + * @throws AcmeCliException + */ + public function storeDomainCertificate($domain, Certificate $certificate); + + /** + * Check if there is a certificate associated to the given domain in the repository. + * + * @param string $domain + * + * @return bool + */ + public function hasDomainCertificate($domain); + + /** + * Load the certificate associated to a given domain. + * + * @param string $domain + * + * @throws AcmeCliException + * + * @return Certificate + */ + public function loadDomainCertificate($domain); + + /** + * Save a given string into a given path handling backup. + * + * @param string $path + * @param string $content + * @param string $visibility the visibilty to use for this file + */ + public function save($path, $content, $visibility = self::VISIBILITY_PRIVATE); +} diff --git a/AcmePhp/Cli/Repository/RepositoryV2Interface.php b/AcmePhp/Cli/Repository/RepositoryV2Interface.php new file mode 100644 index 00000000..f3b67e03 --- /dev/null +++ b/AcmePhp/Cli/Repository/RepositoryV2Interface.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AcmePhp\Cli\Repository; + +use AcmePhp\Cli\Exception\AcmeCliException; +use AcmePhp\Core\Protocol\CertificateOrder; + +/** + * @author Titouan Galopin + */ +interface RepositoryV2Interface extends RepositoryInterface +{ + /** + * Store a given certificate as associated to a given domain. + * + * @param array $domains + * @param CertificateOrder $order + * + * @throws AcmeCliException + */ + public function storeCertificateOrder(array $domains, CertificateOrder $order); + + /** + * Check if there is a certificate associated to the given domain in the repository. + * + * @param string $domain + * + * @return bool + */ + public function hasCertificateOrder(array $domains); + + /** + * Load the certificate associated to a given domain. + * + * @param string $domain + * + * @throws AcmeCliException + * + * @return CertificateOrder + */ + public function loadCertificateOrder(array $domains); +} diff --git a/AcmePhp/Cli/Serializer/PemEncoder.php b/AcmePhp/Cli/Serializer/PemEncoder.php new file mode 100644 index 00000000..01d3a15d --- /dev/null +++ b/AcmePhp/Cli/Serializer/PemEncoder.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AcmePhp\Cli\Serializer; + +use Symfony\Component\Serializer\Encoder\DecoderInterface; +use Symfony\Component\Serializer\Encoder\EncoderInterface; + +/** + * @author Titouan Galopin + */ +class PemEncoder implements EncoderInterface, DecoderInterface +{ + const FORMAT = 'pem'; + + /** + * {@inheritdoc} + */ + public function encode($data, $format, array $context = []) + { + return trim($data)."\n"; + } + + /** + * {@inheritdoc} + */ + public function decode($data, $format, array $context = []) + { + return trim($data)."\n"; + } + + /** + * {@inheritdoc} + */ + public function supportsEncoding($format) + { + return self::FORMAT === $format; + } + + /** + * {@inheritdoc} + */ + public function supportsDecoding($format) + { + return self::FORMAT === $format; + } +} diff --git a/AcmePhp/Cli/Serializer/PemNormalizer.php b/AcmePhp/Cli/Serializer/PemNormalizer.php new file mode 100644 index 00000000..564f1b93 --- /dev/null +++ b/AcmePhp/Cli/Serializer/PemNormalizer.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AcmePhp\Cli\Serializer; + +use AcmePhp\Ssl\Certificate; +use AcmePhp\Ssl\Key; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * @author Titouan Galopin + */ +class PemNormalizer implements NormalizerInterface, DenormalizerInterface +{ + /** + * {@inheritdoc} + */ + public function normalize($object, $format = null, array $context = []) + { + return $object->getPEM(); + } + + /** + * {@inheritdoc} + */ + public function denormalize($data, $class, $format = null, array $context = []) + { + return new $class($data); + } + + /** + * {@inheritdoc} + */ + public function supportsNormalization($data, $format = null) + { + return is_object($data) && ($data instanceof Certificate || $data instanceof Key); + } + + /** + * {@inheritdoc} + */ + public function supportsDenormalization($data, $type, $format = null) + { + return is_string($data); + } +} diff --git a/composer.json b/composer.json index 8faa7653..81133af2 100644 --- a/composer.json +++ b/composer.json @@ -12,18 +12,24 @@ "": "src/", "AcmePhp\\Cli\\": "AcmePhp/Cli" }, - "files": [ "site-command.php" ] + "files": [ + "src/helper/hooks.php", + "src/helper/site-utils.php", + "site-command.php" + ] }, "extra": { "branch-alias": { "dev-master": "1.x-dev" }, - "bundled": true, - "commands": [ - "site", - "site create", - "site list", - "site delete" - ] + "bundled": true + }, + "require": { + "acmephp/core": "dev-master", + "ext-openssl": "*", + "guzzlehttp/guzzle": "^6.0", + "league/flysystem": "^1.0.19", + "symfony/serializer": "^3.0", + "webmozart/assert": "^1.0" } } diff --git a/site-command.php b/site-command.php index 8a10588b..5b78c171 100644 --- a/site-command.php +++ b/site-command.php @@ -1,6 +1,6 @@ level = 0; - pcntl_signal( SIGTERM, [ $this, "rollback" ] ); - pcntl_signal( SIGHUP, [ $this, "rollback" ] ); - pcntl_signal( SIGUSR1, [ $this, "rollback" ] ); - pcntl_signal( SIGINT, [ $this, "rollback" ] ); - $shutdown_handler = new Shutdown_Handler(); - register_shutdown_function( [ $shutdown_handler, "cleanup" ], [ &$this ] ); - $this->docker = EE::docker(); - $this->logger = EE::get_file_logger()->withName( 'site_command' ); - $this->fs = new Filesystem(); - } + private static $instance; /** - * Runs the standard WordPress site installation. - * - * ## OPTIONS - * - * - * : Name of website. + * The singleton method to hold the instance of site-command. * - * [--ssl=] - * : Enables ssl via letsencrypt certificate. - * - * [--wildcard] - * : Gets wildcard SSL . - * [--type=] - * : Type of the site to be created. Values: html,php,wp. - * - * [--skip-status-check] - * : Skips site status check. + * @return Object|Site_Command */ - public function create( $args, $assoc_args ) { + public static function instance() { - EE\Utils\delem_log( 'site create start' ); - EE::warning( 'This is a beta version. Please don\'t use it in production.' ); - $this->logger->debug( 'args:', $args ); - $this->logger->debug( 'assoc_args:', empty( $assoc_args ) ? [ 'NULL' ] : $assoc_args ); - $this->site['url'] = strtolower( EE\Utils\remove_trailing_slash( $args[0] ) ); - $this->site['type'] = EE\Utils\get_flag_value( $assoc_args, 'type', 'html' ); - if ( 'html' !== $this->site['type'] ) { - EE::error( sprintf( 'Invalid site-type: %s', $this->site['type'] ) ); + if ( ! isset( self::$instance ) ) { + self::$instance = new Site_Command(); } - if ( Site::find( $this->site['url'] ) ) { - EE::error( sprintf( "Site %1\$s already exists. If you want to re-create it please delete the older one using:\n`ee site delete %1\$s`", $this->site['url'] ) ); - } - - $this->ssl = EE\Utils\get_flag_value( $assoc_args, 'ssl' ); - $this->ssl_wildcard = EE\Utils\get_flag_value( $assoc_args, 'wildcard' ); - $this->skip_chk = EE\Utils\get_flag_value( $assoc_args, 'skip-status-check' ); - - EE\SiteUtils\init_checks(); - - EE::log( 'Configuring project.' ); - - $this->create_site(); - EE\Utils\delem_log( 'site create end' ); + return self::$instance; } /** - * Display all the relevant site information, credentials and useful links. + * Function to register different site-types. * - * [] - * : Name of the website whose info is required. + * @param string $name Name of the site-type. + * @param string $callback The callback function/class for that type. */ - public function info( $args, $assoc_args ) { - - EE\Utils\delem_log( 'site info start' ); - if ( ! isset( $this->site['url'] ) ) { - $args = EE\SiteUtils\auto_site_name( $args, 'site', __FUNCTION__ ); - $this->populate_site_info( $args ); - } - $ssl = $this->ssl ? 'Enabled' : 'Not Enabled'; - $prefix = ( $this->ssl ) ? 'https://' : 'http://'; - $info = [ - [ 'Site', $prefix . $this->site['url'] ], - [ 'Site Root', $this->site['root'] ], - [ 'SSL', $ssl ], - ]; + public static function add_site_type( $name, $callback ) { - if ( $this->ssl ) { - $info[] = [ 'SSL Wildcard', $this->ssl_wildcard ? 'Yes': 'No' ]; + if ( isset( self::$instance->site_types[ $name ] ) ) { + EE::warning( sprintf( '%s site-type had already been previously registered by %s. It is overridden by the new package class %s. Please update your packages to resolve this.', $name, self::$instance->site_types[ $name ], $callback ) ); } - - EE\Utils\format_table( $info ); - - EE\Utils\delem_log( 'site info end' ); + self::$instance->site_types[ $name ] = $callback; } - /** - * Function to configure site and copy all the required files. + * Method to get the list of registered site-types. + * + * @return array associative array of site-types and their callbacks. */ - private function configure_site_files() { - - $site_conf_dir = $this->site['root'] . '/config'; - $site_docker_yml = $this->site['root'] . '/docker-compose.yml'; - $site_conf_env = $this->site['root'] . '/.env'; - $site_nginx_default_conf = $site_conf_dir . '/nginx/default.conf'; - $site_src_dir = $this->site['root'] . '/app/src'; - $process_user = posix_getpwuid( posix_geteuid() ); - - EE::log( sprintf( 'Creating site %s.', $this->site['url'] ) ); - EE::log( 'Copying configuration files.' ); - - $filter = []; - $filter[] = $this->site['type']; - $site_docker = new Site_Docker(); - $docker_compose_content = $site_docker->generate_docker_compose_yml( $filter ); - $default_conf_content = $default_conf_content = EE\Utils\mustache_render( SITE_TEMPLATE_ROOT . '/config/nginx/default.conf.mustache', [ 'server_name' => $this->site['url'] ] ); - - $env_data = [ - 'virtual_host' => $this->site['url'], - 'user_id' => $process_user['uid'], - 'group_id' => $process_user['gid'], - ]; - $env_content = EE\Utils\mustache_render( SITE_TEMPLATE_ROOT . '/config/.env.mustache', $env_data ); - - try { - $this->fs->dumpFile( $site_docker_yml, $docker_compose_content ); - $this->fs->dumpFile( $site_conf_env, $env_content ); - $this->fs->mkdir( $site_conf_dir ); - $this->fs->mkdir( $site_conf_dir . '/nginx' ); - $this->fs->dumpFile( $site_nginx_default_conf, $default_conf_content ); - - $index_data = [ - 'version' => 'v' . EE_VERSION, - 'site_src_root' => $this->site['root'] . '/app/src', - ]; - $index_html = EE\Utils\mustache_render( SITE_TEMPLATE_ROOT . '/index.html.mustache', $index_data ); - $this->fs->mkdir( $site_src_dir ); - $this->fs->dumpFile( $site_src_dir . '/index.html', $index_html ); - - EE::success( 'Configuration files copied.' ); - } catch ( Exception $e ) { - $this->catch_clean( $e ); - } + public static function get_site_types() { + return self::$instance->site_types; } /** - * Function to create the site. + * Performs site operations. Check `ee help site` for more info. + * Invoked function of site-type routing. Called when `ee site` is invoked. + * Performs the routing to respective site-type passed using either `--type=`, + * Or discovers the type from the site-name and fetches the type from it, + * Or falls down to the default site-type defined by the user, + * Or finally the most basic site-type and the default included in this package, type=html. */ - private function create_site() { - - $this->site['root'] = WEBROOT . $this->site['url']; - $this->level = 1; - try { - EE\SiteUtils\create_site_root( $this->site['root'], $this->site['url'] ); - $this->level = 3; - $this->configure_site_files(); - - EE\SiteUtils\start_site_containers( $this->site['root'] ); - - EE\SiteUtils\create_etc_hosts_entry( $this->site['url'] ); - if ( ! $this->skip_chk ) { - $this->level = 4; - EE\SiteUtils\site_status_check( $this->site['url'] ); - } + public function __invoke( $args, $assoc_args ) { - /* - * This adds http www redirection which is needed for issuing cert for a site. - * i.e. when you create example.com site, certs are issued for example.com and www.example.com - * - * We're issuing certs for both domains as it is needed in order to perform redirection of - * https://www.example.com -> https://example.com - * - * We add redirection config two times in case of ssl as we need http redirection - * when certs are being requested and http+https redirection after we have certs. - */ - EE\SiteUtils\add_site_redirects( $this->site['url'], false, 'inherit' === $this->ssl ); - EE\SiteUtils\reload_proxy_configuration(); + $site_types = self::get_site_types(); - if ( $this->ssl ) { - $this->init_ssl( $this->site['url'], $this->site['root'], $this->ssl, $this->ssl_wildcard ); - EE\SiteUtils\add_site_redirects( $this->site['url'], true, 'inherit' === $this->ssl ); - EE\SiteUtils\reload_proxy_configuration(); - } - } catch ( Exception $e ) { - $this->catch_clean( $e ); + if ( isset( $assoc_args['type'] ) ) { + $type = $assoc_args['type']; + unset( $assoc_args['type'] ); + } else { + $type = $this->determine_type( $args ); } - - $this->info( [ $this->site['url'] ], [] ); - $this->create_site_db_entry(); - } - - /** - * Function to save the site configuration entry into database. - */ - private function create_site_db_entry() { - - $ssl = $this->ssl ? 1 : 0; - $ssl_wildcard = $this->ssl_wildcard ? 1 : 0; - - $site = Site::create([ - 'site_url' => $this->site['url'], - 'site_type' => $this->site['type'], - 'site_fs_path' => $this->site['root'], - 'site_ssl' => $ssl, - 'site_ssl_wildcard' => $ssl_wildcard, - 'created_on' => date( 'Y-m-d H:i:s', time() ), - ]); - - try { - if ( $site ) { - EE::log( 'Site entry created.' ); - } else { - throw new Exception( 'Error creating site entry in database.' ); - } - } catch ( Exception $e ) { - $this->catch_clean( $e ); + array_unshift( $args, 'site' ); + + if ( ! isset( $site_types[ $type ] ) ) { + $error = sprintf( + '\'%1$s\' is not a registered site type of \'ee site --type=%1$s\'. See \'ee help site --type=%1$s\' for available subcommands.', + $type + ); + EE::error( $error ); } - } - - /** - * Populate basic site info from db. - */ - private function populate_site_info( $args ) { - - $this->site['url'] = EE\Utils\remove_trailing_slash( $args[0] ); - $site = Site::find( $this->site['url'] ); + $callback = $site_types[ $type ]; - if ( $site ) { - $this->site['type'] = $site->site_type; - $this->site['root'] = $site->site_fs_path; - $this->ssl = $site->site_ssl; - $this->ssl_wildcard = $site->site_ssl_wildcard; - } else { - EE::error( sprintf( 'Site %s does not exist.', $this->site['url'] ) ); - } - } + $command = EE::get_root_command(); + $leaf_command = CommandFactory::create( 'site', $callback, $command ); + $command->add_subcommand( 'site', $leaf_command ); - /** - * @inheritdoc - */ - public function restart( $args, $assoc_args, $whitelisted_containers = [] ) { - $whitelisted_containers = [ 'nginx' ]; - parent::restart( $args, $assoc_args, $whitelisted_containers ); + EE::run_command( $args, $assoc_args ); } /** - * @inheritdoc - */ - public function reload( $args, $assoc_args, $whitelisted_containers = [], $reload_commands = [] ) { - $whitelisted_containers = [ 'nginx' ]; - parent::reload( $args, $assoc_args, $whitelisted_containers, $reload_commands = [] ); - } - - /** - * Catch and clean exceptions. + * Function to determine type. + * + * Discovers the type from the site-name and fetches the type from it, + * Or falls down to the default site-type defined by the user, + * Or finally the most basic site-type and the default included in this package, type=html. + * + * @param array $args Command line arguments passed to site-command. * - * @param Exception $e + * @return string site-type. */ - private function catch_clean( $e ) { + private function determine_type( $args ) { - EE\Utils\delem_log( 'site cleanup start' ); - EE::warning( $e->getMessage() ); - EE::warning( 'Initiating clean-up.' ); - $this->delete_site( $this->level, $this->site['url'], $this->site['root'] ); - EE\Utils\delem_log( 'site cleanup end' ); - exit; - } - - /** - * Roll back on interrupt. - */ - private function rollback() { + // default site-type + $type = 'html'; - EE::warning( 'Exiting gracefully after rolling back. This may take some time.' ); - if ( $this->level > 0 ) { - $this->delete_site( $this->level, $this->site['url'], $this->site['root'] ); + $last_arg = array_pop( $args ); + if ( substr( $last_arg, 0, 4 ) === 'http' ) { + $last_arg = str_replace( [ 'https://', 'http://' ], '', $last_arg ); } - EE::success( 'Rollback complete. Exiting now.' ); - exit; - } + $url_path = EE\Utils\remove_trailing_slash( $last_arg ); - /** - * Shutdown function to catch and rollback from fatal errors. - */ - private function shutDownFunction() { + $arg_search = Site::find( $url_path, [ 'site_type' ] ); - $error = error_get_last(); - if ( isset( $error ) && $error['type'] === E_ERROR ) { - EE::warning( 'An Error occurred. Initiating clean-up.' ); - $this->logger->error( 'Type: ' . $error['type'] ); - $this->logger->error( 'Message: ' . $error['message'] ); - $this->logger->error( 'File: ' . $error['file'] ); - $this->logger->error( 'Line: ' . $error['line'] ); - $this->rollback(); + if ( $arg_search ) { + return $arg_search->site_type; } + + $site_name = EE\Site\Utils\get_site_name(); + if ( $site_name ) { + if ( strpos( $url_path, '.' ) !== false ) { + $args[] = $site_name; + EE::error( + sprintf( + '%s is not a valid site-name. Did you mean `ee site %s`?', + $last_arg, + implode( ' ', $args ) + ) + ); + } + $type = Site::find( $site_name, [ 'site_type' ] )->site_type; + } + + return $type; } } diff --git a/src/helper/Shutdown_Handler.php b/src/helper/Shutdown_Handler.php new file mode 100644 index 00000000..db032575 --- /dev/null +++ b/src/helper/Shutdown_Handler.php @@ -0,0 +1,21 @@ +getMethod( 'shut_down_function' ); + $method->setAccessible( true ); + $method->invoke( $site_command[0] ); + } +} diff --git a/src/helper/Site_Letsencrypt.php b/src/helper/Site_Letsencrypt.php new file mode 100644 index 00000000..6fd81117 --- /dev/null +++ b/src/helper/Site_Letsencrypt.php @@ -0,0 +1,529 @@ +conf_dir = EE_CONF_ROOT . '/acme-conf'; + $this->setRepository(); + $this->setAcmeClient(); + } + + private function setAcmeClient() { + + if ( ! $this->repository->hasAccountKeyPair() ) { + \EE::debug( 'No account key pair was found, generating one.' ); + \EE::debug( 'Generating a key pair' ); + + $keygen = new KeyPairGenerator(); + $accountKeyPair = $keygen->generateKeyPair(); + \EE::debug( 'Key pair generated, storing' ); + $this->repository->storeAccountKeyPair( $accountKeyPair ); + } else { + \EE::debug( 'Loading account keypair' ); + $accountKeyPair = $this->repository->loadAccountKeyPair(); + } + + $this->accountKeyPair ?? $this->accountKeyPair = $accountKeyPair; + + $secureHttpClient = $this->getSecureHttpClient(); + $csrSigner = new CertificateRequestSigner(); + + $this->client = new AcmeClient( $secureHttpClient, 'https://acme-v02.api.letsencrypt.org/directory', $csrSigner ); + + } + + private function setRepository( $enable_backup = false ) { + $this->serializer ?? $this->serializer = new Serializer( + [ new PemNormalizer(), new GetSetMethodNormalizer() ], + [ new PemEncoder(), new JsonEncoder() ] + ); + $this->master ?? $this->master = new Filesystem( new Local( $this->conf_dir ) ); + $this->backup ?? $this->backup = new Filesystem( new NullAdapter() ); + + $this->repository = new Repository( $this->serializer, $this->master, $this->backup, $enable_backup ); + } + + private function getSecureHttpClient() { + $this->httpClient ?? $this->httpClient = new Client(); + $this->base64SafeEncoder ?? $this->base64SafeEncoder = new Base64SafeEncoder(); + $this->keyParser ?? $this->keyParser = new KeyParser(); + $this->dataSigner ?? $this->dataSigner = new DataSigner(); + $this->serverErrorHandler ?? $this->serverErrorHandler = new ServerErrorHandler(); + + return new SecureHttpClient( + $this->accountKeyPair, + $this->httpClient, + $this->base64SafeEncoder, + $this->keyParser, + $this->dataSigner, + $this->serverErrorHandler + ); + } + + + public function register( $email ) { + try { + $this->client->registerAccount( null, $email ); + } catch ( \Exception $e ) { + \EE::warning( $e->getMessage() ); + \EE::warning( 'It seems you\'re in local environment or there is some issue with network, please check logs. Skipping letsencrypt.' ); + + return false; + } + \EE::debug( "Account with email id: $email registered successfully!" ); + + return true; + } + + public function authorize( Array $domains, $site_root, $wildcard = false ) { + $solver = $wildcard ? new SimpleDnsSolver( null, new ConsoleOutput() ) : new SimpleHttpSolver(); + $solverName = $wildcard ? 'dns-01' : 'http-01'; + try { + $order = $this->client->requestOrder( $domains ); + } catch ( \Exception $e ) { + \EE::warning( $e->getMessage() ); + \EE::warning( 'It seems you\'re in local environment or using non-public domain, please check logs. Skipping letsencrypt.' ); + + return false; + } + + $authorizationChallengesToSolve = []; + foreach ( $order->getAuthorizationsChallenges() as $domainKey => $authorizationChallenges ) { + $authorizationChallenge = null; + foreach ( $authorizationChallenges as $candidate ) { + if ( $solver->supports( $candidate ) ) { + $authorizationChallenge = $candidate; + \EE::debug( 'Authorization challenge supported by solver. Solver: ' . $solverName . ' Challenge: ' . $candidate->getType() ); + break; + } + // Should not get here as we are handling it. + \EE::debug( 'Authorization challenge not supported by solver. Solver: ' . $solverName . ' Challenge: ' . $candidate->getType() ); + } + if ( null === $authorizationChallenge ) { + throw new ChallengeNotSupportedException(); + } + \EE::debug( 'Storing authorization challenge. Domain: ' . $domainKey . ' Challenge: ' . print_r( $authorizationChallenge->toArray(), true ) ); + + $this->repository->storeDomainAuthorizationChallenge( $domainKey, $authorizationChallenge ); + $authorizationChallengesToSolve[] = $authorizationChallenge; + } + + /** @var AuthorizationChallenge $authorizationChallenge */ + foreach ( $authorizationChallengesToSolve as $authorizationChallenge ) { + \EE::debug( 'Solving authorization challenge: Domain: ' . $authorizationChallenge->getDomain() . ' Challenge: ' . print_r( $authorizationChallenge->toArray(), true ) ); + $solver->solve( $authorizationChallenge ); + + if ( ! $wildcard ) { + $token = $authorizationChallenge->toArray()['token']; + $payload = $authorizationChallenge->toArray()['payload']; + \EE::launch( "mkdir -p $site_root/app/src/.well-known/acme-challenge/" ); + \EE::debug( "Creating challange file $site_root/app/src/.well-known/acme-challenge/$token" ); + file_put_contents( "$site_root/app/src/.well-known/acme-challenge/$token", $payload ); + \EE::launch( "chown www-data: $site_root/app/src/.well-known/acme-challenge/$token" ); + } + } + + $this->repository->storeCertificateOrder( $domains, $order ); + + return true; + } + + public function check( Array $domains, $wildcard = false ) { + \EE::debug( ( 'Starting check with solver ' ) . ( $wildcard ? 'dns' : 'http' ) ); + $solver = $wildcard ? new SimpleDnsSolver( null, new ConsoleOutput() ) : new SimpleHttpSolver(); + $validator = new ChainValidator( + [ + new WaitingValidator( new HttpValidator() ), + new WaitingValidator( new DnsValidator() ) + ] + ); + + $order = null; + if ( $this->repository->hasCertificateOrder( $domains ) ) { + $order = $this->repository->loadCertificateOrder( $domains ); + \EE::debug( sprintf( 'Loading the authorization token for domains %s ...', implode( ', ', $domains ) ) ); + } + + $authorizationChallengeToCleanup = []; + foreach ( $domains as $domain ) { + if ( $order ) { + $authorizationChallenge = null; + $authorizationChallenges = $order->getAuthorizationChallenges( $domain ); + foreach ( $authorizationChallenges as $challenge ) { + if ( $solver->supports( $challenge ) ) { + $authorizationChallenge = $challenge; + break; + } + } + if ( null === $authorizationChallenge ) { + throw new ChallengeNotSupportedException(); + } + } else { + if ( ! $this->repository->hasDomainAuthorizationChallenge( $domain ) ) { + \EE::error( "Domain: $domain not yet authorized/has not been started of with EasyEngine letsencrypt site creation." ); + } + $authorizationChallenge = $this->repository->loadDomainAuthorizationChallenge( $domain ); + if ( ! $solver->supports( $authorizationChallenge ) ) { + throw new ChallengeNotSupportedException(); + } + } + \EE::debug( 'Challenge loaded.' ); + + $authorizationChallenge = $this->client->reloadAuthorization( $authorizationChallenge ); + if ( ! $authorizationChallenge->isValid() ) { + \EE::debug( sprintf( 'Testing the challenge for domain %s', $domain ) ); + if ( ! $validator->isValid( $authorizationChallenge ) ) { + \EE::warning( sprintf( 'Can not valid challenge for domain %s', $domain ) ); + } + + \EE::debug( sprintf( 'Requesting authorization check for domain %s', $domain ) ); + try { + $this->client->challengeAuthorization( $authorizationChallenge ); + } catch ( \Exception $e ) { + \EE::debug( $e->getMessage() ); + \EE::warning( 'Challange Authorization failed. Check logs and check if your domain is pointed correctly to this server.' ); + $site_name = isset( $domains[1] ) ? $domains[1] : $domains[0]; + \EE::log( "Re-run `ee site le $site_name` after fixing the issue." ); + + return false; + } + $authorizationChallengeToCleanup[] = $authorizationChallenge; + } + } + + \EE::log( 'The authorization check was successful!' ); + + if ( $solver instanceof MultipleChallengesSolverInterface ) { + $solver->cleanupAll( $authorizationChallengeToCleanup ); + } else { + /** @var AuthorizationChallenge $authorizationChallenge */ + foreach ( $authorizationChallengeToCleanup as $authorizationChallenge ) { + $solver->cleanup( $authorizationChallenge ); + } + } + + return true; + } + + public function request( $domain, $altNames = [], $email, $force = false ) { + $alternativeNames = array_unique( $altNames ); + sort( $alternativeNames ); + + // Certificate renewal + if ( $this->hasValidCertificate( $domain, $alternativeNames ) ) { + \EE::debug( "Certificate found for $domain, executing renewal" ); + + return $this->executeRenewal( $domain, $alternativeNames, $force ); + } + + \EE::debug( "No certificate found, executing first request for $domain" ); + + // Certificate first request + return $this->executeFirstRequest( $domain, $alternativeNames, $email ); + } + + /** + * Request a first certificate for the given domain. + * + * @param string $domain + * @param array $alternativeNames + */ + private function executeFirstRequest( $domain, array $alternativeNames, $email ) { + \EE::log( 'Executing first request.' ); + + // Generate domain key pair + $keygen = new KeyPairGenerator(); + $domainKeyPair = $keygen->generateKeyPair(); + $this->repository->storeDomainKeyPair( $domain, $domainKeyPair ); + + \EE::debug( "$domain Domain key pair generated and stored" ); + + $distinguishedName = $this->getOrCreateDistinguishedName( $domain, $alternativeNames, $email ); + // TODO: ask them ;) + \EE::debug( 'Distinguished name informations have been stored locally for this domain (they won\'t be asked on renewal).' ); + + // Order + $domains = array_merge( [ $domain ], $alternativeNames ); + \EE::debug( sprintf( 'Loading the order related to the domains %s .', implode( ', ', $domains ) ) ); + if ( ! $this->repository->hasCertificateOrder( $domains ) ) { + \EE::error( "$domain has not yet been authorized." ); + } + $order = $this->repository->loadCertificateOrder( $domains ); + + // Request + \EE::log( sprintf( 'Requesting first certificate for domain %s.', $domain ) ); + $csr = new CertificateRequest( $distinguishedName, $domainKeyPair ); + $response = $this->client->finalizeOrder( $order, $csr ); + \EE::log( 'Certificate received' ); + + // Store + $this->repository->storeDomainCertificate( $domain, $response->getCertificate() ); + \EE::log( 'Certificate stored' ); + + // Post-generate actions + $this->moveCertsToNginxProxy( $domain ); + } + + private function moveCertsToNginxProxy( string $domain ) { + + $key_source_file = strtr( $this->conf_dir . '/' . Repository::PATH_DOMAIN_KEY_PRIVATE, [ '{domain}' => $domain ] ); + $crt_source_file = strtr( $this->conf_dir . '/' . Repository::PATH_DOMAIN_CERT_FULLCHAIN, [ '{domain}' => $domain ] ); + $chain_source_file = strtr( $this->conf_dir . '/' . Repository::PATH_DOMAIN_CERT_CHAIN, [ '{domain}' => $domain ] ); + + $key_dest_file = EE_CONF_ROOT . '/nginx/certs/' . $domain . '.key'; + $crt_dest_file = EE_CONF_ROOT . '/nginx/certs/' . $domain . '.crt'; + $chain_dest_file = EE_CONF_ROOT . '/nginx/certs/' . $domain . '.chain.pem'; + + copy( $key_source_file, $key_dest_file ); + copy( $crt_source_file, $crt_dest_file ); + copy( $chain_source_file, $chain_dest_file ); + } + + /** + * Renew a given domain certificate. + * + * @param string $domain + * @param array $alternativeNames + * @param bool $force + */ + private function executeRenewal( $domain, array $alternativeNames, $force = false ) { + try { + // Check expiration date to avoid too much renewal + \EE::log( "Loading current certificate for $domain" ); + + $certificate = $this->repository->loadDomainCertificate( $domain ); + + if ( ! $force ) { + $certificateParser = new CertificateParser(); + $parsedCertificate = $certificateParser->parse( $certificate ); + + if ( $parsedCertificate->getValidTo()->format( 'U' ) - time() >= 604800 ) { + + \EE::log( + sprintf( + 'Current certificate is valid until %s, renewal is not necessary.', + $parsedCertificate->getValidTo()->format( 'Y-m-d H:i:s' ) + ) + ); + + return; + } + + \EE::log( + sprintf( + 'Current certificate will expire in less than a week (%s), renewal is required.', + $parsedCertificate->getValidTo()->format( 'Y-m-d H:i:s' ) + ) + ); + } else { + \EE::log( 'Forced renewal.' ); + } + + // Key pair + \EE::debug( 'Loading domain key pair...' ); + $domainKeyPair = $this->repository->loadDomainKeyPair( $domain ); + + // Distinguished name + \EE::debug( 'Loading domain distinguished name...' ); + $distinguishedName = $this->getOrCreateDistinguishedName( $domain, $alternativeNames ); + + // Order + $domains = array_merge( [ $domain ], $alternativeNames ); + \EE::debug( sprintf( 'Loading the order related to the domains %s.', implode( ', ', $domains ) ) ); + if ( ! $this->repository->hasCertificateOrder( $domains ) ) { + \EE::error( "$domain has not yet been authorized." ); + } + $order = $this->repository->loadCertificateOrder( $domains ); + + // Renewal + \EE::log( sprintf( 'Renewing certificate for domain %s.', $domain ) ); + $csr = new CertificateRequest( $distinguishedName, $domainKeyPair ); + $response = $this->client->finalizeOrder( $order, $csr ); + \EE::log( 'Certificate received' ); + + $this->repository->storeDomainCertificate( $domain, $response->getCertificate() ); + $this->log( 'Certificate stored' ); + + // Post-generate actions + $this->moveCertsToNginxProxy( $domain ); + \EE::log( 'Certificate renewed successfully!' ); + + } catch ( \Exception $e ) { + \EE::warning( 'A critical error occured during certificate renewal' ); + \EE::debug( print_r( $e, true ) ); + + throw $e; + } catch ( \Throwable $e ) { + \EE::warning( 'A critical error occured during certificate renewal' ); + \EE::debug( print_r( $e, true ) ); + + throw $e; + } + } + + private function hasValidCertificate( $domain, array $alternativeNames ) { + if ( ! $this->repository->hasDomainCertificate( $domain ) ) { + return false; + } + + if ( ! $this->repository->hasDomainKeyPair( $domain ) ) { + return false; + } + + if ( ! $this->repository->hasDomainDistinguishedName( $domain ) ) { + return false; + } + + if ( $this->repository->loadDomainDistinguishedName( $domain )->getSubjectAlternativeNames() !== $alternativeNames ) { + return false; + } + + return true; + } + + /** + * Retrieve the stored distinguishedName or create a new one if needed. + * + * @param string $domain + * @param array $alternativeNames + * + * @return DistinguishedName + */ + private function getOrCreateDistinguishedName( $domain, array $alternativeNames, $email ) { + if ( $this->repository->hasDomainDistinguishedName( $domain ) ) { + $original = $this->repository->loadDomainDistinguishedName( $domain ); + + $distinguishedName = new DistinguishedName( + $domain, + $original->getCountryName(), + $original->getStateOrProvinceName(), + $original->getLocalityName(), + $original->getOrganizationName(), + $original->getOrganizationalUnitName(), + $original->getEmailAddress(), + $alternativeNames + ); + } else { + // Ask DistinguishedName + $distinguishedName = new DistinguishedName( + $domain, + // TODO: Ask and fill these values properly + 'US', + 'CA', + 'Mountain View', + 'Let\'s Encrypt', + 'Let\'s Encrypt Authority X3', + $email, + $alternativeNames + ); + + } + + $this->repository->storeDomainDistinguishedName( $domain, $distinguishedName ); + + return $distinguishedName; + } + + + public function status() { + $this->master ?? $this->master = new Filesystem( new Local( $this->conf_dir ) ); + + $certificateParser = new CertificateParser(); + + $table = new Table( $output ); + $table->setHeaders( [ 'Domain', 'Issuer', 'Valid from', 'Valid to', 'Needs renewal?' ] ); + + $directories = $this->master->listContents( 'certs' ); + + foreach ( $directories as $directory ) { + if ( 'dir' !== $directory['type'] ) { + continue; + } + + $parsedCertificate = $certificateParser->parse( $this->repository->loadDomainCertificate( $directory['basename'] ) ); + if ( ! $input->getOption( 'all' ) && $parsedCertificate->isExpired() ) { + continue; + } + $domainString = $parsedCertificate->getSubject(); + + $alternativeNames = array_diff( $parsedCertificate->getSubjectAlternativeNames(), [ $parsedCertificate->getSubject() ] ); + if ( count( $alternativeNames ) ) { + sort( $alternativeNames ); + $last = array_pop( $alternativeNames ); + foreach ( $alternativeNames as $alternativeName ) { + $domainString .= "\n ├── " . $alternativeName; + } + $domainString .= "\n └── " . $last; + } + + $table->addRow( + [ + $domainString, + $parsedCertificate->getIssuer(), + $parsedCertificate->getValidFrom()->format( 'Y-m-d H:i:s' ), + $parsedCertificate->getValidTo()->format( 'Y-m-d H:i:s' ), + ( $parsedCertificate->getValidTo()->format( 'U' ) - time() < 604800 ) ? 'Yes' : 'No', + ] + ); + } + + $table->render(); + } + + public function cleanup( $site_root ) { + $challange_dir = "$site_root/app/src/.well-known"; + if ( file_exists( "$site_root/app/src/.well-known" ) ) { + \EE::debug( 'Cleaning up webroot files.' ); + \EE\Utils\delete_dir( $challange_dir ); + } + } +} diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php new file mode 100644 index 00000000..8e15961c --- /dev/null +++ b/src/helper/class-ee-site.php @@ -0,0 +1,569 @@ +] + * : Render output in a particular format. + * --- + * default: table + * options: + * - table + * - csv + * - yaml + * - json + * - count + * - text + * --- + * + * @subcommand list + */ + public function _list( $args, $assoc_args ) { + + \EE\Utils\delem_log( 'site list start' ); + $format = \EE\Utils\get_flag_value( $assoc_args, 'format' ); + $enabled = \EE\Utils\get_flag_value( $assoc_args, 'enabled' ); + $disabled = \EE\Utils\get_flag_value( $assoc_args, 'disabled' ); + + $sites = Site::all(); + + if ( $enabled && ! $disabled ) { + $sites = Site::where( 'is_enabled', true ); + } elseif ( $disabled && ! $enabled ) { + $sites = Site::where( 'is_enabled', false ); + } + + if ( empty( $sites ) ) { + \EE::error( 'No sites found!' ); + } + + if ( 'text' === $format ) { + foreach ( $sites as $site ) { + \EE::log( $site->site_url ); + } + } else { + $result = array_map( + function ( $site ) { + $site->site = $site->site_url; + $site->status = $site->site_enabled ? 'enabled' : 'disabled'; + + return $site; + }, $sites + ); + + $formatter = new \EE\Formatter( $assoc_args, [ 'site', 'status' ] ); + + $formatter->display_items( $result ); + } + + \EE\Utils\delem_log( 'site list end' ); + } + + + /** + * Deletes a website. + * + * ## OPTIONS + * + * + * : Name of website to be deleted. + * + * [--yes] + * : Do not prompt for confirmation. + */ + public function delete( $args, $assoc_args ) { + + \EE\Utils\delem_log( 'site delete start' ); + $this->populate_site_info( $args ); + \EE::confirm( sprintf( 'Are you sure you want to delete %s?', $this->site['url'] ), $assoc_args ); + $this->delete_site( 5, $this->site['url'], $this->site['root'] ); + \EE\Utils\delem_log( 'site delete end' ); + } + + /** + * Function to delete the given site. + * + * @param int $level Level of deletion. + * Level - 0: No need of clean-up. + * Level - 1: Clean-up only the site-root. + * Level - 2: Try to remove network. The network may or may not have been created. + * Level - 3: Disconnect & remove network and try to remove containers. The containers may + * not have been created. Level - 4: Remove containers. Level - 5: Remove db entry. + * @param string $site_name Name of the site to be deleted. + * @param string $site_root Webroot of the site. + */ + protected function delete_site( $level, $site_name, $site_root ) { + + $this->fs = new Filesystem(); + $proxy_type = EE_PROXY_TYPE; + if ( $level >= 3 ) { + if ( \EE::docker()::docker_compose_down( $site_root ) ) { + \EE::log( "[$site_name] Docker Containers removed." ); + } else { + \EE::exec( "docker rm -f $(docker ps -q -f=label=created_by=EasyEngine -f=label=site_name=$site_name)" ); + if ( $level > 3 ) { + \EE::warning( 'Error in removing docker containers.' ); + } + } + } + + if ( $this->fs->exists( $site_root ) ) { + try { + $this->fs->remove( $site_root ); + } catch ( \Exception $e ) { + \EE::debug( $e ); + \EE::error( 'Could not remove site root. Please check if you have sufficient rights.' ); + } + \EE::log( "[$site_name] site root removed." ); + } + + $config_file_path = EE_CONF_ROOT . '/nginx/conf.d/' . $site_name . '-redirect.conf'; + + if ( $this->fs->exists( $config_file_path ) ) { + try { + $this->fs->remove( $config_file_path ); + } catch ( \Exception $e ) { + \EE::debug( $e ); + \EE::error( 'Could not remove site redirection file. Please check if you have sufficient rights.' ); + } + } + + + if ( $level > 4 ) { + if ( $this->ssl ) { + \EE::log( 'Removing ssl certs.' ); + $crt_file = EE_CONF_ROOT . "/nginx/certs/$site_name.crt"; + $key_file = EE_CONF_ROOT . "/nginx/certs/$site_name.key"; + $conf_certs = EE_CONF_ROOT . "/acme-conf/certs/$site_name"; + $conf_var = EE_CONF_ROOT . "/acme-conf/var/$site_name"; + + $cert_files = [ $conf_certs, $conf_var, $crt_file, $key_file ]; + try { + $this->fs->remove( $cert_files ); + } catch ( \Exception $e ) { + \EE::warning( $e ); + } + } + + if ( Site::find( $site_name )->delete() ) { + \EE::log( 'Removed database entry.' ); + } else { + \EE::error( 'Could not remove the database entry' ); + } + } + \EE::log( "Site $site_name deleted." ); + } + + /** + * Enables a website. It will start the docker containers of the website if they are stopped. + * + * ## OPTIONS + * + * [] + * : Name of website to be enabled. + * + * [--force] + * : Force execution of site up. + */ + public function up( $args, $assoc_args ) { + + \EE\Utils\delem_log( 'site enable start' ); + $force = \EE\Utils\get_flag_value( $assoc_args, 'force' ); + $args = EE\SiteUtils\auto_site_name( $args, 'site', __FUNCTION__ ); + $this->populate_site_info( $args ); + $site = Site::find( $this->site['url'] ); + + if ( $site->site_enabled && ! $force ) { + \EE::error( sprintf( '%s is already enabled!', $site->site_url ) ); + } + + \EE::log( sprintf( 'Enabling site %s.', $site->site_url ) ); + + if ( \EE::docker()::docker_compose_up( $this->site['root'] ) ) { + $site->site_enabled = 1; + $site->save(); + \EE::success( "Site $site->site_url enabled." ); + } else { + \EE::error( sprintf( 'There was error in enabling %s. Please check logs.', $site->site_url ) ); + } + \EE\Utils\delem_log( 'site enable end' ); + } + + /** + * Disables a website. It will stop and remove the docker containers of the website if they are running. + * + * ## OPTIONS + * + * [] + * : Name of website to be disabled. + */ + public function down( $args, $assoc_args ) { + + \EE\Utils\delem_log( 'site disable start' ); + $args = EE\SiteUtils\auto_site_name( $args, 'site', __FUNCTION__ ); + $this->populate_site_info( $args ); + + $site = Site::find( $this->site['url'] ); + + \EE::log( sprintf( 'Disabling site %s.', $site->site_url ) ); + + if ( \EE::docker()::docker_compose_down( $this->site['root'] ) ) { + $site->site_enabled = 0; + $site->save(); + + \EE::success( sprintf( 'Site %s disabled.', $this->site['url'] ) ); + } else { + \EE::error( sprintf( 'There was error in disabling %s. Please check logs.', $this->site['url'] ) ); + } + \EE\Utils\delem_log( 'site disable end' ); + } + + /** + * Restarts containers associated with site. + * When no service(--nginx etc.) is specified, all site containers will be restarted. + * + * [] + * : Name of the site. + * + * [--all] + * : Restart all containers of site. + * + * [--nginx] + * : Restart nginx container of site. + */ + public function restart( $args, $assoc_args, $whitelisted_containers = [] ) { + + \EE\Utils\delem_log( 'site restart start' ); + $args = EE\SiteUtils\auto_site_name( $args, 'site', __FUNCTION__ ); + $all = \EE\Utils\get_flag_value( $assoc_args, 'all' ); + $no_service_specified = count( $assoc_args ) === 0; + + $this->populate_site_info( $args ); + + chdir( $this->site['root'] ); + + if ( $all || $no_service_specified ) { + $containers = $whitelisted_containers; + } else { + $containers = array_keys( $assoc_args ); + } + + foreach ( $containers as $container ) { + EE\Siteutils\run_compose_command( 'restart', $container ); + } + \EE\Utils\delem_log( 'site restart stop' ); + } + + /** + * Reload services in containers without restarting container(s) associated with site. + * When no service(--nginx etc.) is specified, all services will be reloaded. + * + * [] + * : Name of the site. + * + * [--all] + * : Reload all services of site(which are supported). + * + * [--nginx] + * : Reload nginx service in container. + * + */ + public function reload( $args, $assoc_args, $whitelisted_containers = [], $reload_commands = [] ) { + + \EE\Utils\delem_log( 'site reload start' ); + $args = EE\SiteUtils\auto_site_name( $args, 'site', __FUNCTION__ ); + $all = \EE\Utils\get_flag_value( $assoc_args, 'all' ); + if ( ! array_key_exists( 'nginx', $reload_commands ) ) { + $reload_commands['nginx'] = 'nginx sh -c \'nginx -t && service openresty reload\''; + } + $no_service_specified = count( $assoc_args ) === 0; + + $this->populate_site_info( $args ); + + chdir( $this->site['root'] ); + + if ( $all || $no_service_specified ) { + $this->reload_services( $whitelisted_containers, $reload_commands ); + } else { + $this->reload_services( array_keys( $assoc_args ), $reload_commands ); + } + \EE\Utils\delem_log( 'site reload stop' ); + } + + /** + * Executes reload commands. It needs separate handling as commands to reload each service is different. + * + * @param array $services Services to reload. + * @param array $reload_commands Commands to reload the services. + */ + private function reload_services( $services, $reload_commands ) { + + foreach ( $services as $service ) { + EE\SiteUtils\run_compose_command( 'exec', $reload_commands[ $service ], 'reload', $service ); + } + } + + /** + * Runs the acme le registration and authorization. + * + * @param string $site_name Name of the site for ssl. + * + * @throws Exception + */ + protected function inherit_certs( $site_name ) { + $parent_site_name = implode( '.', array_slice( explode( '.', $site_name ), 1 ) ); + $parent_site = Site::find( $parent_site_name, [ 'site_ssl', 'site_ssl_wildcard' ] ); + + if ( ! $parent_site ) { + throw new Exception( 'Unable to find existing site: ' . $parent_site_name ); + } + + if ( ! $parent_site->site_ssl ) { + throw new Exception( "Cannot inherit from $parent_site_name as site does not have SSL cert" . var_dump( $parent_site ) ); + } + + if ( ! $parent_site->site_ssl_wildcard ) { + throw new Exception( "Cannot inherit from $parent_site_name as site does not have wildcard SSL cert" ); + } + + // We don't have to do anything now as nginx-proxy handles everything for us. + \EE::success( 'Inherited certs from parent' ); + } + + /** + * Runs SSL procedure. + * + * @param string $site_name Name of the site for ssl. + * @param string $site_root Webroot of the site. + * @param string $ssl_type Type of ssl cert to issue. + * @param bool $wildcard SSL with wildcard or not. + * + * @throws \EE\ExitException If --ssl flag has unrecognized value + */ + protected function init_ssl( $site_name, $site_root, $ssl_type, $wildcard = false ) { + \EE::debug( 'Starting SSL procedure' ); + if ( 'le' === $ssl_type ) { + \EE::debug( 'Initializing LE' ); + $this->init_le( $site_name, $site_root, $wildcard ); + } elseif ( 'inherit' === $ssl_type ) { + if ( $wildcard ) { + \EE::error( 'Cannot use --wildcard with --ssl=inherit', false ); + } + \EE::debug( 'Inheriting certs' ); + $this->inherit_certs( $site_name ); + } else { + \EE::error( "Unrecognized value in --ssl flag: $ssl_type" ); + } + } + + /** + * Runs the acme le registration and authorization. + * + * @param string $site_name Name of the site for ssl. + * @param string $site_root Webroot of the site. + * @param bool $wildcard SSL with wildcard or not. + */ + protected function init_le( $site_name, $site_root, $wildcard = false ) { + \EE::debug( "Wildcard in init_le: $wildcard" ); + + $this->site['url'] = $site_name; + $this->site['root'] = $site_root; + $this->wildcard = $wildcard; + $client = new Site_Letsencrypt(); + $this->le_mail = \EE::get_runner()->config['le-mail'] ?? \EE::input( 'Enter your mail id: ' ); + \EE::get_runner()->ensure_present_in_config( 'le-mail', $this->le_mail ); + if ( ! $client->register( $this->le_mail ) ) { + $this->ssl = null; + + return; + } + + $domains = $this->get_cert_domains( $site_name, $wildcard ); + + if ( ! $client->authorize( $domains, $this->site['root'], $wildcard ) ) { + $this->le = false; + + return; + } + if ( $wildcard ) { + echo \cli\Colors::colorize( '%YIMPORTANT:%n Run `ee site le ' . $this->site['url'] . '` once the dns changes have propogated to complete the certification generation and installation.', null ); + } else { + $this->le( [], [] ); + } + } + + /** + * Returns all domains required by cert + * + * @param string $site_name Name of site + * @param $wildcard Wildcard cert required? + * + * @return array + */ + private function get_cert_domains( string $site_name, $wildcard ): array { + $domains = [ $site_name ]; + $has_www = ( strpos( $site_name, 'www.' ) === 0 ); + + if ( $wildcard ) { + $domains[] = "*.{$site_name}"; + } else { + $domains[] = $this->get_www_domain( $site_name ); + } + + return $domains; + } + + /** + * If the domain has www in it, returns a domain without www in it. + * Else returns a domain with www in it. + * + * @param string $site_name Name of site + * + * @return string Domain name with or without www + */ + private function get_www_domain( string $site_name ): string { + $has_www = ( strpos( $site_name, 'www.' ) === 0 ); + + if ( $has_www ) { + return ltrim( $site_name, 'www.' ); + } else { + return 'www.' . $site_name; + } + } + + + /** + * Runs the acme le. + * + * ## OPTIONS + * + * + * : Name of website. + * + * [--force] + * : Force renewal. + */ + public function le( $args = [], $assoc_args = [] ) { + + if ( ! isset( $this->site['url'] ) ) { + $this->populate_site_info( $args ); + } + + if ( ! isset( $this->le_mail ) ) { + $this->le_mail = \EE::get_config( 'le-mail' ) ?? \EE::input( 'Enter your mail id: ' ); + } + + $force = \EE\Utils\get_flag_value( $assoc_args, 'force' ); + $domains = $this->get_cert_domains( $this->site['url'], $this->wildcard ); + $client = new Site_Letsencrypt(); + + if ( ! $client->check( $domains, $this->wildcard ) ) { + $this->ssl = null; + + return; + } + + $san = array_values( array_diff( $domains, [ $this->site['url'] ] ) ); + $client->request( $this->site['url'], $san, $this->le_mail, $force ); + + if ( ! $this->wildcard ) { + $client->cleanup( $this->site['root'] ); + } + \EE::launch( 'docker exec ee-nginx-proxy sh -c "/app/docker-entrypoint.sh /usr/local/bin/docker-gen /app/nginx.tmpl /etc/nginx/conf.d/default.conf; /usr/sbin/nginx -s reload"' ); + } + + /** + * Populate basic site info from db. + */ + private function populate_site_info( $args ) { + + $this->site['url'] = \EE\Utils\remove_trailing_slash( $args[0] ); + $site = Site::find( $this->site['url'] ); + if ( $site ) { + + $db_select = $site->site_url; + + $this->site['type'] = $site->site_type; + $this->site['root'] = $site->site_fs_path; + $this->ssl = $site->site_ssl; + $this->wildcard = $site->site_ssl_wildcard; + } else { + \EE::error( sprintf( 'Site %s does not exist.', $this->site['url'] ) ); + } + } + + /** + * Shutdown function to catch and rollback from fatal errors. + */ + protected function shut_down_function() { + + $logger = \EE::get_file_logger()->withName( 'site-command' ); + $error = error_get_last(); + if ( isset( $error ) && $error['type'] === E_ERROR ) { + \EE::warning( 'An Error occurred. Initiating clean-up.' ); + $logger->error( 'Type: ' . $error['type'] ); + $logger->error( 'Message: ' . $error['message'] ); + $logger->error( 'File: ' . $error['file'] ); + $logger->error( 'Line: ' . $error['line'] ); + $this->rollback(); + } + } + + abstract public function create( $args, $assoc_args ); + + abstract protected function rollback(); + +} diff --git a/src/helper/hooks.php b/src/helper/hooks.php new file mode 100644 index 00000000..256709bc --- /dev/null +++ b/src/helper/hooks.php @@ -0,0 +1,45 @@ +add_subcommand( 'site', $leaf_command ); + } else { + $error = sprintf( + '\'%1$s\' is not a registered site type of \'ee site --type=%1$s\'. See \'ee help site --type=%1$s\' for available subcommands.', + $type + ); + EE::error( $error ); + } + +} + +EE::add_hook( 'before_invoke:help', 'ee_site_help_cmd_routing' ); \ No newline at end of file diff --git a/src/helper/site-utils.php b/src/helper/site-utils.php new file mode 100644 index 00000000..0092c19b --- /dev/null +++ b/src/helper/site-utils.php @@ -0,0 +1,371 @@ +site_fs_path; + if ( substr( $cwd, 0, strlen( $site_path ) ) === $site_path ) { + return $name; + } + } + } + } + + return false; +} + +/** + * Function to set the site-name in the args when ee is running in a site folder and the site-name has not been passed + * in the args. If the site-name could not be found it will throw an error. + * + * @param array $args The passed arguments. + * @param String $command The command passing the arguments to auto-detect site-name. + * @param String $function The function passing the arguments to auto-detect site-name. + * @param integer $arg_pos Argument position where Site-name will be present. + * + * @return array Arguments with site-name set. + */ +function auto_site_name( $args, $command, $function, $arg_pos = 0 ) { + + if ( isset( $args[ $arg_pos ] ) ) { + $possible_site_name = $args[ $arg_pos ]; + if ( substr( $possible_site_name, 0, 4 ) === 'http' ) { + $possible_site_name = str_replace( [ 'https', 'http' ], '', $possible_site_name ); + } + $url_path = parse_url( EE\Utils\remove_trailing_slash( $possible_site_name ), PHP_URL_PATH ); + if ( Site::find( $url_path ) ) { + return $args; + } + } + $site_name = get_site_name(); + if ( $site_name ) { + if ( isset( $args[ $arg_pos ] ) ) { + EE::error( $args[ $arg_pos ] . " is not a valid site-name. Did you mean `ee $command $function $site_name`?" ); + } + array_splice( $args, $arg_pos, 0, $site_name ); + } else { + EE::error( "Could not find the site you wish to run $command $function command on.\nEither pass it as an argument: `ee $command $function ` \nor run `ee $command $function` from inside the site folder." ); + } + + return $args; +} + + +/** + * Function to check all the required configurations needed to create the site. + * + * Boots up the container if it is stopped or not running. + */ +function init_checks() { + + $proxy_type = EE_PROXY_TYPE; + if ( 'running' !== EE::docker()::container_status( $proxy_type ) ) { + /** + * Checking ports. + */ + $port_80_status = get_curl_info( 'localhost', 80, true ); + $port_443_status = get_curl_info( 'localhost', 443, true ); + + // if any/both the port/s is/are occupied. + if ( ! ( $port_80_status && $port_443_status ) ) { + EE::error( 'Cannot create/start proxy container. Please make sure port 80 and 443 are free.' ); + } else { + + $fs = new Filesystem(); + + if ( ! $fs->exists( EE_CONF_ROOT . '/docker-compose.yml' ) ) { + generate_global_docker_compose_yml( $fs ); + } + + $EE_CONF_ROOT = EE_CONF_ROOT; + if ( ! EE::docker()::docker_network_exists( 'ee-global-network' ) ) { + if ( ! EE::docker()::create_network( 'ee-global-network' ) ) { + EE::error( 'Unable to create network ee-global-network' ); + } + } + if ( EE::docker()::docker_compose_up( EE_CONF_ROOT, [ 'nginx-proxy' ] ) ) { + $fs->dumpFile( "$EE_CONF_ROOT/nginx/conf.d/custom.conf", file_get_contents( EE_ROOT . '/templates/custom.conf.mustache' ) ); + EE::success( "$proxy_type container is up." ); + } else { + EE::error( "There was some error in starting $proxy_type container. Please check logs." ); + } + } + } +} + +/** + * Generates global docker-compose.yml at EE_CONF_ROOT + * + * @param Filesystem $fs Filesystem object to write file + */ +function generate_global_docker_compose_yml( Filesystem $fs ) { + $img_versions = EE\Utils\get_image_versions(); + + $data = [ + 'services' => [ + 'name' => 'nginx-proxy', + 'container_name' => EE_PROXY_TYPE, + 'image' => 'easyengine/nginx-proxy:' . $img_versions['easyengine/nginx-proxy'], + 'restart' => 'always', + 'ports' => [ + '80:80', + '443:443', + ], + 'environment' => [ + 'LOCAL_USER_ID=' . posix_geteuid(), + 'LOCAL_GROUP_ID=' . posix_getegid(), + ], + 'volumes' => [ + EE_CONF_ROOT . '/nginx/certs:/etc/nginx/certs', + EE_CONF_ROOT . '/nginx/dhparam:/etc/nginx/dhparam', + EE_CONF_ROOT . '/nginx/conf.d:/etc/nginx/conf.d', + EE_CONF_ROOT . '/nginx/htpasswd:/etc/nginx/htpasswd', + EE_CONF_ROOT . '/nginx/vhost.d:/etc/nginx/vhost.d', + '/usr/share/nginx/html', + '/var/run/docker.sock:/tmp/docker.sock:ro', + ], + 'networks' => [ + 'global-network', + ], + ], + ]; + + $contents = EE\Utils\mustache_render( SITE_TEMPLATE_ROOT . '/global_docker_compose.yml.mustache', $data ); + $fs->dumpFile( EE_CONF_ROOT . '/docker-compose.yml', $contents ); +} + +/** + * Creates site root directory if does not exist. + * Throws error if it does exist. + * + * @param string $site_root Root directory of the site. + * @param string $site_name Name of the site. + */ +function create_site_root( $site_root, $site_name ) { + + $fs = new Filesystem(); + if ( $fs->exists( $site_root ) ) { + EE::error( "Webroot directory for site $site_name already exists." ); + } + + $whoami = EE::launch( 'whoami', false, true ); + $terminal_username = rtrim( $whoami->stdout ); + + $fs->mkdir( $site_root ); + $fs->chown( $site_root, $terminal_username ); +} + +/** + * Reloads configuration of global-proxy container + * + * @return bool + */ +function reload_proxy_configuration() { + return EE::exec( sprintf( 'docker exec %s sh -c "/app/docker-entrypoint.sh /usr/local/bin/docker-gen /app/nginx.tmpl /etc/nginx/conf.d/default.conf; /usr/sbin/nginx -s reload"', EE_PROXY_TYPE ) ); +} + +/** + * Adds www to non-www redirection to site + * + * @param string $site_name name of the site. + * @param bool $ssl enable ssl or not. + * @param bool $inherit inherit cert or not. + */ +function add_site_redirects( string $site_name, bool $ssl, bool $inherit ) { + + $fs = new Filesystem(); + $confd_path = EE_CONF_ROOT . '/nginx/conf.d/'; + $config_file_path = $confd_path . $site_name . '-redirect.conf'; + $has_www = strpos( $site_name, 'www.' ) === 0; + $cert_site_name = $site_name; + + if ( $inherit ) { + $cert_site_name = implode( '.', array_slice( explode( '.', $site_name ), 1 ) ); + } + + if ( $has_www ) { + $server_name = ltrim( $site_name, '.www' ); + } else { + $server_name = 'www.' . $site_name; + } + + $conf_data = [ + 'site_name' => $site_name, + 'cert_site_name' => $cert_site_name, + 'server_name' => $server_name, + 'ssl' => $ssl, + ]; + + $content = EE\Utils\mustache_render( EE_ROOT . '/templates/redirect.conf.mustache', $conf_data ); + $fs->dumpFile( $config_file_path, ltrim( $content, PHP_EOL ) ); +} + +/** + * Function to create entry in /etc/hosts. + * + * @param string $site_name Name of the site. + */ +function create_etc_hosts_entry( $site_name ) { + + $host_line = LOCALHOST_IP . "\t$site_name"; + $etc_hosts = file_get_contents( '/etc/hosts' ); + if ( ! preg_match( "/\s+$site_name\$/m", $etc_hosts ) ) { + if ( EE::exec( "/bin/bash -c 'echo \"$host_line\" >> /etc/hosts'" ) ) { + EE::success( 'Host entry successfully added.' ); + } else { + EE::warning( "Failed to add $site_name in host entry, Please do it manually!" ); + } + } else { + EE::log( 'Host entry already exists.' ); + } +} + + +/** + * Checking site is running or not. + * + * @param string $site_name Name of the site. + * + * @throws \Exception when fails to connect to site. + */ +function site_status_check( $site_name ) { + + EE::log( 'Checking and verifying site-up status. This may take some time.' ); + $httpcode = get_curl_info( $site_name ); + $i = 0; + while ( 200 !== $httpcode && 302 !== $httpcode && 301 !== $httpcode ) { + EE::debug( "$site_name status httpcode: $httpcode" ); + $httpcode = get_curl_info( $site_name ); + echo '.'; + sleep( 2 ); + if ( $i ++ > 60 ) { + break; + } + } + if ( 200 !== $httpcode && 302 !== $httpcode && 301 !== $httpcode ) { + throw new \Exception( 'Problem connecting to site!' ); + } + +} + +/** + * Function to get httpcode or port occupancy info. + * + * @param string $url url to get info about. + * @param int $port The port to check. + * @param bool $port_info Return port info or httpcode. + * + * @return bool|int port occupied or httpcode. + */ +function get_curl_info( $url, $port = 80, $port_info = false ) { + + $ch = curl_init( $url ); + curl_setopt( $ch, CURLOPT_HEADER, true ); + curl_setopt( $ch, CURLOPT_RETURNTRANSFER, 1 ); + curl_setopt( $ch, CURLOPT_NOBODY, true ); + curl_setopt( $ch, CURLOPT_TIMEOUT, 10 ); + curl_setopt( $ch, CURLOPT_PORT, $port ); + curl_exec( $ch ); + if ( $port_info ) { + return empty( curl_getinfo( $ch, CURLINFO_PRIMARY_IP ) ); + } + + return curl_getinfo( $ch, CURLINFO_HTTP_CODE ); +} + +/** + * Function to pull the latest images and bring up the site containers. + * + * @param string $site_root Root directory of the site. + * @param array $containers The minimum required conatainers to start the site. Default null, leads to starting of all + * containers. + * + * @throws \Exception when docker-compose up fails. + */ +function start_site_containers( $site_root, $containers = [] ) { + + EE::log( 'Pulling latest images. This may take some time.' ); + chdir( $site_root ); + EE::exec( 'docker-compose pull' ); + EE::log( 'Starting site\'s services.' ); + if ( ! EE::docker()::docker_compose_up( $site_root, $containers ) ) { + throw new \Exception( 'There was some error in docker-compose up.' ); + } +} + + +/** + * Generic function to run a docker compose command. Must be ran inside correct directory. + * + * @param string $action docker-compose action to run. + * @param string $container The container on which action has to be run. + * @param string $action_to_display The action message to be displayed. + * @param string $service_to_display The service message to be displayed. + */ +function run_compose_command( $action, $container, $action_to_display = null, $service_to_display = null ) { + + $display_action = $action_to_display ? $action_to_display : $action; + $display_service = $service_to_display ? $service_to_display : $container; + + EE::log( ucfirst( $display_action ) . 'ing ' . $display_service ); + EE::exec( "docker-compose $action $container", true, true ); +} + +/** + * Function to copy and configure files needed for postfix. + * + * @param string $site_name Name of the site to configure postfix files for. + * @param string $site_conf_dir Configuration directory of the site `site_root/config`. + */ +function set_postfix_files( $site_name, $site_conf_dir ) { + + $fs = new Filesystem(); + $fs->mkdir( $site_conf_dir . '/postfix' ); + $fs->mkdir( $site_conf_dir . '/postfix/ssl' ); + $ssl_dir = $site_conf_dir . '/postfix/ssl'; + + if ( ! EE::exec( sprintf( "openssl req -new -x509 -nodes -days 365 -subj \"/CN=smtp.%s\" -out $ssl_dir/server.crt -keyout $ssl_dir/server.key", $site_name ) ) + && EE::exec( "chmod 0600 $ssl_dir/server.key" ) ) { + throw new Exception( 'Unable to generate ssl key for postfix' ); + } +} + +/** + * Function to execute docker-compose exec calls to postfix to get it configured and running for the site. + * + * @param string $site_name Name of the for which postfix has to be configured. + * @param strin $site_root Site root. + */ +function configure_postfix( $site_name, $site_root ) { + + chdir( $site_root ); + EE::exec( 'docker-compose exec postfix postconf -e \'relayhost =\'' ); + EE::exec( 'docker-compose exec postfix postconf -e \'smtpd_recipient_restrictions = permit_mynetworks\'' ); + $launch = EE::launch( sprintf( 'docker inspect -f \'{{ with (index .IPAM.Config 0) }}{{ .Subnet }}{{ end }}\' %s', $site_name ) ); + $subnet_cidr = trim( $launch->stdout ); + EE::exec( sprintf( 'docker-compose exec postfix postconf -e \'mynetworks = %s 127.0.0.0/8\'', $subnet_cidr ) ); + EE::exec( sprintf( 'docker-compose exec postfix postconf -e \'myhostname = %s\'', $site_name ) ); + EE::exec( 'docker-compose exec postfix postconf -e \'syslog_name = $myhostname\'' ); + EE::exec( 'docker-compose restart postfix' ); +} diff --git a/src/Site_Docker.php b/src/site-type/Site_HTML_Docker.php similarity index 94% rename from src/Site_Docker.php rename to src/site-type/Site_HTML_Docker.php index fe5cfb2b..8f4e5b39 100644 --- a/src/Site_Docker.php +++ b/src/site-type/Site_HTML_Docker.php @@ -1,8 +1,10 @@ 'always' ]; diff --git a/src/site-type/html.php b/src/site-type/html.php new file mode 100644 index 00000000..5c279b5a --- /dev/null +++ b/src/site-type/html.php @@ -0,0 +1,337 @@ +level = 0; + $this->docker = \EE::docker(); + $this->logger = \EE::get_file_logger()->withName( 'html_type' ); + $this->fs = new Filesystem(); + } + + /** + * Runs the standard HTML site installation. + * + * ## OPTIONS + * + * + * : Name of website. + * + * [--ssl=] + * : Enables ssl via letsencrypt certificate. + * + * [--wildcard] + * : Gets wildcard SSL . + * [--type=] + * : Type of the site to be created. Values: html,php,wp etc. + * + * [--skip-status-check] + * : Skips site status check. + */ + public function create( $args, $assoc_args ) { + + \EE\Utils\delem_log( 'site create start' ); + \EE::warning( 'This is a beta version. Please don\'t use it in production.' ); + $this->logger->debug( 'args:', $args ); + $this->logger->debug( 'assoc_args:', empty( $assoc_args ) ? [ 'NULL' ] : $assoc_args ); + $this->site['url'] = strtolower( \EE\Utils\remove_trailing_slash( $args[0] ) ); + $this->site['type'] = \EE\Utils\get_flag_value( $assoc_args, 'type', 'html' ); + if ( 'html' !== $this->site['type'] ) { + \EE::error( sprintf( 'Invalid site-type: %s', $this->site['type'] ) ); + } + + if ( Site::find( $this->site['url'] ) ) { + \EE::error( sprintf( "Site %1\$s already exists. If you want to re-create it please delete the older one using:\n`ee site delete %1\$s`", $this->site['url'] ) ); + } + + $this->ssl = \EE\Utils\get_flag_value( $assoc_args, 'ssl' ); + $this->ssl_wildcard = \EE\Utils\get_flag_value( $assoc_args, 'wildcard' ); + $this->skip_chk = \EE\Utils\get_flag_value( $assoc_args, 'skip-status-check' ); + + \EE\Site\Utils\init_checks(); + + \EE::log( 'Configuring project.' ); + + $this->create_site(); + \EE\Utils\delem_log( 'site create end' ); + } + + /** + * Display all the relevant site information, credentials and useful links. + * + * [] + * : Name of the website whose info is required. + */ + public function info( $args, $assoc_args ) { + + \EE\Utils\delem_log( 'site info start' ); + if ( ! isset( $this->site['url'] ) ) { + $args = \EE\Site\Utils\auto_site_name( $args, 'site', __FUNCTION__ ); + $this->populate_site_info( $args ); + } + $ssl = $this->ssl ? 'Enabled' : 'Not Enabled'; + $prefix = ( $this->ssl ) ? 'https://' : 'http://'; + $info = [ + [ 'Site', $prefix . $this->site['url'] ], + [ 'Site Root', $this->site['root'] ], + [ 'SSL', $ssl ], + ]; + + if ( $this->ssl ) { + $info[] = [ 'SSL Wildcard', $this->ssl_wildcard ? 'Yes' : 'No' ]; + } + + \EE\Utils\format_table( $info ); + + \EE\Utils\delem_log( 'site info end' ); + } + + + /** + * Function to configure site and copy all the required files. + */ + private function configure_site_files() { + + $site_conf_dir = $this->site['root'] . '/config'; + $site_docker_yml = $this->site['root'] . '/docker-compose.yml'; + $site_conf_env = $this->site['root'] . '/.env'; + $site_nginx_default_conf = $site_conf_dir . '/nginx/default.conf'; + $site_src_dir = $this->site['root'] . '/app/src'; + $process_user = posix_getpwuid( posix_geteuid() ); + + \EE::log( sprintf( 'Creating site %s.', $this->site['url'] ) ); + \EE::log( 'Copying configuration files.' ); + + $filter = []; + $filter[] = $this->site['type']; + $site_docker = new Site_HTML_Docker(); + $docker_compose_content = $site_docker->generate_docker_compose_yml( $filter ); + $default_conf_content = $default_conf_content = \EE\Utils\mustache_render( SITE_TEMPLATE_ROOT . '/config/nginx/default.conf.mustache', [ 'server_name' => $this->site['url'] ] ); + + $env_data = [ + 'virtual_host' => $this->site['url'], + 'user_id' => $process_user['uid'], + 'group_id' => $process_user['gid'], + ]; + $env_content = \EE\Utils\mustache_render( SITE_TEMPLATE_ROOT . '/config/.env.mustache', $env_data ); + + try { + $this->fs->dumpFile( $site_docker_yml, $docker_compose_content ); + $this->fs->dumpFile( $site_conf_env, $env_content ); + $this->fs->mkdir( $site_conf_dir ); + $this->fs->mkdir( $site_conf_dir . '/nginx' ); + $this->fs->dumpFile( $site_nginx_default_conf, $default_conf_content ); + + $index_data = [ + 'version' => 'v' . EE_VERSION, + 'site_src_root' => $this->site['root'] . '/app/src', + ]; + $index_html = \EE\Utils\mustache_render( SITE_TEMPLATE_ROOT . '/index.html.mustache', $index_data ); + $this->fs->mkdir( $site_src_dir ); + $this->fs->dumpFile( $site_src_dir . '/index.html', $index_html ); + + \EE::success( 'Configuration files copied.' ); + } catch ( \Exception $e ) { + $this->catch_clean( $e ); + } + } + + /** + * Function to create the site. + */ + private function create_site() { + + $this->site['root'] = WEBROOT . $this->site['url']; + $this->level = 1; + try { + \EE\Site\Utils\create_site_root( $this->site['root'], $this->site['url'] ); + $this->level = 3; + $this->configure_site_files(); + + \EE\Site\Utils\start_site_containers( $this->site['root'] ); + + \EE\Site\Utils\create_etc_hosts_entry( $this->site['url'] ); + if ( ! $this->skip_chk ) { + $this->level = 4; + \EE\Site\Utils\site_status_check( $this->site['url'] ); + } + + /* + * This adds http www redirection which is needed for issuing cert for a site. + * i.e. when you create example.com site, certs are issued for example.com and www.example.com + * + * We're issuing certs for both domains as it is needed in order to perform redirection of + * https://www.example.com -> https://example.com + * + * We add redirection config two times in case of ssl as we need http redirection + * when certs are being requested and http+https redirection after we have certs. + */ + \EE\Site\Utils\add_site_redirects( $this->site['url'], false, 'inherit' === $this->ssl ); + \EE\Site\Utils\reload_proxy_configuration(); + + if ( $this->ssl ) { + $this->init_ssl( $this->site['url'], $this->site['root'], $this->ssl, $this->ssl_wildcard ); + \EE\Site\Utils\add_site_redirects( $this->site['url'], true, 'inherit' === $this->ssl ); + \EE\Site\Utils\reload_proxy_configuration(); + } + } catch ( \Exception $e ) { + $this->catch_clean( $e ); + } + + $this->info( [ $this->site['url'] ], [] ); + $this->create_site_db_entry(); + } + + /** + * Function to save the site configuration entry into database. + */ + private function create_site_db_entry() { + + $ssl = $this->ssl ? 1 : 0; + $ssl_wildcard = $this->ssl_wildcard ? 1 : 0; + + $site = Site::create( [ + 'site_url' => $this->site['url'], + 'site_type' => $this->site['type'], + 'site_fs_path' => $this->site['root'], + 'site_ssl' => $ssl, + 'site_ssl_wildcard' => $ssl_wildcard, + 'created_on' => date( 'Y-m-d H:i:s', time() ), + ] ); + + try { + if ( $site ) { + \EE::log( 'Site entry created.' ); + } else { + throw new Exception( 'Error creating site entry in database.' ); + } + } catch ( \Exception $e ) { + $this->catch_clean( $e ); + } + } + + /** + * Populate basic site info from db. + */ + private function populate_site_info( $args ) { + + $this->site['url'] = \EE\Utils\remove_trailing_slash( $args[0] ); + + $site = Site::find( $this->site['url'] ); + + if ( $site ) { + $this->site['type'] = $site->site_type; + $this->site['root'] = $site->site_fs_path; + $this->ssl = $site->site_ssl; + $this->ssl_wildcard = $site->site_ssl_wildcard; + } else { + \EE::error( sprintf( 'Site %s does not exist.', $this->site['url'] ) ); + } + } + + /** + * @inheritdoc + */ + public function restart( $args, $assoc_args, $whitelisted_containers = [] ) { + $whitelisted_containers = [ 'nginx' ]; + parent::restart( $args, $assoc_args, $whitelisted_containers ); + } + + /** + * @inheritdoc + */ + public function reload( $args, $assoc_args, $whitelisted_containers = [], $reload_commands = [] ) { + $whitelisted_containers = [ 'nginx' ]; + parent::reload( $args, $assoc_args, $whitelisted_containers, $reload_commands = [] ); + } + + /** + * Catch and clean exceptions. + * + * @param \Exception $e + */ + private function catch_clean( $e ) { + + \EE\Utils\delem_log( 'site cleanup start' ); + \EE::warning( $e->getMessage() ); + \EE::warning( 'Initiating clean-up.' ); + $this->delete_site( $this->level, $this->site['url'], $this->site['root'] ); + \EE\Utils\delem_log( 'site cleanup end' ); + exit; + } + + /** + * Roll back on interrupt. + */ + protected function rollback() { + + \EE::warning( 'Exiting gracefully after rolling back. This may take some time.' ); + if ( $this->level > 0 ) { + $this->delete_site( $this->level, $this->site['url'], $this->site['root'] ); + } + \EE::success( 'Rollback complete. Exiting now.' ); + exit; + } + +} diff --git a/templates/global_docker_compose.yml.mustache b/templates/global_docker_compose.yml.mustache new file mode 100644 index 00000000..bb231c72 --- /dev/null +++ b/templates/global_docker_compose.yml.mustache @@ -0,0 +1,54 @@ +version: '3.5' + +services: + +{{#services}} + {{name}}: + container_name: {{container_name}} + image: {{image}} + {{#ports.0}} + ports: + {{#ports}} + - "{{.}}" + {{/ports}} + {{/ports.0}} + {{#depends_on}} + depends_on: + - {{.}} + {{/depends_on}} + {{#restart}} + restart: {{.}} + {{/restart}} + {{#command}} + command: {{.}} + {{/command}} + {{#labels.0}} + labels: + {{#labels}} + - "{{.}}" + {{/labels}} + {{/labels.0}} + {{#volumes.0}} + volumes: + {{#volumes}} + - "{{.}}" + {{/volumes}} + {{/volumes.0}} + {{#environment.0}} + environment: + {{#environment}} + - {{.}} + {{/environment}} + {{/environment.0}} + {{#networks.0}} + networks: + {{#networks}} + - {{.}} + {{/networks}} + {{/networks.0}} +{{/services}} + +networks: + global-network: + external: + name: ee-global-network