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