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: foreach ($controllers as $controller) {
242: // find first charged controller
243: $controller = \Opcenter\System\Cgroup\Controller::make(new \Opcenter\System\Cgroup\Group($this->site), $controller);
244: if (!$controller->exists()) {
245: continue;
246: }
247: $procs = $controller->processes();
248: break;
249: }
250: $isAdmin = ($this->permission_level & PRIVILEGE_ADMIN);
251: if (!$isAdmin && null !== $procs) {
252: return array_map('\intval', $procs);
253: }
254: return \Opcenter\Process::all(function (int $pid) use ($isAdmin){
255: return $isAdmin || filegroup(\Opcenter\Process::PROC_PATH . "/$pid") === $this->group_id;
256: });
257:
258: }
259:
260: /**
261: * Get active process count
262: *
263: * Count is fetched from cache. {@see flush} may be necessary
264: *
265: * @return int
266: */
267: public function pcount()
268: {
269: $count = count($this->_processAccumulator());
270:
271: return $count;
272: }
273:
274: /**
275: * Flush process accumulator cache
276: *
277: * @return bool
278: */
279: public function flush()
280: {
281: $cache = Cache_Account::spawn($this->getAuthContext());
282:
283: return $cache->del(self::PROC_CACHE_KEY);
284: }
285:
286: /**
287: * Get all processes
288: *
289: * @return array {@see stat}
290: */
291: public function get_processes()
292: {
293: if (!IS_CLI) {
294: return $this->query('pman_get_processes');
295: }
296:
297: return $this->_processAccumulator();
298: }
299:
300: /**
301: * Run a process
302: *
303: * Sample response:
304: *
305: * Array
306: * (
307: * [stdin] =>
308: * [stdout] => Hello World!!!
309: * [0] => Hello World!!!
310: * [stderr] =>
311: * [1] =>
312: * [output] => Hello World!!!
313: * [errno] => 0
314: * [return] => 0
315: * [error] =>
316: * [success] => 1
317: * )
318: *
319: * @param string $cmd process name, format specifiers allowed
320: * @param array $args optional arguments to supply to format
321: * @param array $env optional environment vars to set
322: * @param array $options optional options, tee: set tee output to file, user: run as user if site admin
323: * @return bool|array
324: */
325: public function run(string $cmd, null|string|array $args = null, array $env = null, array $options = [])
326: {
327: if (!IS_CLI) {
328: if (is_string($args)) {
329: deprecated_func("\$args must be array or null");
330: }
331: if ($this->auth_is_demo()) {
332: return error('process execution forbidden in demo');
333: }
334: // store msg buffer in event app is killed for
335: // exceeding max wait time
336: $buffer = Error_Reporter::flush_buffer();
337: $resp = $this->query('pman_run', $cmd, $args, $env, $options);
338: if (null === $resp) {
339: // restore old buffer, ignore crash or other nasty error detected! msg
340: Error_Reporter::set_buffer($buffer);
341:
342: return error('process lingered for %d seconds, ' .
343: 'automatically abandoning', self::MAX_WAIT_TIME);
344: }
345: Error_Reporter::set_buffer(array_merge($buffer, \Error_Reporter::flush_buffer()));
346:
347: return $resp;
348: }
349: if (null === $env) {
350: $env = $_ENV;
351: }
352:
353: // always force
354: $env['BASH_ENV'] = null;
355: $proc = Util_Process_Sudo::instantiateContexted($this->getAuthContext());
356: if ($env) {
357: $proc->setEnvironment($env);
358: }
359: // suppress automatically generated errors
360: $proc->setOption('mute_stderr', true);
361: $user = $this->username;
362: if (isset($options['user'])) {
363: if (!$this->permission_level & PRIVILEGE_SITE) {
364: return error("failed to launch `%s': only site admin may specify user parameter to run as",
365: basename($cmd)
366: );
367: }
368: $pwd = $this->user_getpwnam($options['user']);
369: if (!$pwd) {
370: report('Failed getpwnam - ' . $this->inContext() . "\n" . var_export($this->getAuthContext(),
371: true) . "\n" . var_export($this->user_get_users(), true));
372:
373: return error("unknown user `%s'", $options['user']);
374: }
375: $minuid = apnscpFunctionInterceptor::get_autoload_class_from_module('user')::MIN_UID;
376: if ($pwd['uid'] < $minuid) {
377: return error("uid `%d' is less than allowable uid `%d' - system user?", $pwd['uid'], $minuid);
378: }
379: $user = $options['user'];
380: }
381:
382: if (isset($options['tee'])) {
383: if ($options['tee'][0] != '/') {
384: // relative file listed, assume /tmp
385: $options['tee'] = TEMP_DIR . '/' . $options['tee'];
386: }
387: if (file_exists($options['tee']) || is_link($options['tee'])) {
388: // verify not trying to stream something like /etc/shadow
389: return error("tee file `%s' exists", $options['tee']);
390: } else if (!touch($options['tee'])) {
391: return error("cannot use tee file `%s'", $options['tee']);
392: }
393: $tee = new Util_Process_Tee();
394: $tee->setTeeFile($options['tee']);
395: $tee->setProcess($proc);
396: \Opcenter\Filesystem::chogp($options['tee'], WS_UID, WS_UID, 0600);
397: }
398: // capture & extract the safe command, then sudo
399: $proc->setOption('umask', 0022)->
400: setOption('resource', ['cpu' => self::MAX_CPU_TIME])->
401: setOption('timeout', self::MAX_WAIT_TIME)->
402: setOption('user', $user)->
403: setOption('home', true);
404: // temp fix, last arg is checked for user/domain substitution,
405: // wordpress sets user for example
406: $ret = $proc->run($cmd, $args);
407:
408: return $ret;
409: }
410:
411: /**
412: * Background an apnscp function with an optional delay
413: *
414: * @param $realcmd
415: * @param array|null $args
416: * @param string $when
417: */
418: public function schedule_api_cmd($cmd, $args = array(), $when = 'now')
419: {
420: if (DEMO_ADMIN_LOCK && ($this->permission_level & PRIVILEGE_ADMIN) && posix_getuid()) {
421: return error("Demo may not schedule API commands");
422: }
423:
424: if (!IS_CLI) {
425: return $this->query('pman_schedule_api_cmd', $cmd, $args, $when);
426: }
427:
428: return $this->schedule_api_cmd_admin($this->site, $this->username, $cmd, $args, $when);
429: }
430:
431: /**
432: * Background an apnscp function as any user on any domain
433: * with an optional delay
434: *
435: * @param string $site domain or site to runas
436: * @param null|string $user username to run as
437: * @param $cmd api command to run
438: * @param array|null $args api arguments
439: * @param string $when optional time spec
440: * @return bool
441: * @internal param $realcmd
442: */
443: public function schedule_api_cmd_admin($site, ?string $user, $cmd, $args = array(), $when = 'now')
444: {
445: if (DEMO_ADMIN_LOCK && ($this->permission_level & PRIVILEGE_ADMIN) && posix_getuid()) {
446: return error("Demo may not schedule commands");
447: }
448:
449: if (!IS_CLI) {
450: return $this->query('pman_schedule_api_cmd_admin', $site, $user, $cmd, $args, $when);
451: }
452: // @XXX changing the username following api_cmd can result in a failed command
453:
454: $realcmd = '';
455: if ($site) {
456: $realcmd .= '-d ' . escapeshellarg($site) . ' ';
457: }
458: if ($user) {
459: $realcmd .= '-u ' . escapeshellarg($user) . ' ';
460: }
461: // support multiple commands
462: if (!is_array($cmd)) {
463: $cmd = array(array($cmd, $args));
464: } else if (is_scalar($args)) {
465: // [site, user, [[cmd1, [args]], [cmd2, [args]]], when]
466: $when = $args;
467: }
468:
469: // avoid fatals
470: $timespec = new DateTime($when);
471: if (!$timespec) {
472: return error("unparseable timespec `%s'", $when);
473: }
474: $proc = new Util_Process_Schedule($timespec);
475: // send "cpcmd -m"
476: $multi = true;
477: $components = array();
478: for ($i = 0, $n = sizeof($cmd); $i < $n; $i++) {
479: $tmp = $cmd[$i];
480: $cmdcom = $tmp[0];
481: $argcom = $tmp[1] ?? array();
482: $safeargs = array();
483: foreach ($argcom as $a) {
484: if ($multi && array_filter((array)$argcom, static function ($v) {
485: return $v === ';';
486: })) {
487: debug('; detected as lone argument to %s, disabling multi mode to cpcmd', $cmdcom);
488: $multi = false;
489: }
490: if (is_array($a)) {
491: if (isset($a[0])) {
492: // array
493: $a = array_map('escapeshellarg', $a);
494: } else {
495: // hash
496: array_walk($a, static function (&$v, $k) {
497: $v = escapeshellarg($k) . ':' . escapeshellarg($v);
498: });
499: }
500: $a = '[' . join(',', $a) . ']';
501: }
502: $safeargs[] = is_string($a) ? escapeshellarg($a) : $a;
503: }
504:
505: $safeargs = join(' ', $safeargs);
506: $components[] = escapeshellarg($cmdcom) . ' ' . $safeargs;
507: }
508: $realcmd .= join(' \; ', $components);
509: $multi &= count($components) > 1;
510: $basecmd = bin_path('cmd' . ($multi ? ' -m' : ''));
511: $ret = $proc->run($basecmd . ' ' . $realcmd);
512: if (!$ret['success']) {
513: return error("failed to schedule task `%s': %s", $realcmd, $ret['stderr']);
514: }
515:
516: return true;
517: }
518: }