1:    2:    3:    4:    5:    6:    7:    8:    9:   10:   11:   12:   13:   14:   15:   16:   17:   18:   19:   20:   21:   22:   23:   24:   25:   26:   27:   28:   29:   30:   31:   32:   33:   34:   35:   36:   37:   38:   39:   40:   41:   42:   43:   44:   45:   46:   47:   48:   49:   50:   51:   52:   53:   54:   55:   56:   57:   58:   59:   60:   61:   62:   63:   64:   65:   66:   67:   68:   69:   70:   71:   72:   73:   74:   75:   76:   77:   78:   79:   80:   81:   82:   83:   84:   85:   86:   87:   88:   89:   90:   91:   92:   93:   94:   95:   96:   97:   98:   99:  100:  101:  102:  103:  104:  105:  106:  107:  108:  109:  110:  111:  112:  113:  114:  115:  116:  117:  118:  119:  120:  121:  122:  123:  124:  125:  126:  127:  128:  129:  130:  131:  132:  133:  134:  135:  136:  137:  138:  139:  140:  141:  142:  143:  144:  145:  146:  147:  148:  149:  150:  151:  152:  153:  154:  155:  156:  157:  158:  159:  160:  161:  162:  163:  164:  165:  166:  167:  168:  169:  170:  171:  172:  173:  174:  175:  176:  177:  178:  179:  180:  181:  182:  183:  184:  185:  186:  187:  188:  189:  190:  191:  192:  193:  194:  195:  196:  197:  198:  199:  200:  201:  202:  203:  204:  205:  206:  207:  208:  209:  210:  211:  212:  213:  214:  215:  216:  217:  218:  219:  220:  221:  222:  223:  224:  225:  226:  227:  228:  229:  230:  231:  232:  233:  234:  235:  236:  237:  238:  239:  240:  241:  242:  243:  244:  245:  246:  247:  248:  249:  250:  251:  252:  253:  254:  255:  256:  257:  258:  259:  260:  261:  262:  263:  264:  265:  266:  267:  268:  269:  270:  271:  272:  273:  274:  275:  276:  277:  278:  279:  280:  281:  282:  283:  284:  285:  286:  287:  288:  289:  290:  291:  292:  293:  294:  295:  296:  297:  298:  299:  300:  301:  302:  303:  304:  305:  306:  307:  308:  309:  310:  311:  312:  313:  314:  315:  316:  317:  318:  319:  320:  321:  322:  323:  324:  325:  326:  327:  328:  329:  330:  331:  332:  333:  334:  335:  336:  337:  338:  339:  340:  341:  342:  343:  344:  345:  346:  347:  348:  349:  350:  351:  352:  353:  354:  355:  356:  357:  358:  359:  360:  361:  362:  363:  364:  365:  366:  367:  368:  369:  370:  371:  372:  373:  374:  375:  376:  377:  378:  379:  380:  381:  382:  383:  384:  385:  386:  387:  388:  389:  390:  391:  392:  393:  394:  395:  396:  397:  398:  399:  400:  401:  402:  403:  404:  405:  406:  407:  408:  409:  410:  411:  412:  413:  414:  415:  416:  417:  418:  419:  420:  421:  422:  423:  424:  425:  426:  427:  428:  429:  430:  431:  432:  433:  434:  435:  436:  437:  438:  439:  440:  441:  442:  443:  444:  445:  446:  447:  448:  449:  450:  451:  452:  453:  454:  455:  456:  457:  458:  459:  460:  461:  462:  463:  464:  465:  466:  467:  468:  469:  470:  471:  472:  473:  474:  475:  476:  477:  478:  479:  480:  481:  482:  483:  484:  485:  486:  487:  488:  489:  490:  491:  492:  493:  494:  495:  496:  497:  498:  499:  500:  501:  502:  503:  504:  505:  506:  507:  508:  509:  510:  511:  512:  513:  514:  515:  516:  517:  518:  519:  520:  521:  522:  523:  524:  525:  526:  527:  528:  529:  530:  531:  532:  533:  534:  535:  536:  537:  538:  539:  540:  541:  542:  543:  544:  545:  546:  547:  548:  549:  550:  551:  552:  553:  554:  555:  556:  557:  558:  559:  560:  561:  562:  563:  564:  565:  566:  567:  568:  569:  570:  571:  572:  573:  574:  575:  576:  577:  578:  579:  580:  581:  582:  583:  584:  585:  586:  587:  588:  589:  590:  591:  592:  593:  594:  595:  596:  597:  598:  599:  600:  601:  602:  603:  604:  605:  606:  607:  608:  609:  610:  611:  612:  613:  614:  615:  616:  617:  618:  619:  620:  621:  622:  623:  624:  625:  626:  627:  628:  629:  630:  631:  632:  633:  634:  635:  636:  637:  638:  639:  640:  641:  642:  643:  644:  645:  646:  647:  648:  649:  650:  651:  652:  653:  654:  655:  656:  657:  658:  659:  660:  661:  662:  663:  664:  665:  666:  667:  668:  669:  670:  671:  672:  673:  674:  675:  676:  677:  678:  679:  680:  681:  682:  683:  684:  685:  686:  687:  688:  689:  690:  691:  692:  693:  694:  695:  696:  697:  698:  699:  700:  701:  702:  703:  704:  705:  706:  707:  708:  709:  710:  711:  712:  713:  714:  715:  716:  717:  718:  719:  720:  721:  722:  723:  724:  725:  726:  727:  728:  729:  730:  731:  732:  733:  734:  735:  736:  737:  738:  739:  740:  741:  742:  743:  744:  745:  746:  747:  748:  749:  750:  751:  752:  753:  754:  755:  756:  757:  758:  759:  760:  761:  762:  763:  764:  765:  766:  767:  768:  769:  770:  771:  772:  773:  774:  775:  776:  777:  778:  779:  780:  781:  782:  783:  784:  785:  786:  787:  788:  789:  790:  791:  792:  793:  794:  795:  796:  797:  798:  799:  800:  801:  802:  803:  804:  805:  806:  807:  808:  809:  810:  811:  812:  813:  814:  815:  816:  817:  818:  819:  820:  821:  822:  823:  824:  825:  826:  827:  828:  829:  830:  831:  832:  833:  834:  835:  836:  837:  838:  839:  840:  841:  842:  843:  844:  845:  846:  847:  848:  849:  850:  851:  852:  853:  854:  855:  856:  857:  858:  859:  860:  861:  862:  863:  864:  865:  866:  867:  868:  869:  870:  871:  872:  873:  874:  875:  876:  877:  878:  879:  880:  881:  882:  883:  884:  885:  886:  887:  888:  889:  890:  891:  892:  893:  894:  895:  896:  897:  898:  899:  900:  901:  902:  903:  904:  905:  906:  907:  908:  909:  910:  911:  912:  913:  914:  915:  916:  917:  918:  919:  920:  921:  922:  923:  924:  925:  926:  927:  928:  929:  930:  931:  932:  933:  934:  935:  936:  937:  938:  939:  940:  941:  942:  943:  944:  945:  946:  947:  948:  949:  950:  951:  952:  953:  954:  955:  956:  957:  958:  959:  960:  961:  962:  963:  964:  965:  966:  967:  968:  969:  970:  971:  972:  973:  974:  975:  976:  977:  978:  979:  980:  981:  982:  983:  984:  985:  986:  987:  988:  989:  990:  991:  992:  993:  994:  995:  996:  997:  998:  999: 1000: 1001: 1002: 1003: 1004: 1005: 1006: 1007: 1008: 1009: 1010: 1011: 1012: 1013: 1014: 1015: 1016: 1017: 1018: 1019: 1020: 1021: 1022: 1023: 
<?php
    declare(strict_types=1);
    /**
     *  +------------------------------------------------------------+
     *  | apnscp                                                     |
     *  +------------------------------------------------------------+
     *  | Copyright (c) Apis Networks                                |
     *  +------------------------------------------------------------+
     *  | Licensed under Artistic License 2.0                        |
     *  +------------------------------------------------------------+
     *  | Author: Matt Saladna (msaladna@apisnetworks.com)           |
     *  +------------------------------------------------------------+
     */

    use Opcenter\Contracts\Hookable;
    use Opcenter\Crypto\Ssl;
    use Opcenter\Http\Apache;
    use Opcenter\SiteConfiguration;

    /**
     * Provides SSL certificate management for Apache
     *
     * @package core
     */
    class Ssl_Module extends Module_Skeleton implements Hookable
    {
        const DEPENDENCY_MAP = [
            'apache',
            'siteinfo'
        ];

        const CRT_PATH = '/etc/httpd/conf/ssl.crt';
        const KEY_PATH = '/etc/httpd/conf/ssl.key';
        const CSR_PATH = '/etc/httpd/conf/ssl.csr';
        const DEFAULT_CERTIFICATE_NAME = 'server';

        const X509_DAYS = 1095; /* 3 years for self-signed */

        const USER_RHOOK = 'letsencrypt';
        const SYS_RHOOK = 'ssl';

        public function __construct()
        {
            parent::__construct();
            $this->exportedFunctions = array(
                'generate_csr'             => PRIVILEGE_ALL,
                'generate_privatekey'      => PRIVILEGE_ALL,
                'get_alternative_names'    => PRIVILEGE_ALL,
                'has_certificate'          => PRIVILEGE_SITE,
                'get_certificate'          => PRIVILEGE_SITE | PRIVILEGE_ADMIN,
                'get_csr'                  => PRIVILEGE_SITE | PRIVILEGE_ADMIN,
                'get_private_key'          => PRIVILEGE_SITE | PRIVILEGE_ADMIN,
                'get_public_key'           => PRIVILEGE_SITE | PRIVILEGE_ADMIN,
                'is_self_signed'           => PRIVILEGE_ALL,
                'key_exists'               => PRIVILEGE_SITE | PRIVILEGE_ADMIN,
                'parse_certificate'        => PRIVILEGE_ALL,
                'permitted'                => PRIVILEGE_ALL,
                'privkey_info'             => PRIVILEGE_ALL,
                'request_info'             => PRIVILEGE_ALL,
                'resolve_chain'            => PRIVILEGE_ALL,
                'sign_certificate'         => PRIVILEGE_ALL,
                'valid'                    => PRIVILEGE_ALL,
                'verify_certificate_chain' => PRIVILEGE_ALL,
                'verify_key'               => PRIVILEGE_ALL,
                'verify_x509_key'          => PRIVILEGE_ALL,

                '*' => PRIVILEGE_SITE,
            );
        }

        /**
         * Check if certificate is installed for account
         *
         * @return bool
         */
        public function cert_exists()
        {
            if (!IS_CLI) {
                return $this->query('ssl_cert_exists');
            }
            $conf = $this->get_certificates();

            return count($conf) > 0;
        }

        /**
         * Get certificate names installed on account
         *
         * @return array
         */
        public function get_certificates()
        {
            if (!IS_CLI) {
                return $this->query('ssl_get_certificates');
            }
            // @TODO apache parser, maybe Augeas?
            $that = $this;
            $parser = static function ($config) use ($that) {
                $conf = array();
                $token = strtok($config, "\n \t");
                while ($token !== false) {
                    switch (strtoupper($token)) {
                        case 'LISTEN':
                            $key = 'host';
                            break;
                        case 'SSLCERTIFICATEFILE':
                            $key = 'crt';
                            break;
                        case 'SSLCERTIFICATEKEYFILE':
                            $key = 'key';
                            break;
                        case 'SSLCERTIFICATECHAINFILE':
                            $key = 'chain';
                            break;
                        default:
                            $key = null;
                            break;
                    }
                    if (!is_null($key)) {
                        $token = trim(strtok("\t \n"));

                        $constant = $key === 'chain' ? 'crt' : $key;
                        if ($constant == 'key' || $constant == 'crt') {
                            // if no matching file, invalidate certificate
                            if (!file_exists($token)) {
                                return array();
                            }
                        }
                        $token = $that->file_canonicalize_site($token);
                        // let's assume everything is organized nicely in /etc/httpd/conf/ssl.x
                        $conf[$key] = basename($token);
                    }
                    $token = strtok(" \t\n");
                }
                if (isset($conf['chain']) && count($conf) === 1) {
                    // separate config parser
                    return $conf;
                } else {
                    if (!isset($conf['crt']) || !isset($conf['key'])) {
                        return array();
                    }
                }

                return $conf;
            };

            // old format for multiple IP/personalities per account
            $masterconfig = glob('/etc/httpd/conf/virtual/' . $this->site . '{,.*}', GLOB_BRACE);
            $sitecerts = array();
            $accountaddr = (array)$this->common_get_ip_address();

            foreach ($masterconfig as $config) {
                $cert = array();
                $site = basename($config);
                if (!file_exists('/etc/httpd/conf/' . $site . '.ssl')) {
                    return $sitecerts;
                }
                $file = '/etc/httpd/conf/virtual/' . $site;
                if (!file_exists($file)) {
                    continue;
                }
                $config = file_get_contents($file);
                $newcert = $parser($config);
                if (!$newcert) {
                    continue;
                }
                $cert = array_merge($cert, $newcert);
                $sslextra = '/etc/httpd/conf/' . basename($file) . '.ssl/custom';
                if (file_exists($sslextra)) {
                    $config = file_get_contents($sslextra);
                    $cert = array_merge($cert, $parser($config));
                }
                // remove port info
                if (isset($cert['host'])) {
                    $tmp = strpos($cert['host'], ':');
                    if ($tmp) {
                        $cert['host'] = substr($cert['host'], 0, $tmp);
                    }
                } else {
                    $cert['host'] = $accountaddr[0];
                }
                $sitecerts[] = $cert;
            }

            return $sitecerts;
        }

        public function key_exists($key = 'server.key')
        {
            if (!IS_CLI) {
                return $this->query('ssl_key_exists', $key);
            }
            // default key name
            $name = basename($key, '.key');
            if ($this->permission_level & PRIVILEGE_SITE) {
                $key = $this->domain_fs_path() . self::KEY_PATH .
                    '/' . $name . '.key';
            } else {
                if ($key[0] !== '/') {
                    $key = self::KEY_PATH . '/' . $name;
                }
            }

            return file_exists($key);
        }

        public function install($key, $cert, $chain = null)
        {
            if (!IS_CLI) {
                return $this->query('ssl_install', $key, $cert, $chain);
            }
            if (!$this->permitted()) {
                return error('SSL not permitted on account');
            }

            if (!$this->valid($cert, $key)) {
                return error('certificate is not valid for given key: %s', openssl_error_string());
            }


            if ($this->is_self_signed($cert)) {
                $chain = null;
            } else if (!$chain) {
                // try to resolve hierarchy
                $supplemental = $this->resolve_chain($cert);
                if (!$supplemental) {
                    return error('certificate chain is irresolvable');
                }
                info('downloaded chain certificates to satisfy requirement, one or more additional pathways may be missing');
                $chain = join("\n", $supplemental);
            } else if (!$this->verify_certificate_chain($cert, $chain)) {
                return error('chain not valid for certificate');
            }

            $this->file_purge();
            $prefix = $this->domain_fs_path();
            $crtfile = $prefix . self::CRT_PATH . '/server.crt';
            $keyfile = $prefix . self::KEY_PATH . '/server.key';
            // build up in case Ensim is being stupid
            $this->file_shadow_buildup_backend(
                $prefix . self::CSR_PATH . '/server.csr'
            );
            // cert overwritten or moved
            $overwrite = false;
            // backup just in case
            foreach (array($crtfile, $keyfile) as $file) {
                /**
                 * make sure its constituents exist
                 * overlayfs ghosts merged layer if r/w doesn't contain
                 * parent dir
                 */
                $this->file_shadow_buildup_backend($file);
                $dir = dirname($file);
                if (!is_dir($dir)) {
                    \Opcenter\Filesystem::mkdir($dir, 'root', $this->group_id, 0700);
                } else if (file_exists($file)) {
                    $overwrite = true;
                    $old = file_get_contents($file);
                    file_put_contents($file . '-old', $old, LOCK_EX);
                }
            }
            $this->file_purge();
            file_put_contents($crtfile, $cert, LOCK_EX);
            file_put_contents($keyfile, $key, LOCK_EX);
            if (FILESYSTEM_TYPE !== 'xfs') {
                // xfs applies strict quota restrictions, root cannot bypass
                // quota enforcement
                chgrp($crtfile, $this->group_id);
                chgrp($keyfile, $this->group_id);
            }
            chmod($crtfile, 0600);
            chown($crtfile, 'root');
            chmod($keyfile, 0600);
            chown($keyfile, 'root');

            $chainconfig = $this->_getSSLExtraConfig();
            if ($chain) {
                if (!file_exists(dirname($chainconfig))) {
                    mkdir(dirname($chainconfig), 0711);
                }
                file_put_contents($prefix . self::CRT_PATH . '/bundle.crt', $chain, LOCK_EX);
                $chainfile = join(DIRECTORY_SEPARATOR, array($prefix, self::CRT_PATH, 'bundle.crt'));
                if (file_exists($chainconfig)) {
                    $contents = file($chainconfig, FILE_IGNORE_NEW_LINES);
                    $newcontents = array();
                    $directive = 'SSLCertificateChainFile';
                    foreach ($contents as $line) {

                        if (0 === strpos($line, $directive)) {
                            continue;
                        }
                        $newcontents[] = $line;
                    }
                    $newcontents[] = $directive . ' ' . $chainfile;
                    file_put_contents($chainconfig, join("\n", $newcontents));
                    // bundle perms don't really matter since it's public knowledge
                } else {
                    file_put_contents($chainconfig, 'SSLCertificateChainFile ' . $chainfile);
                }
            }

            // pre-flight checks done, let's install
            if (!$overwrite || !$this->enabled()) {
                $cmd = new Util_Account_Editor($this->getAuthContext()->getAccount(), $this->getAuthContext());
                $cmd->setConfig(SiteConfiguration::getModuleRemap('openssl'), 'enabled', 1);
                // ensure HTTP config is rebuild
                $cmd->edit();
            }
            $this->file_purge();
            // "letsencrypt" reason is user SSL, "ssl" is system SSL
            \Util_Account_Hooks::instantiateContexted($this->getAuthContext())->run('reload', [self::USER_RHOOK]);
            info('reloading web server in 2 minutes, stay tuned!');

            return true;
        }

        public function permitted()
        {
            return true;
        }

        /**
         * Verify that the named certificate and key
         *
         * @param string $cert x509 certificate
         * @param string $pkey private key
         * @return bool
         */
        public function valid($cert, $pkey)
        {
            return openssl_x509_check_private_key($cert, $pkey);
        }

        /**
         * Check if certificate issuer matches requestor
         *
         * @param $crt
         * @return bool|void
         */
        public function is_self_signed($crt)
        {
            return Ssl::selfSigned($crt);
        }

        /**
         * Create a self-signed certificate
         *
         * @param string $cn
         * @param array  $sans
         * @return bool
         */
        public function self_sign(string $cn, array $sans = []): bool
        {
            if ($this->cert_exists() && !$this->is_self_signed($this->get_certificate())) {
                return error('Certificate already exists and is not self-signed');
            }
            return serial(function() use($cn, $sans) {
                $key = $this->generate_privatekey(2048);
                $csr = $this->generate_csr($key, $cn, null, null, null, null, null, null, $sans);
                $crt = $this->sign_certificate($csr, $key);
                return $this->install($key, $crt);
            }) ?? false;

        }

        /**
         * Parse certificate and return information
         *
         * @param mixed $crt resource pointed by openssl_x509_read or string
         * @return array
         */
        public function parse_certificate($crt)
        {
            return Ssl::parse($crt);
        }

        /**
         * Resolve a certificate chain, downloading certificates as necessary
         *
         * @param string $crt initial certificate
         * @return bool|string
         */
        public function resolve_chain($crt)
        {
            $buffer = Error_Reporter::flush_buffer();
            // error out if any resolution fails
            $chain = $this->_resolveChain($crt, array());
            $isError = Error_Reporter::is_error();
            Error_Reporter::merge_buffer($buffer);
            if ($isError) {
                return false;
            }
            // remove initial cert returning
            // resulting chain

            return join("\n", $chain);

        }

        private function _resolveChain($crt, $seen)
        {
            /**
             * Some vendors, like GeoTrust supply a DER-formatted certificate
             */
            if (Ssl::isDer($crt)) {
                $crt = Ssl::der2Pem($crt);
            }

            if ($this->is_self_signed($crt)) {
                // terminated endpoint
                return array($crt);
            }
            $info = $this->parse_certificate($crt);

            if (!isset($info['extensions'])) {
                return array();
            } else if (!isset($info['extensions']['subjectKeyIdentifier'])) {
                error('missing subjectKeyIdentifier fingerprint!');
            }
            $fingerprint = $info['extensions']['subjectKeyIdentifier'];

            if (array_search($fingerprint, $seen, true)) {
                return error('chain loop detected, fingerprint: %s', $fingerprint);
            }
            $seen[] = $fingerprint;

            $extensions = $info['extensions'];
            if (!isset($extensions['authorityInfoAccess'])) {
                // no further keys
                return array();
            }

            if (!preg_match_all(Regex::SSL_CRT_URI, $extensions['authorityInfoAccess'], $matches)) {
                error("can't find URI to match in authorityInfoAccess: %s",
                    $extensions['authorityInfoAccess']);

                return array();
            }

            // in certain situations, OCSP is prefixed with URI, defeating the regex
            // so a second pass to look for a non-OCSP URL
            $url = $matches['url'][0];
            foreach ($matches['url'] as $candidate) {
                if (false !== stripos($candidate, 'ocsp')) {
                    continue;
                }
                $url = $candidate;
            }

            $chainedcrt = $this->_downloadChain($url);
            if (!$chainedcrt) {
                error('failed to resolve chain!');

                return array();
            }
            info("downloaded extra chain `%s'", $url);
            if (Ssl::isDer($chainedcrt)) {
                $chainedcrt = Ssl::der2Pem($chainedcrt);
            }
            return array_merge(
                $this->_resolveChain($chainedcrt, $seen),
                (array)$chainedcrt
            );
        }

        /**
         * Download a certificate to resolve a chain
         *
         * @param $url
         * @return mixed
         * @throws Exception
         */
        private function _downloadChain($url)
        {
            if (extension_loaded('curl')) {
                $adapter = new HTTP_Request2_Adapter_Curl();
            } else {
                $adapter = new HTTP_Request2_Adapter_Socket();
            }

            $http = new HTTP_Request2(
                $url,
                HTTP_Request2::METHOD_GET,
                array(
                    'adapter' => $adapter
                )
            );

            try {
                $response = $http->send();
                $code = $response->getStatus();
                switch ($code) {
                    case 200:
                        break;
                    case 403:
                        return error('URL request forbidden by server');
                    case 404:
                        return error('URL not found on server');
                    case 302:
                        $newLocation = $response->getHeader('location');

                        return $this->_downloadChain($newLocation);
                    default:
                        return error("URL request failed, code `%d': %s",
                            $code, $response->getReasonPhrase());
                }
                // this returns nothing as xfer is saved directly to disk
                $cert = $response->getBody();
            } catch (HTTP_Request2_Exception $e) {
                return error("fatal error retrieving URL: `%s'", $e->getMessage());
            }

            return $cert;
        }

        /**
         * Verify cert2 is a chain to cert1
         *
         * @param mixed $cert1 ssl certificate
         * @param mixed $cert2 ssl certificate
         * @return int 1 if cert2 is intermediate of cert1, -1 if cert1 intermediate of cert2, 0 if no match
         */
        public function verify_certificate_chain($cert1, $cert2)
        {
            $resp = $this->_verify_certificate_chain_real($cert1, $cert2);
            if ($resp || null === $resp) {
                return (int)$resp;
            }

            return $this->_verify_certificate_chain_real($cert2, $cert1) ? -1 : 0;
        }

        /**
         * Actual chain verification logic
         *
         * @param mixed $cert1
         * @param mixed $cert2
         * @return int|null
         */
        private function _verify_certificate_chain_real($cert1, $cert2)
        {
            // basicConstraints: CA:TRUE or FALSE
            // if CA:FALSE, authorityKeyIdentifier refers to chain
            // if CA:TRUE, subjectKeyIdentifier == crt authorityKeyIdentifier

            $icert = $this->parse_certificate($cert1);
            $ichain = $this->parse_certificate($cert2);
            if (!isset($ichain['extensions'])) {
                return null;
            }
            $keyidentifier = array_get($icert, 'extensions.authorityKeyIdentifier', '');
            if (0 === strncmp($keyidentifier, "keyid:", 6)) {
                $keyidentifier = trim(substr($keyidentifier, 6));
            }
            if ($keyidentifier == $ichain['extensions']['subjectKeyIdentifier']) {
                return 1;
            }

            return 0;
        }

        private function _getSSLExtraConfig()
        {
            return $this->web_site_config_dir() . '.ssl/custom';
        }

        public function enabled(): bool
        {
            return (bool)$this->getServiceValue(SiteConfiguration::getModuleRemap('openssl'), 'enabled');
        }

        public function delete($key, $crt, $chain = null)
        {
            if (!IS_CLI) {
                return $this->query('ssl_delete', $key, $crt, $chain);
            }
            // flipped argument order
            if (substr($key, -4) == '.crt' && substr($crt, -4) == '.key') {
                $tmp = $crt;
                $crt = $key;
                $key = $tmp;
            }
            if (!$this->get_certificate($crt)) {
                return error("invalid certificate `%s' specified", $crt);
            } else if (!$this->get_private_key($key)) {
                return error("invalid private key `%s' specified", $key);
            }
            if ($chain && !$this->get_certificate($chain)) {
                return error("invalid certificate chain `%s' specified", $chain);
            }
            if (!$this->_delete_wrapper($crt)) {
                // return on crt, since http config builder depends on .crt
                // presence to include SSL support
                return error("failed to delete certificate `%s'", $crt);
            }

            if (!$this->_delete_wrapper($key)) {
                warn("failed to remove ssl key `%s'", $key);
            }

            if ($chain && !$this->_delete_wrapper($chain)) {
                warn("failed to remove ssl chain certficiate `%s'", $chain);
            }
            $sslextra = $this->_getSSLExtraConfig();

            if (file_exists($sslextra)) {
                $contents = file_get_contents($sslextra);
                $newconfig = array();
                foreach (explode("\n", $contents) as $line) {
                    if (preg_match('!/' . preg_quote($chain, '!') . '$!', $line)) {
                        info('detected and removed certificate chain from http config');
                        continue;
                    }
                    $newconfig[] = $line;
                }
                file_put_contents($sslextra, join("\n", $newconfig));
            }
            // reload HTTP server and rebuild config
            $editor = new Util_Account_Editor($this->getAuthContext()->getAccount());
            $editor->setConfig(SiteConfiguration::getModuleRemap('openssl'), 'enabled', 0);
            $status = $editor->edit();
            if (!$status) {
                return error('failed to deactivate openssl on account');
            }
            Util_Account_Hooks::instantiateContexted($this->getAuthContext())->run('reload', [self::USER_RHOOK]);
            return true;
        }

        /**
         * Get raw certificate
         *
         * @param string $name certificate name
         * @return bool|string
         */
        public function get_certificate($name = 'server.crt')
        {
            if (!IS_CLI) {
                return $this->query('ssl_get_certificate', $name);
            }
            $name = basename($name, '.crt');
            if ($this->permission_level & PRIVILEGE_SITE) {
                $file = $this->domain_fs_path() . self::CRT_PATH .
                    '/' . $name . '.crt';
            } else if ($name[0] != '/') {
                $file = self::CRT_PATH . $name . '.crt';
            } else {
                $file = $name . '.crt';
            }

            if (!file_exists($file)) {
                return error("certificate `%s' does not exist", $name);
            }

            return file_get_contents($file);
        }

        public function get_private_key($name = 'server.key')
        {
            if (!IS_CLI) {
                return $this->query('ssl_get_private_key', $name);
            }
            $name = basename($name, '.key');
            if ($this->permission_level & PRIVILEGE_SITE) {
                $file = $this->domain_fs_path() . self::KEY_PATH .
                    '/' . $name . '.key';
            } else {
                if ($name[0] != '/') {
                    $file = self::KEY_PATH . $name . '.key';
                } else {
                    $file = $name . '.key';
                }
            }

            if (!file_exists($file)) {
                return error("private key `%s' does not exist", $name);
            }

            return file_get_contents($file);
        }

        private function _delete_wrapper($file)
        {
            $prefix = $this->domain_fs_path();
            $ext = substr($file, -4);
            switch ($ext) {
                case '.key':
                    $folder = self::KEY_PATH;
                    break;
                case '.csr':
                    $folder = self::CSR_PATH;
                    break;
                case '.crt':
                    $folder = self::CRT_PATH;
                    break;
                default:
                    return error("cannot delete SSL asset: unknown extension `%s'", $ext);
            }
            $file = join(DIRECTORY_SEPARATOR, array($prefix, $folder, $file));
            if (!file_exists($file)) {
                return false;
            }

            return unlink($file);
        }

        /**
         * Generate new private key
         *
         * @param int $bits
         * @return string
         */
        public function generate_privatekey($bits = 2048)
        {
            return Ssl::genkey($bits);
        }

        /**
         * Generate certificate signing request for a CA
         *
         * @param string $privkey  private key
         * @param string $host     common name for which the SSL certificate is valid
         * @param string|null $country  2-letter country code
         * @param string|null $state    state
         * @param string|null $locality city/province
         * @param string|null $org      optional organization
         * @param string|null $orgunit  optional organizational unit (company section)
         * @param string|null $email    contact e-mail
         * @param array  $san      x509 subject alternate names
         * @return bool|string certificate signing request
         */
        public function generate_csr(
            string $privkey,
            string $host,
            ?string $country = '',
            ?string $state = '',
            ?string $locality = '',
            ?string $org = '',
            ?string $orgunit = '',
            ?string $email = '',
            array $san = []
        ) {
            return Ssl::generate_csr(
                $privkey, $host, $country ?? 'US', $state ?? 'GA', $locality ?? 'Atlanta', (string)$org, (string)$orgunit, (string)$email, $san
            );
        }

        /**
         * Get certificate signing request parameters
         *
         * Sample response:
         *   array(7) {
         *   ["C"]=>
         *   string(2) "US"
         *   ["ST"]=>
         *   string(7) "Georgia"
         *   ["L"]=>
         *   string(7) "Lilburn"
         *   ["O"]=>
         *   string(13) "Apis Networks"
         *   ["OU"]=>
         *   string(4) "Test"
         *   ["CN"]=>
         *   string(8) "test.com"
         *   ["emailAddress"]=>
         *   string(25) "msaladna@apisnetworks.com"
         *   }
         *
         * @param string $csr
         * @return array req parameters using shorthand notation
         */
        public function request_info($csr)
        {
            return Ssl::request_info($csr);
        }

        /**
         * Get public key from certificate
         *
         * Array (
         * [bits] => 4096
         * [key] => -----BEGIN PUBLIC KEY-----
         *        ...
         *      ...
         *    [rsa] => Array ( [n] => .., [e] => ..,)
         *  [type] => 0
         *
         * @param string $name certificate name
         * @return array|bool
         */
        public function get_public_key($name)
        {
            if (!IS_CLI) {
                return $this->query('ssl_get_public_key', $name);
            }
            $name = basename($name, '.key');
            $key = $this->get_certificate($name);
            if (!$key) {
                return error("unable to get named certificate `%s'", $name);
            }
            $res = openssl_pkey_get_public($key);
            $details = openssl_pkey_get_details($res);
            openssl_pkey_free($res);

            return $details;

        }

        /**
         * Order a mixed arrangement of certificates in ascending order to root
         *
         * @param array $certs
         * @return array
         */
        public function order_certificates(array $certs)
        {
            foreach ($certs as $cert) {

            }
        }

        public function get_csr($name)
        {
            if (!IS_CLI) {
                return $this->query('ssl_get_csr', $name);
            }
            $name = basename($name, '.csr');
            if ($this->permission_level & PRIVILEGE_SITE) {
                $file = $this->domain_fs_path() . self::CSR_PATH .
                    '/' . $name . '.csr';
            } else {
                if ($name[0] != '/') {
                    $file = self::CSR_PATH . $name . '.csr';
                } else {
                    $file = $name . '.csr';
                }
            }

            if (!file_exists($file)) {
                return error("certificate request `%s' does not exist", $name);
            }

            return file_get_contents($file);

        }

        /**
         * Create a self-signed certificate
         *
         * @param string $csr     certificate signing request {@link generate_csr}
         * @param string $privkey private key to sign certificate
         * @param int    $days    number days valid
         * @param float  $serial  serial number
         * @return string signed certificate
         */
        public function sign_certificate(
            $csr,
            $privkey,
            $days = 365,
            $serial = null
        ) {

            return Ssl::selfsign($csr, $privkey, $days, $serial);
        }

        /**
         * Verify the given private key matches the self-signed certificate
         *
         * @param string $crt
         * @param string $privkey
         * @return bool
         */
        public function verify_x509_key($crt, $privkey)
        {
            return openssl_x509_check_private_key($crt, $privkey);
        }

        public function verify_key($key)
        {
            if (!$key) {
                return error('no key specified');
            }
            $info = $this->privkey_info($key);
            if (!$info) {
                return error('invalid key detected');
            }

            return true;
        }

        /**
         * Get private key details
         *
         * @param $privkey
         * @return array
         */
        public function privkey_info($privkey)
        {
            $res = openssl_pkey_get_private($privkey);
            $details = openssl_pkey_get_details($res);

            return $details;
        }

        /**
         * Get hostnames for which a certificate is valid
         *
         * @param resource|string $certificate
         * @return array
         */
        public function get_alternative_names($certificate): ?array
        {
            return Ssl::alternativeNames($certificate);
        }

        public function _create()
        {
            $this->_edit();
        }

        /**
         * Active certificate contains name
         *
         * @param string $name
         * @return bool
         */
        public function contains_cn(string $name): bool
        {
            if (!$this->cert_exists()) {
                return false;
            }

            $certdata = $this->ssl_get_certificates();
            $certdata = array_pop($certdata);
            $cert = $this->ssl_get_certificate($certdata['crt']);
            $sans = $this->ssl_get_alternative_names($cert);
            $name = strtolower($name);
            if (in_array($name, $sans, true)) {
                return true;
            }

            $offset = 0;
            while (false !== ($offset = strpos($name, '.'))) {
                $name = substr($name, $offset ? $offset + 1 : 0);
                if (in_array("*.${name}", $sans, true)) {
                    return true;
                }
            }

            return false;
        }

        public function _edit()
        {
            $conf_new = $this->getAuthContext()->getAccount()->new;
            $conf_old = $this->getAuthContext()->getAccount()->old;
            $domainprefix = $this->domain_fs_path();
            $renameWrapper = function ($mode) use ($domainprefix) {
                $certdir = $domainprefix . self::CRT_PATH;
                if ($mode === 'disable') {
                    foreach (glob($certdir . '/*.crt') as $cert) {
                        rename($cert, $cert . '-disabled');
                        info('disabled certificate ' . basename($cert));
                    }

                    return;
                }
                $pkeyfile = $domainprefix . self::KEY_PATH . '/server.key';
                if (!file_exists($pkeyfile)) {
                    // cert won't work without private key
                    return false;
                }
                $pkey = file_get_contents($pkeyfile);
                foreach (glob($certdir . '/*.crt-disabled') as $cert) {
                    $crt = file_get_contents($cert);
                    $file = basename($cert);
                    // server.crt is hardcoded SSL CRT
                    if ($file === 'server.crt' && !$this->valid($crt, $pkey)) {
                        info("removing dangling certificate `%s' that does not match pkey modulus", $cert);
                        unlink($cert);
                        // using certificate will break site
                        continue;
                    }
                    rename($cert, substr($cert, 0, -9));
                    info('enabled certificate ' . substr(basename($cert), 0, -9));
                }
            };

            $ssl = SiteConfiguration::getModuleRemap('openssl');
            // Luna and on do things differently
            if (!$conf_new[$ssl]['enabled']) {
                $renameWrapper('disable');
            } else if ($conf_new[$ssl]['enabled'] && !($conf_old[$ssl]['enabled'] ?? false)) {
                $renameWrapper('enable');
            }
        }

        public function _verify_conf(\Opcenter\Service\ConfigurationContext $ctx): bool
        {
            return true;
        }

        public function _delete()
        {
            // TODO: Implement _delete() method.
        }

        public function _create_user(string $user)
        {
            // TODO: Implement _create_user() method.
        }

        public function _delete_user(string $user)
        {
            // TODO: Implement _delete_user() method.
        }

        public function _edit_user(string $userold, string $usernew, array $oldpwd)
        {
            // TODO: Implement _edit_user() method.
        }


    }