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: 1024: 1025: 1026: 1027: 1028: 1029: 1030: 1031: 1032: 1033: 1034: 1035: 1036: 1037: 1038: 1039: 1040: 1041: 1042: 1043: 1044: 1045: 1046: 1047: 1048: 1049: 1050: 1051: 1052: 1053: 1054: 1055: 1056: 1057: 1058: 1059: 1060: 1061: 1062: 1063: 1064: 1065: 1066: 1067: 1068: 1069: 1070: 1071: 1072: 1073: 1074: 1075: 1076: 1077: 1078: 1079: 1080: 1081: 1082: 1083: 1084: 1085: 1086: 1087: 1088: 1089: 1090: 1091: 1092: 1093: 1094: 1095: 1096: 1097: 1098: 1099: 1100: 1101: 1102: 1103: 1104: 1105: 1106: 1107: 1108: 1109: 1110: 1111: 1112: 1113: 1114: 1115: 1116: 1117: 1118: 1119: 1120: 1121: 1122: 1123: 1124: 1125: 1126: 1127: 1128: 1129: 1130: 1131: 1132: 
<?php declare(strict_types=1);

    /**
     *  +------------------------------------------------------------+
     *  | apnscp                                                     |
     *  +------------------------------------------------------------+
     *  | Copyright (c) Apis Networks                                |
     *  +------------------------------------------------------------+
     *  | Licensed under Artistic License 2.0                        |
     *  +------------------------------------------------------------+
     *  | Author: Matt Saladna (msaladna@apisnetworks.com)           |
     *  +------------------------------------------------------------+
     */

    use Daphnie\Collector;
    use Module\Support\Webapps\App\Loader;
    use Module\Support\Webapps\Finder;
    use Module\Support\Webapps\Updater;
    use Opcenter\Account\Enumerate;
    use Opcenter\Admin\Bootstrapper;
    use Opcenter\Admin\Bootstrapper\Config;
    use Opcenter\Apnscp;
    use Opcenter\Bandwidth\Bulk;
    use Opcenter\CliParser;
    use Opcenter\Filesystem\Quota;
    use Opcenter\License;
    use Opcenter\Map;
    use Opcenter\Process;
    use Opcenter\Service\Plans;
    use Opcenter\SiteConfiguration;
    use Opcenter\System\Cgroup\Controller;
    use Opcenter\System\Cgroup\Group;
    use Opcenter\System\Cgroup\MetricsLogging;

    /**
     *  Provides administrative functions
     *
     * @package core
     */
    class Admin_Module extends Module_Skeleton
    {
        use ImpersonableTrait;

        const ADMIN_HOME = '/etc/opcenter/webhost';
        // @var string under ADMIN_HOME
        const ADMIN_CONFIG = '.config/';
        const ADMIN_CONFIG_LEGACY = '/etc/appliance/appliance.ini';
        // @var periodic cgroup cache
        const CGROUP_CACHE_KEY = 'acct.cgroup';

        protected $exportedFunctions = [
            '*' => PRIVILEGE_ADMIN
        ];

        public function __construct()
        {
            parent::__construct();
            if (!AUTH_ADMIN_API) {
                $this->exportedFunctions = array_merge($this->exportedFunctions,
                    array_fill_keys([
                        'activate_site',
                        'deactivate_site',
                        'suspend_site',
                        'add_site',
                        'edit_site',
                        'delete_site',
                        'hijack',
                    ], PRIVILEGE_NONE)
                );
            } else if (!platform_is('7.5')) {
                $this->exportedFunctions += [
                    'add_site'    => PRIVILEGE_NONE,
                    'edit_site'   => PRIVILEGE_NONE,
                    'delete_site' => PRIVILEGE_NONE
                ];
            }
        }


        /**
         * List all domains on the server
         *
         * @return array
         * @throws PostgreSQLError
         */
        public function get_domains(): array
        {

            $q = PostgreSQL::initialize()->query('SELECT domain,site_id FROM siteinfo ORDER BY domain');
            $domains = array();
            while (null !== ($row = $q->fetch_object())) {
                $domains[$row->site_id] = $row->domain;
            }

            return $domains;
        }

        /**
         * Get e-mail from domain
         *
         * @param string $domain
         * @return bool|string address or false on error
         * @throws PostgreSQLError
         */
        public function get_address_from_domain(string $domain)
        {
            if (!preg_match(Regex::DOMAIN, $domain)) {
                return error("invalid domain `%s'", $domain);
            }
            $siteid = $this->get_site_id_from_domain($domain);
            if (!$siteid) {
                return false;
            }
            $pgdb = PostgreSQL::initialize();
            $q = $pgdb->query('SELECT email FROM siteinfo WHERE site_id = ' . (int)$siteid);
            if ($pgdb->num_rows() > 0) {
                return $q->fetch_object()->email;
            }

            return false;
        }

        /**
         * Translate domain to id
         *
         * @param string $domain domain
         * @return null|int
         * @throws PostgreSQLError
         */
        public function get_site_id_from_domain($domain): ?int
        {
            if (!preg_match(Regex::DOMAIN, $domain)) {
                error("invalid domain `%s'", $domain);

                return null;
            }
            $pgdb = PostgreSQL::initialize();
            $q = $pgdb->query("SELECT site_id FROM siteinfo WHERE domain = '" . $domain . "'");
            if ($pgdb->num_rows() > 0) {
                return (int)$q->fetch_object()->site_id;
            }

            return Auth::get_site_id_from_domain($domain);

        }

        /**
         * Get appliance admin email
         *
         * Multiple entries returned as array
         *
         * @return string|null
         */
        public function get_email(): ?string
        {
            if (!IS_CLI) {
                return $this->query('admin_get_email');
            }
            $ini = $this->_get_admin_config();

            return $ini['adminemail'] ?? ($ini['email'] ?? null);
        }

        protected function _get_admin_config(): array
        {
            $file = $this->getAdminConfigFile();
            if (!file_exists($file)) {
                return [];
            }

            return Util_PHP::unserialize(file_get_contents($file));
        }

        private function getAdminConfigFile(): string
        {
            return self::ADMIN_HOME . DIRECTORY_SEPARATOR . self::ADMIN_CONFIG .
                DIRECTORY_SEPARATOR . $this->username;
        }

        /**
         * Set appliance admin email
         *
         * @param string $email
         * @return bool
         */
        public function set_email(string $email): bool
        {
            if (!IS_CLI) {
                $handler = Preferences::factory($this->getAuthContext())->unlock($this->getApnscpFunctionInterceptor());
                $handler->sync();
                $ret = $this->query('admin_set_email', $email);
                $handler->freshen();

                return $ret;
            }

            if (!preg_match(Regex::EMAIL, $email)) {
                return error("invalid email `%s'", $email);
            }

            $prefs = Preferences::factory($this->getAuthContext())->unlock($this->getApnscpFunctionInterceptor());
            $prefs['email'] = $email;
            $cfg = new Config();
            // only update if set
            if ($cfg['apnscp_admin_email'] && $email !== $cfg['apnscp_admin_email']) {
                // prevent double-firing during setup
                $cfg['apnscp_admin_email'] = $email;
                $cfg->sync();
                Bootstrapper::run('apnscp/create-admin', 'software/etckeeper');
            }

            return $prefs->sync();
        }

        /**
         * Get available plans
         *
         * @return array|null
         */
        public function list_plans(): array
        {
            return Plans::list();
        }

        /**
         * Get settings from plan
         *
         * @param null|string $plan plan or default
         * @return array|null plan information or null if missing
         */
        public function get_plan(string $plan = null): ?array
        {
            if (null === $plan) {
                $plan = Plans::default();
            }
            if (!Plans::exists($plan)) {
                return null;
            }

            return (new SiteConfiguration(null))->setPlanName($plan)
                ->getDefaultConfiguration();
        }

        /**
         * Get listing of service variables
         *
         * @param string      $service service name
         * @param string|null $plan    optional plan name
         * @return array|null
         */
        public function get_service_info(string $service = null, string $plan = null): ?array
        {
            $plan = $plan ?? Plans::default();
            if (!Plans::exists($plan)) {
                error("Unknown plan `%s'", $plan);

                return null;
            }
            $data = CliParser::getHelpFromModules($plan, $service);
            ksort($data);
            $data = array_map(static function ($items) {
                unset($items['version']);

                return array_map(static function ($v) {
                    $v['value'] = $v['default'];

                    return array_except($v, ['version']);
                }, $items);
            }, $data);

            return $service ? ($data[$service] ?? null) : $data;
        }

        /**
         * Force bulk update of webapps
         *
         * @param array $options
         * @return bool
         */
        public function update_webapps(array $options = []): bool
        {
            $launcher = Updater::launch();
            foreach ($options as $k => $v) {
                switch ($k) {
                    case 'limit':
                        $launcher->batch((int)$v);
                        break;
                    case 'type':
                        $launcher->limitType($v);
                        break;
                    case 'assets':
                        $launcher->enableAssetUpdates((bool)$v);
                        break;
                    case 'core':
                        $launcher->enableCoreUpdates((bool)$v);
                        break;
                    case 'site':
                        $launcher->limitSite($v);
                        break;
                    default:
                        fatal("unknown option `%s'", $k);
                }
            }

            return (bool)$launcher->run();
        }

        /**
         * List all failed webapps
         *
         * @param null|string $site restrict list to site
         * @return array
         */
        public function list_failed_webapps(string $site = null): array
        {
            $failed = [];
            if ($site) {
                if (!($sites = (array)Auth::get_site_id_from_anything($site))) {
                    warn("Unknown site `%s'", $site);

                    return [];
                }
                $sites = array_map(static function ($s) {
                    return 'site' . $s;
                }, $sites);
            } else {
                $sites = Enumerate::active();
            }
            $getLatest = static function ($app) {
                static $index;
                if (!isset($index[$app])) {
                    $instance = Loader::fromDocroot($app, null);
                    $versions = $instance->getVersions();
                    $version = $versions ? array_pop($versions) : null;
                    $index[$app] = $version;
                }

                return $index[$app];
            };

            foreach ($sites as $s) {
                if (!Auth::get_domain_from_site_id($s)) {
                    continue;
                }
                $oldex = \Error_Reporter::exception_upgrade(\Error_Reporter::E_FATAL);
                try {
                    $auth = Auth::context(null, $s);
                } catch (\apnscpException $e) {
                    continue;
                } finally {
                    \Error_Reporter::exception_upgrade($oldex);
                }
                $finder = new Finder($auth);
                $apps = $finder->getActiveApplications(static function ($appmeta) {
                    return !empty($appmeta['failed']);
                });
                if (!$apps) {
                    continue;
                }
                $list = [];
                foreach ($apps as $path => $app) {
                    $type = $app['type'] ?? null;
                    $latest = $type ? $getLatest($app['type']) : null;
                    $list[$path] = [
                        'type'     => $type,
                        'version'  => $app['version'] ?? null,
                        'hostname' => $app['hostname'] ?? null,
                        'path'     => $app['path'] ?? '',
                        'latest'   => $latest
                    ];
                }
                $failed[$s] = $list;
            }

            return $failed;
        }

        /**
         * Reset failed apps
         *
         * @param array $constraints [site: <anything>, version: <operator> <version>, type: <type>]
         * @return int
         */
        public function reset_webapp_failure(array $constraints = []): int
        {
            $known = ['site', 'version', 'type'];
            if ($bad = array_diff(array_keys($constraints), $known)) {
                error("unknown constraints: `%s'", implode(', ', $bad));

                return 0;
            }
            if (isset($constraints['site'])) {
                $siteid = Auth::get_site_id_from_anything($constraints['site']);
                if (!$siteid) {
                    error("unknown site `%s'", $constraints['site']);

                    return 0;
                }
                $sites = ['site' . $siteid];
            } else {
                $sites = Enumerate::active();
            }
            $versionFilter = static function (array $appmeta) use ($constraints) {
                if (!isset($constraints['version'])) {
                    return true;
                }
                if (!isset($appmeta['version'])) {
                    return false;
                }

                $vercon = explode(' ', $constraints['version'], 2);
                if (count($vercon) === 1) {
                    $vercon = ['=', $vercon[0]];
                }

                return version_compare($appmeta['version'], ...array_reverse($vercon));
            };
            $typeFilter = static function (array $appmeta) use ($constraints) {
                if (!isset($constraints['type'])) {
                    return true;
                }

                return $appmeta['type'] === $constraints['type'];
            };
            $count = 0;
            foreach ($sites as $site) {
                $auth = Auth::context(null, $site);
                $finder = new Finder($auth);
                $apps = $finder->getActiveApplications(static function ($appmeta) {
                    return !empty($appmeta['failed']);
                });
                foreach ($apps as $path => $app) {
                    if (!$typeFilter($app)) {
                        continue;
                    }
                    if (!$versionFilter($app)) {
                        continue;
                    }
                    $instance = Loader::fromDocroot(null, $path, $auth);
                    $instance->clearFailed();
                    info("Reset failed status on `%s/%s'", $instance->getHostname(), $instance->getPath());
                    $count++;
                }
            }

            return $count;
        }

        /**
         * Locate webapps under site
         *
         * @param string|array $site
         * @return array
         */
        public function locate_webapps($site = null): array
        {
            return Finder::find($site);
        }

        /**
         * Locate webapps under site
         *
         * @param string|array $site
         * @return void
         */
        public function prune_webapps($site = null): void
        {
            Finder::prune($site);
        }

        /**
         * Delete site
         *
         * @param string $site  |null site identifier
         * @param array  $flags optional flags to DeleteDomain ("[since: "now"]" is --since=now, "[force: true]" is --force etc)
         * @return bool
         */
        public function delete_site(?string $site, array $flags = []): bool
        {
            if (!IS_CLI) {
                return $this->query('admin_delete_site', $site, $flags);
            }
            $args = CliParser::buildFlags($flags);
            $ret = Util_Process_Safe::exec(INCLUDE_PATH . "/bin/DeleteDomain ${args} --output=json %s", $site);
            Error_Reporter::merge_json($ret['stdout']);
            if (!Error_Reporter::merge_json($ret['stdout'])) {
                return warn('Failed to read response - output received: %s', $ret['stdout']);
            }

            return $ret['success'];
        }

        /**
         * Add site
         *
         * @param string $domain domain name
         * @param string $admin  admin username
         * @param array  $opts   service parameter adjustments, dot or nested format
         * @param array  $flags  optional flags passed to AddDomain (e.g. "[n: true]" is -n)
         * @return bool
         */
        public function add_site(string $domain, string $admin, array $opts = [], array $flags = []): bool
        {
            if (!IS_CLI) {
                return $this->query('admin_add_site', $domain, $admin, $opts, $flags);
            }
            array_set($opts, 'siteinfo.admin_user', $admin);
            array_set($opts, 'siteinfo.domain', $domain);
            $plan = array_pull($opts, 'siteinfo.plan');
            $args = CliParser::commandifyConfiguration($opts);

            if ($plan) {
                $args = '--plan=' . escapeshellarg($plan) . ' ' . $args;
            }
            $args = CliParser::buildFlags($flags) . ' ' . $args;
            $cmd = INCLUDE_PATH . "/bin/AddDomain --output=json ${args}";
            info('%(bin)s command: %(command)s', ['bin' => 'AddDomain', 'command' => $cmd]);
            $ret = Util_Process_Safe::exec($cmd);
            if (!Error_Reporter::merge_json($ret['stdout'])) {
                return error('Failed to read response - output received: %s', $ret['stdout']);
            }

            return $ret['success'];
        }

        /**
         * Edit site
         *
         * @param string $site  site specifier
         * @param array  $opts  service parameter adjustments, dot or nested format
         * @param array  $flags optional flags passed to EditDomain ("[n: true]" is -n, "[reset: true]" is --reset etc)
         * @return bool
         */
        public function edit_site(string $site, array $opts = [], array $flags = []): bool
        {
            if (!IS_CLI) {
                return $this->query('admin_edit_site', $site, $opts, $flags);
            }

            $plan = array_pull($opts, 'siteinfo.plan');
            $args = CliParser::commandifyConfiguration($opts);

            if ($plan) {
                $args = '--plan=' . escapeshellarg($plan) . ' ' . $args;
            }
            $args = CliParser::buildFlags($flags) . ' ' . $args;
            $cmd = INCLUDE_PATH . "/bin/EditDomain --output=json ${args} " . escapeshellarg($site);
            info('%(bin)s command: %(command)s', ['bin' => 'EditDomain', 'command' => $cmd]);
            $ret = Util_Process_Safe::exec($cmd);
            if (!Error_Reporter::merge_json($ret['stdout'])) {
                return error('Failed to read response - output received: %s', $ret['stdout']);
            }

            return $ret['success'];
        }

        /**
         * Activate site
         *
         * @param string|array $site
         * @return bool
         */
        public function activate_site($site): bool
        {
            if (!IS_CLI) {
                return $this->query('admin_activate_site', $site);
            }

            $site = implode(' ', array_map('escapeshellarg', (array)$site));
            $ret = Util_Process::exec(INCLUDE_PATH . '/bin/ActivateDomain --output=json %s', $site);
            Error_Reporter::merge_json($ret['stdout']);

            return $ret['success'];
        }

        /**
         * Alias to
         *
         * @param array|string $site
         * @param array  $flags optional flags
         * @return bool @link suspend_site
         */
        public function deactivate_site($site, array $flags = []): bool
        {
            return $this->suspend_site($site, $flags);
        }

        /**
         * Deactivate site
         *
         * @param string|array $site
         * @param array $flags optional flags passed to SuspendDomain
         * @return bool
         */
        public function suspend_site($site, array $flags = []): bool
        {
            if (!IS_CLI) {
                return $this->query('admin_suspend_site', $site, $flags);
            }

            $site = implode(' ', array_map('escapeshellarg', (array)$site));
            $args = CliParser::buildFlags($flags);
            $ret = Util_Process::exec(INCLUDE_PATH . '/bin/SuspendDomain --output=json %s %s', $args, $site);
            Error_Reporter::merge_json($ret['stdout']);

            return $ret['success'];
        }

        /**
         * Hijack a user account
         *
         * Replaces current session with new account session
         *
         * @param string      $site
         * @param string|null $user
         * @param string|null $gate authentication gate
         * @return null|string
         */
        public function hijack(string $site, string $user = null, string $gate = null): ?string
        {
            return $this->impersonateRole($site, $user, $gate);
        }

        /**
         * Get server storage usage
         *
         * @return array
         */
        public function get_storage(): array
        {
            $mounts = $this->stats_get_partition_information();
            foreach ($mounts as $mount) {
                if ($mount['mount'] !== '/') {
                    continue;
                }

                return [
                    'qused' => $mount['used'],
                    'qhard' => $mount['size'],
                    'qsoft' => $mount['size'],
                    'fused' => 0,
                    'fsoft' => PHP_INT_MAX,
                    'fhard' => PHP_INT_MAX
                ];

            }
            warn('Failed to locate root partition / - storage information incomplete');

            return [];
        }

        /**
         * Get storage used per site
         *
         * @param array $sites
         * @return array
         */
        public function get_site_storage(array $sites = []): array
        {
            return $this->get_usage('storage', $sites);

        }

        /**
         * Get resource usage for site or collection of sites
         *
         * @param string $field resource type: storage, bandwidth
         * @param array  $sites optional list of site specifiers to restrict
         * @return array
         */
        public function get_usage(string $field = 'storage', array $sites = []): array
        {
            if (!IS_CLI) {
                return $this->query('admin_get_usage', $field, $sites);
            }
            if (!$sites) {
                $sites = Enumerate::sites();
            } else {
                $sites = array_filter(array_map(static function ($site) {
                    $id = Auth::get_site_id_from_anything($site);
                    if (null === $id) {
                        return null;
                    }

                    return "site$id";
                }, $sites));
            }

            switch ($field) {
                case 'storage':
                    return $this->getStorageUsage($sites);
                case 'bandwidth':
                    return $this->getBandwidthUsage($sites);
                case 'cgroup':
                case 'cgroups':
                    return $this->getCgroupCacheWrapper($sites);
                default:
                    fatal('Unknown resource spec %s', $field);
            }

            return [];
        }

        private function getStorageUsage(array $sites): array
        {
            $groups = array_filter(array_combine($sites, array_map(static function ($site) {
                $group = Auth::get_group_from_site($site);
                if (false === ($grp = posix_getgrnam($group))) {
                    return null;
                }

                return $grp['gid'];
            }, $sites)));

            $quotas = Quota::getGroup($groups);

            foreach ($groups as $site => $gid) {
                $groups[$site] = $quotas[$gid];
            }

            return array_filter($groups);
        }

        /**
         * Get bandwidth usage
         *
         * @param array $sites
         * @return array
         */
        private function getBandwidthUsage(array $sites): array
        {
            $overage = Bulk::getCurrentUsage();
            $sites = array_flip($sites);
            $built = [];
            foreach ($overage as $o) {
                $site = 'site' . $o['site_id'];
                if (!isset($sites[$site])) {
                    continue;
                }
                $built[$site] = $o;
            }

            return $built;
        }

        /**
         * Cacheable cgroup wrapper
         *
         * @param array $sites
         * @return array
         */
        private function getCgroupCacheWrapper(array $sites): array
        {
            $cache = Cache_Global::spawn();
            $existing = [];
            if (false !== ($tmp = $cache->get(self::CGROUP_CACHE_KEY))) {
                $existing = (array)$tmp;
            }

            $search = array_flip($sites);
            if (!($missing = array_diff_key($search, $existing))) {
                return array_intersect_key($existing, $search);
            }

            $tokens = [];
            $controllers = $this->cgroup_get_controllers();
            $dummyGroup = new Group(null);
            foreach ($controllers as $c) {
                $controller = (Controller::make($dummyGroup, $c));
                $attrs = new MetricsLogging($controller);
                $controllerTokens = $attrs->getMetricTokensFromAttributes($attrs->getLoggableAttributes());
                $tokens = array_values($tokens + append_config($controllerTokens));
            }

            /** @noinspection AdditionOperationOnArraysInspection */
            $existing += $this->getCgroupUsage(array_keys($missing), $tokens);
            $cache->set(self::CGROUP_CACHE_KEY, $existing, CGROUP_PREFETCH_TTL);

            return array_intersect_key($existing, $search);
        }

        /**
         * Get cgroup usage
         *
         * @param array $sites
         * @param array $tokens
         * @return array
         */
        private function getCgroupUsage(array $sites, array $tokens): array
        {
            if (!TELEMETRY_ENABLED) {
                warn('[telemetry] => enabled is set to false');

                return [];
            }

            $sum = (new Collector(PostgreSQL::pdo()))->range(
                $tokens,
                time() - 86400,
                null,
                array_map(static function ($s) {
                    // strip "site"
                    return (int)substr($s, 4);
                }, $sites)
            );

            $built = [];
            $limits = $this->collect(['cgroup']);
            $sites = Enumerate::sites();

            foreach ($sites as $site) {
                $siteid = (int)substr($site, 4);
                $cpuUsed = $sum['c-cpuacct-usage'][$siteid] ?? null;
                if (null !== $cpuUsed) {
                    // centiseconds!!!
                    $cpuUsed /= 100;
                }
                $built[$site] = [
                    'site_id' => $siteid,
                    'cpu'     => [
                        'used'      => $cpuUsed,
                        'threshold' => $limits[$site]['cgroup']['cpu'] ?? null
                    ],
                    'memory'  => [
                        'used'      => $sum['c-memory-peak'][$siteid] ?? null,
                        'threshold' => $limits[$site]['cgroup']['memory'] ?? null
                    ],
                    'pids'    => [
                        'used'      => $sum['c-pids-used'][$siteid] ?? null,
                        'threshold' => $limits[$site]['cgroup']['proclimit'] ?? null
                    ],
                    'io'      => [
                        'read'      => $sum['c-blkio-bw-read'][$siteid] ?? null,
                        'write'     => $sum['c-blkio-bw-write'][$siteid] ?? null,
                        'threshold' => $limits[$site]['cgroup']['io'] ?? null
                    ]
                ];
            }

            return $built;
        }

        /**
         * Collect account info
         *
         * "active" is a special $query param that picks active/inactive (true/false) sites
         *
         * @param array|null $params null cherry-picks all services, [] uses default service list
         * @param array|null $query  pull sites that possess these service values
         * @param array      $sites  restrict selection to sites
         * @return array
         */
        public function collect(?array $params = [], array $query = null, array $sites = []): array
        {
            if ([] === $params) {
                $params = [
                    'siteinfo.email',
                    'siteinfo.admin_user',
                    'aliases.aliases',
                    'billing.invoice',
                    'billing.parent_invoice'
                ];
            } else if ($params && is_array(current($params))) {
                $tmp = [];
                foreach ($params as $k => $items) {
                    foreach ($items as $v) {
                        $tmp[] = "${k}.${v}";
                    }
                }
                $params = $tmp;
            }

            if ($query && !is_array(current($query))) {
                // hydrate
                // passed as $query = ['siteinfo.foo' => 'bar', 'baz' => 'abc']
                // instead of $query = ['siteinfo' => ['foo' => 'bar']]
                $tmp = [];
                foreach ($query as $k => $v) {
                    $k = str_replace(',', '.', $k);
                    array_set($tmp, $k, $v);
                }
                $query = $tmp;
            }
            if (!$sites) {
                $sites = Opcenter\Account\Enumerate::sites();
            } else {
                foreach ($sites as &$s) {
                    if (0 === strncmp($s, 'site', 4) && ctype_digit(substr($s, 4))) {
                        continue;
                    }
                    if (!($all = Auth::get_site_id_from_invoice($s))) {
                        continue;
                    }
                    // push to front to reduce comps
                    array_unshift($sites, ...array_map(static function ($v) {
                        return "site${v}";
                    }, $all));
                    $s = null;
                }
            }
            unset($s);
            $built = array_build(array_filter($sites), static function ($k, $s) use ($params, $query) {
                $oldex = Error_Reporter::exception_upgrade(Error_Reporter::E_FATAL | Error_Reporter::E_ERROR);
                try {
                    $ctx = Auth::context(null, $s);
                } catch (apnscpException $e) {
                    return null;
                } finally {
                    Error_Reporter::exception_upgrade($oldex);
                }

                /** @noinspection PhpUndefinedVariableInspection */
                $account = $ctx->getAccount();
                $meta = [
                    'active' => (bool)$account->active
                ];
                if ((bool)($query['active'] ?? $meta['active']) !== $meta['active']) {
                    return null;
                }

                unset($query['active']);
                if ($query && Util_PHP::array_diff_assoc_recursive($query, $account->cur)) {
                    return null;
                }
                if ($params === null) {
                    $params = array_dot(array_keys($account->cur));
                }
                foreach ($params as $p) {
                    array_set($meta, $p, array_get($account->cur, $p));
                }
                $meta['domain'] = $account->cur['siteinfo']['domain'];

                return [$s, $meta];
            });
            unset($built['']);

            return $built;
        }

        /**
         * Destroy all logins matching site
         *
         * @param string $site
         * @return bool
         */
        public function kill_site(string $site): bool
        {
            if (!IS_CLI) {
                return $this->query('admin_kill_site', $site);
            }
            if (!($admin = $this->get_meta_from_domain($site, 'siteinfo', 'admin'))) {
                return error("Failed to lookup admin for site `%s'", $site);
            }

            foreach (Process::matchGroup($admin) as $pid) {
                Process::kill($pid, SIGKILL);
            }

            return true;

        }

        /**
         * Get account metadata
         *
         * @param string $domain
         * @param string $service
         * @param string $class
         * @return array|bool|mixed|void
         */
        public function get_meta_from_domain(string $domain, string $service, string $class = null)
        {
            if (!IS_CLI) {
                return $this->query('admin_get_meta_from_domain', $domain, $service, $class);
            }
            if (!$context = Auth::context(null, $domain)) {
                return error("Unknown domain `%s'", $domain);
            }
            if (null === ($conf = $context->conf($service))) {
                return error("Unknown service `%s'", $service);
            }

            return array_get($conf, $class, null);
        }

        /**
         * Activate apnscp license
         *
         * @param string $key
         * @return bool
         */
        public function activate_license(string $key): bool
        {
            if (!IS_CLI) {
                return $this->query('admin_activate_license', $key);
            }

            return (License::get()->issue($key) && Apnscp::restart()) || error('Failed to activate license');
        }

        /**
         * Renew apnscp license
         *
         * @return bool
         */
        public function renew_license(): bool
        {
            if (!IS_CLI) {
                return $this->query('admin_renew_license');
            }
            $license = License::get();
            if (!$license->installed()) {
                return error('License is not installed');
            }
            if ($license->isTrial()) {
                return error('Cannot renew trial licenses');
            }
            if ($license->isLifetime()) {
                return warn('Lifetime licenses never need renewal');
            }
            if (!$license->needsReissue()) {
                return warn('License does not need reissue. %d days until expiry', $license->daysUntilExpire());
            }
            if (!$license->reissue()) {
                return false;
            }
            info('Restarting ' . PANEL_BRAND);

            return Apnscp::restart();
        }

        /**
         * Read a map from mappings/
         *
         * @param string $map map name
         * @return array
         */
        public function read_map(string $map): array
        {
            if (!IS_CLI) {
                return $this->query('admin_read_map', $map);
            }
            if (!$map || $map[0] === '.' || $map[0] === '/') {
                error("Invalid map specified `%s'", $map);

                return [];
            }
            $path = Map::home() . DIRECTORY_SEPARATOR . $map;

            if (!file_exists($path)) {
                error("Invalid map specified `%s'", $map);

                return [];
            }

            return Map::load($map)->fetchAll();
        }

        public function _housekeeping()
        {
            $configHome = static::ADMIN_HOME . '/' . self::ADMIN_CONFIG;
            if (!is_dir($configHome)) {
                Opcenter\Filesystem::mkdir($configHome, APNSCP_SYSTEM_USER, APNSCP_SYSTEM_USER, 0700);
            }

            $defplan = Plans::path(Plans::default());
            if (!is_dir($defplan)) {
                $base = Plans::path('');
                // plan name change
                $dh = opendir($base);
                if (!$dh) {
                    return error("Plan path `%s' missing, account creation will fail until fixed",
                        $base
                    );
                }
                while (false !== ($f = readdir($dh))) {
                    if ($f === '..' || $f === '.') {
                        continue;
                    }
                    $path = $base . DIRECTORY_SEPARATOR . $f;
                    if (is_link($path)) {
                        unlink($path);
                        break;
                    }
                }
                if ($f !== false) {
                    info("old default plan `%s' renamed to `%s'",
                        $f, Plans::default()
                    );
                }
                symlink(dirname($defplan) . '/.skeleton', $defplan);
            }


            $themepath = public_path('images/themes/current');
            if (is_link($themepath) && basename(readlink($themepath)) === STYLE_THEME) {
                return;
            }
            is_link($themepath) && unlink($themepath);
            $curpath = dirname($themepath) . '/current';
            symlink(STYLE_THEME, $curpath);
            if (!is_dir(readlink($curpath))) {
                Opcenter\Filesystem::mkdir($curpath, APNSCP_SYSTEM_USER, APNSCP_SYSTEM_USER);
            }
        }

        public function _cron(Cronus $c)
        {
            $c->schedule(
                (int)(Auth_Info_Account::CACHE_DURATION - ceil(Auth_Info_Account::CACHE_DURATION / CRON_RESOLUTION)),
                'account.cache',
                static function () {
                    // keep account meta cached in memory for speed up
                    $exception = Error_Reporter::exception_upgrade(Error_Reporter::E_FATAL);
                    foreach (Enumerate::sites() as $site) {
                        try {
                            Auth::context(null, $site);
                        } catch (apnscpException $e) {
                            continue;
                        }
                    }
                    Error_Reporter::exception_upgrade($exception);
                });

            if (CGROUP_SHOW_USAGE) {
                $cache = Cache_Global::spawn();
                if (!$cache->exists(self::CGROUP_CACHE_KEY)) {
                    $this->getCgroupCacheWrapper(Enumerate::sites());
                }
            }
        }
    }