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