| 1: | <?php declare(strict_types=1); |
| 2: | |
| 3: | |
| 4: | |
| 5: | |
| 6: | |
| 7: | |
| 8: | |
| 9: | |
| 10: | |
| 11: | |
| 12: | |
| 13: | |
| 14: | |
| 15: | use Daphnie\Collector; |
| 16: | use Module\Skeleton\Contracts\Tasking; |
| 17: | use Module\Support\Webapps\App\Loader; |
| 18: | use Module\Support\Webapps\Finder; |
| 19: | use Module\Support\Webapps\Updater; |
| 20: | use Opcenter\Account\Enumerate; |
| 21: | use Opcenter\Admin\Bootstrapper; |
| 22: | use Opcenter\Admin\Bootstrapper\Config; |
| 23: | use Opcenter\Apnscp; |
| 24: | use Opcenter\Bandwidth\Bulk; |
| 25: | use Opcenter\CliParser; |
| 26: | use Opcenter\Filesystem\Quota; |
| 27: | use Opcenter\License; |
| 28: | use Opcenter\Map; |
| 29: | use Opcenter\Process; |
| 30: | use Opcenter\Service\Plans; |
| 31: | use Opcenter\SiteConfiguration; |
| 32: | use Opcenter\System\Cgroup\Controller; |
| 33: | use Opcenter\System\Cgroup\Group; |
| 34: | use Opcenter\System\Cgroup\MetricsLogging; |
| 35: | |
| 36: | |
| 37: | |
| 38: | |
| 39: | |
| 40: | |
| 41: | class Admin_Module extends Module_Skeleton implements Tasking |
| 42: | { |
| 43: | use ImpersonableTrait; |
| 44: | |
| 45: | const ADMIN_HOME = '/etc/opcenter/webhost'; |
| 46: | |
| 47: | const ADMIN_CONFIG = '.config'; |
| 48: | |
| 49: | const CGROUP_CACHE_KEY = 'acct.cgroup'; |
| 50: | |
| 51: | const CLI_OOB_FD = \Util_Account_Editor::OOB_FD; |
| 52: | |
| 53: | protected $exportedFunctions = [ |
| 54: | '*' => PRIVILEGE_ADMIN |
| 55: | ]; |
| 56: | |
| 57: | public function __construct() |
| 58: | { |
| 59: | parent::__construct(); |
| 60: | if (!AUTH_ADMIN_API && static::class == self::class) { |
| 61: | $this->exportedFunctions = array_merge($this->exportedFunctions, |
| 62: | array_fill_keys([ |
| 63: | 'activate_site', |
| 64: | 'deactivate_site', |
| 65: | 'suspend_site', |
| 66: | 'add_site', |
| 67: | 'edit_site', |
| 68: | 'delete_site', |
| 69: | 'hijack', |
| 70: | 'bless' |
| 71: | ], PRIVILEGE_NONE) |
| 72: | ); |
| 73: | } |
| 74: | } |
| 75: | |
| 76: | protected function scopeRestrictor(): string |
| 77: | { |
| 78: | return 'WHERE 1 = 1'; |
| 79: | } |
| 80: | |
| 81: | |
| 82: | |
| 83: | |
| 84: | |
| 85: | |
| 86: | |
| 87: | public function get_domains(): array |
| 88: | { |
| 89: | |
| 90: | $q = PostgreSQL::initialize()->query('SELECT domain,site_id FROM siteinfo ' . $this->scopeRestrictor() . ' ORDER BY domain'); |
| 91: | $domains = array(); |
| 92: | while (null !== ($row = $q->fetch_object())) { |
| 93: | $domains[$row->site_id] = $row->domain; |
| 94: | } |
| 95: | |
| 96: | return $domains; |
| 97: | } |
| 98: | |
| 99: | |
| 100: | |
| 101: | |
| 102: | |
| 103: | |
| 104: | |
| 105: | |
| 106: | public function get_site_id_from_admin(string $admin): ?int |
| 107: | { |
| 108: | if (!preg_match(Regex::USERNAME, $admin)) { |
| 109: | return null; |
| 110: | } |
| 111: | $db = PostgreSQL::initialize(); |
| 112: | $rs = $db->query('SELECT site_id FROM siteinfo ' . $this->scopeRestrictor() . ' AND admin_user = \'' . $db->escape_string($admin) . '\''); |
| 113: | return $rs->num_rows() > 0 ? (int)$rs->fetch_object()->site_id : null; |
| 114: | } |
| 115: | |
| 116: | |
| 117: | |
| 118: | |
| 119: | |
| 120: | |
| 121: | |
| 122: | |
| 123: | public function get_address_from_domain(string $domain) |
| 124: | { |
| 125: | if (!preg_match(Regex::DOMAIN, $domain)) { |
| 126: | return error("invalid domain `%s'", $domain); |
| 127: | } |
| 128: | $siteid = $this->get_site_id_from_domain($domain); |
| 129: | if (!$siteid) { |
| 130: | return false; |
| 131: | } |
| 132: | $pgdb = PostgreSQL::initialize(); |
| 133: | $q = $pgdb->query('SELECT email FROM siteinfo ' . $this->scopeRestrictor() . 'AND site_id = ' . (int)$siteid); |
| 134: | if ($pgdb->num_rows() > 0) { |
| 135: | return $q->fetch_object()->email; |
| 136: | } |
| 137: | |
| 138: | return false; |
| 139: | } |
| 140: | |
| 141: | |
| 142: | |
| 143: | |
| 144: | |
| 145: | |
| 146: | |
| 147: | |
| 148: | public function get_site_id_from_domain($domain): ?int |
| 149: | { |
| 150: | if (!preg_match(Regex::DOMAIN, $domain)) { |
| 151: | error("invalid domain `%s'", $domain); |
| 152: | |
| 153: | return null; |
| 154: | } |
| 155: | $pgdb = PostgreSQL::initialize(); |
| 156: | $q = $pgdb->query("SELECT site_id FROM siteinfo " . $this->scopeRestrictor() . " AND domain = '" . $domain . "'"); |
| 157: | if ($pgdb->num_rows() > 0) { |
| 158: | return (int)$q->fetch_object()->site_id; |
| 159: | } |
| 160: | |
| 161: | return Auth::get_site_id_from_domain($domain); |
| 162: | |
| 163: | } |
| 164: | |
| 165: | |
| 166: | |
| 167: | |
| 168: | |
| 169: | |
| 170: | |
| 171: | |
| 172: | public function get_email(): ?string |
| 173: | { |
| 174: | if (!IS_CLI) { |
| 175: | return $this->query('admin_get_email'); |
| 176: | } |
| 177: | $ini = $this->_get_admin_config(); |
| 178: | |
| 179: | return $ini['adminemail'] ?? ($ini['email'] ?? null); |
| 180: | } |
| 181: | |
| 182: | protected function _get_admin_config(): array |
| 183: | { |
| 184: | $file = $this->getAdminConfigFile(); |
| 185: | if (!file_exists($file)) { |
| 186: | return []; |
| 187: | } |
| 188: | |
| 189: | return Util_PHP::unserialize(file_get_contents($file)); |
| 190: | } |
| 191: | |
| 192: | private function getAdminConfigFile(): string |
| 193: | { |
| 194: | return self::ADMIN_HOME . DIRECTORY_SEPARATOR . self::ADMIN_CONFIG . |
| 195: | DIRECTORY_SEPARATOR . $this->username; |
| 196: | } |
| 197: | |
| 198: | |
| 199: | |
| 200: | |
| 201: | |
| 202: | |
| 203: | |
| 204: | public function set_email(string $email): bool |
| 205: | { |
| 206: | if (DEMO_ADMIN_LOCK && posix_getuid()) { |
| 207: | return error("Email may not be changed in demo mode"); |
| 208: | } |
| 209: | |
| 210: | if (!IS_CLI) { |
| 211: | $handler = Preferences::factory($this->getAuthContext())->unlock($this->getApnscpFunctionInterceptor()); |
| 212: | $handler->sync(); |
| 213: | $ret = $this->query('admin_set_email', $email); |
| 214: | $handler->freshen(); |
| 215: | |
| 216: | return $ret; |
| 217: | } |
| 218: | |
| 219: | if (!preg_match(Regex::EMAIL, $email)) { |
| 220: | return error("invalid email `%s'", $email); |
| 221: | } |
| 222: | |
| 223: | $prefs = Preferences::factory($this->getAuthContext())->unlock($this->getApnscpFunctionInterceptor()); |
| 224: | $prefs['email'] = $email; |
| 225: | |
| 226: | if ($this->permission_level & PRIVILEGE_ADMIN) { |
| 227: | $cfg = new Config(); |
| 228: | |
| 229: | if ($cfg['apnscp_admin_email'] && $email !== $cfg['apnscp_admin_email']) { |
| 230: | |
| 231: | $cfg['apnscp_admin_email'] = $email; |
| 232: | $cfg->sync(); |
| 233: | Bootstrapper::run('apnscp/create-admin', 'software/etckeeper'); |
| 234: | } |
| 235: | } |
| 236: | |
| 237: | return $prefs->sync(); |
| 238: | } |
| 239: | |
| 240: | |
| 241: | |
| 242: | |
| 243: | |
| 244: | |
| 245: | public function list_plans(): array |
| 246: | { |
| 247: | return Plans::list(); |
| 248: | } |
| 249: | |
| 250: | |
| 251: | |
| 252: | |
| 253: | |
| 254: | |
| 255: | |
| 256: | public function get_plan(string $plan = null): ?array |
| 257: | { |
| 258: | if (null === $plan) { |
| 259: | $plan = Plans::default(); |
| 260: | } |
| 261: | if (!Plans::exists($plan)) { |
| 262: | return null; |
| 263: | } |
| 264: | |
| 265: | return (new SiteConfiguration(null))->setPlanName($plan) |
| 266: | ->getDefaultConfiguration(); |
| 267: | } |
| 268: | |
| 269: | |
| 270: | |
| 271: | |
| 272: | |
| 273: | |
| 274: | |
| 275: | |
| 276: | public function get_service_info(string $service = null, string $plan = null): ?array |
| 277: | { |
| 278: | $plan = $plan ?? Plans::default(); |
| 279: | if (null === ($data = Plans::get($plan))) { |
| 280: | error("Unknown plan `%s'", $plan); |
| 281: | return null; |
| 282: | } |
| 283: | |
| 284: | return $service ? ($data[$service] ?? null) : $data; |
| 285: | } |
| 286: | |
| 287: | |
| 288: | |
| 289: | |
| 290: | |
| 291: | |
| 292: | |
| 293: | public function update_webapps(array $options = []): bool |
| 294: | { |
| 295: | $launcher = Updater::launch(); |
| 296: | foreach ($options as $k => $v) { |
| 297: | switch ($k) { |
| 298: | case 'limit': |
| 299: | $launcher->batch((int)$v); |
| 300: | break; |
| 301: | case 'type': |
| 302: | $launcher->limitType($v); |
| 303: | break; |
| 304: | case 'assets': |
| 305: | $launcher->enableAssetUpdates((bool)$v); |
| 306: | break; |
| 307: | case 'core': |
| 308: | $launcher->enableCoreUpdates((bool)$v); |
| 309: | break; |
| 310: | case 'site': |
| 311: | $launcher->limitSite($v); |
| 312: | break; |
| 313: | default: |
| 314: | fatal("unknown option `%s'", $k); |
| 315: | } |
| 316: | } |
| 317: | |
| 318: | return (bool)$launcher->run(); |
| 319: | } |
| 320: | |
| 321: | |
| 322: | |
| 323: | |
| 324: | |
| 325: | |
| 326: | |
| 327: | public function list_failed_webapps(string $site = null): array |
| 328: | { |
| 329: | $failed = []; |
| 330: | if ($site) { |
| 331: | if (!($sites = (array)Auth::get_site_id_from_anything($site))) { |
| 332: | warn("Unknown site `%s'", $site); |
| 333: | |
| 334: | return []; |
| 335: | } |
| 336: | $sites = array_map(static function ($s) { |
| 337: | return 'site' . $s; |
| 338: | }, $sites); |
| 339: | } else { |
| 340: | $sites = Enumerate::active(); |
| 341: | } |
| 342: | $getLatest = function ($app) { |
| 343: | static $index; |
| 344: | if (!isset($index[$app])) { |
| 345: | $instance = Loader::fromDocroot($app, null, $this->getAuthContext()); |
| 346: | $versions = $instance->getVersions(); |
| 347: | $version = $versions ? array_pop($versions) : null; |
| 348: | $index[$app] = $version; |
| 349: | } |
| 350: | |
| 351: | return $index[$app]; |
| 352: | }; |
| 353: | |
| 354: | foreach ($sites as $s) { |
| 355: | if (!Auth::get_domain_from_site_id($s)) { |
| 356: | continue; |
| 357: | } |
| 358: | |
| 359: | $auth = Auth::nullableContext(null, $s); |
| 360: | if (null === $auth) { |
| 361: | continue; |
| 362: | } |
| 363: | |
| 364: | $finder = new Finder($auth); |
| 365: | $apps = $finder->getActiveApplications(static function ($appmeta) { |
| 366: | return !empty($appmeta['failed']); |
| 367: | }); |
| 368: | if (!$apps) { |
| 369: | continue; |
| 370: | } |
| 371: | $list = []; |
| 372: | foreach ($apps as $path => $app) { |
| 373: | $type = $app['type'] ?? null; |
| 374: | $latest = $type ? $getLatest($app['type']) : null; |
| 375: | $list[$path] = [ |
| 376: | 'type' => $type, |
| 377: | 'version' => $app['version'] ?? null, |
| 378: | 'hostname' => $app['hostname'] ?? null, |
| 379: | 'path' => $app['path'] ?? '', |
| 380: | 'latest' => $latest |
| 381: | ]; |
| 382: | } |
| 383: | $failed[$s] = $list; |
| 384: | } |
| 385: | |
| 386: | return $failed; |
| 387: | } |
| 388: | |
| 389: | |
| 390: | |
| 391: | |
| 392: | |
| 393: | |
| 394: | |
| 395: | public function reset_webapp_failure(array $constraints = []): int |
| 396: | { |
| 397: | $known = ['site', 'version', 'type']; |
| 398: | if ($bad = array_diff(array_keys($constraints), $known)) { |
| 399: | error("unknown constraints: `%s'", implode(', ', $bad)); |
| 400: | |
| 401: | return 0; |
| 402: | } |
| 403: | if (isset($constraints['site'])) { |
| 404: | $siteid = Auth::get_site_id_from_anything($constraints['site']); |
| 405: | if (!$siteid) { |
| 406: | error("unknown site `%s'", $constraints['site']); |
| 407: | |
| 408: | return 0; |
| 409: | } |
| 410: | $sites = ['site' . $siteid]; |
| 411: | } else { |
| 412: | $sites = Enumerate::active(); |
| 413: | } |
| 414: | $versionFilter = static function (array $appmeta) use ($constraints) { |
| 415: | if (!isset($constraints['version'])) { |
| 416: | return true; |
| 417: | } |
| 418: | if (!isset($appmeta['version'])) { |
| 419: | return false; |
| 420: | } |
| 421: | |
| 422: | $vercon = explode(' ', $constraints['version'], 2); |
| 423: | if (count($vercon) === 1) { |
| 424: | $vercon = ['=', $vercon[0]]; |
| 425: | } |
| 426: | |
| 427: | return version_compare($appmeta['version'], ...array_reverse($vercon)); |
| 428: | }; |
| 429: | $typeFilter = static function (array $appmeta) use ($constraints) { |
| 430: | if (!isset($constraints['type'])) { |
| 431: | return true; |
| 432: | } |
| 433: | |
| 434: | return $appmeta['type'] === $constraints['type']; |
| 435: | }; |
| 436: | $count = 0; |
| 437: | foreach ($sites as $site) { |
| 438: | $auth = Auth::context(null, $site); |
| 439: | $finder = new Finder($auth); |
| 440: | $apps = $finder->getActiveApplications(static function ($appmeta) { |
| 441: | return !empty($appmeta['failed']); |
| 442: | }); |
| 443: | foreach ($apps as $path => $app) { |
| 444: | if (!$typeFilter($app)) { |
| 445: | continue; |
| 446: | } |
| 447: | if (!$versionFilter($app)) { |
| 448: | continue; |
| 449: | } |
| 450: | $instance = Loader::fromDocroot(null, $path, $auth); |
| 451: | $instance->clearFailed(); |
| 452: | info("Reset failed status on `%s/%s'", $instance->getHostname(), $instance->getPath()); |
| 453: | $count++; |
| 454: | } |
| 455: | } |
| 456: | |
| 457: | return $count; |
| 458: | } |
| 459: | |
| 460: | |
| 461: | |
| 462: | |
| 463: | |
| 464: | |
| 465: | |
| 466: | public function locate_webapps($site = null): array |
| 467: | { |
| 468: | return Finder::find($site); |
| 469: | } |
| 470: | |
| 471: | |
| 472: | |
| 473: | |
| 474: | |
| 475: | |
| 476: | |
| 477: | public function prune_webapps($site = null): void |
| 478: | { |
| 479: | Finder::prune($site); |
| 480: | } |
| 481: | |
| 482: | |
| 483: | |
| 484: | |
| 485: | |
| 486: | |
| 487: | |
| 488: | |
| 489: | public function delete_site(?string $site, array $flags = []): bool |
| 490: | { |
| 491: | if (DEMO_ADMIN_LOCK && posix_getuid()) { |
| 492: | return error("Sites may not be modified in demo mode"); |
| 493: | } |
| 494: | |
| 495: | if (!IS_CLI) { |
| 496: | return $this->query('admin_delete_site', $site, $flags); |
| 497: | } |
| 498: | $args = CliParser::buildFlags($flags); |
| 499: | $proc = new Util_Process_Safe; |
| 500: | $proc->setDescriptor(self::CLI_OOB_FD, 'pipe', 'w', 'oob'); |
| 501: | $ret = $proc->run(INCLUDE_PATH . "/bin/DeleteDomain {$args} --fd=%d --output=json %s", self::CLI_OOB_FD, $site); |
| 502: | |
| 503: | if (!Error_Reporter::merge_json($ret['oob'])) { |
| 504: | return warn('Failed to read response - output received: %s', $ret['oob']); |
| 505: | } |
| 506: | |
| 507: | return $ret['success']; |
| 508: | } |
| 509: | |
| 510: | |
| 511: | |
| 512: | |
| 513: | |
| 514: | |
| 515: | |
| 516: | |
| 517: | |
| 518: | |
| 519: | public function add_site(string $domain, string $admin, array $opts = [], array $flags = []): bool |
| 520: | { |
| 521: | if (DEMO_ADMIN_LOCK && posix_getuid()) { |
| 522: | return error("Sites may not be modified in demo mode"); |
| 523: | } |
| 524: | |
| 525: | if (!IS_CLI) { |
| 526: | return $this->query('admin_add_site', $domain, $admin, $opts, $flags); |
| 527: | } |
| 528: | array_set($opts, 'siteinfo.admin_user', $admin); |
| 529: | array_set($opts, 'siteinfo.domain', $domain); |
| 530: | $plan = array_pull($opts, 'siteinfo.plan'); |
| 531: | $args = CliParser::commandifyConfiguration($opts); |
| 532: | |
| 533: | if ($plan) { |
| 534: | $args = '--plan=' . escapeshellarg($plan) . ' ' . $args; |
| 535: | } |
| 536: | $args = CliParser::buildFlags($flags) . ' ' . $args; |
| 537: | $cmd = INCLUDE_PATH . "/bin/AddDomain --fd=%d --output=json {$args}"; |
| 538: | info('%(bin)s command: %(command)s', ['bin' => 'AddDomain', 'command' => $cmd]); |
| 539: | $proc = new Util_Process_Safe; |
| 540: | $proc->setDescriptor(self::CLI_OOB_FD, 'pipe', 'w', 'oob'); |
| 541: | $ret = $proc->run($cmd, self::CLI_OOB_FD); |
| 542: | if (!Error_Reporter::merge_json($ret['oob'])) { |
| 543: | return error('Failed to read response - output received: %s', $ret['oob']); |
| 544: | } |
| 545: | |
| 546: | return $ret['success']; |
| 547: | } |
| 548: | |
| 549: | |
| 550: | |
| 551: | |
| 552: | |
| 553: | |
| 554: | |
| 555: | |
| 556: | |
| 557: | public function edit_site(string $site, array $opts = [], array $flags = []): bool |
| 558: | { |
| 559: | if (DEMO_ADMIN_LOCK && posix_getuid()) { |
| 560: | return error("Sites may not be modified in demo mode"); |
| 561: | } |
| 562: | |
| 563: | if (!IS_CLI) { |
| 564: | return $this->query('admin_edit_site', $site, $opts, $flags); |
| 565: | } |
| 566: | |
| 567: | $plan = array_pull($opts, 'siteinfo.plan'); |
| 568: | $args = CliParser::commandifyConfiguration($opts); |
| 569: | |
| 570: | if ($plan) { |
| 571: | $args = '--plan=' . escapeshellarg($plan) . ' ' . $args; |
| 572: | } |
| 573: | $args = CliParser::buildFlags($flags) . ' ' . $args; |
| 574: | $cmd = INCLUDE_PATH . "/bin/EditDomain --fd=%d --output=json {$args} %s"; |
| 575: | info('%(bin)s command: %(command)s', ['bin' => 'EditDomain', 'command' => $cmd]); |
| 576: | $proc = new Util_Process_Safe; |
| 577: | $proc->setDescriptor(self::CLI_OOB_FD, 'pipe', 'w', 'oob'); |
| 578: | $ret = $proc->run($cmd, self::CLI_OOB_FD, $site); |
| 579: | if (!Error_Reporter::merge_json($ret['oob'])) { |
| 580: | return error('Failed to read response - output received: %s', $ret['oob']); |
| 581: | } |
| 582: | |
| 583: | return $ret['success']; |
| 584: | } |
| 585: | |
| 586: | |
| 587: | |
| 588: | |
| 589: | |
| 590: | |
| 591: | |
| 592: | public function activate_site($site): bool |
| 593: | { |
| 594: | if (!IS_CLI) { |
| 595: | return $this->query('admin_activate_site', $site); |
| 596: | } |
| 597: | |
| 598: | $site = implode(' ', array_map('escapeshellarg', (array)$site)); |
| 599: | $proc = new Util_Process; |
| 600: | $proc->setDescriptor(self::CLI_OOB_FD, 'pipe', 'w', 'oob'); |
| 601: | $ret = $proc->run(INCLUDE_PATH . '/bin/ActivateDomain --fd=%d --output=json %s', self::CLI_OOB_FD, $site); |
| 602: | Error_Reporter::merge_json($ret['oob']); |
| 603: | |
| 604: | return $ret['success']; |
| 605: | } |
| 606: | |
| 607: | |
| 608: | |
| 609: | |
| 610: | |
| 611: | |
| 612: | |
| 613: | |
| 614: | public function deactivate_site($site, array $flags = []): bool |
| 615: | { |
| 616: | return $this->suspend_site($site, $flags); |
| 617: | } |
| 618: | |
| 619: | |
| 620: | |
| 621: | |
| 622: | |
| 623: | |
| 624: | |
| 625: | |
| 626: | public function suspend_site($site, array $flags = []): bool |
| 627: | { |
| 628: | if (DEMO_ADMIN_LOCK && posix_getuid()) { |
| 629: | return error("Sites may not be modified in demo mode"); |
| 630: | } |
| 631: | |
| 632: | if (!IS_CLI) { |
| 633: | return $this->query('admin_suspend_site', $site, $flags); |
| 634: | } |
| 635: | |
| 636: | $site = implode(' ', array_map('escapeshellarg', (array)$site)); |
| 637: | $args = CliParser::buildFlags($flags); |
| 638: | $proc = new Util_Process; |
| 639: | $proc->setDescriptor(self::CLI_OOB_FD, 'pipe', 'w', 'oob'); |
| 640: | $ret = $proc->run(INCLUDE_PATH . '/bin/SuspendDomain --fd=%d --output=json %s %s', self::CLI_OOB_FD, $args, $site); |
| 641: | Error_Reporter::merge_json($ret['oob']); |
| 642: | |
| 643: | return $ret['success']; |
| 644: | } |
| 645: | |
| 646: | |
| 647: | |
| 648: | |
| 649: | |
| 650: | |
| 651: | |
| 652: | |
| 653: | |
| 654: | |
| 655: | |
| 656: | public function hijack(string $site, string $user = null, string $gate = null): ?string |
| 657: | { |
| 658: | return $this->impersonateRole($site, $user, $gate); |
| 659: | } |
| 660: | |
| 661: | |
| 662: | |
| 663: | |
| 664: | |
| 665: | |
| 666: | public function get_storage(): array |
| 667: | { |
| 668: | $mounts = $this->stats_get_partition_information(); |
| 669: | foreach ($mounts as $mount) { |
| 670: | if ($mount['mount'] !== '/') { |
| 671: | continue; |
| 672: | } |
| 673: | |
| 674: | return [ |
| 675: | 'qused' => $mount['used'], |
| 676: | 'qhard' => $mount['size'], |
| 677: | 'qsoft' => $mount['size'], |
| 678: | 'fused' => 0, |
| 679: | 'fsoft' => PHP_INT_MAX, |
| 680: | 'fhard' => PHP_INT_MAX |
| 681: | ]; |
| 682: | |
| 683: | } |
| 684: | warn('Failed to locate root partition / - storage information incomplete'); |
| 685: | |
| 686: | return []; |
| 687: | } |
| 688: | |
| 689: | |
| 690: | |
| 691: | |
| 692: | |
| 693: | |
| 694: | |
| 695: | public function get_site_storage(array $sites = []): array |
| 696: | { |
| 697: | return $this->get_usage('storage', $sites); |
| 698: | |
| 699: | } |
| 700: | |
| 701: | |
| 702: | |
| 703: | |
| 704: | |
| 705: | |
| 706: | |
| 707: | |
| 708: | public function get_usage(string $field = 'storage', array $sites = []): array |
| 709: | { |
| 710: | if (!IS_CLI) { |
| 711: | return $this->query('admin_get_usage', $field, $sites); |
| 712: | } |
| 713: | if (!$sites) { |
| 714: | $sites = Enumerate::sites(); |
| 715: | } else { |
| 716: | $sites = array_filter(array_map(static function ($site) { |
| 717: | $id = Auth::get_site_id_from_anything($site); |
| 718: | if (null === $id) { |
| 719: | return null; |
| 720: | } |
| 721: | |
| 722: | return "site$id"; |
| 723: | }, $sites)); |
| 724: | } |
| 725: | |
| 726: | switch ($field) { |
| 727: | case 'storage': |
| 728: | return $this->getStorageUsage($sites); |
| 729: | case 'bandwidth': |
| 730: | return $this->getBandwidthUsage($sites); |
| 731: | case 'cgroup': |
| 732: | case 'cgroups': |
| 733: | return $this->getCgroupCacheWrapper($sites); |
| 734: | default: |
| 735: | fatal('Unknown resource spec %s', $field); |
| 736: | } |
| 737: | |
| 738: | return []; |
| 739: | } |
| 740: | |
| 741: | private function getStorageUsage(array $sites): array |
| 742: | { |
| 743: | $groups = array_filter(array_combine($sites, array_map(static function ($site) { |
| 744: | $group = Auth::get_group_from_site($site); |
| 745: | if (false === ($grp = posix_getgrnam($group))) { |
| 746: | return null; |
| 747: | } |
| 748: | |
| 749: | return $grp['gid']; |
| 750: | }, $sites))); |
| 751: | |
| 752: | $quotas = Quota::getGroup($groups); |
| 753: | |
| 754: | foreach ($groups as $site => $gid) { |
| 755: | $groups[$site] = $quotas[$gid]; |
| 756: | } |
| 757: | |
| 758: | return array_filter($groups); |
| 759: | } |
| 760: | |
| 761: | |
| 762: | |
| 763: | |
| 764: | |
| 765: | |
| 766: | |
| 767: | private function getBandwidthUsage(array $sites): array |
| 768: | { |
| 769: | $overage = Bulk::getCurrentUsage(); |
| 770: | $sites = array_flip($sites); |
| 771: | $built = []; |
| 772: | foreach ($overage as $o) { |
| 773: | $site = 'site' . $o['site_id']; |
| 774: | if (!isset($sites[$site])) { |
| 775: | continue; |
| 776: | } |
| 777: | $built[$site] = $o; |
| 778: | } |
| 779: | |
| 780: | return $built; |
| 781: | } |
| 782: | |
| 783: | |
| 784: | |
| 785: | |
| 786: | |
| 787: | |
| 788: | |
| 789: | private function getCgroupCacheWrapper(array $sites): array |
| 790: | { |
| 791: | $cache = Cache_Global::spawn(); |
| 792: | $existing = []; |
| 793: | if (false !== ($tmp = $cache->get(self::CGROUP_CACHE_KEY))) { |
| 794: | $existing = (array)$tmp; |
| 795: | } |
| 796: | |
| 797: | $search = array_flip($sites); |
| 798: | if (!($missing = array_diff_key($search, $existing))) { |
| 799: | return array_intersect_key($existing, $search); |
| 800: | } |
| 801: | |
| 802: | $tokens = []; |
| 803: | $controllers = $this->cgroup_get_controllers(); |
| 804: | $dummyGroup = new Group(null); |
| 805: | foreach ($controllers as $c) { |
| 806: | $controller = (Controller::make($dummyGroup, $c)); |
| 807: | $attrs = new MetricsLogging($controller); |
| 808: | $controllerTokens = $attrs->getMetricTokensFromAttributes($attrs->getLoggableAttributes()); |
| 809: | $tokens = array_values($tokens + append_config($controllerTokens)); |
| 810: | } |
| 811: | |
| 812: | |
| 813: | $existing += $this->getCgroupUsage(array_keys($missing), $tokens); |
| 814: | $cache->set(self::CGROUP_CACHE_KEY, $existing, CGROUP_PREFETCH_TTL); |
| 815: | |
| 816: | return array_intersect_key($existing, $search); |
| 817: | } |
| 818: | |
| 819: | |
| 820: | |
| 821: | |
| 822: | |
| 823: | |
| 824: | |
| 825: | |
| 826: | private function getCgroupUsage(array $sites, array $tokens): array |
| 827: | { |
| 828: | if (!TELEMETRY_ENABLED) { |
| 829: | warn('[telemetry] => enabled is set to false'); |
| 830: | |
| 831: | return []; |
| 832: | } |
| 833: | |
| 834: | $sum = (new Collector(PostgreSQL::pdo()))->range( |
| 835: | $tokens, |
| 836: | time() - 86400, |
| 837: | null, |
| 838: | array_map(static function ($s) { |
| 839: | |
| 840: | return (int)substr($s, 4); |
| 841: | }, $sites) |
| 842: | ); |
| 843: | |
| 844: | $built = []; |
| 845: | $limits = $this->collect(['cgroup']); |
| 846: | $sites = Enumerate::sites(); |
| 847: | |
| 848: | foreach ($sites as $site) { |
| 849: | $siteid = (int)substr($site, 4); |
| 850: | $cpuUsed = $sum['c-cpuacct-usage'][$siteid] ?? null; |
| 851: | if (null !== $cpuUsed) { |
| 852: | |
| 853: | $cpuUsed /= 100; |
| 854: | } |
| 855: | $memlimit = $limits[$site]['cgroup']['memory'] ?? null; |
| 856: | if (null !== $memlimit) { |
| 857: | $memlimit *= 1024; |
| 858: | } |
| 859: | |
| 860: | $built[$site] = [ |
| 861: | 'site_id' => $siteid, |
| 862: | 'cpu' => [ |
| 863: | 'used' => $cpuUsed, |
| 864: | 'threshold' => $limits[$site]['cgroup']['cpu'] ?? null |
| 865: | ], |
| 866: | 'memory' => [ |
| 867: | 'used' => $sum['c-memory-used'][$siteid] ?? null, |
| 868: | 'peak' => $sum['c-memory-peak'][$siteid] ?? null, |
| 869: | 'threshold' => $memlimit |
| 870: | ], |
| 871: | 'pids' => [ |
| 872: | 'used' => $sum['c-pids-used'][$siteid] ?? null, |
| 873: | 'threshold' => $limits[$site]['cgroup']['proclimit'] ?? null |
| 874: | ], |
| 875: | 'io' => [ |
| 876: | 'read' => $sum['c-blkio-bw-read'][$siteid] ?? null, |
| 877: | 'write' => $sum['c-blkio-bw-write'][$siteid] ?? null, |
| 878: | 'threshold' => $limits[$site]['cgroup']['io'] ?? null |
| 879: | ] |
| 880: | ]; |
| 881: | } |
| 882: | |
| 883: | return $built; |
| 884: | } |
| 885: | |
| 886: | |
| 887: | |
| 888: | |
| 889: | |
| 890: | |
| 891: | |
| 892: | |
| 893: | |
| 894: | |
| 895: | |
| 896: | public function collect(?array $params = [], array $query = null, array $sites = []): array |
| 897: | { |
| 898: | if ([] === $params) { |
| 899: | $params = [ |
| 900: | 'siteinfo.email', |
| 901: | 'siteinfo.admin_user', |
| 902: | 'aliases.aliases', |
| 903: | 'billing.invoice', |
| 904: | 'billing.parent_invoice' |
| 905: | ]; |
| 906: | } else if ($params && is_array(current($params))) { |
| 907: | $tmp = []; |
| 908: | foreach ($params as $k => $items) { |
| 909: | foreach ($items as $v) { |
| 910: | $tmp[] = "{$k}.{$v}"; |
| 911: | } |
| 912: | } |
| 913: | $params = $tmp; |
| 914: | } |
| 915: | |
| 916: | if ($query && !is_array(current($query))) { |
| 917: | |
| 918: | |
| 919: | |
| 920: | $query = \Util_Conf::hydrate($query); |
| 921: | } |
| 922: | |
| 923: | $sites = $sites ? \Opcenter\Account\Enumerate::freeform($sites) : \Opcenter\Account\Enumerate::sites(); |
| 924: | |
| 925: | $built = array_build(array_filter($sites), static function ($k, $s) use ($params, $query) { |
| 926: | $oldex = Error_Reporter::exception_upgrade(Error_Reporter::E_FATAL | Error_Reporter::E_ERROR); |
| 927: | try { |
| 928: | $id = Auth::get_site_id_from_anything($s); |
| 929: | $ctx = Auth::context(null, "site{$id}"); |
| 930: | } catch (apnscpException $e) { |
| 931: | return null; |
| 932: | } finally { |
| 933: | Error_Reporter::exception_upgrade($oldex); |
| 934: | } |
| 935: | |
| 936: | |
| 937: | $account = $ctx->getAccount(); |
| 938: | $meta = [ |
| 939: | 'active' => (bool)$account->active |
| 940: | ]; |
| 941: | if ((bool)($query['active'] ?? $meta['active']) !== $meta['active']) { |
| 942: | return null; |
| 943: | } |
| 944: | |
| 945: | unset($query['active']); |
| 946: | if ($query && Util_PHP::array_diff_assoc_recursive($query, $account->cur)) { |
| 947: | return null; |
| 948: | } |
| 949: | if ($params === null) { |
| 950: | $params = array_dot(array_keys($account->cur)); |
| 951: | } |
| 952: | foreach ($params as $p) { |
| 953: | array_set($meta, $p, array_get($account->cur, $p)); |
| 954: | } |
| 955: | $meta['domain'] = $account->cur['siteinfo']['domain']; |
| 956: | |
| 957: | return [$s, $meta]; |
| 958: | }); |
| 959: | unset($built['']); |
| 960: | |
| 961: | return $built; |
| 962: | } |
| 963: | |
| 964: | |
| 965: | |
| 966: | |
| 967: | |
| 968: | |
| 969: | |
| 970: | public function kill_site(string $site): bool |
| 971: | { |
| 972: | if (!IS_CLI) { |
| 973: | return $this->query('admin_kill_site', $site); |
| 974: | } |
| 975: | if (!($admin = $this->get_meta_from_site($site, 'siteinfo', 'admin'))) { |
| 976: | return error("Failed to lookup admin for site `%s'", $site); |
| 977: | } |
| 978: | |
| 979: | foreach (Process::matchGroup($admin) as $pid) { |
| 980: | Process::kill($pid, SIGKILL); |
| 981: | } |
| 982: | |
| 983: | return true; |
| 984: | } |
| 985: | |
| 986: | |
| 987: | |
| 988: | |
| 989: | |
| 990: | |
| 991: | |
| 992: | |
| 993: | |
| 994: | |
| 995: | public function get_meta_from_site(string $site, string $service, string $class = null) |
| 996: | { |
| 997: | if (!IS_CLI) { |
| 998: | return $this->query('admin_get_meta_from_site', $site, $service, $class); |
| 999: | } |
| 1000: | if (!$context = Auth::context(null, $site)) { |
| 1001: | return error(\Web_Module::ERR_UNKNOWN_DOMAIN, $site); |
| 1002: | } |
| 1003: | if (null === ($conf = $context->conf($service))) { |
| 1004: | return error("Unknown service `%s'", $service); |
| 1005: | } |
| 1006: | |
| 1007: | return array_get($conf, $class, null); |
| 1008: | } |
| 1009: | |
| 1010: | public function get_meta_from_domain(string $domain, string $service, string $class = null) |
| 1011: | { |
| 1012: | deprecated_func('Use %(name)s', 'admin_get_meta_from_site'); |
| 1013: | return $this->get_meta_from_site($domain, $service, $class); |
| 1014: | } |
| 1015: | |
| 1016: | |
| 1017: | |
| 1018: | |
| 1019: | |
| 1020: | |
| 1021: | |
| 1022: | public function activate_license(string $key): bool |
| 1023: | { |
| 1024: | if (!IS_CLI) { |
| 1025: | return $this->query('admin_activate_license', $key); |
| 1026: | } |
| 1027: | |
| 1028: | return (License::get()->issue($key) && Apnscp::restart()) || error('Failed to activate license'); |
| 1029: | } |
| 1030: | |
| 1031: | |
| 1032: | |
| 1033: | |
| 1034: | |
| 1035: | |
| 1036: | public function renew_license(): bool |
| 1037: | { |
| 1038: | if (!IS_CLI) { |
| 1039: | return $this->query('admin_renew_license'); |
| 1040: | } |
| 1041: | $license = License::get(); |
| 1042: | if (!$license->installed()) { |
| 1043: | return error('License is not installed'); |
| 1044: | } |
| 1045: | if ($license->isTrial()) { |
| 1046: | return error('Cannot renew trial licenses'); |
| 1047: | } |
| 1048: | if ($license->isLifetime()) { |
| 1049: | return warn('Lifetime licenses never need renewal'); |
| 1050: | } |
| 1051: | if (!$license->needsReissue()) { |
| 1052: | return warn('License does not need reissue. %d days until expiry', $license->daysUntilExpire()); |
| 1053: | } |
| 1054: | if (!$license->reissue()) { |
| 1055: | return false; |
| 1056: | } |
| 1057: | info('Restarting ' . PANEL_BRAND); |
| 1058: | |
| 1059: | return Apnscp::restart(); |
| 1060: | } |
| 1061: | |
| 1062: | |
| 1063: | |
| 1064: | |
| 1065: | |
| 1066: | |
| 1067: | |
| 1068: | public function read_map(string $map): array |
| 1069: | { |
| 1070: | if (!IS_CLI) { |
| 1071: | return $this->query('admin_read_map', $map); |
| 1072: | } |
| 1073: | if (!$map || $map[0] === '.' || $map[0] === '/') { |
| 1074: | error("Invalid map specified `%s'", $map); |
| 1075: | |
| 1076: | return []; |
| 1077: | } |
| 1078: | $path = Map::home() . DIRECTORY_SEPARATOR . $map; |
| 1079: | |
| 1080: | if (!file_exists($path)) { |
| 1081: | error("Invalid map specified `%s'", $map); |
| 1082: | |
| 1083: | return []; |
| 1084: | } |
| 1085: | |
| 1086: | return Map::load($map)->fetchAll(); |
| 1087: | } |
| 1088: | |
| 1089: | |
| 1090: | |
| 1091: | |
| 1092: | |
| 1093: | |
| 1094: | |
| 1095: | |
| 1096: | public function create_from_meta(string $site, array $meta = []): ?string |
| 1097: | { |
| 1098: | $createConfig = []; |
| 1099: | foreach (dot($meta) as $k => $v) { |
| 1100: | array_set($createConfig, $k, $v); |
| 1101: | } |
| 1102: | if (null === ($ctx = \Auth::nullableContext(null, $site))) { |
| 1103: | error("Site `%s' not found - pass \$site as null to generate fresh command", $site); |
| 1104: | return null; |
| 1105: | } |
| 1106: | |
| 1107: | $createConfig = array_replace_recursive($ctx->getAccount()->cur, $createConfig); |
| 1108: | $ctx = $ctx->getAccount(); |
| 1109: | |
| 1110: | $editor = new Util_Account_Editor($ctx); |
| 1111: | |
| 1112: | foreach ($createConfig as $svc => $v) { |
| 1113: | foreach ($v as $var => $val) { |
| 1114: | $editor->setConfig($svc, $var, $val); |
| 1115: | } |
| 1116: | } |
| 1117: | return $editor->setMode('add')->getCommand(); |
| 1118: | } |
| 1119: | |
| 1120: | public function _housekeeping() |
| 1121: | { |
| 1122: | $configHome = static::ADMIN_HOME . '/' . self::ADMIN_CONFIG; |
| 1123: | if (!is_dir($configHome)) { |
| 1124: | Opcenter\Filesystem::mkdir($configHome, APNSCP_SYSTEM_USER, APNSCP_SYSTEM_USER, 0700); |
| 1125: | } |
| 1126: | |
| 1127: | $defplan = Plans::path(Plans::default()); |
| 1128: | if (!is_dir($defplan)) { |
| 1129: | $base = Plans::path(''); |
| 1130: | |
| 1131: | $dh = opendir($base); |
| 1132: | if (!$dh) { |
| 1133: | return error("Plan path `%s' missing, account creation will fail until fixed", |
| 1134: | $base |
| 1135: | ); |
| 1136: | } |
| 1137: | while (false !== ($f = readdir($dh))) { |
| 1138: | if ($f === '..' || $f === '.') { |
| 1139: | continue; |
| 1140: | } |
| 1141: | $path = $base . DIRECTORY_SEPARATOR . $f; |
| 1142: | if (is_link($path)) { |
| 1143: | unlink($path); |
| 1144: | break; |
| 1145: | } |
| 1146: | } |
| 1147: | if ($f !== false) { |
| 1148: | info("old default plan `%s' renamed to `%s'", |
| 1149: | $f, Plans::default() |
| 1150: | ); |
| 1151: | } |
| 1152: | symlink(dirname($defplan) . '/.skeleton', $defplan); |
| 1153: | } |
| 1154: | |
| 1155: | |
| 1156: | $themepath = public_path('images/themes/current'); |
| 1157: | if (is_link($themepath) && basename(readlink($themepath)) === STYLE_THEME) { |
| 1158: | return; |
| 1159: | } |
| 1160: | is_link($themepath) && unlink($themepath); |
| 1161: | $curpath = dirname($themepath) . '/current'; |
| 1162: | symlink(STYLE_THEME, $curpath); |
| 1163: | if (!is_dir(readlink($curpath))) { |
| 1164: | Opcenter\Filesystem::mkdir($curpath, APNSCP_SYSTEM_USER, APNSCP_SYSTEM_USER); |
| 1165: | } |
| 1166: | } |
| 1167: | |
| 1168: | |
| 1169: | |
| 1170: | |
| 1171: | |
| 1172: | |
| 1173: | |
| 1174: | |
| 1175: | |
| 1176: | public function bless(string $domain, string $user, array $creation = []): bool |
| 1177: | { |
| 1178: | if (DEMO_ADMIN_LOCK && posix_getuid()) { |
| 1179: | return error("Demo may not bless sites"); |
| 1180: | } |
| 1181: | |
| 1182: | if (!\Auth::domain_exists($domain)) { |
| 1183: | return error("Domain `%s' does not exist", $domain); |
| 1184: | } |
| 1185: | |
| 1186: | $args = [ |
| 1187: | 'siteinfo.admin_user' => $user |
| 1188: | ]; |
| 1189: | return (new \Opcenter\Account\Bless($this->getAuthContext(), $domain))->create($creation + $args); |
| 1190: | } |
| 1191: | |
| 1192: | public function _cron(Cronus $c) |
| 1193: | { |
| 1194: | |
| 1195: | $exception = Error_Reporter::exception_upgrade(Error_Reporter::E_FATAL); |
| 1196: | foreach (Enumerate::sites() as $site) { |
| 1197: | try { |
| 1198: | Auth::context(null, $site); |
| 1199: | } catch (apnscpException $e) { |
| 1200: | continue; |
| 1201: | } |
| 1202: | } |
| 1203: | Error_Reporter::exception_upgrade($exception); |
| 1204: | |
| 1205: | $c->schedule( |
| 1206: | (int)(Auth_Info_Account::CACHE_DURATION - ceil(Auth_Info_Account::CACHE_DURATION / CRON_RESOLUTION)), |
| 1207: | 'account.cache', |
| 1208: | static function () { |
| 1209: | |
| 1210: | $exception = Error_Reporter::exception_upgrade(Error_Reporter::E_FATAL); |
| 1211: | foreach (Enumerate::sites() as $site) { |
| 1212: | try { |
| 1213: | Auth::context(null, $site); |
| 1214: | } catch (apnscpException $e) { |
| 1215: | continue; |
| 1216: | } |
| 1217: | } |
| 1218: | Error_Reporter::exception_upgrade($exception); |
| 1219: | }); |
| 1220: | |
| 1221: | if (CGROUP_SHOW_USAGE) { |
| 1222: | $cache = Cache_Global::spawn(); |
| 1223: | if (!$cache->exists(self::CGROUP_CACHE_KEY)) { |
| 1224: | $this->getCgroupCacheWrapper(Enumerate::sites()); |
| 1225: | } |
| 1226: | } |
| 1227: | } |
| 1228: | } |