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