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