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