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 Opcenter\Filesystem\Quota;
16: use Opcenter\Role\User;
17:
18: /**
19: * User-specific functions and user creation
20: *
21: * @package core
22: */
23: class User_Module extends Module_Skeleton implements \Module\Skeleton\Contracts\Hookable
24: {
25: const DEPENDENCY_MAP = [
26: 'siteinfo',
27: // user subdomains must be removed first
28: 'apache'
29: ];
30: const MIN_UID = USER_MIN_UID;
31:
32: // minimum UID for secondary users
33: const VIRT_MIN_UID = 20000;
34:
35: // @var int user max length
36: public const USER_MAXLEN = 32;
37:
38: /*
39: * number of dummy users present within /etc/passwd
40: * that possess the same uid/gid as the main user
41: * majordomo, ftp, and mail
42: */
43: protected $uid_mappings = array();
44:
45: protected $exportedFunctions = [
46: '*' => PRIVILEGE_SITE,
47: 'flush' => PRIVILEGE_SITE | PRIVILEGE_USER,
48: 'get_user_home' => PRIVILEGE_ALL,
49: 'get_home' => PRIVILEGE_ALL,
50: 'get_users' => PRIVILEGE_SITE | PRIVILEGE_USER,
51: 'change_gecos' => PRIVILEGE_SITE | PRIVILEGE_USER,
52: 'get_uid_from_username' => PRIVILEGE_SITE | PRIVILEGE_USER,
53: 'get_username_from_uid' => PRIVILEGE_ALL,
54: 'exists' => PRIVILEGE_SITE | PRIVILEGE_USER,
55: 'get_quota' => PRIVILEGE_SITE | PRIVILEGE_USER,
56: 'getpwnam' => PRIVILEGE_SITE | PRIVILEGE_USER,
57: 'resolve_uid' => PRIVILEGE_ADMIN
58: ];
59:
60: // {{{ change_quota()
61:
62: /**
63: * Change disk and file count quotas for a given user
64: *
65: * @param string $user
66: * @param integer $diskquota disk quota provided in megabytes
67: * @param integer $filequota file count limit
68: * @return bool
69: */
70: public function change_quota($user, $diskquota, $filequota = 0)
71: {
72: if (!IS_CLI) {
73: return $this->query('user_change_quota', $user, $diskquota, $filequota);
74: }
75: if ($user == $this->getServiceValue('siteinfo', 'admin_user')) {
76: return error('cannot set quota for administrator');
77: }
78:
79: if (!$this->exists($user)) {
80: return false;
81: }
82: if (floatval($diskquota) != $diskquota || $diskquota < 0) {
83: return error($diskquota . ': invalid disk quota');
84: }
85: $limit = $this->site_get_account_quota()['qhard'] ?? PHP_INT_MAX;
86: if ($diskquota > $limit) {
87: warn('%d: quota exceeds site limit (%d), defaulting to unlimited', $diskquota, $limit);
88: $diskquota = 0;
89: }
90:
91: if ((int)$filequota != $filequota || $filequota < 0) {
92: return error($filequota . ': invalid file quota');
93: }
94:
95: return Quota::setUser(
96: $this->get_uid_from_username($user),
97: (int)round($diskquota * 1024),
98: $filequota,
99: max(0, (int)round($diskquota * 1024) - 16),
100: $filequota
101: );
102: }
103:
104: // }}}
105:
106: /**
107: * Checks for existence of user
108: *
109: * @param string username
110: * @return bool
111: */
112: public function exists($user)
113: {
114: return $this->get_uid_from_username($user) !== false;
115: }
116:
117: public function get_uid_from_username($username)
118: {
119: $user = $this->getpwnam($username);
120: if (!$user) {
121: return false;
122: }
123:
124: return $user['uid'];
125: }
126:
127: /**
128: * Perform getpwnam() lookup on virtual account
129: *
130: * name: username
131: * uid: uid
132: * gid: gid
133: * gecos: gecos field
134: * home: home directory
135: * shell: shell
136: *
137: * @param string $user
138: * @return array
139: */
140: public function getpwnam($user = null)
141: {
142: if (!$user) {
143: $user = $this->username;
144: }
145: $virtpwnam = $this->domain_fs_path() . '/etc/passwd';
146: $cache = Cache_Account::spawn($this->getAuthContext());
147: if (!IS_CLI) {
148: $gen = $cache->hGet('users', 'gen');
149: if ($gen === filemtime($virtpwnam)) {
150: $users = $cache->hGet('users', 'pwd');
151: if ($users && isset($users[$user])) {
152: return $users[$user];
153: }
154: }
155:
156: return $this->query('user_getpwnam', $user);
157: }
158: $pwd = User::bindTo($this->domain_fs_path())->getpwnam(null);
159: $cache = Cache_Account::spawn($this->getAuthContext());
160: $cache->hMSet('users',
161: array(
162: 'gen' => filemtime($virtpwnam),
163: 'pwd' => $pwd,
164: )
165: );
166: $cache->expire('users', 7200);
167:
168: return array_get($pwd, $user, []);
169: }
170:
171: /**
172: * Add user
173: *
174: * @deprecated
175: *
176: * @param $user
177: * @param $password
178: * @param string $gecos
179: * @param int $quota
180: * @param array $options
181: */
182: public function add_user($user, $password, $gecos = '', $quota = 0, array $options = [])
183: {
184: deprecated_func('use user_add');
185: return $this->add($user, $password, $gecos, $quota, $options);
186: }
187:
188: /**
189: * Add new user to account
190: *
191: * @param $user
192: * @param $password
193: * @param string $gecos
194: * @param int $quota storage quota in MB
195: * @param array $options
196: * password : 'crypted': password is encrypted via crypt()
197: * ftp : control ftp service [1,0]
198: * imap : imap access allowed [1,0]
199: * smtp : smtp access
200: * cp : CP access
201: * ssh : ssh access enabled
202: * shell : user shell
203: * @return bool
204: * @link Ftp_Module::jail_user()
205: * @link Web_Module::create_subdomain()
206: * @link Email_Module::create_mailbox()
207: */
208: public function add($user, $password, $gecos = '', $quota = 0, array $options = array())
209: {
210: if (!IS_CLI) {
211: if (!IS_SOAP && $user == 'test') {
212: return error('insecure, commonly-exploited username');
213: }
214:
215: return $this->query('user_add', $user, $password, $gecos, $quota, $options);
216: }
217: if (null !== ($max = $this->getServiceValue('users', 'max'))) {
218: // admin always included
219: if (\count($this->get_users()) > $max) {
220: return error('User limit %d reached', $max);
221: }
222: }
223:
224: $userorig = $user;
225: $user = strtolower((string)$user);
226: if ($user !== $userorig) {
227: warn("user `$user' converted to lowercase");
228: }
229: if (!$user) {
230: return error('no username specified)');
231: }
232: if (!preg_match(Regex::USERNAME, $user)) {
233: return error("invalid user `%s'", $user);
234: }
235: if (strlen($user) > self::USER_MAXLEN) {
236: return error('user max length %d', self::USER_MAXLEN);
237: }
238:
239: if (!$this->auth_password_permitted($password, $user)) {
240: return error('weak password disallowed');
241: }
242: $units = $this->getServiceValue('diskquota', 'units');
243: $quotamax = Formatter::changeBytes($this->getServiceValue('diskquota', 'quota'), 'MB', $units);
244: if (!isset($options['password']) || $options['password'] != 'crypted') {
245: $password = $this->auth_crypt($password);
246: }
247: if ($quota != (float)$quota || $quota < 0) {
248: return error(
249: "disk quota `%(quota)s' outside of range (min: 0, max: %(max)d %(unit)s)",
250: ['quota' => $quota, 'max' => $quotamax, 'unit' => $units]
251: );
252: } else if ($quota > $quotamax) {
253: warn('quota %.1f exceeds limit %.1f: defaulting to %.1f',
254: $quota, $quotamax, $quotamax);
255: $quota = $quotamax;
256: }
257: $users = $this->get_users();
258: if (isset($users[$user])) {
259: return error('username %s exists', $user);
260: }
261:
262: $smtp_enable = $this->email_enabled('smtp') && isset($options['smtp']) && $options['smtp'] != 0;
263: $imap_enable = $this->email_enabled('imap') && isset($options['imap']) && $options['imap'] != 0;
264: $ftp_enable = isset($options['ftp']) && $options['ftp'] != 0;
265: $cp_enable = isset($options['cp']) && $options['cp'] != 0;
266: $dav_enable = isset($options['dav']) && $options['dav'] != 0;
267: $ssh_enable = $this->getServiceValue('ssh',
268: 'enabled') && isset($options['smtp'], $options['ssh']) && $options['ssh'] != 0;
269:
270: if ($this->auth_is_demo()) {
271: $blacklist = ['imap', 'smtp', 'dav', 'ssh', 'ftp'];
272: foreach ($blacklist as $svc) {
273: $var = $svc . '_enable';
274: if ($$var) {
275: warn('%s access disabled in demo mode', strtoupper($svc));
276: $$var = false;
277: }
278: }
279: }
280:
281: if (!$ftp_enable) {
282: info('FTP service not enabled. User will not be permitted FTP access');
283: }
284: if (!$smtp_enable && $imap_enable) {
285: info('SMTP service not enabled. User will be able to receive mail, but not send');
286: } else if ($smtp_enable && !$imap_enable) {
287: info('IMAP service not enabled. User will be able to send mail, but not receive');
288: } else if ($this->email_configured() && !$smtp_enable && !$imap_enable) {
289: info('Email not enabled for user');
290: }
291: $shell = $options['shell'] ?? '/bin/bash';
292: if (!in_array($shell, $this->get_shells(), true)) {
293: return error("Unknown shell `%s'", $shell);
294: }
295: $instance = User::bindTo($this->domain_fs_path());
296: $uid = $instance->captureUid($this->site_id);
297: $ret = $instance->create($user, [
298: 'cpasswd' => $password,
299: 'gid' => $this->group_id,
300: 'gecos' => $gecos,
301: 'uid' => $uid,
302: 'shell' => $shell
303: ]);
304: if (!$ret) {
305: $instance->releaseUid($uid, $this->site_id);
306: // user creation failed
307: return false;
308: }
309:
310: (new \Opcenter\Database\PostgreSQL\Opcenter(\PostgreSQL::pdo()))->createUser(
311: $this->site_id,
312: $uid,
313: $user
314: );
315:
316: $this->flush();
317:
318: if ($quota) {
319: $this->user_change_quota($user, $quota);
320: }
321: if ($this->ssh_enabled() && $this->ssh_user_enabled($user)) {
322: $this->ssh_permit_user($user);
323: }
324:
325: if ($ftp_enable) {
326: $this->ftp_permit_user($user);
327: }
328:
329: if ($imap_enable) {
330: $this->email_permit_user($user, 'imap');
331: }
332:
333: if ($smtp_enable) {
334: $this->email_permit_user($user, 'smtp');
335: }
336:
337: if ($cp_enable) {
338: $this->auth_permit_user($user, 'cp');
339: }
340:
341: if ($dav_enable) {
342: $this->auth_permit_user($user, 'dav');
343: }
344:
345: if (!$this->exists($user)) {
346: return false;
347: }
348:
349: Util_Account_Hooks::instantiateContexted($this->getAuthContext())->run('create_user', [$user]);
350:
351:
352: return true;
353: }
354:
355: /**
356: * Get users belonging to account
357: *
358: * Finds all applicable users created and returns an array consisting
359: * of their information from /etc/passwd. Indexed by username.
360: *
361: * The following indexes are provided:
362: * uid: user id
363: * gid: group id (which will be the same as the uid of the site admin)
364: * home: home directory of the user
365: * shell: path to the shell used by the user
366: *
367: * @return array
368: */
369: public function get_users()
370: {
371: if (!IS_CLI) {
372: $cache = Cache_Account::spawn($this->getAuthContext());
373:
374: $gen = $cache->hGet('users', 'gen');
375: $mtime = filemtime($this->domain_fs_path() . '/etc/passwd');
376: if ($gen == $mtime) {
377: $users = $cache->hGet('users', 'list');
378: if (!empty($users)) {
379: return $users;
380: }
381: }
382:
383: return $this->query('user_get_users');
384: }
385: $fp = fopen($this->domain_fs_path('/etc/shadow'), 'r');
386: flock($fp, LOCK_SH);
387: $mtime = filemtime($this->domain_fs_path('/etc/passwd'));
388: if (!$fp) {
389: return error($this->domain . ': unable to open /etc/shadow');
390: }
391: $users = array();
392: while (($line = fgets($fp)) !== false) {
393: if (!preg_match(Regex::SHADOW_PHY_ENTRY, $line)) {
394: continue;
395: }
396: $line = explode(':', $line);
397: if ($line[1] !== '!!' && $line[1] !== '') {
398: $users[$line[0]] = $this->getpwnam($line[0]);
399: }
400: }
401: flock($fp, LOCK_UN);
402: fclose($fp);
403: ksort($users);
404: $cache = Cache_Account::spawn($this->getAuthContext());
405: $cache->hMSet('users', [
406: 'gen' => $mtime,
407: 'list' => $users
408: ]);
409: $cache->expire('users', 7200);
410:
411: return $users;
412: }
413:
414: /**
415: * Get shells valid for account
416: *
417: * /bin/false blocks access via PAM controlled services
418: *
419: * @return array
420: */
421: public function get_shells(): array
422: {
423: return array_values(array_unique(file($this->domain_fs_path('/etc/shells'),
424: FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) + [999 => '/bin/false', '/sbin/nologin']));
425: }
426:
427: /**
428: * Flush account user cache
429: *
430: * @return bool
431: */
432: public function flush()
433: {
434: $cache = Cache_Account::spawn($this->getAuthContext());
435: $cache->del('users');
436: $this->uid_mappings[$this->site_id] = [];
437:
438: return true;
439: }
440:
441: public function get_user_home($user = null)
442: {
443: return $this->get_home($user);
444: }
445:
446: public function get_home($user = null)
447: {
448: if (!$user) {
449: $user = $this->username;
450: }
451:
452: $pwnam = $this->getpwnam($user);
453:
454: return !$pwnam ? false : $pwnam['home'];
455: }
456:
457: public function get_user_count(): array
458: {
459: $users = $this->get_users();
460:
461: return array(
462: 'users' => \count($users),
463: // bc pre v7.5
464: 'max' => $this->getServiceValue('users', 'max', $this->getServiceValue('users', 'maxusers'))
465: );
466: }
467:
468: /**
469: * Change username in the system
470: *
471: * @param string $user
472: * @param string $newuser
473: * @return bool
474: */
475: public function rename_user($user, $newuser)
476: {
477: if (!IS_CLI) {
478: return $this->query('user_rename_user', $user, $newuser);
479: }
480:
481: $user = strtolower($user);
482: $newuser = strtolower($newuser);
483: // flush getpwnam cache
484: $this->flush();
485: $admin = $this->getServiceValue('siteinfo', 'admin_user');
486: if (!$this->exists($user)) {
487: return error("invalid user specified `%s'", $user);
488: } else if ($this->exists($newuser)) {
489: return error("target user `%s' already exists", $newuser);
490: } else if (!preg_match(Regex::USERNAME, $newuser)) {
491: return error('invalid target user `%s', $newuser);
492: } else if ($user === $admin) {
493: return error('use auth_change_username to change primary user');
494: } else if (strlen($newuser) > static::USER_MAXLEN) {
495: return error('user max length %d', static::USER_MAXLEN);
496: }
497:
498: $pwd = $this->getpwnam($user);
499:
500: $newhome = preg_replace('!' . DIRECTORY_SEPARATOR . $user . '!',
501: DIRECTORY_SEPARATOR . $newuser,
502: $pwd['home'],
503: 1
504: );
505: $prefix = $this->domain_fs_path();
506: if (file_exists($prefix . $newhome)) {
507: return error("proposed home directory `%s' already exists", $newhome);
508: }
509: \Opcenter\Process::killUser($pwd['uid']);
510: if (!$this->usermod_driver($user,
511: array(
512: 'username' => $newuser,
513: 'home' => $newhome,
514: 'move_home' => true
515: )
516: )) {
517: return false;
518: }
519:
520: // update uids in uids table
521: (new \Opcenter\Database\PostgreSQL\Opcenter(\PostgreSQL::pdo()))->renameUser(
522: $pwd['uid'],
523: $newuser,
524: $this->site_id
525: );
526: return true;
527: }
528:
529: /**
530: * usermod driver
531: *
532: * Possible attribute keys
533: * gecos: gecos/comment field
534: * home: home directory
535: * username: new username *DANGEROUS*
536: * passwd: password encrypted via crypt()
537: * pw_expire: number of days after which the password expires
538: * pw_disable: date on which the account will expire (YYYY-MM-DD)
539: * shell: user shell
540: * pw_lock: lock/unlock password
541: * pw_unlock
542: * move_home: move home directory
543: *
544: * @private
545: * @param string $user
546: * @param array $attributes new attributes to set
547: * @return bool
548: */
549: public function usermod_driver(string $user, array $attributes): bool
550: {
551: if (!IS_CLI) {
552: return $this->query('user_usermod_driver', $user, $attributes);
553: }
554:
555: if (!$this->exists($user)) {
556: return error($user . ': user does not exist');
557: }
558: if (isset($attributes['shell']) && !in_array($attributes['shell'], $this->get_shells(), true)) {
559: return error("Unknown/invalid shell `%s'", $attributes['shell']);
560: }
561: // before changing user, if user change, grab
562: $newuser = array_get($attributes, 'username');
563: $oldpwd = $this->getpwnam($user);
564: if (!User::bindTo($this->domain_fs_path())->change($user, $attributes)) {
565: return false;
566: }
567:
568: // user changed
569: if ($newuser && $newuser !== $user) {
570: // make a symlink to the original home to workaround fs checks
571: // during the rename process
572: //rename($prefix . $pwd['home'], $prefix . $newhome);
573: //$this->file_symlink($pwd['home'], $newhome);
574: $this->flush();
575:
576: if (!Util_Account_Hooks::instantiateContexted($this->getAuthContext())->run('edit_user', [$user, $newuser, $oldpwd])) {
577: return error('unable to fully rename user, hook failed');
578: }
579:
580: $userpath = $this->domain_info_path() . '/users/';
581: if (file_exists($userpath . '/' . $user)) {
582: rename($userpath . '/' . $user, $userpath . '/' . $newuser);
583: }
584:
585: //$this->file_delete($newhome);
586: // rename user in gecos
587: }
588:
589: return true;
590: }
591:
592: /**
593: * array get_quota_history(string[, int = 0[, int = 0]])
594: *
595: * @param string $mUser
596: * @param int $mBegin
597: * @param int $mEnd
598: * @return array|bool
599: */
600: public function get_quota_history(string $mUser, int $mBegin = 0, int $mEnd = null)
601: {
602: $key = 'q.' . base64_encode(pack('LLa*', $mBegin, $mEnd, $mUser));
603: $cache = Cache_Account::spawn($this->getAuthContext());
604: $data = $cache->get($key);
605: if ($data) {
606: return \Util_PHP::unserialize(gzinflate($data));
607: }
608: $quotas = array();
609: if (is_null($mEnd)) {
610: $mEnd = time();
611: }
612: if (!is_int($mBegin) || !is_int($mEnd)) {
613: return error('Invalid start, end range');
614: }
615: if ($mBegin < 1) {
616: $mBegin = 0;
617: }
618: $uids = $this->user_get_users();
619:
620: if (!isset($uids[$mUser])) {
621: return error('Invalid user');
622: }
623: $uid = $this->get_uid_from_username($mUser);
624: $db = PostgreSQL::initialize();
625: $db->query('SELECT
626: EXTRACT(epoch FROM ts::TIMESTAMPTZ(0)) as ts,
627: quota
628: FROM
629: storage_log
630: WHERE
631: uid = ' . $uid . '
632: AND
633: ts >= TO_TIMESTAMP(' . $mBegin . ')
634: AND
635: ts < TO_TIMESTAMP(' . $mEnd . ') ORDER BY ts');
636: while ($row = $db->fetch_object()) {
637: $quotas[] = array('ts' => (int)$row->ts, 'quota' => (int)$row->quota);
638: }
639: $cache->set($key, gzdeflate(serialize($quotas)), 43200);
640:
641: return $quotas;
642: }
643:
644: /**
645: * Fetch storage and file quotas from the underlying quota subsystem
646: *
647: * qused: disk space used in KB
648: * qsoft: soft limit on disk space in KB
649: * qhard: hard limit on disk space in KB
650: * fused: files used
651: * fsoft: soft limit on files
652: * fhard: hard limit on files
653: *
654: * Multi-user lookups returns a hash, while a
655: * single-user lookup returns a single quota record
656: *
657: * @see Site_Module::get_account_quota()
658: *
659: * @param mixed $username single user or array of users
660: * @return array
661: */
662: public function get_quota($users = null)
663: {
664: if (!IS_CLI) {
665: return $this->query('user_get_quota', $users);
666: }
667: $formatArray = \is_array($users);
668: if (!$users || ($this->permission_level & PRIVILEGE_USER)) {
669: $users = array($this->username);
670: } else if (!is_array($users)) {
671: $users = array($users);
672: }
673: $webuser = $this->web_get_sys_user();
674: $do_apache = $this->permission_level & PRIVILEGE_SITE &&
675: in_array($webuser, $users, true);
676:
677: $quota_sum = array('qused' => 0, 'fused' => 0);
678: $uids = array();
679: foreach ($users as $key => $user) {
680: if ($do_apache && $user === $webuser) {
681: continue;
682: }
683: if (!($uid = $this->get_uid_from_username($user))) {
684: warn($user . ': user does not exist');
685: unset($users[$key]);
686: }
687: $uids[$uid] = $user;
688: }
689:
690: $quotas = Quota::getUser(array_keys($uids));
691:
692: $quota_stat = [];
693: $max = $this->getServiceValue('diskquota', 'enabled') ?
694: Quota::getGroup($this->group_id)['qhard'] : 0;
695:
696: $hasFileLimit = null;
697: if (platform_is('7.5')) {
698: $hasFileLimit = $this->getServiceValue('diskquota', 'fquota', null);
699: }
700: foreach ($quotas as $uid => $quota) {
701: if (!isset($uids[$uid])) {
702: warn("Unrecognized UID detected `%d' - continuing", $uid);
703: continue;
704: }
705: if ($quota['qhard'] === 0) {
706: $quota['qhard'] = $max;
707: }
708: if ($hasFileLimit && $quota['fhard'] === 0) {
709: $quota['fhard'] = $hasFileLimit;
710: }
711:
712: $user = $uids[$uid];
713: $quota_stat[$user] = $quota;
714:
715: if ($do_apache) {
716: $quota_sum['qused'] += $quota['qused'];
717: $quota_sum['fused'] += $quota['fused'];
718: }
719: }
720: if ($do_apache) {
721: $grp = $this->site_get_account_quota();
722: $mysql_qquota = 0;
723: $tmpq = Util_Process::exec('du -s %s%s',
724: $this->domain_fs_path(),
725: \Mysql_Module::MYSQL_DATADIR
726: );
727:
728: if ($tmpq['success']) {
729: $tmp = explode(' ', $tmpq['output']);
730: $mysql_qquota = (int)array_shift($tmp);
731: }
732:
733: $ap_qquota = max(-1, $grp['qused'] - $quota_sum['qused'] - $mysql_qquota);
734: $ap_fquota = max(-1, $grp['qused'] - $quota_sum['qused']);
735: $quota_stat[$webuser] = array(
736: 'qused' => $ap_qquota,
737: 'qsoft' => $grp['qsoft'],
738: 'qhard' => $grp['qhard'],
739: 'fused' => $ap_fquota,
740: 'fsoft' => $grp['fsoft'],
741: 'fhard' => $grp['fsoft']
742: );
743: }
744:
745: return $formatArray ? $quota_stat : array_pop($quota_stat);
746: }
747:
748: // {{{ change_gecos()
749:
750: /**
751: * Change a user's gecos field
752: *
753: * Updates the gecos field in /etc/passwd
754: * If called by admin, change_gecos() takes two parameters:
755: * $user and $gecos. Users only need to supply one parameter,
756: * the new gecos value.
757: *
758: * @param string $user target user or gecos field if called by user
759: * @param string $gecos gecos field supplied
760: * @return bool
761: */
762: public function change_gecos($user, $gecos = null)
763: {
764: if (!IS_CLI) {
765: return $this->query('user_change_gecos', $user, $gecos);
766: }
767: if ($this->permission_level & PRIVILEGE_USER || !$gecos) {
768: $gecos = $user;
769: $user = $this->username;
770: }
771:
772: return $this->usermod_driver($user, array('gecos' => $gecos));
773: }
774:
775: // }}}
776:
777: // {{{ usermod_driver()
778:
779: public function get_username_from_uid($uid)
780: {
781: if ($this->permission_level & PRIVILEGE_ADMIN) {
782: return posix_getpwuid($uid)['name'] ?? $uid;
783: }
784: $site = $this->site_id;
785: if (!isset($this->uid_mappings[$site])) {
786: $this->uid_mappings[$site] = array();
787: } else {
788: if (isset($this->uid_mappings[$site][$uid])) {
789: return $this->uid_mappings[$site][$uid];
790: }
791: }
792: if (!($fp = fopen($this->domain_fs_path() . '/etc/passwd', 'r'))) {
793: return error('/etc/passwd: cannot access file');
794: }
795: while (false !== ($line = fgets($fp))) {
796: $line = explode(':', $line);
797: if (!isset($line[2]) || !is_numeric($line[2]) || isset($this->uid_mappings[$site][$line[2]])) {
798: continue;
799: }
800: $this->uid_mappings[$site][$line[2]] = $line[0];
801: }
802: fclose($fp);
803: if (!isset($this->uid_mappings[$site][$uid])) {
804: return false;
805: }
806:
807: return $this->uid_mappings[$site][$uid];
808: }
809:
810: /**
811: * Resolve a uid to site/username combo
812: *
813: * @param int $uid
814: * @return array
815: */
816: public function resolve_uid(int $uid): array
817: {
818: $db = PostgreSQL::pdo();
819: $query = Opcenter\Database\PostgreSQL::vendor()->userTupleFromUid($uid);
820: $rs = $db->query($query);
821: if (!$rs || !$rs->rowCount()) {
822: return [];
823: }
824:
825: return $rs->fetch(\PDO::FETCH_NUM);
826: }
827:
828: // }}}
829:
830: /**
831: * Generate a list of files contributing towards the account quota
832: *
833: * Upon successful generation, the list is stored under ~/filelist-<PANEL_BRAND>.txt
834: *
835: * @param string $user restrict search to user
836: * @param string $base glob-style directories to inspect
837: * @param bool $sort sort by size
838: * @return bool|string
839: */
840: public function generate_quota_list(
841: string $user = '',
842: string $base = '/{home,usr/local,var/www,var/lib,var/log,tmp}',
843: bool $sort = true
844: ) {
845: if (!IS_CLI) {
846: return $this->query('user_generate_quota_list', $user, $base, $sort);
847: }
848: $file = 'filelist-' . PANEL_BRAND . '.txt';
849: if (!$user) {
850: $user_args = '';
851: } else if (!$this->exists($user)) {
852: return error('%s: does not exist', $user);
853: } else {
854: $user_args = '-user ' . $user;
855: }
856: // permit glob...
857: if (false !== ($pos = strpos($base, '{')) && false !== ($end = strpos($base, '}'))) {
858: $tmp = substr($base, 0, ++$pos);
859: $tmp .= escapeshellarg(substr($base, $pos, $end - $pos));
860: $tmp .= substr($base, $end);
861: $base = $tmp;
862: } else {
863: $base = escapeshellarg($base);
864: }
865: $chroot_cmd = sprintf('find %s -type f -group %s %s -printf "%s"',
866: $base,
867: $this->group_id,
868: $user_args,
869: '%10k\t%16s\t%-16u\t%p\r\n'
870: );
871: if ($sort) {
872: $chroot_cmd .= ' | sort -nr';
873: }
874:
875: $proc = new Util_Process_Chroot($this->domain_fs_path());
876: $file = tempnam($this->domain_fs_path() . sys_get_temp_dir(), 'flapns');
877: $fp = fopen($file, 'wb');
878:
879: $proc->addCallback(function () use ($file, $fp) {
880: fclose($fp);
881: Opcenter\Filesystem::chogp($file, APNSCP_USER, APNSCP_USER, 0660);
882: }, 'close');
883: $proc->addCallback(static function ($output) use ($fp, $file) {
884: fwrite($fp, $output);
885: }, 'read');
886:
887: $ret = $proc->run(
888: '/bin/sh -c \'printf %s ; %s\'',
889: '"%10s\t%16s\t%-16s\t%s\r\n" "szquota (KB)" "szdisk (B)" username path',
890: $chroot_cmd
891: );
892: if (!$ret['success']) {
893: return false;
894: }
895:
896: return basename($file);
897: }
898:
899: /**
900: * Remove a supplemental group
901: *
902: * @param string $group
903: * @return bool
904: */
905: public function sgroupdel($group)
906: {
907: if (!preg_match(Regex::GROUPNAME, $group)) {
908: return error("invalid group `%s'", $group);
909: }
910:
911: if ($group === $this->username) {
912: return error("cannot remove base group name `%s'", $this->username);
913: }
914: $groups = $this->sgroups();
915: if (!in_array($group, $groups)) {
916: return error("cannot remove non-existent group `%s'", $group);
917: }
918:
919: $file = $this->domain_fs_path() . '/etc/group';
920: $fp = fopen($file, 'r+');
921: flock($fp, LOCK_EX);
922: $lines = array();
923: while (false !== ($line = fgets($fp))) {
924: list($group_name, $password, $gid, $user_list) =
925: explode(':', $line);
926: if ($group_name === $group) {
927: continue;
928: }
929: $lines[] = $line;
930: }
931: ftruncate($fp, 0);
932: rewind($fp);
933: $lines = implode('', $lines);
934: fwrite($fp, $lines);
935: flock($fp, LOCK_UN);
936: fclose($fp);
937:
938: return true;
939: }
940:
941: /**
942: * List supplemental groups
943: *
944: * @return array
945: */
946: public function sgroups()
947: {
948: $groups = array();
949: $file = $this->domain_fs_path() . '/etc/group';
950: $fp = fopen($file, 'r');
951: while (false !== ($line = fgets($fp))) {
952: list($group_name, $password, $gid, $user_list) =
953: explode(':', $line);
954: if ($gid != $this->group_id) {
955: continue;
956: }
957: $groups[] = $group_name;
958: }
959:
960: return $groups;
961: }
962:
963: /**
964: * Add a supplemental group
965: *
966: * @param string $group
967: * @return bool
968: */
969: public function sgroupadd(string $group): bool
970: {
971: if (!preg_match(Regex::GROUPNAME, $group)) {
972: return error("invalid group `%s'", $group);
973: }
974:
975: $groups = $this->sgroups();
976: if (in_array($group, $groups)) {
977: return error("duplicate group `%s'", $group);
978: }
979:
980: // @XXX -o is a Redhat-specific param to override duplicate gid
981: return (new \Opcenter\Role\Group($this->domain_fs_path()))->create($group, [
982: 'force' => true,
983: 'duplicate' => true,
984: 'gid' => $this->group_id
985: ]);
986: }
987:
988: public function _verify_conf(\Opcenter\Service\ConfigurationContext $ctx): bool
989: {
990: return true;
991: }
992:
993: public function _create()
994: {
995: }
996:
997: public function _delete()
998: {
999: $this->deleteUserPreferences($this->getAuthContext());
1000: }
1001:
1002: public function _delete_user(string $user)
1003: {
1004: $pam = new Util_Pam($this->getAuthContext());
1005: foreach ($this->enrollment($user) as $svc) {
1006: $pam->remove($user, $svc);
1007: }
1008: $this->erase_quota_history($user);
1009: }
1010:
1011: /**
1012: * Get list of services for which user is enabled
1013: *
1014: * @param string $user
1015: * @return array|false
1016: */
1017: public function enrollment(string $user)
1018: {
1019: if (!$this->exists($user) || $this->get_uid_from_username($user) < self::MIN_UID) {
1020: return error("unknown or system user `%s'", $user);
1021: }
1022: $pam = new Util_Pam($this->getAuthContext());
1023:
1024: return $pam->enrolled($user);
1025: }
1026:
1027: /**
1028: * Remove historical quota data
1029: *
1030: * @param string $user
1031: * @param int $until erase records until this timestamp
1032: * @return bool
1033: */
1034: public function erase_quota_history($user, $until = -1)
1035: {
1036: if (!$this->exists($user)) {
1037: return error("user `$user' does not exist");
1038: }
1039: $uid = $this->get_uid_from_username($user);
1040: $until = intval($until);
1041: if ($until < 0) {
1042: $until = time() + 86400 * 30;
1043: }
1044: $db = MySQL::initialize();
1045: $q = $db->query('DELETE FROM quota_tracker WHERE uid = ' . $uid . ' AND ts < FROM_UNIXTIME(' . $until . ');');
1046:
1047: return (bool)$q;
1048:
1049: }
1050:
1051: /**
1052: * @deprecated
1053: *
1054: * @param $user
1055: */
1056: public function delete_user($user, bool $force = false)
1057: {
1058: deprecated_func('use user_delete');
1059: return $this->delete($user, $force);
1060: }
1061:
1062: /**
1063: * Delete user
1064: * @param string $user username to delete
1065: * @param bool $force bypass sanity checks
1066: * @return bool
1067: */
1068: public function delete($user, bool $force = false): bool
1069: {
1070: if (!IS_CLI) {
1071: return $this->query('user_delete', $user, $force);
1072: }
1073:
1074: $users = $this->get_users();
1075: if (!isset($users[$user])) {
1076: return error("user `%s' not found", $user);
1077: } else if ($user == $this->getServiceValue('siteinfo', 'admin_user')) {
1078: return error('cannot delete primary user');
1079: }
1080:
1081: $uid = $users[$user]['uid'];
1082: // check to make sure subdomains/domains aren't hosted by user
1083: $domains = $this->aliases_list_shared_domains();
1084: $home = $this->get_home($user);
1085: $subdomains = array_keys(
1086: $this->web_list_subdomains('path', '!^' . $home . '/!')
1087: );
1088:
1089: $blocking = array();
1090: foreach ($domains as $domain => $path) {
1091: if (!$this->file_exists($path)) {
1092: continue;
1093: }
1094: $stat = $this->file_stat($path);
1095: if (!$stat) {
1096: continue;
1097: }
1098: if (0 === strpos($home, $path) || $stat['uid'] == $uid) {
1099: $blocking[] = $domain;
1100: }
1101: }
1102: $subcount = count($subdomains);
1103: $domaincount = count($blocking);
1104: if (!$force && ($domaincount > 0 || $subcount > 0))
1105: {
1106: Util_Conf::sort_domains($blocking);
1107: if ($domaincount > 0) {
1108: error("one or more domains rely on user `%s', remove or relocate these domains first (DNS > Addon Domains): `%s'",
1109: $user, implode(', ', $blocking));
1110: }
1111:
1112: if (count($subdomains) === 1 && ($subdomains[0] === $user || 0 === strpos($subdomains[0] . '.',
1113: $user . '.'))) {
1114: $subcount--;
1115: info("removed user-specific subdomain, `%s'", $subdomains[0]);
1116: $this->web_remove_subdomain($subdomains[0]);
1117: } else {
1118: if (count($subdomains) > 0) {
1119: error("one or more subdomains rely on user `%s', remove or relocate these subdomains first (Web > Subdomains): `%s'",
1120: $user, implode(', ', $subdomains));
1121: }
1122: }
1123:
1124: if ($domaincount || $subcount) {
1125: return false;
1126: }
1127:
1128: }
1129: $userCtx = \Auth::context($user, $this->site);
1130: Util_Account_Hooks::instantiateContexted($this->getAuthContext())->run('delete_user', [$user]);
1131: $instance = User::bindTo($this->domain_fs_path());
1132: $ret = $instance->delete($user, true);
1133: if (!$ret) {
1134: return false;
1135: }
1136:
1137: (new \Opcenter\Database\PostgreSQL\Opcenter(\PostgreSQL::pdo()))->deleteUser(
1138: $this->site_id,
1139: $uid
1140: );
1141:
1142: $instance->releaseUid($uid, $this->site_id);
1143: \apnscpSession::invalidate_by_user($this->site_id, $user);
1144: $this->deleteUserPreferences($userCtx);
1145: $this->flush();
1146:
1147: $key = $this->site . '.' . $user;
1148:
1149: if (array_has($this->uid_mappings, $key)) {
1150: array_forget($this->uid_mappings, $key);
1151: }
1152: // cleanup systemd-wrapped users
1153: if ($uid >= self::VIRT_MIN_UID && false !== ($pwd = posix_getpwuid($uid))) {
1154: User::bindTo('/')->delete($pwd['name'], false);
1155: }
1156: return $ret;
1157:
1158: }
1159:
1160: public function _edit()
1161: {
1162: $new = $this->getAuthContext()->conf('siteinfo', 'new');
1163: $old = $this->getAuthContext()->conf('siteinfo', 'old');
1164: if ($new['admin_user'] === $old['admin_user']) {
1165: return true;
1166: }
1167:
1168: return $this->_edit_user($old['admin_user'], $new['admin_user'], []);
1169: }
1170:
1171: public function _edit_user(string $user, string $usernew, array $oldpwd)
1172: {
1173: $pam = new Util_Pam($this->getAuthContext());
1174: $pam->renameUser($user, $usernew);
1175: $this->flush();
1176: }
1177:
1178: public function _create_user(string $user)
1179: {
1180:
1181: }
1182:
1183: private function deleteUserPreferences(\Auth_Info_User $ctx): void
1184: {
1185: User::bindTo($ctx->domain_fs_path())->flushCache($ctx);
1186: }
1187: }
1188: