1: <?php
2: declare(strict_types=1);
3:
4: /**
5: * +------------------------------------------------------------+
6: * | apnscp |
7: * +------------------------------------------------------------+
8: * | Copyright (c) Apis Networks |
9: * +------------------------------------------------------------+
10: * | Licensed under Artistic License 2.0 |
11: * +------------------------------------------------------------+
12: * | Author: Matt Saladna (msaladna@apisnetworks.com) |
13: * +------------------------------------------------------------+
14: */
15:
16: use Adoy\FastCGI\Client;
17: use Daphnie\Collector;
18: use Daphnie\Metrics\Php as PhpMetrics;
19: use Module\Skeleton\Contracts\Hookable;
20: use Module\Skeleton\Contracts\Reactive;
21: use Module\Skeleton\Contracts\Tasking;
22: use Module\Support\Php;
23: use Opcenter\Http\Php\Fpm;
24: use Opcenter\Http\Php\Fpm\CacheInspector;
25: use Opcenter\Http\Php\Fpm\Configuration;
26: use Opcenter\Http\Php\Fpm\PoolPolicy;
27: use Opcenter\Http\Php\Fpm\PoolStatus;
28: use Opcenter\Http\Php\Fpm\SystemdSynthesizer;
29: use Opcenter\Map;
30:
31: /**
32: * Class Php_Module
33: *
34: * @package core
35: */
36: class Php_Module extends Php implements Hookable, Reactive, Tasking
37: {
38: const COMPOSER_LOCATION = '/usr/share/pear/composer.phar';
39:
40: public $exportedFunctions = array(
41: '*' => PRIVILEGE_SITE,
42: 'pool_get_version' => PRIVILEGE_SITE|PRIVILEGE_USER,
43: 'pool_name' => PRIVILEGE_SITE|PRIVILEGE_USER,
44: 'pool_set_policy' => PRIVILEGE_ADMIN,
45: 'pool_get_policy' => PRIVILEGE_SITE|PRIVILEGE_USER,
46: 'version' => PRIVILEGE_ALL
47: );
48:
49: /**
50: * Install PEAR package
51: *
52: * @param string $module
53: * @return bool
54: */
55: public function install_package($module)
56: {
57: if (!IS_CLI) {
58: return $this->query('php_install_package', $module);
59: }
60:
61:
62: if (!preg_match('!^[a-zA-Z0-9_-]+$!', $module)) {
63: return error($module . ': invalid package name');
64: }
65:
66: $args = '-d display_errors=0 -d track_errors=1 -d include_path=/usr/local/share/pear:/usr/share/pear';
67: if (version_compare(platform_version(), '4.5', '<')) {
68: $args .= ' -d disable_functions=ini_set';
69: }
70: $pearcmd = '/usr/share/pear/pearcmd.php';
71: $proc = Util_Process_Tee::watch(new Util_Process_Sudo);
72: $proc->log('Installing ' . $module);
73: if (file_exists($this->domain_fs_path() . '/usr/local/share/pear/pearcmd.php')) {
74: $this->_unsetPearIni();
75: $pearcmd = '/usr/local/share/pear/pearcmd.php';
76: }
77:
78: $status = $proc->exec('php %s %s install -f -o %s',
79: $args,
80: $pearcmd,
81: escapeshellarg($module)
82: );
83:
84: return $status['success'];
85: }
86:
87: private function _unsetPearIni()
88: {
89: $pearfile = $this->domain_fs_path() . '/usr/local/share/pear/PEAR.php';
90: if (!file_exists($pearfile)) {
91: return false;
92: }
93: $content = file_get_contents($pearfile, 0, null, 0, 1024);
94: $changed = false;
95: $pos = strpos($content, 'ini_set');
96: if ($pos === false) {
97: return false;
98: }
99: $content = file_get_contents($pearfile);
100: file_put_contents($pearfile, str_replace('@ini_set', '// @ini_set', $content));
101:
102: return true;
103: }
104:
105: /**
106: * List PEAR packages installed for account
107: *
108: * Keys-
109: * is_local (bool) : package is local to account
110: * version (double): version number
111: *
112: * @return array
113: */
114: public function list_installed_packages()
115: {
116: if (!IS_CLI) {
117: return $this->query('php_list_installed_packages');
118: }
119: $status = Util_Process::exec('pear list');
120: if ($status instanceof Exception) {
121: return $status;
122: }
123: $packages = array();
124:
125: $packageSizeSys = preg_match_all('!^(\S+)\s+([0-9,\. ]+)!m', $status['output'], $packageMatches);
126:
127: for ($i = 1; $i < $packageSizeSys; $i++) {
128: $packages[$packageMatches[1][$i]] = array('is_local' => false, 'version' => $packageMatches[2][$i]);
129: }
130:
131:
132: $status = Util_Process_Sudo::exec('pear list');
133: if ($status instanceof Exception) {
134: return $status;
135: }
136: $packageSize = preg_match_all('!^(\S+)\s+([0-9,\. ]+)!m', $status['output'], $packageMatches);
137:
138: for ($i = 1; $i < $packageSize; $i++) {
139:
140: $packages[$packageMatches[1][$i]] = array(
141: 'is_local' => true,
142: 'version' => trim($packageMatches[2][$i])
143: );
144: }
145: ksort($packages);
146:
147: return $packages;
148: }
149:
150: /**
151: * string get_pear_description (string)
152: * Fetches the description for a PEAR package
153: *
154: * @param string $mModule package name
155: * @return string description of the package
156: */
157:
158: public function package_description($mModule)
159: {
160: $packages = $this->list_remote_packages();
161: if (!isset($packages[$mModule])) {
162: return false;
163: }
164:
165: return $packages[$mModule]['description'];
166: }
167:
168: /**
169: * array list_remote_packages (void)
170: * Queries PEAR for all available PEAR packages, analogous to
171: * running pear list-all from the command line.
172: *
173: * @return array Listing of PEAR modules with the following indexes:
174: * versions, description. :KLUDGE: versions only contains one version
175: * number, the most current on PEAR at this time. This index is kept for
176: * consistency with the "Package Manager" component of the control panel
177: */
178: public function list_remote_packages()
179: {
180: if (file_exists(TEMP_DIR . '/pear-cache') && ((time() - filemtime(TEMP_DIR . '/pear-cache')) < 86400)) {
181: $data = \Util_PHP::unserialize(file_get_contents(TEMP_DIR . '/pear-cache'));
182:
183: return $data;
184: }
185: $status = Util_Process::exec('/usr/bin/pear list-all');
186: if ($status instanceof Exception) {
187: return $status;
188: }
189: $pear = array();
190: $pearCount = preg_match_all('!^pear/(\S+)\s+(\S+)\s+([0-9\.]*)\s+(.*)$!m', $status['output'],
191: $pearTmp);
192:
193: for ($i = 0; $i < $pearCount; $i++) {
194: $pear[$pearTmp[1][$i]] = array(
195: 'versions' => array(trim($pearTmp[2][$i])),
196: 'description' => $pearTmp[4][$i]
197: );
198: }
199: file_put_contents(TEMP_DIR . '/pear-cache', serialize($pear));
200:
201: return $pear;
202: }
203:
204: /**
205: * Add PHP channel to PEAR package manager
206: *
207: * @param string $xml URL reference to package.xml
208: * @return bool
209: */
210: public function add_pear_channel($xml)
211: {
212: if (substr($xml, -4) != '.xml') {
213: return error("channel `$xml' must refer to .xml");
214: }
215: $status = Util_Process_Sudo::exec('pear add-channel %s', $xml);
216:
217: return $status['success'];
218: }
219:
220: /**
221: * Remove PEAR channel from PEAR package manager
222: *
223: * @param string $channel channel previously added
224: * @return bool
225: */
226:
227: public function remove_channel($channel)
228: {
229: $status = Util_Process_Sudo::exec('pear remove-channel %s', $channel);
230:
231: return $status['success'];
232: }
233:
234: /**
235: * List all channels configured in PHP PEAR package manager
236: *
237: * Sample response-
238: * array(2) {
239: * [0]=>
240: * array(5) {
241: * ["channel"]=> string(12) "pear.php.net"
242: * ["summary"]=> string(40) "PHP Extension and Application Repository"
243: * }
244: * [1]=>
245: * array(5) {
246: * ["channel"]=> string(12) "pecl.php.net"
247: * ["summary"]=> string(31) "PHP Extension Community Library"
248: * }
249: * }
250: *
251: * @return array
252: */
253: public function list_channels()
254: {
255: $channels = array();
256: $status = Util_Process_Sudo::exec('pear list-channels');
257: if (!$status['success']) {
258: return $channels;
259: }
260: $chmatches = null;
261: if (!preg_match_all(Regex::PEAR_CHANNELS_LONG, $status['output'], $chmatches, PREG_SET_ORDER)) {
262: return $channels;
263: }
264: foreach ($chmatches as $channel) {
265: $channels[] = array(
266: 'channel' => $channel['channel'],
267: 'summary' => $channel['summary']
268: );
269: }
270:
271: return $channels;
272: }
273:
274: /**
275: * Retrieve PEAR channel information
276: *
277: * Basic wrapper to pear channel-info <channel> command
278: * Sample response-
279: * array(4) {
280: * ["server"]=>
281: * string(12) "pear.php.net"
282: * ["alias"]=>
283: * string(4) "pear"
284: * ["summary"]=>
285: * string(40) "PHP Extension and Application Repository"
286: * ["version"]=>
287: * NULL
288: * }
289: *
290: * @param string $channel
291: * @return array
292: */
293: public function get_channel_info($channel)
294: {
295: $info = array();
296: $status = Util_Process_Sudo::exec('pear channel-info %s', $channel);
297: if (!$status['success']) {
298: return false;
299: }
300: $line = strtok($status['output'], '=');
301: $parse = false;
302:
303: for ($idx = null; $line !== false; $line = strtok("\n")) {
304: // delimiter ===
305: if (!$parse) {
306: if ($line[0] != '=') {
307: continue;
308: } else {
309: $parse = true;
310: }
311: }
312:
313: if ($idx) {
314: $info[$idx] = trim($line);
315: }
316: $idx = null;
317:
318: $lookup = strtok(" \n");
319: if ($lookup == 'Name') {
320: strtok(' ');
321: strtok(' ');
322: $idx = 'server';
323: } else {
324: if ($lookup == 'Alias') {
325: $idx = 'alias';
326: } else {
327: if ($lookup == 'Summary') {
328: $idx = 'summary';
329: } else {
330: if ($lookup == 'Version') {
331: // Special case if Version field is null
332: $version = null;
333: $line = strtok("\n");
334: if (false === strpos($line, 'SERVER CAPABILITIES')) {
335: $version = trim($line);
336: }
337: $info['version'] = $version;
338: } else {
339: if ($lookup[0] == '=') {
340: break;
341: }
342: }
343: }
344: }
345: }
346: }
347:
348: return $info;
349: }
350:
351: /**
352: * string get_php_version()
353: * Returns the available PHP interpreter version on the server
354: *
355: * @cache yes
356: * @privilege PRIVILEGE_ALL
357: *
358: * @return string
359: */
360: public function version()
361: {
362: if ($this->jailed()) {
363: return $this->pool_get_version();
364: }
365:
366: static $ver;
367: if (null === $ver) {
368: $key = 'php.version';
369: $ver = apcu_fetch($key);
370: if ($ver) {
371: return $ver;
372: }
373: $ver = \Opcenter\Php::version();
374: apcu_add($key, $ver, 86400);
375: }
376:
377: return $ver;
378: }
379:
380: public function _housekeeping()
381: {
382: // composer.phar seems standard nowadays..
383: if ($this->composer_exists()) {
384: return true;
385: }
386:
387: $versions = file_get_contents('https://getcomposer.org/versions');
388: if (!$versions) {
389: return false;
390: }
391: $versions = json_decode($versions, true);
392: $url = 'https://getcomposer.org/' . $versions['stable'][0]['path'];
393: $res = Util_HTTP::download($url, self::COMPOSER_LOCATION);
394: if (!$res) {
395: return error('failed to download composer');
396: }
397: chmod(self::COMPOSER_LOCATION, 0755);
398: $fstPath = $this->service_template_path('siteinfo') . self::COMPOSER_LOCATION;
399: if (!file_exists($fstPath) || fileinode(self::COMPOSER_LOCATION) !== fileinode($fstPath)) {
400: copy(self::COMPOSER_LOCATION, $fstPath);
401: }
402:
403: info('installed %s!', basename(self::COMPOSER_LOCATION));
404:
405: return true;
406: }
407:
408: public function composer_exists()
409: {
410: return file_exists(self::COMPOSER_LOCATION);
411: }
412:
413: public function _delete()
414: {
415: foreach ($this->get_fallbacks() as $fallback) {
416: if ($this->fallback_enabled($fallback)) {
417: $this->disable_fallback($fallback);
418: }
419: }
420: }
421:
422: public function get_fallbacks()
423: {
424: return $this->getPersonalities();
425: }
426:
427: /**
428: * Verify if fallback enabled for given personality
429: *
430: * @param string|null $mode
431: * @return bool
432: */
433: public function fallback_enabled($mode = null)
434: {
435: if ($this->jailed()) {
436: return false;
437: }
438: if (is_null($mode)) {
439: $mode = $this->getPersonalities();
440: $mode = array_pop($mode);
441: }
442:
443: return file_exists($this->getPersonalityPathFromPersonality($mode, $this->site));
444: }
445:
446: /**
447: * Disable PHP fallback support
448: *
449: * @return bool
450: */
451: public function disable_fallback($mode = '')
452: {
453: if (!IS_CLI) {
454: return $this->query('php_disable_fallback');
455: }
456: if ($this->jailed()) {
457: return false;
458: }
459:
460: if ($mode) {
461: $personalities = [$mode];
462: } else {
463: $personalities = $this->getPersonalities();
464: }
465: foreach ($personalities as $personality) {
466: if (!$this->personalityExists($personality)) {
467: error("unknown personality `%s', skipping", $personality);
468: continue;
469: }
470: $path = $this->getPersonalityPathFromPersonality($personality, $this->site);
471: if (file_exists($path)) {
472: unlink($path);
473: } else {
474: warn("fallback `%s' not enabled", $personality);
475: }
476: }
477:
478: // defer reloading to a later date
479: return true;
480: }
481:
482: public function _verify_conf(\Opcenter\Service\ConfigurationContext $ctx): bool
483: {
484: // TODO: Implement _verify_conf() method.
485: }
486:
487: public function _create()
488: {
489: // TODO: Implement _create() method.
490: }
491:
492: public function _edit()
493: {
494: foreach ($this->get_fallbacks() as $fallback) {
495: if ($this->fallback_enabled($fallback)) {
496: $this->disable_fallback($fallback) && $this->enable_fallback($fallback);
497: }
498: }
499: }
500:
501: /**
502: * Enable fallback interpreter support
503: *
504: * @param null|string $mode specific personality in multi-personality environments
505: * @return bool
506: */
507: public function enable_fallback($mode = null)
508: {
509: if (!IS_CLI) {
510: return $this->query('php_enable_fallback', $mode);
511: }
512: if ($this->jailed()) {
513: return error('Fallbacks may be used when PHP jails are disabled');
514: }
515: if (!$mode) {
516: $mode = $this->getPersonalities();
517: }
518:
519: $file = file_get_contents($this->web_config_dir() . '/virtual/' .
520: $this->site);
521: // @todo helper function?
522: $config = preg_replace(Regex::compile(
523: Regex::PHP_COMPILABLE_STRIP_NONHTTP_APACHE_CONTAINER,
524: ['port' => 80]
525: ), '', $file);
526: $serverip = (array)$this->common_get_ip_address();
527: $in = $serverip[0] . ':80';
528: foreach ((array)$mode as $m) {
529: if (!$this->personalityExists($m)) {
530: error("unknown personality `%s' - not enabling", $m);
531: continue;
532: }
533: $port = $this->getPersonalityPort($m);
534: $out = $serverip[0] . ':' . $port;
535: $newconfig = str_replace($in, $out, $config);
536: $confpath = $this->getPersonalityPathFromPersonality($m, $this->site);
537: file_put_contents($confpath, $newconfig) && info("enabled fallback for `%s'", $m);
538: }
539: Util_Account_Hooks::instantiateContexted($this->getAuthContext())->run('reload', ['php']);
540:
541: return true;
542: }
543:
544: /**
545: * PHP is jailed with PHP-FPM
546: *
547: * @return bool
548: */
549: public function jailed(): bool
550: {
551: return (bool)$this->getConfig('apache', 'jail');
552: }
553:
554: /**
555: * Pool name from docroot
556: *
557: * @param string|null $docroot
558: * @return string|null
559: */
560: public function pool_name(string $docroot = null): ?string
561: {
562: if (!$this->jailed()) {
563: return null;
564: }
565:
566: return $this->pools()[0] ?? null;
567: }
568: /**
569: * Change pool owner
570: *
571: * @param string $owner
572: * @param string|null $pool optional pool name
573: * @return bool
574: */
575: public function pool_change_owner(string $owner, string $pool = null): bool
576: {
577: if (!IS_CLI) {
578: return $this->query('php_pool_change_owner', $owner, $pool);
579: }
580:
581: if (!HTTPD_FPM_USER_CHANGE) {
582: return error('Pool ownership disallowed');
583: }
584:
585: if (!$this->jailed()) {
586: return error('Pools not utilized for site');
587: }
588:
589: $pools = $this->pools();
590: if ($pool && !\in_array($pool, $pools, true)) {
591: return error("Unknown pool `%s'", $pool);
592: } else if ($pool) {
593: warn('Per-pool ownership not supported yet - applying bulk change');
594: }
595:
596: if (!$this->user_exists($owner)) {
597: return error("User `%s' does not exist", $owner);
598: }
599: if ($owner !== $this->web_get_sys_user() && $owner !== $this->username) {
600: return error("Unknown or unsupported pool owner `%s'", $owner);
601: }
602:
603: $editor = new \Util_Account_Editor($this->getAuthContext()->getAccount(), $this->getAuthContext());
604: $editor->setConfig('apache', 'webuser', $owner);
605: if (!$editor->edit()) {
606: return false;
607: }
608:
609: $systemd = Fpm\SystemdSynthesizer::mock($this->site . '-' . ($pool ?? array_shift($pools)));
610: return $systemd::waitFor(10);
611: }
612:
613: /**
614: * Get pool cache statistics
615: *
616: * @param string|null $pool
617: * @return array
618: */
619: public function pool_cache_status(string $pool = null): ?array
620: {
621: if (!IS_CLI) {
622: return $this->query('php_pool_cache_status', $pool);
623: }
624: if (!$this->jailed()) {
625: error('Jails disabled for site');
626: return null;
627: }
628:
629: $ip = $this->site_ip_address();
630: // @TODO pull configuration, determine deets in multipool
631: $hostname = $this->domain ?? SERVER_NAME;
632: $docroot = $this->web_get_docroot($hostname);
633: return CacheInspector::instantiateContexted($this->getAuthContext(), [$ip, $hostname])->readFromPath($docroot);
634: }
635:
636: /**
637: * Username PHP-FPM pool operates as
638: *
639: * @param string|null $pool
640: * @return bool|string
641: */
642: public function pool_owner(string $pool = null) {
643: if (!$this->jailed()) {
644: return error('Pools not utilized for site');
645: }
646:
647: $pools = $this->pools();
648: if ($pool && !\in_array($pool, $pools, true)) {
649: return error("Unknown pool `%s'", $pool);
650: }
651: $pool = $pool ?? array_shift($pools);
652: return $this->user_get_username_from_uid((new PoolStatus($this->php_pool_info($pool)))->getUser());
653: }
654:
655: /**
656: * Restart PHP-FPM pool state
657: *
658: * @param string $pool
659: * @return bool
660: */
661: public function pool_restart(string $pool = null): bool
662: {
663: return $this->pool_set_state($pool, 'restart');
664: }
665:
666: /**
667: * Set PHP-FPM pool state
668: *
669: * @param string $pool
670: * @param string $state active systemd state type (stop, start, restart)
671: * @return bool
672: */
673: public function pool_set_state(?string $pool = null, string $state = 'stop'): bool
674: {
675: if (!IS_CLI) {
676: return $this->query('php_pool_set_state', $pool, $state);
677: }
678: if (!\in_array($state, ['stop', 'start', 'restart', 'reload'], true)) {
679: return error("Unknown pool state `%s'", $state);
680: }
681:
682: if ($pool && !\in_array($pool, $this->pools(), true)) {
683: return error("Invalid pool specified `%s'", $pool);
684: }
685: if (!$pool) {
686: $pool = $this->pools();
687: }
688: foreach ((array)$pool as $p)
689: {
690: $svc = Fpm\SystemdSynthesizer::mock($this->site . '-' . $p);
691: if ($state !== 'stop' && $svc::failed()) {
692: $svc::reset();
693: }
694: if ($svc::run($state)) {
695: continue;
696: }
697:
698: if ($state === 'start' || $state === 'restart') {
699: // Perform quota check
700: $log = Configuration::bindTo($this->domain_fs_path())->
701: setServiceContainer(\Opcenter\SiteConfiguration::shallow($this->getAuthContext()))->
702: setName($p)->getLog();
703: if (!file_exists($this->domain_fs_path() . $log) && $this->file_touch($log) &&
704: $this->file_chown($log, $this->pool_owner($p)))
705: {
706: warn("Log `%s' missing, possibly inhibiting startup. Created", $log);
707: $this->pool_set_state($p, $state);
708: continue;
709: }
710: }
711: warn('Failed to %s %s-%s', $state, $this->site, $p);
712:
713: }
714:
715: return true;
716: }
717:
718: /**
719: * Get jail pools
720: *
721: * @return array
722: */
723: public function pools(): array
724: {
725: if (!$this->jailed()) {
726: return [];
727: }
728: $key = 'php.pools';
729: $cache = \Cache_Account::spawn($this->getAuthContext());
730: if (false !== ($pools = $cache->get($key))) {
731: return $pools;
732: }
733: $pools = array_map(function (string $pool) {
734: return substr($pool, strlen($this->site) + 1);
735: }, Fpm::getPoolsFromGroup($this->site));
736: // @todo possible ghosting
737: $cache->set($key, $pools, 30);
738: return $pools;
739: }
740:
741: /**
742: * Get pool status
743: *
744: * @param string $pool named pool or default
745: * @return array
746: */
747: public function pool_status(string $pool = ''): array
748: {
749: if (!($status = $this->pool_info($pool))) {
750: return [];
751: }
752: return (new Fpm\PoolStatus($status))->getMetrics();
753: }
754:
755: public function _cron(Cronus $c) {
756: static $ctr = 0;
757:
758: if (!TELEMETRY_ENABLED || ++$ctr < 600 / CRON_RESOLUTION) {
759: return;
760: }
761:
762: $ctr = 0;
763: $oldex = \Error_Reporter::exception_upgrade(Error_Reporter::E_FATAL, true);
764: $collector = new Collector(PostgreSQL::pdo());
765: foreach (\Opcenter\Account\Enumerate::active() as $site) {
766: try {
767: $ctx = \Auth::context(null, $site);
768: $afi = \apnscpFunctionInterceptor::factory($ctx);
769: if (!$afi->php_jailed() || !($service = $afi->php_pool_info())) {
770: continue;
771: }
772:
773: $inspector = new Fpm\PoolStatus($service);
774: if (!$inspector->running()) {
775: continue;
776: }
777: $cacheMetrics = $inspector->getCacheMetrics($ctx);
778: $stats = $afi->php_pool_status();
779: if (null === array_get($stats, 'traffic')) {
780: // pool outage/not started - let's not trigger the pool
781: $stats = PhpMetrics::fill(0);
782: } else {
783: $stats['uptime'] = time() - $inspector->getStart();
784: $stats['cache-used'] = array_get($cacheMetrics, 'memory_usage.used_memory', 0)/1024;
785: $stats['cache-total'] = (array_get($cacheMetrics, 'memory_usage.free_memory') + $stats['cache-used'])/1024;
786: $stats['cache-hits'] = array_get($cacheMetrics, 'opcache_statistics.hits', 0);
787: $stats['cache-misses'] = array_get($cacheMetrics, 'opcache_statistics.misses', 0);
788: $stats['traffic'] *= 100;
789: }
790:
791:
792: foreach (PhpMetrics::getAttributeMap() as $attr => $metric) {
793: $val = $stats[$metric];
794:
795: if ($val instanceof Closure) {
796: $val = $val($stats);
797: }
798: // @TODO register additional pool names
799: $collector->add("php-${attr}", $ctx->site_id, (int)$val);
800: }
801: } catch (\apnscpException $e) {
802: // site doesn't exist
803: continue;
804: }
805: }
806: \Error_Reporter::exception_upgrade($oldex);
807:
808: }
809: /**
810: * Get pool info
811: *
812: * @param string $pool named pool or default
813: * @return array
814: */
815: public function pool_info(string $pool = ''): array
816: {
817: $pools = $this->pools();
818: if ($pool && !in_array($pool, $pools, true)) {
819: error("Invalid pool specified `%s'", $pool);
820:
821: return [];
822: }
823:
824: if (!$pool) {
825: if (\in_array($this->domain, $pools, true)) {
826: $pool = $this->domain;
827: } else {
828: $pool = array_shift($pools);
829: }
830: }
831:
832: // assume default is named after the domain otherwise grab first
833: if (class_exists(\Opcenter\Dbus\Systemd::class)) {
834: return (array)Fpm\SystemdSynthesizer::mock($this->site . '-' . $pool)->status() + Fpm\SystemdSynthesizer::mock($this->site . '-' . $pool)->unitInfo(['Id', 'Description', 'ActiveState']);
835: }
836:
837: return Fpm\SystemdSynthesizer::mock($this->site . '-' . $pool)->status() ?: [];
838: }
839:
840: /**
841: * Get PHP version
842: *
843: * @param string $pool
844: * @return string
845: */
846: public function pool_get_version(string $pool = ''): string
847: {
848: if (!IS_CLI) {
849: return $this->query('php_pool_get_version', $pool);
850: }
851: if (!$this->jailed()) {
852: return $this->version();
853: }
854:
855: return PoolPolicy::instantiateContexted($this->getAuthContext())->getPoolVersion($pool);
856: }
857:
858: /**
859: * Send direct request to FPM worker bypassing HTTP server routing
860: *
861: * @param string $hostname
862: * @param string $file
863: * @return string
864: * @throws \Adoy\FastCGI\ForbiddenException
865: * @throws \Adoy\FastCGI\TimedOutException
866: */
867: public function pool_direct_read(string $hostname, string $file): ?string
868: {
869: if (!IS_CLI) {
870: return $this->query('php_pool_direct_read', $hostname, $file);
871: }
872:
873: if (!$this->jailed()) {
874: warn("PHP-FPM disabled for site - ignoring PHP-FPM pool read");
875: return '';
876: }
877:
878: $path = $this->web_normalize_path($hostname, $file) ?? (\Web_Module::MAIN_DOC_ROOT . "/$file");
879: $pool = $this->php_pool_name($path);
880: $socket = \Opcenter\Http\Php\Fpm\Configuration::bindTo($this->domain_fs_path())->
881: setGroup($this->site)->setName($pool)->getSocketPath();
882:
883: $client = new Client('unix://' . $socket, -1);
884: try {
885: $contents = $client->request([
886: 'REQUEST_METHOD' => 'GET',
887: 'SCRIPT_NAME' => basename($file),
888: 'SCRIPT_FILENAME' => $path,
889: 'DOCUMENT_ROOT' => $this->web_get_docroot($hostname),
890: 'SERVER_SOFTWARE' => PANEL_BRAND . ' ' . APNSCP_VERSION . ' Self-Referential Check',
891: 'REMOTE_ADDR' => '127.0.0.1',
892: 'SERVER_NAME' => $hostname
893: ], '');
894:
895: return ltrim((string)strstr($contents, "\r\n\r\n"));
896: } catch (\Adoy\FastCGI\ForbiddenException $e) {
897: return nerror("Unable to access resource - are permissions correct?");
898: } catch (\Adoy\FastCGI\TimedOutException $e) {
899: return nerror("Request timed out");
900: } catch (\Exception $e) {
901: return nerror("Request failed: %s", $e->getMessage());
902: }
903:
904: }
905:
906: /**
907: * Return pool version from FS path
908: *
909: * @param string $path
910: * @return string
911: */
912: public function pool_version_from_path(string $path): string
913: {
914: if (!IS_CLI) {
915: return $this->query('php_pool_version_from_path', $path);
916: }
917:
918: if (!$this->jailed()) {
919: return $this->version();
920: }
921:
922: $pool = $this->pool_name($path);
923: return PoolPolicy::instantiateContexted($this->getAuthContext())->getPoolVersion($pool);
924: }
925:
926: /**
927: * Set PHP version on pool
928: *
929: * @param ?string $version specified version or server default
930: * @param string $pool optional pool name
931: * @return bool
932: */
933: public function pool_set_version(?string $version, string $pool = ''): bool
934: {
935: if (!IS_CLI) {
936: return $this->query('php_pool_set_version', $version, $pool);
937: }
938:
939: if (!$this->jailed()) {
940: return error("Cannot change version for non-jailed account");
941: }
942:
943: $origPool = $pool;
944: if (!$pool) {
945: $pool = $this->pools();
946: } else if (!$this->pool_exists($pool)) {
947: return error("Invalid pool specified `%s'", $pool);
948: }
949:
950: if (!PoolPolicy::instantiateContexted($this->getAuthContext())->allowed($version)) {
951: return error("PHP version `%s' not installed or permitted on account", $version);
952: }
953:
954: $version = \Opcenter\Versioning::asMinor($version);
955:
956: if (!array_key_exists((string)$version, Fpm\MultiPhp::list())) {
957: if ($version !== ($tmp = \Opcenter\Versioning::asMinor($version))) {
958: warn("Truncating version to `%s'", $tmp);
959: return $this->switch_version($version, $pool);
960: }
961:
962: return error("PHP interpreter %s does not exist", $version);
963: }
964:
965: $svc = \Opcenter\SiteConfiguration::shallow($this->getAuthContext());
966:
967: foreach ((array)$pool as $p) {
968: // @TODO multipool
969: $config = Fpm\Configuration::bindTo($this->domain_fs_path());
970: $config->setServiceContainer($svc);
971: $config->setVersion($version);
972: if ($config->isDefault()) {
973: $config->shimBinaries();
974: }
975: }
976: // write configuration
977: unset($config);
978:
979: return \Util_Account_Editor::instantiateContexted($this->getAuthContext())->setConfig(['apache.jail' => 1])->edit();
980: }
981:
982: /**
983: * @param string $var
984: * @param string|null $pool
985: * @param string|null $default
986: * @return array|ArrayAccess|mixed|null
987: */
988: public function pool_get_policy(string $var, string $pool = null, string $default = null)
989: {
990: if (!IS_CLI) {
991: return $this->query('php_pool_get_policy', $var, $pool, $default);
992: }
993: return PoolPolicy::instantiateContexted($this->getAuthContext())->get($pool, $var, $default);
994: }
995:
996: /**
997: * Set pool policy value
998: *
999: * Note: to set defaults applied to all pools, edit resources/apache/php/policy.blade.php
1000: * @param string $anything site, domain, or invoice
1001: * @param string $var
1002: * @param $val
1003: * @param string|null $pool
1004: * @return bool
1005: */
1006: public function pool_set_policy(string $anything, string $var, $val, string $pool = null): bool
1007: {
1008: if (!IS_CLI) {
1009: return $this->query('php_pool_set_policy', $anything, $var, $val, $pool);
1010: }
1011:
1012: $sites = \Opcenter\Account\Enumerate::matches($anything);
1013: if (!$sites) {
1014: return error("Unknown qualifier `%s'", $anything);
1015: }
1016:
1017: foreach ($sites as $site) {
1018: $ctx = \Auth::context(null, $site);
1019: $afi = \apnscpFunctionInterceptor::factory($ctx);
1020: if ($pool && !$afi->php_pool_exists($pool)) {
1021: return error("Unknown PHP pool `%s'", $pool);
1022: }
1023: PoolPolicy::instantiateContexted($ctx)->set($pool, $var, $val)->sync();
1024: $cmd = (new \Util_Account_Editor(null,
1025: $ctx))->setMode('edit')->setFlags(['reconfig' => true])->getCommand();
1026: $proc = (new Util_Process_Schedule)->setID("php." . $ctx->site);
1027: if (!$proc->preempted("php." . $ctx->site)) {
1028: $proc->run($cmd);
1029: }
1030: }
1031: info("PHP pool settings batched to background");
1032:
1033: return true;
1034: }
1035:
1036: /**
1037: * Get available PHP pool versions
1038: *
1039: * @return array
1040: */
1041: public function pool_versions(): array
1042: {
1043: if (!IS_CLI) {
1044: return $this->query('php_pool_versions');
1045: }
1046: return array_keys(Fpm\MultiPhp::list());
1047: }
1048:
1049: /**
1050: * PHP pool exists
1051: *
1052: * @param string $pool pool name
1053: * @return bool
1054: */
1055: public function pool_exists(string $pool): bool
1056: {
1057: return in_array($pool, $this->pools(), true);
1058: }
1059:
1060: /**
1061: * Migrate PHP directives to configured engine format
1062: *
1063: * @param string $hostname
1064: * @param string $path
1065: * @param string|null $from null to auto-detect (jail=1 implies "isapi", otherwise "fpm")
1066: * @return bool
1067: */
1068: public function migrate_directives(string $hostname, string $path = '', string $from = null): bool
1069: {
1070: if (!IS_CLI) {
1071: return $this->query('php_migrate_directives', $hostname, $path, $from);
1072: }
1073: if (null === $from) {
1074: $from = $this->jailed() ? 'isapi' : 'fpm';
1075: }
1076:
1077: if ($from !== 'isapi' && $from !== 'fpm') {
1078: return error("Unknown underlying PHP engine `%s'", $from);
1079: }
1080:
1081: if (! ($docroot = $this->web_get_docroot($hostname, $path)) ) {
1082: return false;
1083: }
1084:
1085: $srcFile = $docroot . DIRECTORY_SEPARATOR . ($from === 'isapi' ? '.htaccess' : '.user.ini');
1086: $destFile = $docroot . DIRECTORY_SEPARATOR . ($from === 'isapi' ? '.user.ini' : '.htaccess');
1087: if (!$this->file_exists($srcFile)) {
1088: return true;
1089: }
1090: $srcContents = $destContents = '';
1091: if ($this->file_exists($srcFile)) {
1092: $srcContents = $this->file_get_file_contents($srcFile);
1093: }
1094: if ($this->file_exists($destFile)) {
1095: $destContents = $this->file_get_file_contents($destFile);
1096: }
1097: $directives = [];
1098: $lines = explode("\n", $srcContents);
1099: if ($from === 'fpm') {
1100: foreach ($lines as $line) {
1101: if (false === strpos($line, '=')) {
1102: continue;
1103: } else if (strpos($line, ';') === strcspn(';', $line)) {
1104: // comment
1105: continue;
1106: }
1107: [$ini, $val] = explode('=', trim($line), 2);
1108: $directive = 'php_value';
1109: if (strtolower($val) === 'on' || strtolower($val) === 'off') {
1110: $directive = 'php_flag';
1111: }
1112: $directives[] = "${directive} ${ini} ${val}";
1113: }
1114: } else {
1115: foreach ($lines as &$line) {
1116: if (false === strpos($line, 'php_flag') && false === strpos($line, 'php_value')) {
1117: continue;
1118: }
1119: if (!preg_match('/^\s*(?<directive>php_(?:flag|value))\s+(?<ini>[^ ]+)\s+(?<value>.+)$/', $line, $match)) {
1120: debug("Unknown line `%s' encountered - ignoring", $line);
1121: continue;
1122: }
1123:
1124: $directives[] = $match['ini'] . '=' . $match['value'];
1125: $line = null;
1126: }
1127: }
1128:
1129: // @TODO validate consistency
1130: return $this->file_put_file_contents($destFile, rtrim(rtrim($destContents) . "\n" . implode("\n", $directives)) . "\n") &&
1131: $this->file_put_file_contents($srcFile, implode("\n", array_filter($lines)) . "\n");
1132: }
1133:
1134: public function _create_user(string $user)
1135: { }
1136:
1137: public function _delete_user(string $user)
1138: { }
1139:
1140: public function _edit_user(string $userold, string $usernew, array $oldpwd)
1141: { }
1142:
1143: public function _reload(string $why = '', array $args = [])
1144: {
1145: if (!$this->permission_level & PRIVILEGE_SITE || $why !== \Opcenter\Timezone::RELOAD_HOOK) {
1146: return;
1147: }
1148: if (!$this->jailed()) {
1149: return false;
1150: }
1151: $ini = Map::load($this->domain_fs_path('/etc/php.ini'), 'cd', 'inifile')->section('Custom');
1152: $ini['date.timezone'] = $args['timezone'];
1153: $ini->save();
1154:
1155: return SystemdSynthesizer::mock(Fpm::getGroupActivator($this->site))::restart();
1156: }
1157: }