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