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: 1133: 1134: 1135: 1136: 1137: 1138: 1139: 1140: 1141: 1142: 1143: 1144: 1145: 1146: 1147: 1148: 1149: 1150: 1151: 1152: 1153: 1154: 1155: 1156: 1157: 1158: 1159: 1160: 1161: 1162: 1163: 1164: 1165: 1166: 1167: 1168: 1169: 1170: 1171: 1172: 1173: 1174: 1175: 1176: 1177: 1178: 1179: 1180: 1181: 1182: 1183: 1184: 1185: 1186: 1187: 1188: 1189: 1190: 1191: 1192: 1193: 1194: 1195: 1196: 1197: 1198: 1199: 1200: 1201: 1202: 1203: 1204: 1205: 1206: 1207: 1208: 1209: 1210: 1211: 1212: 1213: 1214: 1215: 1216: 1217: 1218: 1219: 1220: 1221: 1222: 1223: 1224: 1225: 1226: 1227: 1228: 1229: 1230: 1231: 1232: 1233: 1234: 1235: 1236: 1237: 1238: 1239: 1240: 1241: 1242: 1243: 1244: 1245: 1246: 1247: 1248: 1249: 1250: 1251: 1252: 1253: 1254: 1255: 1256: 1257: 1258: 1259: 1260: 1261: 1262: 1263: 1264: 1265: 1266: 1267: 1268: 1269: 1270: 1271: 1272: 1273: 1274: 1275: 1276: 1277: 1278: 1279: 1280: 1281: 1282: 1283: 1284: 1285: 1286: 1287: 1288: 1289: 1290: 1291: 1292: 1293: 1294: 1295: 1296: 1297: 1298: 1299: 1300: 1301: 1302: 1303: 1304: 1305: 1306: 1307: 1308: 1309: 1310: 1311: 1312: 1313: 1314: 1315: 1316: 1317: 1318: 1319: 1320: 1321: 1322: 1323: 1324: 1325: 1326: 1327: 1328: 1329: 1330: 1331: 1332: 1333: 1334: 1335: 1336: 1337: 1338: 1339: 1340: 1341: 1342: 1343: 1344: 1345: 1346: 1347: 1348: 1349: 1350: 1351: 1352: 1353: 1354: 1355: 1356: 1357: 1358: 1359: 1360: 1361: 1362: 1363: 1364: 1365: 1366: 1367: 1368: 1369: 1370: 1371: 1372: 1373: 1374: 1375: 1376: 1377: 1378: 1379: 1380: 1381: 1382: 1383: 1384: 1385: 1386: 1387: 1388: 1389: 1390: 1391: 1392: 1393: 1394: 1395: 1396: 1397: 1398: 1399: 1400: 1401: 1402: 1403: 1404: 1405: 1406: 1407: 1408: 1409: 1410: 1411: 1412: 1413: 1414: 1415: 1416: 1417: 1418: 1419: 1420: 1421: 1422: 1423: 1424: 1425: 1426: 1427: 1428: 1429: 1430: 1431: 1432: 1433: 1434: 1435: 1436: 1437: 1438: 1439: 1440: 1441: 1442: 1443: 1444: 1445: 1446: 1447: 1448: 1449: 1450: 1451: 1452: 1453: 1454: 1455: 1456: 1457: 1458: 1459: 1460: 1461: 1462: 1463: 1464: 1465: 1466: 1467: 1468: 1469: 1470: 1471: 1472: 1473: 1474: 1475: 1476: 1477: 1478: 1479: 1480: 1481: 1482: 1483: 1484: 1485: 1486: 1487: 1488: 1489: 1490: 1491: 1492: 1493: 1494: 1495: 1496: 1497: 1498: 1499: 1500: 1501: 1502: 1503: 1504: 1505: 1506: 1507: 1508: 1509: 1510: 1511: 1512: 1513: 1514: 1515: 1516: 1517: 1518: 1519: 1520: 1521: 1522: 1523: 1524: 1525: 1526: 1527: 1528: 1529: 1530: 1531: 1532: 1533: 1534: 1535: 1536: 1537: 1538: 1539: 1540: 1541: 1542: 1543: 1544: 1545: 1546: 1547: 1548: 1549: 1550: 1551: 1552: 1553: 1554: 1555: 1556: 1557: 1558: 1559: 1560: 1561: 1562: 1563: 
<?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 Daphnie\Metrics\Apache as ApacheMetrics;
    use Module\Support\Webapps\MetaManager;
    use Opcenter\Filesystem;
    use Opcenter\Http\Apache;
    use Opcenter\Http\Apache\Map;
    use Opcenter\Provisioning\ConfigurationWriter;

    /**
     * Web server and package management
     *
     * @package core
     */
    class Web_Module extends Module_Skeleton implements \Opcenter\Contracts\Hookable
    {
        const DEPENDENCY_MAP = [
            'ipinfo',
            'ipinfo6',
            'siteinfo',
            'dns',
            // required for PHP-FPM cgroup binding
            'cgroup'
        ];

        // primary domain document root
        const MAIN_DOC_ROOT = '/var/www/html';
        const WEB_USERNAME = APACHE_USER;
        const WEB_GROUPID = APACHE_GID;
        const PROTOCOL_MAP = '/etc/httpd/conf/http10';
        const SUBDOMAIN_ROOT = '/var/subdomain';

        protected $pathCache = [];
        protected $service_cache;
        protected $exportedFunctions = [
            '*'                 => PRIVILEGE_SITE,
            'add_subdomain_raw' => PRIVILEGE_SITE | PRIVILEGE_SERVER_EXEC,
            'host_html_dir'     => PRIVILEGE_SITE | PRIVILEGE_USER,
            'reload'            => PRIVILEGE_SITE | PRIVILEGE_ADMIN,
            'status'            => PRIVILEGE_ADMIN,
            'get_sys_user'      => PRIVILEGE_ALL,
            'capture'           => PRIVILEGE_SERVER_EXEC | PRIVILEGE_SITE,
            'inventory_capture' => PRIVILEGE_ADMIN
        ];
        protected $hostCache = [];

        /**
         * void __construct(void)
         *
         * @ignore
         */
        public function __construct()
        {
            parent::__construct();

            if (!DAV_APACHE) {
                $this->exportedFunctions += [
                    'bind_dav' => PRIVILEGE_NONE,
                    'unbind_dav' => PRIVILEGE_NONE,
                    'list_dav_locations' => PRIVILEGE_NONE,
                ];
            }

        }

        public function __wakeup()
        {
            $this->pathCache = [];
        }

        /**
         * User capability is enabled for web service
         *
         * Possible values subdomain, cgi
         *
         * @param  string $user user
         * @param  string $svc  service name possible values subdomain, cgi
         * @return bool
         */
        public function user_service_enabled($user, $svc)
        {
            if (!IS_CLI) {
                return $this->query('web_user_service_enabled',
                    array($user, $svc));
            }
            if ($svc != 'cgi' && $svc != 'subdomain') {
                return new ArgumentError('Invalid service name ' . $svc . ' passed');
            }

            return true;
        }

        /**
         * Sweep all subdomains to confirm accessibility
         *
         * @return array list of subdomains invalid
         */
        public function validate_subdomains()
        {
            $prefix = $this->domain_fs_path();
            $invalid = array();
            foreach (glob($prefix . self::SUBDOMAIN_ROOT . '/*/') as $entry) {
                $subdomain = basename($entry);
                if ((is_link($entry . '/html') || is_dir($entry . '/html')) && file_exists($entry . '/html')) {
                    continue;
                }
                warn("inaccessible subdomain `%s' detected", $subdomain);
                $file = File_Module::convert_relative_absolute($entry . '/html',
                    readlink($entry . '/html'));
                $invalid[$subdomain] = substr($file, strlen($prefix));
            }

            return $invalid;
        }

        /**
         * Check if hostname is a subdomain
         *
         * @param string $hostname
         * @return bool
         */
        public function is_subdomain(string $hostname): bool
        {
            if (false !== strpos($hostname, '.') && !preg_match(Regex::SUBDOMAIN, $hostname)) {
                return false;
            }

            return is_dir($this->domain_fs_path(self::SUBDOMAIN_ROOT . "/$hostname"));
        }

        public function subdomain_accessible($subdomain)
        {
            if ($subdomain[0] == '*') {
                $subdomain = substr($subdomain, 2);
            }

            return file_exists($this->domain_fs_path(self::SUBDOMAIN_ROOT . "/$subdomain/html")) &&
                is_executable($this->domain_fs_path(self::SUBDOMAIN_ROOT . "/$subdomain/html"));
        }

        /**
         * Get assigned web user for host/docroot
         *
         * @see get_sys_user() for HTTPD user
         *
         * @param string $nametype hostname or path
         * @param string $path
         * @return string
         */
        public function get_user(string $hostname, string $path = ''): string
        {
            // @TODO gross oversimplification of path check,
            // assert path is readable and if not default to apache,webuser
            if ($hostname[0] === '/' && $path) {
                warn('$path variable should be omitted when specifying docroot');
            }
            return $this->getServiceValue('apache', 'webuser', static::WEB_USERNAME);
        }

        /**
         * Get HTTP system user
         *
         * HTTP user has limited requirements except read.
         * In a PHP-FPM environment, no write access is permitted by this user.
         *
         * @return string
         */
        public function get_sys_user(): string
        {
            return static::WEB_USERNAME;
        }

        /**
         * Retrieve document root for given host
         *
         * Doubly useful to evaluate where documents
         * will be served given a particular domain
         *
         * @param string $hostname HTTP host
         * @param string $path     optional path component
         * @return string document root path
         */
        public function normalize_path(string $hostname, string $path = ''): ?string
        {
            if (!IS_CLI && isset($this->pathCache[$hostname][$path])) {
                return $this->pathCache[$hostname][$path];
            }
            $prefix = $this->domain_fs_path();
            if (false === ($docroot = $this->get_docroot($hostname, $path))) {
                return null;
            }

            $checkpath = $prefix . DIRECTORY_SEPARATOR . $docroot;
            clearstatcache(true, $checkpath);
            if (\Util_PHP::is_link($checkpath)) {
                // take the referent unless the path doesn't exist
                // let the API figure out what to do with it
                if (false === ($checkpath = realpath($checkpath))) {
                    return $docroot;
                }
                if (0 !== strpos($checkpath, $prefix)) {
                    error("docroot for `%s/%s' exceeds site root", $hostname, $path);

                    return null;
                }
                $docroot = substr($checkpath, strlen($prefix));
            }
            if (!file_exists($checkpath)) {
                $subpath = dirname($checkpath);
                if (!file_exists($subpath)) {
                    error("invalid domain `%s', docroot `%s' does not exist", $hostname, $docroot);

                    return null;
                }
            }
            if (!isset($this->pathCache[$hostname])) {
                $this->pathCache[$hostname] = [];
            }

            $this->pathCache[$hostname][$path] = $docroot;

            return $docroot ?: null;
        }

        /**
         * Get document root from hostname
         *
         * @param        $hostname
         * @param string $path
         * @return bool|string
         */
        public function get_docroot($hostname, $path = '')
        {
            $domains = $this->list_domains();
            $path = ltrim($path, '/');
            if (isset($domains[$hostname])) {
                return rtrim($domains[$hostname] . '/' . $path, '/');
            }

            $domains = $this->list_subdomains();
            if (array_key_exists($hostname, $domains)) {
                // missing symlink will report as NULL
                if (null !== $domains[$hostname]) {
                    return rtrim($domains[$hostname] . '/' . $path, '/');
                }
                $info = $this->subdomain_info($hostname);

                return rtrim($info['path'] . '/' . $path, '/');
            }

            if (0 === strncmp($hostname, "www.", 4)) {
                $tmp = substr($hostname, 4);

                return $this->get_docroot($tmp, $path);
            }
            if (false !== strpos($hostname, '.')) {
                $host = $this->split_host($hostname);
                if (!empty($host['subdomain']) && $this->subdomain_exists($host['subdomain'])) {
                    return $this->get_docroot($host['subdomain'], $path);
                }

            }

            return error("unknown domain `$hostname'");
        }

        /**
         * Import subdomains from domain
         *
         * @param string $target target domain to import into
         * @param string $src    source domain
         * @return bool
         */
        public function import_subdomains_from_domain(string $target, string $src): bool
        {
            $domains = $this->web_list_domains();
            foreach ([$target, $src] as $chk) {
                if (!isset($domains[$chk])) {
                    return error("Unknown domain `%s'", $chk);
                }
            }
            if ($target === $src) {
                return error('Cannot import - target is same as source');
            }
            foreach ($this->list_subdomains('local', $target) as $subdomain => $path) {
                $this->remove_subdomain($subdomain);
            }
            foreach ($this->list_subdomains('local', $src) as $subdomain => $path) {
                if ($src !== substr($subdomain, -\strlen($src))) {
                    warn("Subdomain attached to `%s' does not match target domain `%s'??? Skipping", $subdomain, $target);
                    continue;
                }
                $subdomain = substr($subdomain, 0, -\strlen($src)) . $target;
                $this->add_subdomain($subdomain, $path);
            }

            return true;
        }

        /**
         * List subdomains on the account
         *
         * Array format- subdomain => path
         *
         * @param string       $filter  filter by "global", "local", "path"
         * @param string|array $domains only show subdomains bound to domain or re for path
         * @return array matching subdomains
         */
        public function list_subdomains($filter = '', $domains = array())
        {
            if ($filter && $filter != 'local' && $filter != 'global' && $filter != 'path') {
                return error("invalid filter mode `%s'", $filter);
            }
            $subdomains = array();
            if ($filter == 'path') {
                $re = $domains;
                if ($re && $re[0] !== $re[-1]) {
                    $re = '!' . preg_quote($re, '!') . '!';
                }
            } else {
                $re = null;
            }
            if ($domains && !is_array($domains)) {
                $domains = array($domains);
            }
            foreach (glob($this->domain_fs_path() . self::SUBDOMAIN_ROOT . '/*', GLOB_NOSORT) as $entry) {
                $subdomain = basename($entry);
                $path = '';
                if (is_link($entry . '/html') || is_dir($entry . '/html') /* smh... */) {
                    if (!is_link($entry . '/html')) {
                        warn("subdomain `%s' doc root is directory", $subdomain);
                        $path = Opcenter\Http\Apache::makeSubdomainPath($entry);
                    } else {
                        $path = (string)substr(File_Module::convert_relative_absolute($entry . '/html',
                            readlink($entry . '/html')),
                            strlen($this->domain_fs_path()));
                    }
                }
                if ($filter && ($filter == 'local' && !strpos($subdomain, '.') ||
                        $filter == 'global' && strpos($subdomain, '.'))
                ) {
                    continue;
                }
                if ($filter == 'path' && !preg_match($re, $path)) {
                    continue;
                }

                if ($filter !== 'path' && strpos($subdomain, '.') && $domains) {
                    $skip = 0;
                    foreach ($domains as $domain) {
                        $lendomain = strlen($domain);
                        if (substr($subdomain, -$lendomain) != $domain) {
                            $skip = 1;
                            break;
                        }
                    }
                    if ($skip) {
                        continue;
                    }
                }

                $subdomains[$subdomain] = $path;
            }

            asort($subdomains, SORT_LOCALE_STRING);

            return $subdomains;
        }

        /**
         * Get detailed information on a subdomain
         *
         * Response:
         *  path (string): filesystem location
         *  active (bool): subdomain references accessible directory
         *  user (string): owner of subdomain
         *  type (string): local, global, or fallthrough
         *
         * @param  string $subdomain
         * @return array
         */
        public function subdomain_info($subdomain)
        {
            if ($subdomain[0] == '*') {
                $subdomain = substr($subdomain, 2);
            }

            if (!$subdomain) {
                return error('no subdomain provided');
            }
            if (!$this->subdomain_exists($subdomain)) {
                return error($subdomain . ': subdomain does not exist');
            }

            $info = array(
                'path'   => null,
                'active' => false,
                'user'   => null,
                'type'   => null
            );

            $fs_location = $this->domain_fs_path() . self::SUBDOMAIN_ROOT . "/$subdomain";

            if (!strpos($subdomain, '.')) {
                $type = 'global';
            } else if (!array_key_exists($subdomain, $this->list_domains())) {
                $type = 'local';
            } else {
                $type = 'fallthrough';
            }

            $info['type'] = $type;
            $link = $fs_location . '/html';
            /**
             * link does not exist
             * test first if no symlink referent is present,
             * then verify (is_link()) that the $link is not present
             * file_exists() checks the referent
             */
            if (!file_exists($link) && !is_link($link)) {
                return $info;
            }
            // case when <subdomain>/html is directory instead of symlink
            if (!is_link($link)) {
                $path = $link;
            } else {
                clearstatcache(true, $link);
                $path = File_Module::convert_relative_absolute($link, readlink($link));
            }
            $info['path'] = $this->file_canonicalize_site($path);

            $info['active'] = file_exists($link) && is_readable($link);
            $stat = $this->file_stat($info['path']);
            if (!$stat || $stat instanceof Exception) {
                return $info;
            }
            $info['user'] = $stat['owner'];

            return $info;
        }

        /**
         * Check if named subdomain exists
         *
         * Fallthrough, local, and global subdomain patterns
         * are valid
         *
         * @see add_subdomain()
         *
         * @param  string $subdomain
         * @return bool
         */
        public function subdomain_exists($subdomain)
        {
            if ($subdomain[0] === '*') {
                $subdomain = substr($subdomain, 2);
            }
            $path = $this->domain_fs_path(self::SUBDOMAIN_ROOT . "/$subdomain");

            return file_exists($path);
        }

        public function list_domains()
        {
            return array_merge(
                array($this->getConfig('siteinfo', 'domain') => self::MAIN_DOC_ROOT),
                $this->aliases_list_shared_domains());
        }

        /**
         * Split hostname into subdomain + domain components
         *
         * @param string $hostname
         * @return array|bool components or false on error
         */
        public function split_host($host)
        {
            if (!preg_match(Regex::HTTP_HOST, $host)) {
                return error("can't split, invalid host `%s'", $host);
            }
            $split = array(
                'subdomain' => '',
                'domain'    => $host
            );
            $domain_lookup = $this->list_domains();
            if (!$host || isset($domain_lookup[$host])) {
                return $split;
            }

            $offset = 0;
            $level_sep = strpos($host, '.');
            do {
                $subdomain = substr($host, $offset, $level_sep - $offset);
                $domain = substr($host, $level_sep + 1);
                if (isset($domain_lookup[$domain])) {
                    break;
                }

                $offset = $level_sep + 1;
                $level_sep = strpos($host, '.', $offset + 1);
            } while ($level_sep !== false);
            if (!isset($domain_lookup[$domain])) {
                return $split;
            }
            $split['subdomain'] = (string)substr($host, 0, $offset) . $subdomain;
            $split['domain'] = $domain;

            return $split;
        }

        /**
         * Get the normalized hostname from a global subdomain
         *
         * @param string $host
         * @return string
         */
        public function normalize_hostname(string $host): string
        {
            if (false !== strpos($host, '.')) {
                return $host;
            }

            // @todo track domain/entry_domain better in contexted roles
            return $host . '.' .
                ($this->inContext() ? $this->domain : \Session::get('entry_domain', $this->domain));
        }

        // {{{ remove_user_subdomain()

        /**
         * Get information on a domain
         *
         * Info elements
         *    path (string): filesystem path
         *  active (bool): domain is active and readable
         *  user (string): owner of directory
         *
         * @param  string $domain
         * @return array domain information
         */
        public function domain_info($domain)
        {
            if (!$this->domain_exists($domain)) {
                return error($domain . ': domain does not exist');
            }

            $info = array(
                'path'   => null,
                'active' => false,
                'user'   => null
            );

            if ($domain === $this->getConfig('siteinfo', 'domain')) {
                $path = self::MAIN_DOC_ROOT;
            } else {
                $domains = $this->aliases_list_shared_domains();
                $path = $domains[$domain];
            }
            $info['path'] = $path;
            $info['active'] = is_readable($this->domain_fs_path() . $path);

            $stat = $this->file_stat($path);
            if (!$stat || $stat instanceof Exception) {
                return $stat;
            }
            $info['user'] = $stat['owner'];

            return $info;
        }

        /**
         * Test if domain is attached to account
         *
         * aliases:list-aliases is used to check site configuration for fixed aliases
         * aliases:list-shared-domains checks presence in info/domain_map
         *
         * @see aliases_domain_exists() to check against domain_map
         *
         * @param  string $domain
         * @return bool
         */
        public function domain_exists($domain)
        {
            return $domain == $this->getConfig('siteinfo', 'domain') ||
                in_array($domain, $this->aliases_list_aliases(), true);

        }

        // }}}

        /**
         * Get hostname from location
         *
         * @param string $docroot document root as seen by server (does not resolve symlinks!)
         * @return string|null
         */
        public function get_hostname_from_docroot(string $docroot): ?string
        {
            $docroot = rtrim($docroot, '/');
            if ($docroot === static::MAIN_DOC_ROOT) {
                return $this->getServiceValue('siteinfo', 'domain');
            }
            $aliases = $this->aliases_list_shared_domains();
            if (false !== ($domain = array_search($docroot, $aliases, true))) {
                return $domain;
            }

            if ($subdomain = $this->list_subdomains('path', $docroot)) {
                return key($subdomain);
            }

            return null;
        }

        /**
         * Given a docroot, find all hostnames that serve from here
         *
         * @xxx expensive lookup
         *
         * @param string $docroot
         * @return array
         */
        public function get_all_hostnames_from_path(string $docroot): array
        {
            $hosts = [];
            if ($docroot === static::MAIN_DOC_ROOT) {
                $hosts[] = $this->getServiceValue('siteinfo', 'domain');
            }
            foreach ($this->aliases_list_shared_domains() as $domain => $path) {
                if ($docroot === $path) {
                    $hosts[] = $domain;
                }
            }

            return array_merge($hosts, array_keys($this->list_subdomains('path', '!' . preg_quote($docroot, '!') . '$!')));
        }

        /**
         * Decompose a path into its hostname/path components
         *
         * @param string $docroot
         * @return null|array
         */
        public function extract_components_from_path(string $docroot): ?array
        {
            $path = [];
            do {
                if (null !== ($hostname = $this->get_hostname_from_docroot($docroot))) {
                    return [
                        'hostname' => $hostname,
                        'path' => implode('/', $path)
                    ];
                }
                array_unshift($path, \basename($docroot));
                $docroot = \dirname($docroot);
            } while ($docroot !== '/');

            return null;
        }

        /**
         * Assign a path as a DAV-aware location
         *
         * @param string $location filesystem location
         * @param string $provider DAV provider
         * @return \Exception|boolean
         */
        public function bind_dav($location, $provider)
        {
            if (!IS_CLI) {
                return $this->query('web_bind_dav', $location, $provider);
            }

            if (!$this->verco_svn_enabled() && (strtolower($provider) == 'svn')) {
                return error('Cannot use Subversion provider when not enabled');
            } else if (!\in_array($provider, ['on', 'dav', 'svn'])) {
                return error("Unknown dav provider `%s'", $provider);
            }
            if ($provider === 'dav') {
                $provider = 'on';
            }
            if ($location[0] != '/') {
                return error("DAV location `%s' is not absolute", $location);
            }
            if (!file_exists($this->domain_fs_path() . $location)) {
                return error('DAV location `%s\' does not exist', $location);
            }

            $stat = $this->file_stat($location);
            if ($stat instanceof Exception) {
                return $stat;
            }

            if ($stat['file_type'] != 'dir') {
                return error("bind_dav: `$location' is not directory");
            } else if (!$stat['can_write']) {
                return error("`%s': cannot write to directory", $location);
            }

            $this->query('file_fix_apache_perms_backend', $location);
            $file = $this->site_config_dir() . '/dav';

            $locations = $this->parse_dav($file);
            if (null !== ($chk = $locations[$location] ?? null) && $chk === $provider) {
                return warn("DAV already enabled for `%s'", $location);
            }
            $locations[$location] = $provider;

            return $this->write_dav($file, $locations);
        }

        /**
         * Parse DAV configuration
         *
         * @param string $path
         * @return array
         */
        private function parse_dav(string $path): array
        {
            $locations = [];
            if (!file_exists($path)) {
                return [];
            }
            $dav_config = trim(file_get_contents($path));

            if (preg_match_all(\Regex::DAV_CONFIG, $dav_config, $matches, PREG_SET_ORDER)) {
                foreach ($matches as $match) {
                    $cfgpath = $this->file_unmake_path($match['path']);
                    $locations[$cfgpath] = $match['provider'];
                }
            }
            return $locations;
        }

        /**
         * Convert DAV to text representation
         *
         * @param string $path
         * @param array  $cfg
         * @return bool
         */
        private function write_dav(string $path, array $cfg): bool
        {
            if (!$cfg) {
                if (file_exists($path)) {
                    unlink($path);
                }
                return true;
            }
            $template = (new \Opcenter\Provisioning\ConfigurationWriter('apache.dav-provider',
                \Opcenter\SiteConfiguration::shallow($this->getAuthContext())))
                ->compile([
                    'prefix'    => $this->domain_fs_path(),
                    'locations' => $cfg
                ]);
            return file_put_contents($path, $template) !== false;
        }

        public function site_config_dir()
        {
            return Apache::siteStoragePath($this->site);
        }

        /**
         * Permit a disallowed protocol access to hostname
         *
         * @param string $hostname
         * @param string $proto only http10 is valid
         * @return bool
         */
        public function allow_protocol(string $hostname, string $proto = 'http10'): bool
        {
            if (!IS_CLI) {
                return $this->query('web_allow_protocol', $hostname, $proto);
            }
            if ($proto !== 'http10') {
                return error("protocol `%s' not known, only http10 accepted", $proto);
            }
            if (!$this->protocol_disallowed($hostname, $proto)) {
                return true;
            }
            if (!$this->split_host($hostname)) {
                // unowned domain/subdomain
                return error("Invalid hostname `%s'", $hostname);
            }
            $map = Map::open(self::PROTOCOL_MAP, Map::MODE_WRITE);
            $map[$hostname] = $this->site_id;

            return $map->sync();
        }

        /**
         * Specified protocol is disallowed
         *
         * @param string $hostname
         * @param string $proto
         * @return bool
         */
        public function protocol_disallowed(string $hostname, string $proto = 'http10'): bool
        {
            if ($proto !== 'http10') {
                return error("protocol `%s' not known, only http10 accepted", $proto);
            }
            $map = Map::open(self::PROTOCOL_MAP);

            return !isset($map[$hostname]);
        }

        /**
         * Disallow protocol
         *
         * @param string $hostname
         * @param string $proto
         * @return bool
         */
        public function disallow_protocol(string $hostname, string $proto = 'http10'): bool
        {
            if (!IS_CLI) {
                return $this->query('web_disallow_protocol', $hostname, $proto);
            }
            if ($proto !== 'http10') {
                return error("protocol `%s' not known, only http10 accepted", $proto);
            }
            if ($this->protocol_disallowed($hostname, $proto)) {
                return true;
            }
            $map = Map::open(self::PROTOCOL_MAP, Map::MODE_WRITE);
            if ((int)($map[$hostname] ?? -1) !== $this->site_id) {
                return warn("Site `%s' not found in map", $hostname);
            }

            unset($map[$hostname]);

            return $map->sync();

        }

        public function unbind_dav($location)
        {
            if (!IS_CLI) {
                return $this->query('web_unbind_dav', $location);
            }
            $file = $this->site_config_dir() . '/dav';
            $locations = $this->parse_dav($file);
            if (!isset($locations[$location])) {
                return warn("DAV not enabled for `%s'", $location);
            }
            unset($locations[$location]);

            return $this->write_dav($file, $locations);

        }

        public function list_dav_locations()
        {
            $file = $this->site_config_dir() . '/dav';
            $locations = [];
            foreach ($this->parse_dav($file) as $path => $type) {
                $locations[] = [
                    'path' => $path,
                    'provider' => $type === 'on' ? 'dav' : $type
                ];
            }
            return $locations;
        }

        public function _edit()
        {
            $conf_new = $this->getAuthContext()->getAccount()->new;
            $conf_old = $this->getAuthContext()->getAccount()->old;
            // change to web config or ipconfig
            $ssl = \Opcenter\SiteConfiguration::getModuleRemap('openssl');
            if ($conf_new['apache'] != $conf_old['apache'] ||
                $conf_new['ipinfo'] != $conf_old['ipinfo'] ||
                $conf_new[$ssl] != $conf_old[$ssl] ||
                $conf_new['aliases'] != $conf_old['aliases']
            ) {
                Apache::activate();
            }

        }

        public function _edit_user(string $userold, string $usernew, array $oldpwd)
        {
            if ($userold === $usernew) {
                return;
            }
            /**
             * @TODO
             * Assert that all users are stored under /home/username
             * edit_user hook is called after user is changed, so
             * this is lost without passing user pwd along
             */
            $userhome = $this->user_get_user_home($usernew);
            $re = '!^' . $oldpwd['home'] . '!';
            mute_warn();
            $subdomains = $this->list_subdomains('path', $re);
            unmute_warn();
            foreach ($subdomains as $subdomain => $path) {
                $newpath = preg_replace('!' . DIRECTORY_SEPARATOR . $userold . '!',
                    DIRECTORY_SEPARATOR . $usernew, $path, 1);
                if ($subdomain === $userold) {
                    $newsubdomain = $usernew;
                } else {
                    $newsubdomain = $subdomain;
                }
                if ($this->rename_subdomain($subdomain, $newsubdomain, $newpath)) {
                    info("moved subdomain `%s' from `%s' to `%s'", $subdomain, $path, $newpath);
                }
            }

            return true;
        }

        /**
         * Rename a subdomain and/or change its path
         *
         * @param string $subdomain    source subdomain
         * @param string $newsubdomain new subdomain
         * @param string $newpath
         * @return bool
         */
        public function rename_subdomain(string $subdomain, string $newsubdomain = null, string $newpath = null): bool
        {
            if (!$this->subdomain_exists($subdomain)) {
                return error($subdomain . ': subdomain does not exist');
            }
            if ($newsubdomain && $subdomain !== $newsubdomain && $this->subdomain_exists($newsubdomain)) {
                return error("destination subdomain `%s' already exists", $newsubdomain);
            }
            if (!$newsubdomain && !$newpath) {
                return error('no rename operation specified');
            }
            if ($newpath && ($newpath[0] != '/' && $newpath[0] != '.')) {
                return error("invalid path `%s', subdomain path must " .
                    'be relative or absolute', $newpath);
            }

            if (!$newsubdomain) {
                $newsubdomain = $subdomain;
            } else {
                $newsubdomain = strtolower($newsubdomain);
            }

            unset($this->hostCache[$subdomain], $this->hostCache[$newsubdomain]);
            $sdpath = Opcenter\Http\Apache::makeSubdomainPath($subdomain);
            $stat = $this->file_stat($sdpath);
            $old_path = $stat['link'] ? $stat['referent'] : $sdpath;

            if (!$newpath) {
                $newpath = $old_path;
            }
            if (!$newsubdomain) {
                $newsubdomain = $subdomain;
            }

            if ($subdomain !== $newsubdomain) {
                if (!$this->remove_subdomain($subdomain) || !$this->add_subdomain($newsubdomain, $newpath)) {
                    error("changing subdomain `%s' to `%s' failed", $subdomain, $newsubdomain);
                    if (!$this->add_subdomain($subdomain, $old_path)) {
                        error("critical: could not reassign subdomain `%s' to `%s' after failed rename", $subdomain,
                            $old_path);
                    }

                    return false;
                }
            } else if (!$this->remove_subdomain($subdomain) || !$this->add_subdomain($subdomain, $newpath)) {
                error("failed to change path for `%s' from `%s' to `%s'",
                    $subdomain,
                    $old_path,
                    $newpath);
                if (!$this->add_subdomain($subdomain, $old_path)) {
                    error("failed to restore subdomain `%s' to old path `%s'",
                        $subdomain,
                        $old_path);
                }

                return false;
            }

            if ($subdomain !== $newsubdomain) {
                MetaManager::instantiateContexted($this->getAuthContext())
                    ->merge($newpath, ['host' => $newsubdomain])->sync();
            }
            return true;
        }

        /**
         * Remove a subdomain
         *
         * @param string $subdomain fully or non-qualified subdomain
         * @param bool   $keepdns   preserve DNS records for subdomain
         * @return bool
         */
        public function remove_subdomain(string $subdomain, bool $keepdns = false): bool
        {
            // clear both ends
            $this->purge();
            if (!IS_CLI) {
                // remove Web App first
                $docroot = $this->get_docroot($subdomain);
                if (false && $docroot) {
                    // @TODO rename_subdomain calls remove + add, which would break renaming meta
                    // this is how it should be done, but can't implement *yet*
                    $mm = MetaManager::factory($this->getAuthContext());
                    $app = \Module\Support\Webapps\App\Loader::fromDocroot(
                        array_get($mm->get($docroot), 'type', 'unknown'),
                        $docroot,
                        $this->getAuthContext()
                    );
                    $app->uninstall();
                }

                if (!$this->query('web_remove_subdomain', $subdomain)) {
                    return false;
                }

                if (false && $docroot) {
                    $mm->forget($docroot)->sync();
                }
                return true;
            }

            $subdomain = strtolower((string)$subdomain);

            if (!preg_match(Regex::SUBDOMAIN, $subdomain) &&
                (0 !== strncmp($subdomain, '*.', 2) ||
                !preg_match(Regex::DOMAIN, substr($subdomain, 2))))
            {
                return error('%s: invalid subdomain', $subdomain);
            }
            if ($subdomain[0] === '*') {
                $subdomain = substr($subdomain, 2);
            }
            if (!$this->subdomain_exists($subdomain)) {
                return warn('%s: subdomain does not exist', $subdomain);
            }

            $this->map_subdomain('delete', $subdomain);
            $path = $this->domain_fs_path() . self::SUBDOMAIN_ROOT . "/$subdomain";
            if (is_link($path)) {
                return unlink($path) && warn("subdomain `%s' path `%s' corrupted, removing reference",
                        $subdomain,
                        $this->file_unmake_path($path)
                    );
            }
            $dh = opendir($path);
            while (false !== ($entry = readdir($dh))) {
                if ($entry === '..' || $entry === '.') {
                    continue;
                }
                if (!is_link($path . '/' . $entry) && is_dir($path . '/' . $entry)) {
                    warn("directory found in subdomain `%s'", $entry);
                    continue;
                } else {
                    unlink($path . '/' . $entry);
                }
            }
            closedir($dh);
            rmdir($path);
            if (!$this->dns_configured() || $keepdns) {
                return true;
            }
            $hostcomponents = [
                'subdomain' => $subdomain,
                'domain' => ''
            ];
            if (false !== strpos($subdomain, '.')) {
                $hostcomponents = $this->split_host($subdomain);
            }
            if (!$hostcomponents['subdomain']) {
                return true;
            }
            if (!$hostcomponents['domain']) {
                $hostcomponents['domain'] = array_keys($this->list_domains());
            }
            $ret = true;

            $ips = [];
            if ($tmp = $this->dns_get_public_ip()) {
                $ips = (array)$tmp;
            }
            if ($tmp = $this->dns_get_public_ip6()) {
                $ips = array_merge($ips, (array)$tmp);
            }

            foreach ((array)$hostcomponents['domain'] as $domain) {
                foreach (['', 'www'] as $component) {
                    $subdomain = ltrim("${component}." . $hostcomponents['subdomain'], '.');
                    foreach ($ips as $ip) {
                        $rr = false === strpos($ip, ':') ? 'A' : 'AAAA';
                        if ($this->dns_record_exists($domain, $subdomain, $rr, $ip)) {
                            $ret &= $this->dns_remove_record($domain, $subdomain, $rr, $ip);
                        }
                    }
                }
            }

            return (bool)$ret;
        }

        /**
         * Clear path cache
         *
         * @return void
         */
        public function purge(): void
        {
            $this->pathCache = [];
            $this->hostCache = [];
            if (!IS_CLI) {
                $this->query('web_purge');
            }
        }

        /**
         * Manage subdomain symlink mapping
         *
         * @todo   merge from Web_Module::map_domain()
         * @param  string $mode      add/delete
         * @param  string $subdomain subdomain to add/remove
         * @param  string $path      domain path
         * @param  string $user      user to assign mapping
         * @return bool
         */
        public function map_subdomain(string $mode, string $subdomain, string $path = null, string $user = null): bool
        {
            if (!IS_CLI) {
                return $this->query('web_map_subdomain',
                    $mode,
                    $subdomain,
                    $path,
                    $user);
            }

            $mode = substr($mode, 0, 3);
            if (!preg_match(Regex::SUBDOMAIN, $subdomain)) {
                return error($subdomain . ': invalid subdomain');
            }
            if ($mode != 'add' && $mode != 'del') {
                return error($mode . ': invalid mapping operation');
            }
            if ($mode == 'del') {
                $docroot = $this->get_docroot($subdomain);
                if ($docroot) {
                    MetaManager::factory($this->getAuthContext())->forget($docroot)->sync();
                }

                return $this->file_delete('/home/*/all_subdomains/' . $subdomain);
            }
            if ($mode == 'add') {
                if (!$user) {
                    $stat = $this->file_stat($path);
                    if ($stat instanceof Exception) {
                        return error("Cannot map subdomain - failed to determine user from `%s'", $path);
                    }
                    $user = $this->user_get_username_from_uid($stat['uid']);
                }
                $user_home = '/home/' . $user;
                $user_home_abs = $this->domain_fs_path() . $user_home;

                if (!file_exists($this->domain_fs_path() . $path)) {
                    warn($path . ': path does not exist, creating link');
                }
                if (!file_exists($user_home_abs . '/all_subdomains')) {
                    $this->file_create_directory($user_home . '/all_subdomains');
                    $this->file_chown($user_home . '/all_subdomains', $user);
                }
                $this->file_symlink($path, $user_home . '/all_subdomains/' . $subdomain);
            }

            return true;
        }

        /**
         * Add subdomain to account
         *
         * There are 3 types of subdomains:
         * Local- subdomain includes subdomain + domain - foo.bar.com
         * Fallthrough- subdomain is named after domain - bar.com
         * Global- subdomain excludes domain - foo
         *
         * @param  string $subdomain
         * @param  string $docroot document root of the subdomain
         * @return bool
         */
        public function add_subdomain($subdomain, $docroot)
        {
            if (!IS_CLI) {
                return $this->query('web_add_subdomain', $subdomain, $docroot);
            }
            $subdomain = strtolower(trim((string)$subdomain));
            if ($subdomain === 'www') {
                return error('illegal subdomain name');
            }
            $subdomain = preg_replace('/^www\./', '', strtolower($subdomain));
            if (!$subdomain) {
                return error('Missing subdomain');
            }

            if (!preg_match(Regex::SUBDOMAIN, $subdomain) &&
                0 !== strncmp($subdomain, '*.', 2) &&
                !preg_match(Regex::DOMAIN, $subdomain)
            ) {
                return error($subdomain . ': invalid subdomain');
            }
            if ($this->subdomain_exists($subdomain)) {
                return error($subdomain . ': subdomain exists');
            } else if ($subdomain === gethostname()) {
                warn("Subdomain duplicates system hostname `%s'. Supplied document root will " .
                    'never have precedence over system document root.', $subdomain);
            }
            if ($docroot[0] != '/' && $docroot[0] != '.') {
                return error("invalid path `%s', subdomain path must " .
                    'be relative or absolute', $docroot);
            }
            /**
             * This is particularly nasty because add_subdomain can provide
             * either the subdomain or the subdomain + domain as the $subdomain
             * argument.  We need to (1) loop through each domain to determine if
             * a FQDN or subdomain, (2) query each DNS record to ensure
             * it is provisioned correctly, (3) add missing records.
             *
             * A FQDN for a hostname on the other hand is  a bit easier; just
             * add the record.  First we check to see if it's FQDN or not.  If
             * FQDN, check DNS and add.
             */
            $domains = array_keys($this->list_domains());
            if ($subdomain[0] === '*') {
                $subdomain = substr($subdomain, 2);
                $domain = '';
                if (!in_array($subdomain, $domains, true)) {
                    return error("domain `%s' not attached to account (DNS > Addon Domains)", $domain);
                }
            }
            if ( ($limit = $this->getConfig('apache', 'subnum', null) ) && ($limit < count($this->list_subdomains())) ) {
                return error('Subdomain limit %d has been reached - cannot add %s', $limit, $subdomain);
            }
            // is it a fully-qualified domain name? i.e. www.apisnetworks.com or
            // a subdomain? e.g. "www"
            $FQDN = false;

            // hostnames to query and setup DNS records for
            $recs_to_add = array();
            foreach ($domains as $domain) {
                if (preg_match('/\.' . $domain . '$/', $subdomain)) {
                    // local subdomain
                    $FQDN = true;
                    $recs_to_add = array(
                        array(
                            'subdomain' => substr($subdomain, 0, -strlen($domain) - 1),
                            'domain'    => $domain
                        )
                    );
                    break;
                } else if ($subdomain === $domain) {
                    // subdomain is fallthrough
                    $recs_to_add[] = array(
                        'subdomain' => '*',
                        'domain'    => $domain
                    );

                }
            }
            if (!$recs_to_add) {
                // domain is global subdomain
                foreach ($domains as $domain) {
                    $recs_to_add[] = array(
                        'subdomain' => $subdomain,
                        'domain'    => $domain
                    );
                }
            }

            $ips = [];
            if ($tmp = $this->dns_get_public_ip()) {
                $ips = (array)$tmp;
            }
            if ($tmp = $this->dns_get_public_ip6()) {
                $ips = array_merge($ips, (array)$tmp);
            }

            foreach ($recs_to_add as $record) {
                foreach ($ips as $ip) {
                    $rr = false === strpos($ip, ':') ? 'A' : 'AAAA';
                    $this->dns_add_record_conditionally($record['domain'], $record['subdomain'], $rr, $ip);
                    if ($record['subdomain'] !== '*') {
                        $this->dns_add_record_conditionally(
                            $record['domain'],
                            'www.' . $record['subdomain'],
                            $rr,
                            $ip
                        );
                    }
                }
            }

            /**
             * Home directories without subdomains explicitly enabled
             * are created with 700.  This bears the side-effect of Apache
             * being unable to descend past /home/<user>/.  Fix by giving
             * the execute bit
             */
            if (preg_match('!^/home/([^/]+)!', $docroot, $user_home)) {
                $user = $user_home[1];
                $stat = $this->file_stat('/home/' . $user);
                if ($stat instanceof Exception || !$stat) {
                    return error("user `%s' does not exist", $user);
                }
                // give Apache access
                if (!$this->file_chmod("/home/${user}", decoct($stat['permissions']) | 001)) {
                    return false;
                }

                if ($this->php_jailed()) {
                    // and FPM, DACs will match group rather than world
                    $this->file_set_acls("/home/${user}", $this->get_user($subdomain,''), 'x');
                }

            } else {
                $user = $this->getServiceValue('siteinfo', 'admin_user');
            }

            $prefix = $this->domain_fs_path();
            if (!file_exists($prefix . $docroot)) {
                if (\Util_PHP::is_link($prefix . $docroot)) {
                    // fix cases where a client links the doc root to an absolute symlink outside the scope
                    // of apache, e.g. /var/www/html -> /foo, apache would see <fst>/foo, not /foo
                    $newlink = $this->file_convert_absolute_relative($docroot, readlink($prefix . $docroot));
                    warn('converted unfollowable absolute symlink to relative (document root): %s -> %s', $docroot,
                        $newlink);
                    unlink($prefix . $docroot);
                    $ret = $this->file_symlink($newlink, $docroot);
                } else {
                    $ret = $this->file_create_directory($docroot, 0755, true);
                }

                if (!$ret) {
                    return $ret;
                }
                $this->file_chown($docroot, $user);
                $index = $prefix . $docroot . '/index.html';
                file_put_contents($index, (string)(new ConfigurationWriter('apache.placeholder', \Opcenter\SiteConfiguration::shallow($this->getAuthContext())))->compile([])) &&
                    Filesystem::chogp($index, (int)$this->user_get_uid_from_username($user), $this->group_id, 0644);
            }
            $subdomainpath = Opcenter\Http\Apache::makeSubdomainPath($subdomain);

            return $this->add_subdomain_raw($subdomain,
                    $this->file_convert_absolute_relative($subdomainpath, $docroot)) &&
                $this->map_subdomain('add', $subdomain, $docroot, $user);
        }

        public function add_subdomain_raw($subdomain, $docroot)
        {

            $prefix = $this->domain_fs_path();
            $subdomain_path = Opcenter\Http\Apache::makeSubdomainPath($subdomain);
            $subdomain_parent = dirname($prefix . $subdomain_path);
            if (!file_exists($subdomain_parent)) {
                \Opcenter\Filesystem::mkdir($subdomain_parent, $this->user_id, $this->group_id);
            }
            $tmp = $docroot;
            if ($docroot[0] === '.' && $docroot[1] == '.') {
                $tmp = $subdomain_parent . DIRECTORY_SEPARATOR . $docroot;
            }
            clearstatcache(true, $tmp);
            $user = fileowner($tmp);
            if (!file_exists($tmp)) {
                Error_Reporter::print_debug_bt();
            }

            return symlink($docroot, $prefix . $subdomain_path) &&
                Util_PHP::lchown($prefix . $subdomain_path, $user) &&
                Util_PHP::lchgrp($prefix . $subdomain_path, $this->group_id);
        }

        /**
         * Get Apache service status
         *
         * @return array
         */
        public function status(): array
        {
            return Apache::getReportedServiceStatus();
        }

        /**
         * Account created
         */
        public function _create()
        {
            Apache::activate();
        }

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

        public function _reload($why = null)
        {
            if (in_array($why, [null, 'php', 'aliases', Ssl_Module::SYS_RHOOK, Ssl_Module::USER_RHOOK], true)) {
                return Apache::activate();
            }
        }

        public function _housekeeping() {
            // kill chromedriver if persisting between startups
            $class = new ReflectionClass(\Service\CaptureDevices\Chromedriver::class);
            $instance = $class->newInstanceWithoutConstructor();
            if ($instance->running()) {
                $instance->stop(true);
            }
        }

        public function _cron(Cronus $c) {
            if (SCREENSHOTS_ENABLED) {
                $c->schedule(60 * 60, 'screenshots', static function () {
                    // need Laravel 6+ for closure serialization support to Horizon
                    $n = (int)sprintf('%u', SCREENSHOTS_BATCH);
                    $job = (new \Service\BulkCapture(new \Service\CaptureDevices\Chromedriver));
                    $job->batch($n);

                });
            }


            if (TELEMETRY_ENABLED) {
                $collector = new Collector(PostgreSQL::pdo());

                if ( !($status = $this->status()) ) {
                    // service down, zero fill
                    // gap filling via TSDB would lend itself to false positives
                    // if the cron interval ever changes
                    $status = array_fill_keys(array_values(ApacheMetrics::ATTRVAL_MAP), 0);
                }

                foreach (ApacheMetrics::ATTRVAL_MAP as $attr => $metric) {
                    $collector->add("apache-${attr}", null, $status[$metric]);
                }
            }
        }

        public function _delete()
        {

        }

        public function http_config_dir()
        {
            deprecated_func('use site_config_dir');

            return $this->site_config_dir();
        }

        public function config_dir()
        {
            return Apache::CONFIG_PATH;
        }

        public function _delete_user(string $user)
        {
            $this->remove_user_subdomain($user);
        }

        /**
         * Removes all subdomains associated with a named user
         *
         * @param string $user
         * @return bool
         */
        public function remove_user_subdomain(string $user): bool
        {
            $ret = true;
            foreach ($this->list_subdomains() as $subdomain => $dir) {
                if (!preg_match('!^/home/' . preg_quote($user, '!') . '(/|$)!', (string)$dir)) {
                    continue;
                }
                $ret &= $this->remove_subdomain($subdomain);
            }

            return (bool)$ret;
        }

        /**
         * Bulk screenshot of all sites
         *
         * @param array|null $sites
         * @return void
         */
        public function inventory_capture(array $sites = null): void {
            if (!$sites) {
                $sites = \Opcenter\Account\Enumerate::sites();
            }
            $driver = new \Service\BulkCapture(new \Service\CaptureDevices\Chromedriver);
            foreach ($sites as $site) {
                $oldex = \Error_Reporter::exception_upgrade(\Error_Reporter::E_FATAL|\Error_Reporter::E_ERROR);
                try {
                    $ctx = \Auth::context(null, $site);
                    $afi = \apnscpFunctionInterceptor::factory($ctx);
                } catch (\apnscpException $e) {
                    continue;
                } finally {
                    \Error_Reporter::exception_upgrade($oldex);
                }

                if (!posix_getuid()) {
                    $serviceRef = new \Opcenter\Http\Php\Fpm\StateRestore($ctx->site);
                }
                foreach (\Module\Support\Webapps::getAllHostnames($ctx) as $host) {
                    debug('%s: Capturing %s (IP: %s)', $ctx->site, $host, $afi->site_ip_address());
                    $driver->snap($host, '', $afi->site_ip_address());
                }

            }
        }

        /**
         * Capture screenshot of site
         *
         * @XXX Restricted from backend.
         *
         * @param string                    $hostname
         * @param string                    $path
         * @param \Service\BulkCapture|null $service optional BulkCapture service
         * @return bool
         */
        public function capture(string $hostname, string $path = '', \Service\BulkCapture $service = null): bool
        {
            if (APNSCPD_HEADLESS) {
                return warn('Panel in headless mode');
            }

            $hostname = strtolower($hostname);
            if (!$this->normalize_path($hostname, $path)) {
                return error("Site `%s/%s' is not hosted on account", $hostname, $path);
            }
            if (!$service) {
                $service = new \Service\BulkCapture(new \Service\CaptureDevices\Chromedriver);
            }
            return $service->snap($hostname, $path, $this->site_ip_address());
        }

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