<?php

/********************************************
 * LiteSpeed Web Cache Management Plugin for cPanel
 *
 * @author LiteSpeed Technologies, Inc. (https://www.litespeedtech.com)
 * @copyright (c) 2020
 * @since 2.1
 *********************************************/

/**
 *
 * @since 2.1
 */
class CertAction
{

    /**
     * @since 2.1
     * @var string
     */
    const BOOTSTRAP_FILE = __DIR__ . '/cert_action_entry';

    /**
     * Data stored in this directory is intended to persist after plugin
     * removal.
     *
     * @since 2.1
     * @var string
     */
    const EXTERNAL_DATA_DIR = '/usr/local/cpanel/3rdparty/ls_webcache_mgr';

    /**
     * @since 2.1
     * @var string
     */
    const LE_ACCT_KEY = 'account_key.pem';

    /**
     * @since 2.1
     * @var int
     */
    const EXIT_SUCC = 0;

    /**
     * @since 2.1
     * @var int
     */
    const EXIT_ERROR = 1;

    /**
     * CAA record for domain prevents issuing a Let's Encrypt certificate.
     *
     * @since 2.1
     * @var int
     */
    const VALIDATION_FAILURE_CAA = 1;

    /**
     * Failed pre acme challenge for domain.
     *
     * @since 2.1
     * @var int
     */
    const VALIDATION_FAILURE_CHALLENGE = 2;

    /**
     * @since 2.1
     * @var string
     */
    const CMD_GENERATE_SIGNED_EC_CERT = 'geneccert';

    /**
     * @since 2.1
     * @var string
     */
    const CMD_RENEW_EC_CERT = 'renewcert';

    /**
     * @since 2.1
     * @var string
     */
    const CMD_RENEW_ALL_EC_CERTS = 'renewall';

    /**
     * @since 2.1
     * @var null|ACMECert
     */
    private $acmeCertObj = null;

    /**
     * @since 2.1
     * @var string
     */
    private $cmd;

    /**
     * @since 2.1
     * @var string[]
     */
    private $params = array();

    /**
     *
     * @since 2.1
     *
     * @param string[]  $input
     * @throws Exception  Thrown indirectly.
     */
    private function __construct( $input )
    {
        $this->init($input);
    }

    /**
     *
     * @since 2.1
     *
     * @param string[]  $input
     * @throws Exception  Thrown indirectly.
     */
    private function init( $input )
    {
        $this->parseCommands($input);
    }


    /**
     *
     * @since 2.1
     *
     * @param string[]  $input
     * @throws Exception  Thrown directly and indirectly.
     */
    private function parseCommands( $input )
    {

        $cmd = array_shift($input);

        switch ($cmd) {

            case self::CMD_GENERATE_SIGNED_EC_CERT:
            case self::CMD_RENEW_EC_CERT:
                $this->handleEcCertOperationInput($cmd, $input);
                break;

            default:
                throw new Exception(
                    'Invalid Command Provided.',
                    self::EXIT_ERROR
                );
        }

        if ( !empty($input) ) {
            throw new Exception(
                'Invalid Command Parameters Provided.',
                self::EXIT_ERROR
            );
        }
    }

    /**
     *
     * @since 2.1
     *
     * @param string    $cmd
     * @param string[]  $input
     * @throws Exception
     */
    private function handleEcCertOperationInput($cmd, &$input )
    {
        switch ( $cmd ) {

            case self::CMD_GENERATE_SIGNED_EC_CERT:

                if ( ($key = array_search('-user', $input)) === false ) {
                    throw new Exception(
                        'Invalid Command, missing required \'-user\' '
                            . 'parameter.',
                        self::EXIT_ERROR
                    );
                }

                if ( empty($input[$key + 1])
                        || ($user = trim($input[$key + 1])) == '' ) {

                    throw new Exception(
                        'Invalid Command, missing \'-user\' value.',
                        self::EXIT_ERROR
                    );
                }

                $userdataDir = "/var/cpanel/userdata/{$user}";

                if ( !file_exists($userdataDir) ) {
                    throw new Exception(
                        'Invalid value for parameter \'-user\'.',
                        self::EXIT_ERROR
                    );
                }

                $this->params['user'] = $user;

                unset($input[$key], $input[$key + 1]);

                if ( ($key = array_search('-domain', $input)) === false ) {
                    throw new Exception(
                        'Invalid Command, missing required \'-domain\' '
                            . 'parameter.',
                        self::EXIT_ERROR
                    );
                }

                if ( empty($input[$key + 1])
                    || ($domain = trim($input[$key + 1])) == '' ) {

                    throw new Exception(
                        'Invalid Command, missing \'-domain\' value.',
                        self::EXIT_ERROR
                    );
                }

                preg_match(
                    '/(?:http:\/\/|https:\/\/)*(?:www.)*(.*)/',
                    $domain,
                    $matches
                );

                $domain = $matches[1];

                if ( !file_exists("{$userdataDir}/{$domain}") ) {
                    throw new Exception(
                        'Invalid value for parameter \'-domain\'.',
                        self::EXIT_ERROR
                    );
                }

                $this->params['domain'] = $domain;

                unset($input[$key], $input[$key + 1]);

                $this->cmd = $cmd;

                break;

            case self::CMD_RENEW_EC_CERT:

                if (($allKey = array_search('--all', $input)) !== false) {
                    unset($input[$allKey]);

                    $this->cmd = self::CMD_RENEW_ALL_EC_CERTS;
                }
                elseif ( ($userKey = array_search('-user', $input)) !== false
                        && ($domainKey = array_search('-domain', $input)) !== false ) {

                    if (empty($input[$userKey + 1])
                            || ($user = trim($input[$userKey + 1])) == '') {

                        throw new Exception(
                            'Invalid Command, missing \'-user\' value.',
                            self::EXIT_ERROR
                        );
                    }

                    $userdataDir = "/var/cpanel/userdata/{$user}";

                    if ( !file_exists($userdataDir) ) {
                        throw new Exception(
                            'Invalid value for parameter \'-user\'.',
                            self::EXIT_ERROR
                        );
                    }

                    $this->params['user'] = $user;

                    unset($input[$userKey], $input[$userKey + 1]);

                    if (empty($input[$domainKey + 1])
                            || ($domain = trim($input[$domainKey + 1])) == '') {

                        throw new Exception(
                            'Invalid Command, missing \'-domain\' value.',
                            self::EXIT_ERROR
                        );
                    }

                    preg_match(
                        '/(?:http:\/\/|https:\/\/)*(?:www.)*(.*)/',
                        $domain,
                        $matches
                    );

                    $domain = $matches[1];

                    if ( !file_exists("{$userdataDir}/{$domain}") ) {
                        throw new Exception(
                            'Invalid value for parameter \'-domain\'.',
                            self::EXIT_ERROR
                        );
                    }

                    $this->params['domain'] = $domain;

                    unset($input[$domainKey], $input[$domainKey + 1]);

                    $this->cmd = $cmd;

                    break;
                }
                else {
                    throw new Exception(
                        'Invalid Command, missing required parameters '
                            . '{--all | -user <user> -domain <domain>}.',
                        self::EXIT_ERROR
                    );
                }

                break;
        }
    }

    /**
     *
     * @since 2.1
     *
     * @throws Exception  Thrown indirectly.
     */
    private function initACMECertObj()
    {
        /**
         * Requires: PHP v7.1+, OpenSSL extension,
         *           [allow_url_fopen=1 | cURL extension].
         */

        $ac = new ACMECert();

        $accountKeyFile = self::EXTERNAL_DATA_DIR . '/' . self::LE_ACCT_KEY;

        $registerNewAccount = false;

        if ( !file_exists($accountKeyFile) ) {

            if (!file_exists(self::EXTERNAL_DATA_DIR)) {
                self::createExtDataDir();
            }

            try{
                $key = $ac->generateEcKey('P-384');
            }
            catch ( Exception $e ) {
                throw new Exception(
                    'Error encountered while generating new Let\'s Encrypt '
                        . 'account key.',
                    self::EXIT_ERROR
                );
            }

            file_put_contents($accountKeyFile, $key);
            chmod($accountKeyFile, 0600);

            $registerNewAccount = true;
        }

        try {
            $ac->loadAccountKey("file://{$accountKeyFile}");
        }
        catch ( Exception $e ) {
            throw new Exception(
                'Error encountered while loading Let\'s Encrypt account key.',
                self::EXIT_ERROR
            );
        }

        if ($registerNewAccount) {
            try {
                $ac->register(true, 'admin@quic.cloud');
            }
            catch( ACME_Exception $e ) {
                throw new Exception(
                    $e->getMessage(),
                    self::EXIT_ERROR
                );
            }
            catch ( Exception $e ) {
                throw new Exception(
                    'Error encountered while registering Let\'s Encrpt '
                        . 'account.',
                    self::EXIT_ERROR
                );
            }
        }

        $this->acmeCertObj = $ac;
    }

    /**
     *
     * @since 2.1
     *
     * @return ACMECert
     * @throws Exception  Thrown indirectly.
     */
    private function getACMECertObj()
    {
        if ( $this->acmeCertObj == null ) {
            $this->initACMECertObj();
        }

        return $this->acmeCertObj;
    }

    /**
     *
     * @since 2.1
     *
     * @throws Exception
     */
    private static function createExtDataDir()
    {
        if ( !mkdir(self::EXTERNAL_DATA_DIR, 0700) ) {
            throw new Exception(
                'Could not create external data directory '
                    . self::EXTERNAL_DATA_DIR . '.',
                self::EXIT_ERROR
            );
        }
    }

    /**
     *
     * @since 2.1
     *
     * @param string  $url
     * @param bool    $headerOnly
     * @return string
     */
    private static function get_url_contents( $url, $headerOnly = false )
    {
        if ( ini_get('allow_url_fopen') ) {
            /**
             * silence warning when OpenSSL missing while getting LSCWP ver
             * file.
             */
            $url_content = @file_get_contents($url);

            if ( $url_content !== false ) {

                if ( $headerOnly ) {
                    return implode("\n", $http_response_header);
                }

                return $url_content;
            }
        }

        if ( function_exists('curl_version') ) {
            $ch = curl_init();

            curl_setopt_array(
                $ch,
                array(
                    CURLOPT_URL => $url,
                    CURLOPT_RETURNTRANSFER => true,
                    CURLOPT_HEADER => $headerOnly,
                    CURLOPT_NOBODY => $headerOnly,
                    CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1
                )
            );

            $url_content = curl_exec($ch);
            curl_close($ch);

            if ( $url_content !== false ) {
                return $url_content;
            }
        }

        $cmd = 'curl';

        if ( $headerOnly ) {
            $cmd .= ' -s -I';
        }

        exec("{$cmd} {$url}", $output, $ret);

        if ( $ret === 0 ) {
            $url_content = implode("\n", $output);
            return $url_content;
        }

        return '';
    }

    /**
     *
     * @since 2.1
     *
     * @param string  $domain
     * @return string[]
     */
    private static function getSslDomainAliases( $domain )
    {
        $aliases = array();

        $fileContent = file_get_contents('/etc/apache2/conf/httpd.conf');

        $escapedDomain = preg_quote($domain);


        $matches = preg_match(
            "!:443>\s+ServerName\s+{$escapedDomain}\s+ServerAlias\s+(.*)!",
            $fileContent,
            $m
        );

        if ( $matches ) {
            $aliases = explode(" ", trim($m[1]));
        }

        return $aliases;
    }

    /**
     *
     * Requires: PHP v7.1.2+ to check CAA records with dns_get_record().
     *
     * @since 2.1
     *
     * @param string    $docroot
     * @param string[]  $domainNames
     * @return string[][]
     * @throws Exception
     */
    private static function doDomainPreValidation( $docroot, $domainNames )
    {
        $ret = array( 'valid' => array(), 'failed' => array() );

        foreach ( $domainNames as $index => $domainName ) {
            $dnsCaaRecords = dns_get_record($domainName, DNS_CAA);

            /**
             * Empty case will inherit root domain CAA record (or lack thereof),
             * so if root domain passes we can assume these do as well.
             */

            if ( empty($dnsCaaRecords) ) {
                continue;
            }

            $found = false;

            foreach($dnsCaaRecords as $caaRecord) {

                if ( $caaRecord['tag'] == 'issue'
                        && $caaRecord['value'] == 'letsencrypt.org') {

                    $found = true;
                    break;
                }
            }

            if ( !$found ) {
                $ret['failed'][$domainName] = self::VALIDATION_FAILURE_CAA;
                unset($domainNames[$index]);
                continue;
            }
        }

        if ( ($pid = getmypid()) === false ) {
            throw new Exception(
                'Failed getting PID during domain pre-validation',
                self::EXIT_ERROR
            );
        }

        $checkFile = "ls_pre_validate{$pid}";
        $wellKnownDir = "{$docroot}/.well-known";
        $acmeChallengeDir = "{$wellKnownDir}/acme-challenge";

        if ( !file_exists($acmeChallengeDir) ) {

            if ( ($owner = fileowner($wellKnownDir)) === false ) {
                throw new Exception(
                    'Unable to get owner for .well-known/ directory',
                    self::EXIT_ERROR
                );
            }

            if ( ($group = filegroup($wellKnownDir)) === false ) {
                throw new Exception(
                    'Unable to get group for .well-known/ directory',
                    self::EXIT_ERROR
                );
            }

            mkdir($acmeChallengeDir, 0755);
            chown($acmeChallengeDir, $owner);
            chgrp($acmeChallengeDir, $group);
        }

        $checkFilePath = "{$acmeChallengeDir}/{$checkFile}";
        $num = rand();

        file_put_contents($checkFilePath, $num);
        chmod($checkFilePath, 0644);

        foreach ( $domainNames as $index => $domainName ) {
            $content = self::get_url_contents(
                "http://{$domainName}/.well-known/acme-challenge/{$checkFile}"
            );

            if ( $content != (string)$num ) {
                unlink($checkFilePath);
                $ret['failed'][$domainName] =
                    self::VALIDATION_FAILURE_CHALLENGE;
                unset($domainNames[$index]);
                continue;
            }
        }

        unlink($checkFilePath);

        $ret['valid'] = $domainNames;

        return $ret;
    }

    /**
     *
     * @since 2.1
     *
     * @param string  $combinedEcCertFile
     * @return string
     */
    private function getEcCertFingerprint($combinedEcCertFile)
    {
        $fileContent = file_get_contents($combinedEcCertFile);

        $foundEcCertFingerprint = preg_match(
            '/(-+BEGIN CERTIFICATE-+[\s\S]*?-+END CERTIFICATE-+)/',
            $fileContent,
            $m
        );

        if ( $foundEcCertFingerprint ) {
            return $m[1];
        }

        return '';
    }

    /**
     *
     * @since 2.1
     *
     * @param string  $user
     * @param string  $domain
     * @param bool    $renewal
     * @throws Exception       Thrown directly and indirectly.
     */
    private function generateSignedEcCert( $user, $domain, $renewal = false )
    {
        $sslVhostDir = '/var/cpanel/ssl/apache_tls';

        if ( !file_exists("{$sslVhostDir}/{$domain}")
                || file_exists("{$sslVhostDir}/.pending_delete/{$domain}") ) {

            throw new Exception(
                'No SSL Vhost set for this domain or related SSL Vhost is '
                    . 'pending deletion',
                self::EXIT_ERROR
            );
        }

        $siteDataFile = "/var/cpanel/userdata/{$user}/{$domain}";

        if ( !file_exists($siteDataFile) ) {
            throw new Exception(
                'Could not find expected site data file.',
                self::EXIT_ERROR
            );
        }

        $siteData = file_get_contents($siteDataFile);

        preg_match('/documentroot:(.*)/', $siteData, $matches);

        if ( !isset($matches[1]) ) {
            throw new Exception(
                'Could not find documentroot for given domain.',
                self::EXIT_ERROR
            );
        }

        $docroot = trim($matches[1]);

        $domainNames = self::getSslDomainAliases($domain);
        array_unshift($domainNames, $domain);

        $preValidationResults =
            self::doDomainPreValidation($docroot, $domainNames);

        $validDomainNames = $preValidationResults['valid'];

        $combinedEcCertFile = "{$sslVhostDir}/{$domain}/combined.ecc";

        if ( !$renewal && file_exists($combinedEcCertFile) ) {
            $ecCertFingerprint =
                $this->getEcCertFingerprint($combinedEcCertFile);

            if ( $ecCertFingerprint != '' ) {
                $certInfo = openssl_x509_parse($ecCertFingerprint);

                $rawEcCertAltNames = array_map(
                    'trim',
                    explode(',', $certInfo['extensions']['subjectAltName'])
                );

                $ecCertAltNames = preg_replace(
                    '/DNS:(.+)/',
                    '$1',
                    $rawEcCertAltNames
                );

                if ( empty(array_diff($validDomainNames, $ecCertAltNames)) ) {
                    throw new Exception(
                        'Qualifying EC cert detected for this domain. EC cert '
                            . 'generation aborted.',
                        self::EXIT_ERROR
                    );
                }
            }
        }

        $ac = $this->getACMECertObj();

        $domain_config = array();

        foreach ( $validDomainNames as $domainName ) {
            $domain_config[$domainName] = array(
                'challenge' => 'http-01',
                'docroot' => $docroot
            );
        }

        $handler =
            function ($opts) {
                $fn = $opts['config']['docroot'] . $opts['key'];
                @mkdir(dirname($fn), 0777, true);
                file_put_contents($fn, $opts['value']);

                return
                    function ($opts) {
                        unlink($opts['config']['docroot'] . $opts['key']);
                    };
            };

        try {
            $privateKeyPem = $ac->generateEcKey('P-384');
        }
        catch ( Exception $e ) {
            throw new Exception(
                'Error encountered while generating new certificate key.',
                self::EXIT_ERROR
            );
        }

        try {
            $fullchain = $ac->getCertificateChain(
                $privateKeyPem,
                $domain_config,
                $handler
            );
        }
        catch ( ACME_Exception $e ) {
            throw new Exception(
                $e->getMessage(),
                self::EXIT_ERROR
            );
        }
        catch ( Exception $e ) {
            throw new Exception(
                'Encountered an error while getting certificate chain.',
                self::EXIT_ERROR
            );
        }

        touch($combinedEcCertFile);
        chmod($combinedEcCertFile, 0640);

        $combinedContent = preg_replace(
            "!\n\n!m",
            "\n",
            "{$privateKeyPem}\n{$fullchain}"
        );

        if ($combinedContent === null ) {
            throw new Exception(
                'Error occurred when creating combined EC cert.',
                self::EXIT_ERROR
            );
        }

        file_put_contents($combinedEcCertFile, $combinedContent);

        $coveredDomains = implode(', ', $validDomainNames);
        $excludedDomains = implode(
            ', ',
            array_keys($preValidationResults['failed'])
        );

        throw new Exception(
            "Successfully generated combined EC cert.\n\n"
                . "Covered Domains: {$coveredDomains}\n\n"
                . "Excluded Domains: {$excludedDomains}",
            self::EXIT_SUCC
        );
    }

    /**
     *
     * @since 2.1
     *
     * @param string  $ecCertPath
     * @param int     $days         Time in days when a certificate is
     *                              considered to be expiring soon.
     * @return bool
     * @throws Exception  Thrown directly and indirectly.
     */
    private function certIsExpiringSoon($ecCertPath, $days = 15 )
    {
        $ecCertFingerprint = $this->getEcCertFingerprint($ecCertPath);

        if ( $ecCertFingerprint == '' ) {
            throw new Exception(
                "Could not get ecCertFingerprint for cert file {$ecCertPath}",
                self::EXIT_ERROR
            );
        }

        $ac = $this->getACMECertObj();

        try {
            $daysRemaining = $ac->getRemainingDays($ecCertFingerprint);
        }
        catch ( Exception $e ) {
            throw new Exception(
                'Error encountered while trying to get remaining time before '
                    . 'expiry for certificate.',
                self::EXIT_ERROR
            );
        }

        if ( $daysRemaining <= $days ) {
            return true;
        }

        return false;
    }

    /**
     *
     * @since 2.1
     *
     * @throws Exception
     */
    private function renewAllEcCerts()
    {
        exec(
            'find /var/cpanel/ssl/apache_tls/ -type f -name combined.ecc '
                . '-not -path \'*/\.*\'',
            $ecCertPaths
        );

        foreach ( $ecCertPaths as $ecCertPath ) {
            preg_match('!apache_tls/(.+)/combined.ecc!', $ecCertPath, $m);

            $domain = $m[1];

            $output1 = array();
            exec(
                '/usr/local/cpanel/bin/whmapi1 getdomainowner '
                . 'domain=' . escapeshellarg($domain)
                . ' --output=json',
                $output1,
                $ret
            );

            $data = json_decode($output1[0], true);

            if ( !isset($data['data']['user'])
                    || $data['data']['user'] === null ) {

                echo "\n{$domain}: Failed to get domain owner.";
                continue;
            }

            $user = $data['data']['user'];

            try {
                $this->renewEcCert($user, $domain);
            }
            catch ( Exception $e ) {
                echo "\n{$domain}: " . $e->getMessage();
            }
        }

        echo "\n";

        throw new Exception(
            'Completed renew all command',
            self::EXIT_SUCC
        );
    }

    /**
     *
     * @since 2.1
     *
     * @param string  $user
     * @param string  $domain
     * @throws Exception  Thrown directly and indirectly.
     */
    private function renewEcCert( $user, $domain )
    {
        $combinedEcCert = "/var/cpanel/ssl/apache_tls/{$domain}/combined.ecc";

        if ( !file_exists($combinedEcCert) ) {
            throw new Exception(
                'No combined EC cert found.',
                self::EXIT_ERROR
            );
        }

        if ( $this->certIsExpiringSoon($combinedEcCert) ) {
            $this->generateSignedEcCert($user, $domain, true);
        }
        else {
            throw new Exception(
                'Certificate expires in 15+ days. No need to renew.',
                self::EXIT_SUCC
            );
        }
    }

    /**
     *
     * @since 2.1
     *
     * @throws Exception
     */
    private function runEcCertCmd()
    {
        try {
            require_once __DIR__ . '/../lib/ACMECert/ACMECert.php';

            switch ($this->cmd) {

                case self::CMD_GENERATE_SIGNED_EC_CERT:
                    $user = $this->params['user'];
                    $domain = $this->params['domain'];

                    $this->generateSignedEcCert($user, $domain);
                    break;

                case self::CMD_RENEW_ALL_EC_CERTS:
                    $this->renewAllEcCerts();
                    break;

                case self::CMD_RENEW_EC_CERT:
                    $user = $this->params['user'];
                    $domain = $this->params['domain'];

                    $this->renewEcCert($user, $domain);
                    break;

                // no default case
            }
        }
        catch ( Exception $e ) {

            /**
             * Re-throw success.
             */
            if ( $e->getCode() == self::EXIT_SUCC ) {
                throw $e;
            }

            throw new Exception(
                "[ERROR] {$e->getMessage()}\n",
                self::EXIT_ERROR
            );
        }
    }

    /**
     *
     * @since 2.1
     *
     * @param string    $caller
     * @param string[]  $input
     * @throws Exception  Thrown directly and indirectly.
     */
    public static function run( $caller, $input )
    {
        /**
         * Only run if called from bootstrap file.
         */
        if ( realpath($caller) != self::BOOTSTRAP_FILE ) {
            return;
        }

        if ( version_compare(phpversion(), '7.1.2', '<') ) {
            throw new Exception(
                'PHP version must be at least v7.1.2 to run this script.',
                self::EXIT_ERROR
            );
        }

        date_default_timezone_set('UTC');

        $certAction = new self($input);
        $certAction->runEcCertCmd();
    }

}
