1: <?php
2: declare(strict_types=1);
3:
4: /**
5: * +------------------------------------------------------------+
6: * | apnscp |
7: * +------------------------------------------------------------+
8: * | Copyright (c) Apis Networks |
9: * +------------------------------------------------------------+
10: * | Licensed under Artistic License 2.0 |
11: * +------------------------------------------------------------+
12: * | Author: Matt Saladna (msaladna@apisnetworks.com) |
13: * +------------------------------------------------------------+
14: */
15:
16: /**
17: * Class Pman_Module
18: *
19: * Process management
20: *
21: * @package core
22: */
23: class Pman_Module extends Module_Skeleton
24: {
25: const PROC_CACHE_KEY = 'pman.all';
26: const MAX_WAIT_TIME = 900;
27: // max CPU time in seconds for a process executed by pman:run
28: const MAX_CPU_TIME = self::MAX_WAIT_TIME*2;
29: /* biggest signal number + 1 taken from bits/signum.h */
30: const _NSIG = 65;
31: // conditionally defined if pcntl enabled
32: const SIGKILL = 9;
33:
34: public $exportedFunctions = array(
35: '*' => PRIVILEGE_ALL,
36: 'schedule_api_cmd_admin' => PRIVILEGE_ADMIN
37: );
38:
39: public function __construct()
40: {
41: parent::__construct();
42: }
43:
44: /**
45: * Terminal a process with SIGKILL
46: *
47: * @param int $pid process
48: * @return bool
49: */
50: public function kill($pid)
51: {
52: if (DEMO_ADMIN_LOCK && ($this->permission_level & PRIVILEGE_ADMIN) && posix_getuid()) {
53: return error("Demo may not modify processes");
54: }
55:
56: // SIGKILL isn't defined in ISAPI?
57: return $this->signal($pid);
58: }
59:
60: /**
61: * Send a POSIX signal a process
62: *
63: * @param int $pid
64: * @param int $signal
65: * @return bool
66: */
67: public function signal($pid, $signal = self::SIGKILL)
68: {
69: if (DEMO_ADMIN_LOCK && ($this->permission_level & PRIVILEGE_ADMIN) && posix_getuid()) {
70: return error("Demo may not modify processes");
71: }
72:
73: if (!IS_CLI) {
74: return $this->query('pman_signal', $pid, $signal);
75: }
76:
77: if (is_string($pid) && !ctype_digit($pid)) {
78: return error("invalid pid `%s'", $pid);
79: }
80: $signal = (int)$signal;
81: if ($signal < -1 || $signal > self::_NSIG) {
82: return error('invalid signal %d', $signal);
83: }
84:
85: if ($this->permission_level & PRIVILEGE_ADMIN) {
86: return posix_kill((int)$pid, $signal);
87: }
88:
89: $user = $this->username;
90:
91: if ($this->permission_level & PRIVILEGE_SITE) {
92: $procs = $this->get_processes();
93: if (isset($procs[$pid]) && $procs[$pid]['user'] >= User_Module::MIN_UID) {
94: $user = $this->user_get_username_from_uid($procs[$pid]['user']) ?: $user;
95: }
96: }
97: $proc = new Util_Process_Sudo;
98: $proc->setUser($user);
99: $status = $proc->run('/bin/kill -%d %d ', $signal, (int)$pid, [0], ['user' => $user]);
100:
101: if (!$status['success']) {
102: return error('kill failed: %s', $status['stderr']);
103: }
104:
105: return $status['success'];
106: }
107:
108: /**
109: * Stat a running process
110: *
111: * @param int $pid process id
112: * @return array stat or empty array
113: *
114: * Sample response:
115: * Array
116: * (
117: * [pid] => 8849
118: * [comm] => bash
119: * [stat] => S
120: * [ppid] => 8848
121: * [pgrp] => 8849
122: * [session] => 5185
123: * [tty_nr] => 34816
124: * [tpgid] => 27992
125: * [flags] => 4219136
126: * [minflt] => 47250071
127: * [cminflt] => 154934160
128: * [majflt] => 0
129: * [cmajflt] => 0
130: * [utime] => 101.48
131: * [stime] => 403.61
132: * [cutime] => 0
133: * [cstime] => 0
134: * [priority] => 39
135: * [nice] => 19
136: * [num_threads] => 1
137: * [itrealvalue] => 0
138: * [starttime] => 50639.3
139: * [vsize] => 4988
140: * [rss] => 2516
141: * [rsslim] => 524288
142: * [user] => 514
143: * [cwd] => /
144: * [startutime] => 1430844663
145: * [args] => Array
146: * (
147: * )
148: *
149: * pid: process id
150: * comm: raw command name
151: * args: command arguments
152: * stat: process state, one char of RSDZTW, R = running
153: * ppid: parent PID
154: * pgrp: process group ID
155: * session: process session ID
156: * tty_nr: controlling terminal in bitmap
157: * tpgid: ID of foreground process group of controlling terminal proc
158: * flags: task flags
159: * minflt: number of minor faults
160: * cminflt: number of minor faults in children
161: * majflt: number of major faults
162: * cmajflt: number of major faults in children
163: * utime: user time in seconds (NB: converted from jiffies)
164: * stime: system time in seconds (NB: converted from jiffies)
165: * cutime: user time of children in seconds (NB: converted from jiffies)
166: * cstime: system time of chldren in seconds (NB: converted from jiffies)
167: * priority: process priority level
168: * nice: nice level
169: * num_threads: number of threads
170: * itrealvalue: obsolete (always 0)
171: * starttime: time the process started after boot
172: * startutime: time the process started after boot in unixtime
173: * vsize: virtual memory size in KB (NB: converted from pages)
174: * rss: resident set memory size in KB (NB: converted from pages)
175: * rsslim: current limit in KB of the rss
176: * user: user id of the process (translate w/ user_get_username_from_uid)
177: *
178: */
179: public function stat(int $pid)
180: {
181: if (!IS_CLI) {
182: if (DEMO_ADMIN_LOCK && ($this->permission_level & PRIVILEGE_ADMIN)) {
183: error("Demo may not modify processes");
184: return [];
185: }
186:
187: return $this->query('pman_stat', $pid);
188: }
189: $procs = $this->_processAccumulator();
190: if (isset($procs[$pid])) {
191: return $procs[$pid];
192: }
193:
194: return array();
195: }
196:
197: /**
198: * Collect all processes for a site
199: *
200: * @return array
201: */
202: private function _processAccumulator()
203: {
204: $cache = Cache_Account::spawn($this->getAuthContext());
205: $all = $cache->get(self::PROC_CACHE_KEY);
206: if ($all !== false && \is_array($all)) {
207: return $all;
208: }
209:
210: $pids = $this->_collectPids();
211: $all = Error_Reporter::silence(static function() use($pids) {
212: return \Opcenter\Process::stat($pids);
213: });
214: $uptime = file_get_contents('/proc/uptime');
215: $now = time();
216: [$uptime] = explode(' ', $uptime, 1);
217:
218: foreach ($all as &$proc) {
219: if (!$this->permission_level & PRIVILEGE_ADMIN) {
220: $proc['cwd'] = $this->file_canonicalize_site($proc['cwd']);
221: }
222: $proc['startutime'] = round($now - ((int)$uptime - $proc['starttime']));
223: }
224: unset($proc);
225:
226: $cache->set(self::PROC_CACHE_KEY, $all, 15);
227: return $all;
228: }
229:
230: /**
231: * Get active processes
232: *
233: * @return array
234: */
235: private function _collectPids()
236: {
237: $controllers = $this->cgroup_enabled() ? $this->cgroup_get_controllers() : [];
238: // memory + cpu proc lists are balanced
239: $procs = null;
240:
241: $group = new \Opcenter\System\Cgroup\Group($this->site);
242: if ($this->permission_level & (PRIVILEGE_SITE | PRIVILEGE_USER) && $group->hasGroups()) {
243: $group = new \Opcenter\System\Cgroup\Group($group . \Opcenter\System\Cgroup\Group::CIRCULAR_IDENTIFIER);
244: }
245: foreach ($controllers as $controller) {
246: // find first charged controller
247: $controller = \Opcenter\System\Cgroup\Controller::make($group, $controller);
248: if (!$controller->exists()) {
249: continue;
250: }
251: $procs = $controller->processes();
252: break;
253: }
254: $isAdmin = ($this->permission_level & PRIVILEGE_ADMIN);
255: if (!$isAdmin && null !== $procs) {
256: return array_map('\intval', $procs);
257: }
258: return \Opcenter\Process::all(function (int $pid) use ($isAdmin){
259: return $isAdmin || filegroup(\Opcenter\Process::PROC_PATH . "/$pid") === $this->group_id;
260: });
261:
262: }
263:
264: /**
265: * Get active process count
266: *
267: * Count is fetched from cache. {@see flush} may be necessary
268: *
269: * @return int
270: */
271: public function pcount()
272: {
273: $count = count($this->_processAccumulator());
274:
275: return $count;
276: }
277:
278: /**
279: * Flush process accumulator cache
280: *
281: * @return bool
282: */
283: public function flush()
284: {
285: $cache = Cache_Account::spawn($this->getAuthContext());
286:
287: return $cache->del(self::PROC_CACHE_KEY);
288: }
289:
290: /**
291: * Get all processes
292: *
293: * @return array {@see stat}
294: */
295: public function get_processes()
296: {
297: if (!IS_CLI) {
298: return $this->query('pman_get_processes');
299: }
300:
301: return $this->_processAccumulator();
302: }
303:
304: /**
305: * Run a process
306: *
307: * Sample response:
308: *
309: * Array
310: * (
311: * [stdin] =>
312: * [stdout] => Hello World!!!
313: * [0] => Hello World!!!
314: * [stderr] =>
315: * [1] =>
316: * [output] => Hello World!!!
317: * [errno] => 0
318: * [return] => 0
319: * [error] =>
320: * [success] => 1
321: * )
322: *
323: * @param string $cmd process name, format specifiers allowed
324: * @param array $args optional arguments to supply to format
325: * @param array $env optional environment vars to set
326: * @param array $options optional options, tee: set tee output to file, user: run as user if site admin
327: * @return bool|array
328: */
329: public function run(string $cmd, null|string|array $args = null, array $env = null, array $options = [])
330: {
331: if (!IS_CLI) {
332: if (is_string($args)) {
333: deprecated_func("\$args must be array or null");
334: }
335: if ($this->auth_is_demo()) {
336: return error('process execution forbidden in demo');
337: }
338: // store msg buffer in event app is killed for
339: // exceeding max wait time
340: $buffer = Error_Reporter::flush_buffer();
341: $resp = $this->query('pman_run', $cmd, $args, $env, $options);
342: if (null === $resp) {
343: // restore old buffer, ignore crash or other nasty error detected! msg
344: Error_Reporter::set_buffer($buffer);
345:
346: return error('process lingered for %d seconds, ' .
347: 'automatically abandoning', self::MAX_WAIT_TIME);
348: }
349: Error_Reporter::set_buffer(array_merge($buffer, \Error_Reporter::flush_buffer()));
350:
351: return $resp;
352: }
353: if (null === $env) {
354: $env = $_ENV;
355: }
356:
357: // always force
358: $env['BASH_ENV'] = null;
359: $env['TZ'] ??= $this->getAuthContext()->timezone;
360: $env['LANG'] ??= $this->getAuthContext()->language;
361:
362: $proc = Util_Process_Sudo::instantiateContexted($this->getAuthContext());
363: if ($env) {
364: $proc->setEnvironment($env);
365: }
366: // suppress automatically generated errors
367: $proc->setOption('mute_stderr', true);
368: $user = $this->username;
369: if (isset($options['user'])) {
370: if (!$this->permission_level & PRIVILEGE_SITE) {
371: return error("failed to launch `%s': only site admin may specify user parameter to run as",
372: basename($cmd)
373: );
374: }
375: $pwd = $this->user_getpwnam($options['user']);
376: if (!$pwd) {
377: report('Failed getpwnam - ' . $this->inContext() . "\n" . var_export($this->getAuthContext(),
378: true) . "\n" . var_export($this->user_get_users(), true));
379:
380: return error("unknown user `%s'", $options['user']);
381: }
382: $minuid = apnscpFunctionInterceptor::get_autoload_class_from_module('user')::MIN_UID;
383: if ($pwd['uid'] < $minuid) {
384: return error("uid `%d' is less than allowable uid `%d' - system user?", $pwd['uid'], $minuid);
385: }
386: $user = $options['user'];
387: }
388:
389: if (isset($options['tee'])) {
390: if ($options['tee'][0] != '/') {
391: // relative file listed, assume /tmp
392: $options['tee'] = TEMP_DIR . '/' . $options['tee'];
393: }
394: if (file_exists($options['tee']) || is_link($options['tee'])) {
395: // verify not trying to stream something like /etc/shadow
396: return error("tee file `%s' exists", $options['tee']);
397: } else if (!touch($options['tee'])) {
398: return error("cannot use tee file `%s'", $options['tee']);
399: }
400: $tee = new Util_Process_Tee();
401: $tee->setTeeFile($options['tee']);
402: $tee->setProcess($proc);
403: \Opcenter\Filesystem::chogp($options['tee'], WS_UID, WS_UID, 0600);
404: }
405: // capture & extract the safe command, then sudo
406: $proc->setOption('umask', 0022)->
407: setOption('resource', ['cpu' => self::MAX_CPU_TIME])->
408: setOption('timeout', self::MAX_WAIT_TIME)->
409: setOption('user', $user)->
410: setOption('home', true);
411: // temp fix, last arg is checked for user/domain substitution,
412: // wordpress sets user for example
413: $ret = $proc->run($cmd, $args);
414:
415: return $ret;
416: }
417:
418: /**
419: * Background an apnscp function with an optional delay
420: *
421: * @param $realcmd
422: * @param array|null $args
423: * @param string $when
424: */
425: public function schedule_api_cmd($cmd, $args = array(), $when = 'now')
426: {
427: if (DEMO_ADMIN_LOCK && ($this->permission_level & PRIVILEGE_ADMIN) && posix_getuid()) {
428: return error("Demo may not schedule API commands");
429: }
430:
431: if (!IS_CLI) {
432: return $this->query('pman_schedule_api_cmd', $cmd, $args, $when);
433: }
434:
435: return $this->schedule_api_cmd_admin($this->site, $this->username, $cmd, $args, $when);
436: }
437:
438: /**
439: * Background an apnscp function as any user on any domain
440: * with an optional delay
441: *
442: * @param string $site domain or site to runas
443: * @param null|string $user username to run as
444: * @param $cmd api command to run
445: * @param array|null $args api arguments
446: * @param string $when optional time spec
447: * @return bool
448: * @internal param $realcmd
449: */
450: public function schedule_api_cmd_admin($site, ?string $user, $cmd, $args = array(), $when = 'now')
451: {
452: if (DEMO_ADMIN_LOCK && ($this->permission_level & PRIVILEGE_ADMIN) && posix_getuid()) {
453: return error("Demo may not schedule commands");
454: }
455:
456: if (!IS_CLI) {
457: return $this->query('pman_schedule_api_cmd_admin', $site, $user, $cmd, $args, $when);
458: }
459: // @XXX changing the username following api_cmd can result in a failed command
460:
461: $realcmd = '';
462: if ($site) {
463: $realcmd .= '-d ' . escapeshellarg($site) . ' ';
464: }
465: if ($user) {
466: $realcmd .= '-u ' . escapeshellarg($user) . ' ';
467: }
468: // support multiple commands
469: if (!is_array($cmd)) {
470: $cmd = array(array($cmd, $args));
471: } else if (is_scalar($args)) {
472: // [site, user, [[cmd1, [args]], [cmd2, [args]]], when]
473: $when = $args;
474: }
475:
476: // avoid fatals
477: $timespec = new DateTime($when);
478: if (!$timespec) {
479: return error("unparseable timespec `%s'", $when);
480: }
481: $proc = new Util_Process_Schedule($timespec);
482: // send "cpcmd -m"
483: $multi = true;
484: $components = array();
485: for ($i = 0, $n = sizeof($cmd); $i < $n; $i++) {
486: $tmp = $cmd[$i];
487: $cmdcom = $tmp[0];
488: $argcom = $tmp[1] ?? array();
489: $safeargs = array();
490: foreach ($argcom as $a) {
491: if ($multi && array_filter((array)$argcom, static function ($v) {
492: return $v === ';';
493: })) {
494: debug('; detected as lone argument to %s, disabling multi mode to cpcmd', $cmdcom);
495: $multi = false;
496: }
497: if (is_array($a)) {
498: if (isset($a[0])) {
499: // array
500: $a = array_map('escapeshellarg', $a);
501: } else {
502: // hash
503: array_walk($a, static function (&$v, $k) {
504: $v = escapeshellarg($k) . ':' . escapeshellarg($v);
505: });
506: }
507: $a = '[' . join(',', $a) . ']';
508: }
509: $safeargs[] = is_string($a) ? escapeshellarg($a) : $a;
510: }
511:
512: $safeargs = join(' ', $safeargs);
513: $components[] = escapeshellarg($cmdcom) . ' ' . $safeargs;
514: }
515: $realcmd .= join(' \; ', $components);
516: $multi &= count($components) > 1;
517: $basecmd = bin_path('cmd' . ($multi ? ' -m' : ''));
518: $ret = $proc->run($basecmd . ' ' . $realcmd);
519: if (!$ret['success']) {
520: return error("failed to schedule task `%s': %s", $realcmd, $ret['stderr']);
521: }
522:
523: return true;
524: }
525: }