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