1: <?php
2: declare(strict_types=1);
3: /**
4: * +------------------------------------------------------------+
5: * | apnscp |
6: * +------------------------------------------------------------+
7: * | Copyright (c) Apis Networks |
8: * +------------------------------------------------------------+
9: * | Licensed under Artistic License 2.0 |
10: * +------------------------------------------------------------+
11: * | Author: Matt Saladna (msaladna@apisnetworks.com) |
12: * +------------------------------------------------------------+
13: */
14:
15: use Frontend\Css\StyleManager;
16: use Lararia\Bootstrapper;
17: use Lararia\JobDaemon;
18: use Laravel\Horizon\Contracts\JobRepository;
19: use Module\Skeleton\Contracts\Tasking;
20: use Opcenter\Apnscp;
21: use Opcenter\Map;
22: use Opcenter\System\Cgroup\Attributes\Freezer\State;
23: use Opcenter\System\Cgroup\Controller;
24: use Opcenter\System\Memory;
25:
26: /**
27: * Miscellaneous functions that just don't have a place elsewhere
28: *
29: * @package core
30: */
31: class Misc_Module extends Module_Skeleton implements Tasking
32: {
33: const MOUNTRC = '/etc/init.d/vmount';
34: const MEMTEST_KEY = '_misc_cron_memory_test';
35: const MOUNTABLE_SERVICES = [
36: 'procfs', 'fcgi'
37: ];
38: protected $exportedFunctions =
39: [
40: '*' => PRIVILEGE_SITE,
41: 'run_cron' => PRIVILEGE_ADMIN,
42: 'get_job_queue' => PRIVILEGE_ADMIN|PRIVILEGE_SITE,
43: 'jobify' => PRIVILEGE_ADMIN,
44: 'flush_cp_version' => PRIVILEGE_ADMIN,
45: 'cp_version' => PRIVILEGE_ALL,
46: 'platform_version' => PRIVILEGE_ALL,
47: 'dashboard_memory_usage' => PRIVILEGE_ALL,
48: 'lservice_memory_usage' => PRIVILEGE_ALL,
49: 'changelog' => PRIVILEGE_ALL,
50: 'run' => PRIVILEGE_SITE,
51: 'notify_installed' => PRIVILEGE_ADMIN,
52: 'notify_update_failure' => PRIVILEGE_ADMIN,
53: 'list_commands' => PRIVILEGE_ALL,
54: 'command_info' => PRIVILEGE_ALL,
55: 'debug_session' => PRIVILEGE_ADMIN,
56: 'release_fsghost' => PRIVILEGE_ADMIN,
57: 'theme_inventory' => PRIVILEGE_ADMIN,
58: // wrappers for list_commands, command_info
59: 'i' => PRIVILEGE_ALL,
60: 'l' => PRIVILEGE_ALL,
61: ];
62:
63: /**
64: * Current control panel version
65: *
66: * @param string $field
67: * @return array|string
68: */
69: public function cp_version(string $field = '')
70: {
71: return \Opcenter::versionData($field) + ['debug' => is_debug()];
72: }
73:
74: /**
75: * Force recheck on next cp_version query
76: *
77: * @return bool
78: */
79: public function flush_cp_version(): bool
80: {
81: return Opcenter::forgetVersion();
82: }
83:
84: /**
85: * Get platform version
86: *
87: * @return string
88: */
89: public function platform_version(): string
90: {
91: return platform_version();
92: }
93:
94: /**
95: * int dashboard_memory_usage()
96: *
97: * @return int memory usage, in bytes, that the dashboard is currently
98: * consuming
99: */
100: public function dashboard_memory_usage(): int
101: {
102: return memory_get_usage();
103: }
104:
105: /**
106: * int lservice_memory_usage()
107: *
108: * @return int memory usage in bytes
109: */
110: public function apnscpd_memory_usage(): int
111: {
112: if (!IS_CLI) {
113: return $this->query('misc_apnscpd_memory_usage');
114: }
115:
116: return memory_get_usage();
117: }
118:
119: /**
120: * Toggle procfs presence
121: *
122: * @return bool
123: */
124: public function toggle_procfs(): bool
125: {
126: if (!$this->getServiceValue('ssh', 'enabled')) {
127: return error('procfs requires ssh');
128: }
129: if ($this->is_mounted('procfs')) {
130: return $this->unmount_service('procfs');
131: }
132:
133: return $this->mount_service('procfs');
134: }
135:
136: /**
137: * Service is mounted
138: *
139: * @param string $svc
140: * @return bool
141: */
142: public function is_mounted(string $svc): bool
143: {
144: if (!\in_array($svc, static::MOUNTABLE_SERVICES, true)) {
145: return error("Unknown service `%s'", $svc);
146: }
147: // helios & apollo automatically mount fcgi
148: if (version_compare(platform_version(), '6', '>=')) {
149: // sol automatically mounts procfs
150: return true;
151: }
152: $proc = Util_Process::exec('%s mounted %s %s',
153: self::MOUNTRC,
154: $this->site,
155: $svc,
156: array(0, 1)
157: );
158:
159: return $proc['return'] === 0;
160: }
161:
162: /**
163: * Unmount service from site
164: *
165: * @param string $svc
166: * @return bool
167: */
168: public function unmount_service(string $svc): bool
169: {
170: if (!\in_array($svc, static::MOUNTABLE_SERVICES, true)) {
171: return error("Unknown service `%s'", $svc);
172: }
173: // helios & apollo automatically mount fcgi
174: if ($svc == 'procfs' && version_compare(platform_version(), '6', '>=')) {
175: return true;
176: }
177:
178: if (!IS_CLI) {
179: return $this->query('misc_unmount_service', $svc);
180: }
181: $proc = Util_Process::exec(
182: '%s unmount %s %s',
183: self::MOUNTRC,
184: $this->site,
185: $svc
186: );
187: if ($proc['errno'] != 0) {
188: return false;
189: }
190:
191: return $this->_edit_mount_map($svc, false) !== 0;
192: }
193:
194: /**
195: * Update internal mount map
196: *
197: * @param string $svc
198: * @param bool $mount
199: * @return int
200: */
201: private function _edit_mount_map(string $svc, bool $mount): int
202: {
203: $sysconf = '/etc/sysconfig/vmount-' . $svc;
204: touch($sysconf);
205: $sites = explode("\n", trim(file_get_contents($sysconf)));
206: $idx = array_search($this->site, $sites, true);
207: if ($mount && $idx === false) {
208: $sites[] = $this->site;
209: } else if (!$mount && $idx !== false) {
210: unset($sites[$idx]);
211: } else {
212: return -1;
213: }
214: file_put_contents($sysconf, join("\n", $sites));
215:
216: return 1;
217: }
218:
219: /**
220: * Mount service
221: *
222: * @param string $svc
223: * @return bool
224: */
225: public function mount_service($svc): bool
226: {
227: if (!\in_array($svc, static::MOUNTABLE_SERVICES, true)) {
228: return error("Unknown service `%s'", $svc);
229: }
230: // helios & apollo automatically mount fcgi
231: if ($svc == 'fcgi' && version_compare(platform_version(), '4.5', '>=')) {
232: return true;
233: }
234: if ($svc == 'procfs' && version_compare(platform_version(), '6', '>=')) {
235: return true;
236: }
237: if (!IS_CLI) {
238: return $this->query('misc_mount_service', $svc);
239: }
240: $proc = Util_Process::exec(
241: '%s mount %s %s',
242: self::MOUNTRC,
243: $this->site,
244: $svc
245: );
246: if ($proc['return'] !== 0) {
247: return false;
248: }
249:
250: return $this->_edit_mount_map($svc, true) !== 0;
251: }
252:
253: /**
254: * procfs is mounted
255: *
256: * @return bool
257: */
258: public function procfs_enabled(): bool
259: {
260: return $this->is_mounted('procfs');
261: }
262:
263: /**
264: * Get changelog
265: *
266: * @return array
267: */
268: public function changelog(): array
269: {
270: $cache = \Cache_Global::spawn();
271: $key = 'misc.changelog';
272: $changelog = $cache->get($key);
273: if ($changelog) {
274: return $changelog;
275: }
276:
277: $proc = Util_Process::exec('cd ' . INCLUDE_PATH . ' && git log --submodule -n 15 ');
278: if (!$proc['success']) {
279: return [];
280: }
281: $res = [];
282: preg_match_all(Regex::CHANGELOG_COMMIT, $proc['output'], $matches, PREG_SET_ORDER);
283: foreach ($matches as $match) {
284: foreach (array_keys($match) as $key) {
285: if (is_numeric($key)) {
286: unset($match[$key]);
287: } else if ($key === 'msg') {
288: $match[$key] = trim($match[$key]);
289: } else if ($key === 'date') {
290: // rename to ts for more appropriate data type
291: $match['ts'] = strtotime($match[$key]);
292: unset($match[$key]);
293: }
294: }
295: $res[] = $match;
296: }
297: $cache->set($key, $res);
298:
299: return $res;
300: }
301:
302: /**
303: * Notify admin panel has been installed
304: *
305: * @param string $password
306: * @return bool
307: */
308: public function notify_installed(string $password): bool
309: {
310: if (!($email = $this->admin_get_email())) {
311: return error('Cannot send notification email - no email defined! See docs/INSTALL.md');
312: }
313: $ip = \Opcenter\Net\Ip4::my_ip();
314: $link = "https://" . $ip . ":" . Auth_Redirect::CP_SSL_PORT;
315:
316: if ($ip === Net_Gethost::gethostbyname_t(SERVER_NAME, 1500) && $this->common_get_email()) {
317: $link = "https://" . SERVER_NAME . ":" . Auth_Redirect::CP_SSL_PORT;
318: }
319:
320: $mail = Illuminate\Support\Facades\Mail::to($email);
321: $args = [
322: 'secure_link' => $link,
323: 'hostname' => SERVER_NAME,
324: 'admin_user' => $this->username,
325: 'admin_password' => $password,
326: 'apnscp_root' => INCLUDE_PATH,
327: 'ip' => \Opcenter\Net\Ip4::my_ip()
328: ];
329: $mail->send(new \Lararia\Mail\PanelInstalled($args));
330:
331: return true;
332: }
333:
334: /**
335: * Scan for update failure notifying admin
336: *
337: * @return bool
338: */
339: public function notify_update_failure(): bool
340: {
341: // @TODO crm_notify() support
342:
343: if (!($email = $this->admin_get_email())) {
344: return error('Cannot send notification email - no email defined! See docs/INSTALL.md');
345: }
346:
347: if (!file_exists($path = storage_path('.upcp.failure'))) {
348: return true;
349: }
350:
351: $subject = \ArgumentFormatter::format('%s Update Failure', [PANEL_BRAND]);
352: $mail = Illuminate\Support\Facades\Mail::to($email);
353: $msg = (new \Lararia\Mail\Simple('email.admin.update-failed'))
354: ->asMarkdown()->subject($subject)->attach($path, ['as' => 'update-log.txt', 'mime' => 'text/plain']);
355: $mail->send($msg);
356: unlink($path);
357: return true;
358: }
359:
360: /**
361: * Get all available module commands
362: *
363: * @param string $filter optional filter following glob-style rules
364: * @return array
365: */
366: public function list_commands(string $filter = ''): array
367: {
368: $fns = [];
369: $modules = \apnscpFunctionInterceptor::list_all_modules();
370: asort($modules);
371: foreach ($modules as $module) {
372: $moduleFns = $this->getApnscpFunctionInterceptor()->authorized_functions($module);
373: asort($moduleFns);
374: if ($filter) {
375: $moduleFns = array_filter($moduleFns, static function ($fn) use ($filter, $module) {
376: return $filter === $module || fnmatch($filter, "${module}_${fn}")
377: || fnmatch($filter, "$module:" . str_replace('_', '-', $fn));
378: });
379: }
380: $fns[$module] = array_values($moduleFns);
381: }
382:
383: return array_filter($fns);
384: }
385:
386: /**
387: * Enable debugging for a frontend session
388: *
389: * @param string $id session ID
390: * @param bool $state debug state to set
391: * @return bool
392: */
393: public function debug_session(string $id, bool $state = true): bool
394: {
395: if (!is_debug()) {
396: return error('%s may only be called when debug mode is enabled', __FUNCTION__);
397: }
398: if (!apnscpSession::init()->exists($id)) {
399: return error('Session %s does not exist', $id);
400: }
401:
402: if (!$old = session_id()) {
403: fatal('???');
404: }
405:
406: if (extension_loaded('pcntl')) {
407: $asyncEnabled = pcntl_async_signals(false);
408: }
409: $oldId = \session_id();
410: if (!apnscpSession::restore_from_id($id, false)) {
411: fatal('Unable to restore session');
412: }
413:
414: Session::set('DEBUG', $state);
415:
416: if (!apnscpSession::restore_from_id($oldId, false)) {
417: fatal('Failed to revert session');
418: }
419:
420: if (extension_loaded('pcntl')) {
421: pcntl_signal_dispatch();
422: pcntl_async_signals($asyncEnabled);
423: }
424:
425: return true;
426: }
427:
428: /**
429: * Get command information
430: *
431: * @param string $filter
432: * @return array single or multi keyed by name => [doc, parameters, min, max, return, signature]
433: */
434: public function command_info(string $filter = ''): array
435: {
436: $fns = $this->list_commands($filter);
437: if (!$fns) {
438: return [];
439: }
440: $info = [];
441:
442: foreach ($fns as $module => $moduleFunctions) {
443: $class = apnscpFunctionInterceptor::get_autoload_class_from_module($module);
444: $instance = $class::autoloadModule($this->getAuthContext());
445: try {
446: $rfxn = new ReflectionClass($instance);
447: } catch (ReflectionException $e) {
448: debug("Failed to reflect class `%s': %s", $class, $e->getMessage());
449: continue;
450: }
451: foreach ($moduleFunctions as $fn) {
452: try {
453: $rfxnMethod = $rfxn->getMethod($fn);
454: } catch (ReflectionException $e) {
455: debug("Failed to reflect `%s'::`%s': %s", $module, $fn, $e->getMessage());
456: continue;
457: }
458: $signature = "${module}_${fn}(";
459: $args = [];
460: foreach ($rfxnMethod->getParameters() as $param) {
461: $parameterSignature = '';
462: if ($param->isOptional()) {
463: $parameterSignature .= '[';
464: }
465: if ($param->getType()) {
466: $parameterSignature .= $param->getType()->getName() . ' ';
467: }
468: $parameterSignature .= '$' . $param->getName();
469: $args[] = $parameterSignature;
470: }
471: $signature .= implode(',', $args) .
472: str_repeat(
473: ']',
474: $rfxnMethod->getNumberOfParameters() - $rfxnMethod->getNumberOfRequiredParameters()
475: ) . ')';
476: $return = null;
477: if ($rfxnMethod->getReturnType()) {
478: $return = $rfxnMethod->getReturnType()->getName();
479: }
480: $args = [
481: 'doc' => preg_replace('/^\s+/m', '', $rfxnMethod->getDocComment()),
482: 'parameters' => array_map('\strval', $rfxnMethod->getParameters()),
483: 'min' => $rfxnMethod->getNumberOfRequiredParameters(),
484: 'max' => $rfxnMethod->getNumberOfParameters(),
485: 'return' => $return,
486: 'signature' => $signature
487: ];
488: $info["${module}_${fn}"] = $args;
489: }
490: }
491:
492: if (\count($info) === 1) {
493: return array_pop($info);
494: }
495:
496: return $info;
497: }
498:
499:
500: /**
501: * Wrapper for list_commands
502: *
503: * @param string $filter
504: * @return array
505: */
506: public function l(string $filter = ''): array
507: {
508: return $this->list_commands($filter);
509: }
510:
511: /**
512: * Wrapper for command_info
513: *
514: * @param string $filter
515: * @return array
516: */
517: public function i(string $filter = ''): array
518: {
519: return $this->command_info($filter);
520: }
521:
522: /**
523: * Get pending/running job queue
524: *
525: * @return array
526: */
527: public function get_job_queue(): array
528: {
529: $app = \Lararia\Bootstrapper::minstrap();
530: $jobs = $app->make(JobRepository::class);
531: if (!$jobs) {
532: return [];
533: }
534: return $jobs->getRecent()->map(static function ($job) {
535: $payload = json_decode((string)$job->payload, true);
536: $job->tag = (array)array_get((array)$payload, 'tags', []);
537: $job->payload = null;
538: return $job;
539: })->filter(function ($job) {
540: return (!$this->site || in_array($this->site, $job->tag, true)) &&
541: !$job->completed_at && !$job->failed_at && $job->status;
542: })->values()->toArray();
543: }
544:
545: /**
546: * Run command as a job
547: *
548: * @param string $cmd
549: * @param array $args
550: * @param string|null $site optional site to run as
551: * @return bool
552: */
553: public function jobify(string $cmd, array $args = [], string $site = null): bool
554: {
555: if (DEMO_ADMIN_LOCK && posix_getuid()) {
556: return error("Demo may not schedule jobs");
557: }
558: $context = \Auth::context(null, $site);
559: $job = \Lararia\Jobs\Job::create(
560: \Lararia\Jobs\SimpleCommandJob::class,
561: $context,
562: $cmd,
563: ...$args
564: );
565: $job->setTags([$context->site, $cmd]);
566: $job->dispatch();
567:
568: return true;
569: }
570:
571:
572: public function _edit()
573: {
574: $conf_old = $this->getAuthContext()->getAccount()->old;
575: $conf_new = $this->getAuthContext()->getAccount()->new;
576: if ($conf_new == $conf_old) {
577: return;
578: }
579: if (!$conf_new['ssh']['enabled']) {
580: $this->_delete();
581: }
582:
583: return;
584: }
585:
586: public function _delete()
587: {
588: $services = array('procfs', 'fcgi');
589: foreach ($services as $s) {
590: if ($this->is_mounted($s)) {
591: $this->unmount_service($s);
592: }
593: }
594: }
595:
596: public function _cron(Cronus $cron) {
597: \Opcenter\Http\Apnscp::cull();
598: if (JobDaemon::isStandalone() && !JobDaemon::checkState()) {
599: JobDaemon::get()->start();
600: }
601: $this->checkMemory();
602:
603: if (!APNSCPD_HEADLESS && SCREENSHOTS_ENABLED) {
604: $cron->schedule(86400*5, 'theme', function () {
605: $this->theme_inventory();
606: });
607: }
608:
609: if (\Opcenter\License::get()->isTrial() && ($email = $this->admin_get_email())) {
610: $cron->schedule(86400, 'notify-trial', function () use ($email) {
611: $license = \Opcenter\License::get();
612: if (in_array($license->daysUntilExpire(), [1, 3, 7], true)) {
613: $ip = \Opcenter\Net\Ip4::my_ip();
614: $link = "https://" . $ip . ":" . Auth_Redirect::CP_SSL_PORT;
615:
616: if ($ip === Net_Gethost::gethostbyname_t(SERVER_NAME, 1500) && $this->common_get_email()) {
617: $link = "https://" . SERVER_NAME . ":" . Auth_Redirect::CP_SSL_PORT;
618: }
619:
620: $mail = Illuminate\Support\Facades\Mail::to($email);
621: $args = [
622: 'secure_link' => $link,
623: 'hostname' => SERVER_NAME,
624: 'expire' => $license->daysUntilExpire(),
625: 'ip' => \Opcenter\Net\Ip4::my_ip()
626: ];
627: $mail->send(new \Lararia\Mail\TrialEnding($args));
628: }
629: });
630: }
631: }
632:
633: private function checkMemory(): void
634: {
635: static $cfg;
636:
637: if (null === $cfg) {
638: $cfg = [
639: 'maxmemory' => Memory::stats()['memtotal'] . 'KB'
640: ];
641: foreach (['redis.conf'] as $f) {
642: $path = config_path($f);
643: if (!file_exists($path)) {
644: continue;
645: }
646: $cfg = Map::load($path, 'r', 'textfile')->fetchAll() + $cfg;
647: }
648: $cfg['maxmemory'] = Formatter::changeBytes($cfg['maxmemory']);
649: }
650:
651: $cache = \Cache_Global::spawn();
652: $stats = $cache->info();
653:
654: if ($stats['used_memory'] < ($cfg['maxmemory'] * 0.995 /* crit limit */)) {
655: return;
656: }
657:
658: try {
659: // reclaimable entries may be purged to push a storage through, emulate the request to verify
660: $count = (int)(max(0, $cfg['maxmemory'] - $stats['used_memory']) + 2);
661: $payload = str_repeat('X', $count);
662: if (!$cache->rawCommand("SET", \Cache_Global::$key . self::MEMTEST_KEY, $payload, 1)) {
663: throw new RuntimeException($cache->getLastError());
664: }
665: } catch (RedisException|RuntimeException $e) {
666: warn("Redis memory usage `%.2f' MB within maxmemory `%.2f' MB - raising by 20%%",
667: Formatter::changeBytes($stats['used_memory'], 'MB', 'B'),
668: Formatter::changeBytes($cfg['maxmemory'], 'MB', 'B')
669: );
670: // can't use Map::load() when multiple rename-command directives exist
671: $path = config_path('redis.conf');
672: $contents = file_get_contents($path);
673: $replacement = 'maxmemory ' . (int)(Formatter::changeBytes($stats['maxmemory'], 'MB', 'B') * 1.2) . 'MB';
674: $re = Regex::compile(Regex::REDIS_DIRECTIVE_C, ['directive' => 'maxmemory']);
675: $new = preg_replace($re, $replacement, $contents);
676: if ($new === $contents) {
677: $new .= "\n" . $replacement;
678: }
679: file_put_contents($path, $new);
680: silence(static function () use ($cache) {
681: Lararia\Bootstrapper::minstrap();
682: JobDaemon::get()->running() && JobDaemon::get()->kill();
683: try {
684: // a shutdown will abruptly exit
685: $cache->rawCommand('SHUTDOWN');
686: } catch (RedisException $e) {
687: }
688: unset($cache);
689: Apnscp::restart('now');
690: exit;
691: });
692: } finally {
693: $cache->del(self::MEMTEST_KEY);
694: }
695: }
696:
697: public function theme_inventory() {
698: if (!IS_CLI) {
699: return $this->query('misc_theme_inventory');
700: }
701:
702: $site = \Opcenter\Account\Ephemeral::create(['apache.enabled' => false]);
703: $driver = new \Service\BulkCapture(new \Service\CaptureDevices\Chromedriver);
704: $ctx = $site->getContext();
705: $afi = $site->getApnscpFunctionInterceptor();
706: $id = $this->admin_hijack($ctx->site, null, 'UI');
707: debug("Setting id: %s", $id);
708: $prefs = $afi->common_load_preferences();
709: foreach (StyleManager::getThemes() as $theme) {
710: array_set($prefs, Page_Renderer::THEME_KEY, $theme);
711: $afi->common_save_preferences($prefs);
712: debug('Capturing theme %s on %s', $theme, $ctx->site);
713: $driver->snap(\Opcenter\Http\Apnscp::CHECK_URL, '/apps/dashboard?' . session_name() . '=' . $id, null, storage_path('themes/' . $theme . '.png'));
714: usleep(500000);
715: }
716: $site->destroy();
717:
718: return true;
719: }
720:
721: /**
722: * Immediately run marked cron services
723: *
724: * @param mixed $module
725: * @return void
726: */
727: public function run_cron(mixed $module = null): void
728: {
729: if (!IS_CLI && posix_getuid()) {
730: $this->query('misc_run_cron', $module);
731:
732: return;
733: }
734:
735: if (!$module) {
736: $module = \apnscpFunctionInterceptor::list_all_modules();
737: } else {
738: $module = (array)$module;
739: }
740:
741: foreach ($module as $m) {
742: $class = \apnscpFunctionInterceptor::get_class_from_module($m);
743: if (!method_exists($class, '_cron')) {
744: debug("No cron method on %(module)s (%(impl)s)", ['module' => $m, 'impl' => $class]);
745: continue;
746: }
747:
748: $instance = $class::instantiateContexted($this->getAuthContext());
749: $cron = new Cronus;
750: $cron->force = true;
751: $instance->_cron($cron);
752:
753: }
754: }
755:
756: public function _housekeeping()
757: {
758: $this->checkMemory();
759:
760: // flush cp pagespeed cache
761: if (extension_loaded('curl')) {
762: $adapter = new HTTP_Request2_Adapter_Curl();
763: } else {
764: $adapter = new HTTP_Request2_Adapter_Socket();
765: }
766: if (!APNSCPD_HEADLESS) {
767: dlog('Purging CP pagespeed cache');
768: $url = 'http://localhost:' . Auth_Redirect::CP_PORT . '/*';
769:
770: $http = new HTTP_Request2(
771: $url,
772: 'PURGE',
773: array(
774: 'adapter' => $adapter,
775: 'store_body' => false,
776: 'timeout' => 5,
777: 'connect_timeout' => 3
778: )
779: );
780: try {
781: $http->send();
782: } catch (Exception $e) {
783: dlog("WARN: failed to purge pagespeed cache, %s. Is `%s' reachable?",
784: $e->getMessage(),
785: dirname($url));
786: }
787: }
788:
789: $ret = \Util_Process::exec(['%s/artisan', 'config:cache'], INCLUDE_PATH);
790: if ($ret['success']) {
791: dlog('Cached Laravel configuration');
792: } else if (str_contains($ret['stderr'], 'type array, int given')) {
793: foreach(['packages', 'services'] as $cfg) {
794: unlink(storage_path("cache/{$cfg}.php"));
795: }
796: Apnscp::restart('now');
797: } else {
798: dlog('Failed to cache Laravel configuration - %s', coalesce($ret['stderr'], $ret['stdout']));
799: }
800: $path = Bootstrapper::app()->getCachedConfigPath();
801: if (file_exists($path) && filesize($path) === 0) {
802: dlog("Removing zero-byte cached configuration in `%s'", $path);
803: unlink($path);
804: }
805:
806: dlog('Updating browscap');
807:
808: \Util_Browscap::update();
809:
810: if (Opcenter::updateTags()) {
811: dlog('Release tags updated');
812: }
813:
814: dlog('Rewriting AOF data');
815: try {
816: /**
817: * Close connection once BGREWRITEAOF command is sent.
818: * Failure to close results in desynchronous results getting sent back
819: * such as BGREWRITEAOF status or incorrect GETs
820: */
821: if (!Cache_Global::spawn()->bgrewriteaof()) {
822: throw new \RedisException('Failed to perform bgrewrite operation');
823: }
824: Cache_Base::disconnect();
825: } catch (\RedisException $e) {
826: warn('Failed to rewrite AOF');
827: }
828:
829: return true;
830: }
831:
832: /**
833: * Flush sites retaining old inode copy
834: *
835: * @param string $bin
836: * @return bool
837: */
838: public function release_fsghost(string $bin): bool
839: {
840: if (!IS_CLI) {
841: return $this->query('misc_release_fsghost', $bin);
842: }
843: if ($bin[0] !== '/') {
844: return error("Path must be absolute");
845: }
846:
847: $inode = null;
848: foreach(\Opcenter\Service\ServiceLayer::available() as $service) {
849: if (is_file($tmp = FILESYSTEM_TEMPLATE . "/{$service}/{$bin}")) {
850: $inode = stat($tmp)['ino'];
851: debug("Detected binary %(path)s under %(service)s with inode %(inode)d", [
852: 'path' => $bin, 'service' => $service, 'inode' => $inode
853: ]);
854: break;
855: }
856: }
857: if (null === $inode) {
858: return error("File does not exist within `%(path)s'", ['path' => FILESYSTEM_TEMPLATE]);
859: }
860:
861: // update symlinks
862: $bin = realpath($bin);
863: $inodeWhitelist = [$inode];
864: if (is_file($bin) && ($tmp = stat($bin)['ino']) !== $inode) {
865: debug("Detected system binary %(path)s with additional inode %(inode)d", ['path' => $bin, 'inode' => $tmp]);
866: $inodeWhitelist[] = $tmp;
867: }
868:
869: $siteKill = [];
870: \Opcenter\Process::all(function($pid) use ($bin, $inodeWhitelist, &$siteKill) {
871: debug("Examining PID %d", $pid);
872: $filemap = \Opcenter\Process::maps($pid);
873: foreach ((array)$filemap as $entry) {
874: if (null === $entry['pathname']) {
875: continue;
876: }
877:
878: if ($entry['pathname'] !== $bin && !fnmatch(FILESYSTEM_VIRTBASE . "/site[0-9]*/fst{$bin}", $entry['pathname'])) {
879: continue;
880: }
881:
882: if (in_array($entry['inode'], $inodeWhitelist, true)) {
883: continue;
884: }
885:
886: info("Process %(pid)s with path %(path)s - inode %(inode)d ghosted",
887: ['pid' => $pid, 'path' => $entry['pathname'], 'inode' => $entry['inode']]);
888: $siteid = \Opcenter\Process::siteProcess($pid);
889: if (null === $siteid) {
890: warn("Cannot detect site root from process %(pid)d", ['pid' => $pid]);
891: continue;
892: }
893:
894: if (isset($siteKill[$siteid])) {
895: debug("Lingering process post-kill on %(site)s? PID: %(pid)d, name: %(name)s", [
896: 'site' => "site{$siteid}", 'pid' => $pid, 'name' => $entry['pathname']]);
897: continue;
898: }
899:
900: $controller = Controller::make(new \Opcenter\System\Cgroup\Group("site{$siteid}"), 'freezer');
901: if ($controller->exists()) {
902: $controller->createAttribute('state', State::STATE_FROZEN)->activate();
903: defer($_,
904: static fn() => $controller->createAttribute('state', State::STATE_THAWED)->activate());
905: }
906:
907: $this->admin_kill_site("site{$siteid}");
908: (new \Opcenter\Service\ServiceLayer("site{$siteid}"))->flush();
909: info("Forced process kill and flush on site%(siteid)d", ['siteid' => $siteid]);
910: $siteKill[$siteid] = 1;
911: break;
912: }
913: });
914:
915: return true;
916: }
917: }