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