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 && 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: * List all domains on the server
83: *
84: * @return array
85: * @throws PostgreSQLError
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: * Get site ID from administrative user
101: *
102: * @param string $admin
103: * @return int|null
104: * @throws PostgreSQLError
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: * Get e-mail from domain
118: *
119: * @param string $domain
120: * @return bool|string address or false on error
121: * @throws PostgreSQLError
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: * Translate domain to id
143: *
144: * @param string $domain domain
145: * @return null|int
146: * @throws PostgreSQLError
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: * Get appliance admin email
167: *
168: * Multiple entries returned as array
169: *
170: * @return string|null
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: * Set appliance admin email
200: *
201: * @param string $email
202: * @return bool
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: // only update if set
229: if ($cfg['apnscp_admin_email'] && $email !== $cfg['apnscp_admin_email']) {
230: // prevent double-firing during setup
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: * Get available plans
242: *
243: * @return array|null
244: */
245: public function list_plans(): array
246: {
247: return Plans::list();
248: }
249:
250: /**
251: * Get settings from plan
252: *
253: * @param null|string $plan plan or default
254: * @return array|null plan information or null if missing
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: * Get listing of service variables
271: *
272: * @param string $service service name
273: * @param string|null $plan optional plan name
274: * @return array|null
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: * Force bulk update of webapps
289: *
290: * @param array $options
291: * @return bool
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: * List all failed webapps
323: *
324: * @param null|string $site restrict list to site
325: * @return array
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: * Reset failed apps
391: *
392: * @param array $constraints [site: <anything>, version: <operator> <version>, type: <type>]
393: * @return int
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: * Locate webapps under site
462: *
463: * @param string|array $site
464: * @return array
465: */
466: public function locate_webapps($site = null): array
467: {
468: return Finder::find($site);
469: }
470:
471: /**
472: * Locate webapps under site
473: *
474: * @param string|array $site
475: * @return void
476: */
477: public function prune_webapps($site = null): void
478: {
479: Finder::prune($site);
480: }
481:
482: /**
483: * Delete site
484: *
485: * @param string $site |null site identifier
486: * @param array $flags optional flags to DeleteDomain ("[since: "now"]" is --since=now, "[force: true]" is --force etc)
487: * @return bool
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: * Add site
512: *
513: * @param string $domain domain name
514: * @param string $admin admin username
515: * @param array $opts service parameter adjustments, dot or nested format
516: * @param array $flags optional flags passed to AddDomain (e.g. "[n: true]" is -n)
517: * @return bool
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: * Edit site
551: *
552: * @param string $site site specifier
553: * @param array $opts service parameter adjustments, dot or nested format
554: * @param array $flags optional flags passed to EditDomain ("[n: true]" is -n, "[reset: true]" is --reset etc)
555: * @return bool
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: * Activate site
588: *
589: * @param string|array $site
590: * @return bool
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: * Alias to
609: *
610: * @param array|string $site
611: * @param array $flags optional flags
612: * @return bool @link suspend_site
613: */
614: public function deactivate_site($site, array $flags = []): bool
615: {
616: return $this->suspend_site($site, $flags);
617: }
618:
619: /**
620: * Deactivate site
621: *
622: * @param string|array $site
623: * @param array $flags optional flags passed to SuspendDomain
624: * @return bool
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: * Hijack a user account
648: *
649: * Replaces current session with new account session
650: *
651: * @param string $site
652: * @param string|null $user
653: * @param string|null $gate authentication gate
654: * @return null|string
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: * Get server storage usage
663: *
664: * @return array
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: * Get storage used per site
691: *
692: * @param array $sites
693: * @return array
694: */
695: public function get_site_storage(array $sites = []): array
696: {
697: return $this->get_usage('storage', $sites);
698:
699: }
700:
701: /**
702: * Get resource usage for site or collection of sites
703: *
704: * @param string $field resource type: storage, bandwidth
705: * @param array $sites optional list of site specifiers to restrict
706: * @return array
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: * Get bandwidth usage
763: *
764: * @param array $sites
765: * @return array
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: * Cacheable cgroup wrapper
785: *
786: * @param array $sites
787: * @return array
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: /** @noinspection AdditionOperationOnArraysInspection */
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: * Get cgroup usage
821: *
822: * @param array $sites
823: * @param array $tokens
824: * @return array
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: // strip "site"
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: // centiseconds!!!
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: * Collect account info
888: *
889: * "active" is a special $query param that picks active/inactive (true/false) sites
890: *
891: * @param array|null $params null cherry-picks all services, [] uses default service list
892: * @param array|null $query pull sites that possess these service values
893: * @param array $sites restrict selection to sites
894: * @return array
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: // hydrate
918: // passed as $query = ['siteinfo.foo' => 'bar', 'baz' => 'abc']
919: // instead of $query = ['siteinfo' => ['foo' => 'bar']]
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: /** @noinspection PhpUndefinedVariableInspection */
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: * Destroy all logins matching site
966: *
967: * @param string $site
968: * @return bool
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: * Get account metadata
989: *
990: * @param string $domain
991: * @param string $service
992: * @param string $class
993: * @return array|bool|mixed|void
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: * Activate apnscp license
1018: *
1019: * @param string $key
1020: * @return bool
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: * Renew apnscp license
1033: *
1034: * @return bool
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: * Read a map from mappings/
1064: *
1065: * @param string $map map name
1066: * @return array
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: * Create an AddDomain command
1091: *
1092: * @param string|null $site
1093: * @param array $meta meta passed as array or dot notation
1094: * @return string
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: // assemble domain creation cmd from current config
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: // plan name change
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: * Break an addon domain (or subdomain) into a new account
1170: *
1171: * @param string $domain
1172: * @param string $user
1173: * @param array $creation creation arguments, dot-notation
1174: * @return bool
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);//->migrate();
1190: }
1191:
1192: public function _cron(Cronus $c)
1193: {
1194: // keep account meta cached in memory for speed up
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: // keep account meta cached in memory for speed up
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: }