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:
695: if ($state === 'reload' && !$svc::running()) {
696: // no-op
697: continue;
698: }
699:
700: if ($svc::run($state)) {
701: continue;
702: }
703:
704: if ($state === 'start' || $state === 'restart') {
705: // Perform quota check
706: $log = Configuration::bindTo($this->domain_fs_path())->
707: setServiceContainer(\Opcenter\SiteConfiguration::shallow($this->getAuthContext()))->
708: setName($p)->getLog();
709: if (!file_exists($this->domain_fs_path() . $log) && $this->file_touch($log) &&
710: $this->file_chown($log, $this->pool_owner($p)))
711: {
712: warn("Log `%s' missing, possibly inhibiting startup. Created", $log);
713: $this->pool_set_state($p, $state);
714: continue;
715: }
716: }
717: warn('Failed to %s %s-%s', $state, $this->site, $p);
718:
719: }
720:
721: return true;
722: }
723:
724: /**
725: * Get jail pools
726: *
727: * @return array
728: */
729: public function pools(): array
730: {
731: if (!$this->jailed()) {
732: return [];
733: }
734: $key = 'php.pools';
735: $cache = \Cache_Account::spawn($this->getAuthContext());
736: if (false !== ($pools = $cache->get($key))) {
737: return $pools;
738: }
739: $pools = array_map(function (string $pool) {
740: return substr($pool, strlen($this->site) + 1);
741: }, Fpm::getPoolsFromGroup($this->site));
742: // @todo possible ghosting
743: $cache->set($key, $pools, 30);
744: return $pools;
745: }
746:
747: /**
748: * Get pool status
749: *
750: * @param string $pool named pool or default
751: * @return array
752: */
753: public function pool_status(string $pool = ''): array
754: {
755: if (!($status = $this->pool_info($pool))) {
756: return [];
757: }
758: return (new Fpm\PoolStatus($status))->getMetrics();
759: }
760:
761: public function _cron(Cronus $c) {
762: static $ctr = 0;
763:
764: if (!TELEMETRY_ENABLED || ++$ctr < 600 / CRON_RESOLUTION) {
765: return;
766: }
767:
768: $ctr = 0;
769: $oldex = \Error_Reporter::exception_upgrade(Error_Reporter::E_FATAL, true);
770: $collector = new Collector(PostgreSQL::pdo());
771: foreach (\Opcenter\Account\Enumerate::active() as $site) {
772: try {
773: $ctx = \Auth::context(null, $site);
774: $afi = \apnscpFunctionInterceptor::factory($ctx);
775: if (!$afi->php_jailed() || !($service = $afi->php_pool_info())) {
776: continue;
777: }
778:
779: $inspector = new Fpm\PoolStatus($service);
780: if (!$inspector->running()) {
781: continue;
782: }
783: $cacheMetrics = $inspector->getCacheMetrics($ctx);
784: $stats = $afi->php_pool_status();
785: if (null === array_get($stats, 'traffic')) {
786: // pool outage/not started - let's not trigger the pool
787: $stats = PhpMetrics::fill(0);
788: } else {
789: $stats['uptime'] = time() - $inspector->getStart();
790: $stats['cache-used'] = array_get($cacheMetrics, 'memory_usage.used_memory', 0)/1024;
791: $stats['cache-total'] = (array_get($cacheMetrics, 'memory_usage.free_memory') + $stats['cache-used'])/1024;
792: $stats['cache-hits'] = array_get($cacheMetrics, 'opcache_statistics.hits', 0);
793: $stats['cache-misses'] = array_get($cacheMetrics, 'opcache_statistics.misses', 0);
794: $stats['traffic'] *= 100;
795: }
796:
797:
798: foreach (PhpMetrics::getAttributeMap() as $attr => $metric) {
799: $val = $stats[$metric];
800:
801: if ($val instanceof Closure) {
802: $val = $val($stats);
803: }
804: // @TODO register additional pool names
805: $collector->add("php-{$attr}", $ctx->site_id, (int)$val);
806: }
807: } catch (\apnscpException $e) {
808: // site doesn't exist
809: continue;
810: }
811: }
812: \Error_Reporter::exception_upgrade($oldex);
813:
814: }
815: /**
816: * Get pool info
817: *
818: * @param string $pool named pool or default
819: * @return array
820: */
821: public function pool_info(string $pool = ''): array
822: {
823: $pools = $this->pools();
824: if ($pool && !in_array($pool, $pools, true)) {
825: error("Invalid pool specified `%s'", $pool);
826:
827: return [];
828: }
829:
830: if (!$pool) {
831: if (\in_array($this->domain, $pools, true)) {
832: $pool = $this->domain;
833: } else {
834: $pool = array_shift($pools);
835: }
836: }
837:
838: // assume default is named after the domain otherwise grab first
839: if (class_exists(\Opcenter\Dbus\Systemd::class)) {
840: return (array)Fpm\SystemdSynthesizer::mock($this->site . '-' . $pool)->status() + Fpm\SystemdSynthesizer::mock($this->site . '-' . $pool)->unitInfo(['Id', 'Description', 'ActiveState']);
841: }
842:
843: return Fpm\SystemdSynthesizer::mock($this->site . '-' . $pool)->status() ?: [];
844: }
845:
846: /**
847: * Get PHP version
848: *
849: * @param string $pool
850: * @return string
851: */
852: public function pool_get_version(string $pool = ''): string
853: {
854: if (!IS_CLI) {
855: return $this->query('php_pool_get_version', $pool);
856: }
857: if (!$this->jailed()) {
858: return $this->version();
859: }
860:
861: return PoolPolicy::instantiateContexted($this->getAuthContext())->getPoolVersion($pool);
862: }
863:
864: /**
865: * Send direct request to FPM worker bypassing HTTP server routing
866: *
867: * @param string $hostname
868: * @param string $file
869: * @return string
870: * @throws \Adoy\FastCGI\ForbiddenException
871: * @throws \Adoy\FastCGI\TimedOutException
872: */
873: public function pool_direct_read(string $hostname, string $file): ?string
874: {
875: if (!IS_CLI) {
876: return $this->query('php_pool_direct_read', $hostname, $file);
877: }
878:
879: if (!$this->jailed()) {
880: warn("PHP-FPM disabled for site - ignoring PHP-FPM pool read");
881: return '';
882: }
883:
884: $path = $this->web_normalize_path($hostname, $file) ?? (\Web_Module::MAIN_DOC_ROOT . "/$file");
885: $pool = $this->php_pool_name($path);
886: $socket = \Opcenter\Http\Php\Fpm\Configuration::bindTo($this->domain_fs_path())->
887: setGroup($this->site)->setName($pool)->getSocketPath();
888:
889: $client = new Client('unix://' . $socket, -1);
890: try {
891: $contents = $client->request([
892: 'REQUEST_METHOD' => 'GET',
893: 'SCRIPT_NAME' => basename($file),
894: 'SCRIPT_FILENAME' => $path,
895: 'DOCUMENT_ROOT' => $this->web_get_docroot($hostname),
896: 'SERVER_SOFTWARE' => PANEL_BRAND . ' ' . APNSCP_VERSION . ' Self-Referential Check',
897: 'REMOTE_ADDR' => '127.0.0.1',
898: 'SERVER_NAME' => $hostname
899: ], '');
900:
901: return ltrim((string)strstr($contents, "\r\n\r\n"));
902: } catch (\Adoy\FastCGI\ForbiddenException $e) {
903: return nerror("Unable to access resource - are permissions correct?");
904: } catch (\Adoy\FastCGI\TimedOutException $e) {
905: return nerror("Request timed out");
906: } catch (\Exception $e) {
907: return nerror("Request failed: %s", $e->getMessage());
908: }
909:
910: }
911:
912: /**
913: * Return pool version from FS path
914: *
915: * @param string $path
916: * @return string
917: */
918: public function pool_version_from_path(string $path): string
919: {
920: if (!IS_CLI) {
921: return $this->query('php_pool_version_from_path', $path);
922: }
923:
924: if (!$this->jailed()) {
925: return $this->version();
926: }
927:
928: $pool = $this->pool_name($path);
929: return PoolPolicy::instantiateContexted($this->getAuthContext())->getPoolVersion($pool);
930: }
931:
932: /**
933: * Set PHP version on pool
934: *
935: * @param ?string $version specified version or server default
936: * @param string $pool optional pool name
937: * @return bool
938: */
939: public function pool_set_version(?string $version, string $pool = ''): bool
940: {
941: if (!IS_CLI) {
942: return $this->query('php_pool_set_version', $version, $pool);
943: }
944:
945: if (!$this->jailed()) {
946: return error("Cannot change version for non-jailed account");
947: }
948:
949: $origPool = $pool;
950: if (!$pool) {
951: $pool = $this->pools();
952: } else if (!$this->pool_exists($pool)) {
953: return error("Invalid pool specified `%s'", $pool);
954: }
955:
956: if (!PoolPolicy::instantiateContexted($this->getAuthContext())->allowed($version)) {
957: return error("PHP version `%s' not installed or permitted on account", $version);
958: }
959:
960: $version = \Opcenter\Versioning::asMinor($version);
961:
962: if (!array_key_exists((string)$version, Fpm\MultiPhp::list())) {
963: if ($version !== ($tmp = \Opcenter\Versioning::asMinor($version))) {
964: warn("Truncating version to `%s'", $tmp);
965: return $this->switch_version($version, $pool);
966: }
967:
968: return error("PHP interpreter %s does not exist", $version);
969: }
970:
971: $svc = \Opcenter\SiteConfiguration::shallow($this->getAuthContext());
972:
973: foreach ((array)$pool as $p) {
974: // @TODO multipool
975: $config = Fpm\Configuration::bindTo($this->domain_fs_path());
976: $config->setServiceContainer($svc);
977: $config->setVersion($version);
978: if ($config->isDefault()) {
979: $config->shimBinaries();
980: }
981: }
982: // write configuration
983: unset($config);
984:
985: return \Util_Account_Editor::instantiateContexted($this->getAuthContext())->setConfig(['apache.jail' => 1])->edit();
986: }
987:
988: /**
989: * @param string $var
990: * @param string|null $pool
991: * @param string|null $default
992: * @return array|ArrayAccess|mixed|null
993: */
994: public function pool_get_policy(string $var, string $pool = null, string $default = null)
995: {
996: if (!IS_CLI) {
997: return $this->query('php_pool_get_policy', $var, $pool, $default);
998: }
999: return PoolPolicy::instantiateContexted($this->getAuthContext())->get($pool, $var, $default);
1000: }
1001:
1002: /**
1003: * Set pool policy value
1004: *
1005: * Note: to set defaults applied to all pools, edit resources/apache/php/policy.blade.php
1006: * @param string $anything site, domain, or invoice
1007: * @param string $var
1008: * @param $val
1009: * @param string|null $pool
1010: * @return bool
1011: */
1012: public function pool_set_policy(string $anything, string $var, $val, string $pool = null): bool
1013: {
1014: if (!IS_CLI) {
1015: return $this->query('php_pool_set_policy', $anything, $var, $val, $pool);
1016: }
1017:
1018: $sites = \Opcenter\Account\Enumerate::matches($anything);
1019: if (!$sites) {
1020: return error("Unknown qualifier `%s'", $anything);
1021: }
1022:
1023: foreach ($sites as $site) {
1024: $ctx = \Auth::context(null, $site);
1025: $afi = \apnscpFunctionInterceptor::factory($ctx);
1026: if ($pool && !$afi->php_pool_exists($pool)) {
1027: return error("Unknown PHP pool `%s'", $pool);
1028: }
1029: PoolPolicy::instantiateContexted($ctx)->set($pool, $var, $val)->sync();
1030: $cmd = (new \Util_Account_Editor(null,
1031: $ctx))->setMode('edit')->setFlags(['reconfig' => true])->getCommand();
1032: $proc = (new Util_Process_Schedule)->setID("php." . $ctx->site);
1033: if (!$proc->preempted("php." . $ctx->site)) {
1034: $proc->run($cmd);
1035: }
1036: }
1037: info("PHP pool settings batched to background");
1038:
1039: return true;
1040: }
1041:
1042: /**
1043: * Get available PHP pool versions
1044: *
1045: * @return array
1046: */
1047: public function pool_versions(): array
1048: {
1049: if (!IS_CLI) {
1050: return $this->query('php_pool_versions');
1051: }
1052: return array_keys(Fpm\MultiPhp::list());
1053: }
1054:
1055: /**
1056: * PHP pool exists
1057: *
1058: * @param string $pool pool name
1059: * @return bool
1060: */
1061: public function pool_exists(string $pool): bool
1062: {
1063: return in_array($pool, $this->pools(), true);
1064: }
1065:
1066: /**
1067: * Migrate PHP directives to configured engine format
1068: *
1069: * @param string $hostname
1070: * @param string $path
1071: * @param string|null $from null to auto-detect (jail=1 implies "isapi", otherwise "fpm")
1072: * @return bool
1073: */
1074: public function migrate_directives(string $hostname, string $path = '', string $from = null): bool
1075: {
1076: if (!IS_CLI) {
1077: return $this->query('php_migrate_directives', $hostname, $path, $from);
1078: }
1079: if (null === $from) {
1080: $from = $this->jailed() ? 'isapi' : 'fpm';
1081: }
1082:
1083: if ($from !== 'isapi' && $from !== 'fpm') {
1084: return error("Unknown underlying PHP engine `%s'", $from);
1085: }
1086:
1087: if (! ($docroot = $this->web_get_docroot($hostname, $path)) ) {
1088: return false;
1089: }
1090:
1091: $srcFile = $docroot . DIRECTORY_SEPARATOR . ($from === 'isapi' ? '.htaccess' : '.user.ini');
1092: $destFile = $docroot . DIRECTORY_SEPARATOR . ($from === 'isapi' ? '.user.ini' : '.htaccess');
1093: if (!$this->file_exists($srcFile)) {
1094: return true;
1095: }
1096: $srcContents = $destContents = '';
1097: if ($this->file_exists($srcFile)) {
1098: $srcContents = $this->file_get_file_contents($srcFile);
1099: }
1100: if ($this->file_exists($destFile)) {
1101: $destContents = $this->file_get_file_contents($destFile);
1102: }
1103: $directives = [];
1104: $lines = explode("\n", $srcContents);
1105: if ($from === 'fpm') {
1106: foreach ($lines as $line) {
1107: if (false === strpos($line, '=')) {
1108: continue;
1109: } else if (strpos($line, ';') === strcspn(';', $line)) {
1110: // comment
1111: continue;
1112: }
1113: [$ini, $val] = explode('=', trim($line), 2);
1114: $directive = 'php_value';
1115: if (strtolower($val) === 'on' || strtolower($val) === 'off') {
1116: $directive = 'php_flag';
1117: }
1118: $directives[] = "{$directive} {$ini} {$val}";
1119: }
1120: } else {
1121: foreach ($lines as &$line) {
1122: if (false === strpos($line, 'php_flag') && false === strpos($line, 'php_value')) {
1123: continue;
1124: }
1125: if (!preg_match('/^\s*(?<directive>php_(?:flag|value))\s+(?<ini>[^ ]+)\s+(?<value>.+)$/', $line, $match)) {
1126: debug("Unknown line `%s' encountered - ignoring", $line);
1127: continue;
1128: }
1129:
1130: $directives[] = $match['ini'] . '=' . $match['value'];
1131: $line = null;
1132: }
1133: }
1134:
1135: // @TODO validate consistency
1136: return $this->file_put_file_contents($destFile, rtrim(rtrim($destContents) . "\n" . implode("\n", $directives)) . "\n") &&
1137: $this->file_put_file_contents($srcFile, implode("\n", array_filter($lines)) . "\n");
1138: }
1139:
1140: public function _create_user(string $user)
1141: { }
1142:
1143: public function _delete_user(string $user)
1144: { }
1145:
1146: public function _edit_user(string $userold, string $usernew, array $oldpwd)
1147: { }
1148:
1149: public function _reload(string $why = '', array $args = [])
1150: {
1151: if (!$this->permission_level & PRIVILEGE_SITE || $why !== \Opcenter\Timezone::RELOAD_HOOK) {
1152: return;
1153: }
1154: if (!$this->jailed()) {
1155: return false;
1156: }
1157: $ini = Map::load($this->domain_fs_path('/etc/php.ini'), 'cd', 'inifile')->section('Custom');
1158: $ini['date.timezone'] = $args['timezone'];
1159: $ini->save();
1160:
1161: return SystemdSynthesizer::mock(Fpm::getGroupActivator($this->site))::restart();
1162: }
1163: }