| 1: | <?php |
| 2: | declare(strict_types=1); |
| 3: | |
| 4: | |
| 5: | |
| 6: | |
| 7: | |
| 8: | |
| 9: | |
| 10: | |
| 11: | |
| 12: | |
| 13: | |
| 14: | |
| 15: | |
| 16: | |
| 17: | |
| 18: | |
| 19: | |
| 20: | class Ftp_Module extends Module_Skeleton implements \Module\Skeleton\Contracts\Hookable, \Module\Skeleton\Contracts\Reactive |
| 21: | { |
| 22: | const DEPENDENCY_MAP = [ |
| 23: | 'siteinfo', |
| 24: | 'users' |
| 25: | ]; |
| 26: | |
| 27: | |
| 28: | |
| 29: | |
| 30: | |
| 31: | |
| 32: | const VSFTPD_CONF_DIR = '/etc/vsftpd'; |
| 33: | const VSFTPD_CHROOT_FILE = '/etc/vsftpd.chroot_list'; |
| 34: | const PAM_SVC_NAME = 'ftp'; |
| 35: | |
| 36: | public function __construct() |
| 37: | { |
| 38: | parent::__construct(); |
| 39: | $this->exportedFunctions = array( |
| 40: | '*' => PRIVILEGE_SITE, |
| 41: | 'enabled' => PRIVILEGE_SITE | PRIVILEGE_USER |
| 42: | ); |
| 43: | } |
| 44: | |
| 45: | public function jail_user($user, $dir = '') |
| 46: | { |
| 47: | if (!IS_CLI) { |
| 48: | return $this->query('ftp_jail_user', $user, $dir); |
| 49: | } |
| 50: | if (!$this->user_exists($user)) { |
| 51: | return error('user ' . $user . ' does not exist'); |
| 52: | } |
| 53: | |
| 54: | $chroot_file = $this->domain_fs_path() . self::VSFTPD_CHROOT_FILE; |
| 55: | $chroot_users = array(); |
| 56: | if (file_exists($chroot_file)) { |
| 57: | $chroot_users = preg_split("/[\r\n]+/", trim(file_get_contents($chroot_file))); |
| 58: | } |
| 59: | |
| 60: | if (\in_array($user, $chroot_users, true)) { |
| 61: | if (!$dir) { |
| 62: | return warn('user ' . $user . ' already jailed'); |
| 63: | } |
| 64: | } else { |
| 65: | $chroot_users[] = $user; |
| 66: | } |
| 67: | |
| 68: | file_put_contents($this->domain_fs_path(self::VSFTPD_CHROOT_FILE), |
| 69: | join("\n", $chroot_users) . "\n"); |
| 70: | if ($dir) { |
| 71: | $dir = strtr($dir, ['~' => $this->user_get_home($user), '%u' => $user]); |
| 72: | if (!$this->file_exists($dir)) { |
| 73: | $this->file_create_directory($dir, 0755, true); |
| 74: | } else { |
| 75: | $stat = $this->file_stat($dir); |
| 76: | if ($stat['link']) { |
| 77: | info("target is symlink, converted jailed path `%s' to `%s'", |
| 78: | $dir, |
| 79: | $stat['referent'] |
| 80: | ); |
| 81: | $dir = $stat['referent']; |
| 82: | } |
| 83: | } |
| 84: | $this->file_chown($dir, $user) && $this->set_option($user, 'local_root', $dir); |
| 85: | } |
| 86: | |
| 87: | return true; |
| 88: | } |
| 89: | |
| 90: | public function set_option($user, $c_directive, $c_val = null) |
| 91: | { |
| 92: | if (!IS_CLI) { |
| 93: | return $this->query('ftp_set_option', $user, $c_directive, $c_val); |
| 94: | } |
| 95: | |
| 96: | if (!$this->user_exists($user)) { |
| 97: | return error('user ' . $user . ' does not exist'); |
| 98: | } |
| 99: | |
| 100: | return $this->_set_option_real($user, $c_directive, $c_val); |
| 101: | } |
| 102: | |
| 103: | protected function _set_option_real($user, $c_directive, $c_val = null) |
| 104: | { |
| 105: | $user_conf = self::VSFTPD_CONF_DIR . '/' . $user; |
| 106: | |
| 107: | if (!file_exists($this->domain_fs_path() . $user_conf) && |
| 108: | ($status = file_put_contents($this->domain_fs_path(self::VSFTPD_CONF_DIR . '/' . $user), '') === false) |
| 109: | ) { |
| 110: | return $status; |
| 111: | } |
| 112: | |
| 113: | $fp = fopen($this->domain_fs_path() . $user_conf, 'r'); |
| 114: | if (!$fp) { |
| 115: | return error(self::VSFTPD_CONF_DIR . '/' . $user . ': cannot access file'); |
| 116: | } |
| 117: | $new = true; |
| 118: | for ($buffer = array(); !feof($fp);) { |
| 119: | $line = trim((string)fgets($fp)); |
| 120: | if (!$line) { |
| 121: | continue; |
| 122: | } |
| 123: | |
| 124: | if (false !== strpos($line, '=')) { |
| 125: | list($lval, $rval) = explode('=', $line, 2); |
| 126: | } else { |
| 127: | $rval = ''; |
| 128: | $lval = $line; |
| 129: | } |
| 130: | |
| 131: | if ($lval == $c_directive) { |
| 132: | $new = false; |
| 133: | if (!$c_val) { |
| 134: | continue; |
| 135: | } |
| 136: | $rval = $c_val; |
| 137: | } |
| 138: | $buffer[] = $lval . ($rval ? '=' . $rval : ''); |
| 139: | } |
| 140: | if ($new) { |
| 141: | $buffer[] = $c_directive . ($c_val ? '=' . $c_val : ''); |
| 142: | } |
| 143: | $path = $this->domain_fs_path() . $user_conf; |
| 144: | file_put_contents($path, join("\n", $buffer) . "\n"); |
| 145: | |
| 146: | |
| 147: | chown($path, 'root'); |
| 148: | |
| 149: | return true; |
| 150: | } |
| 151: | |
| 152: | public function deny_user($user) |
| 153: | { |
| 154: | return (new Util_Pam($this->getAuthContext()))->remove($user, $this->getPamServiceName()); |
| 155: | } |
| 156: | |
| 157: | |
| 158: | |
| 159: | |
| 160: | |
| 161: | |
| 162: | |
| 163: | |
| 164: | protected function getPamServiceName(): string |
| 165: | { |
| 166: | |
| 167: | if (version_compare(platform_version(), '7.5', '<')) { |
| 168: | return 'proftpd'; |
| 169: | } |
| 170: | |
| 171: | return static::PAM_SVC_NAME; |
| 172: | } |
| 173: | |
| 174: | public function permit_user($user) |
| 175: | { |
| 176: | if ($this->auth_is_demo()) { |
| 177: | return error('FTP disabled for demo account'); |
| 178: | } |
| 179: | |
| 180: | return (new Util_Pam($this->getAuthContext()))->add($user, $this->getPamServiceName()); |
| 181: | } |
| 182: | |
| 183: | public function _edit_user(string $user, string $usernew, array $pwd) |
| 184: | { |
| 185: | if ($user === $usernew) { |
| 186: | return; |
| 187: | } |
| 188: | if (!$this->user_permitted($user)) { |
| 189: | return true; |
| 190: | } |
| 191: | (new Util_Pam($this->getAuthContext()))->remove($user, $this->getPamServiceName()); |
| 192: | (new Util_Pam($this->getAuthContext()))->add($usernew, $this->getPamServiceName()); |
| 193: | |
| 194: | $home = $pwd['home']; |
| 195: | if ($this->_user_jailed_real($user)) { |
| 196: | $jailhome = null; |
| 197: | if ($this->has_configuration($user)) { |
| 198: | $jailhome = $this->get_option($user, 'local_root'); |
| 199: | if (!strncmp($jailhome, $home, strlen($home))) { |
| 200: | $newhome = preg_replace('!' . DIRECTORY_SEPARATOR . $user . '!', |
| 201: | DIRECTORY_SEPARATOR . $usernew, $jailhome, 1); |
| 202: | $this->set_option($user, 'local_root', $newhome); |
| 203: | $jailhome = $newhome; |
| 204: | } |
| 205: | } |
| 206: | $jailconf = $this->domain_fs_path() . '/' . self::VSFTPD_CHROOT_FILE; |
| 207: | $conf = file_get_contents($jailconf); |
| 208: | $conf = preg_replace('/^' . $user . '$/m', $usernew, $conf); |
| 209: | file_put_contents($jailconf, $conf); |
| 210: | } |
| 211: | if ($this->has_configuration($user)) { |
| 212: | $ftpconfdir = $this->domain_fs_path() . self::VSFTPD_CONF_DIR; |
| 213: | if (file_exists($ftpconfdir . '/' . $user)) { |
| 214: | rename($ftpconfdir . '/' . $user, $ftpconfdir . '/' . $usernew); |
| 215: | } |
| 216: | } |
| 217: | |
| 218: | return true; |
| 219: | } |
| 220: | |
| 221: | public function user_permitted($user = null) |
| 222: | { |
| 223: | return (new Util_Pam($this->getAuthContext()))->check($user ?? $this->username, $this->getPamServiceName()); |
| 224: | } |
| 225: | |
| 226: | public function user_enabled($user = null) |
| 227: | { |
| 228: | deprecated_func('Use user_permitted'); |
| 229: | return $this->user_permitted($user); |
| 230: | } |
| 231: | |
| 232: | protected function _user_jailed_real($user) |
| 233: | { |
| 234: | $chroot_file = $this->domain_fs_path() . self::VSFTPD_CHROOT_FILE; |
| 235: | if (!file_exists($chroot_file)) { |
| 236: | return false; |
| 237: | } |
| 238: | |
| 239: | return (bool)preg_match('/\b' . $user . '\b/', file_get_contents($chroot_file)); |
| 240: | } |
| 241: | |
| 242: | public function has_configuration($user) |
| 243: | { |
| 244: | $path = $this->domain_fs_path() . self::VSFTPD_CONF_DIR . '/' . $user; |
| 245: | |
| 246: | return file_exists($path); |
| 247: | } |
| 248: | |
| 249: | public function get_option($user, $c_directive) |
| 250: | { |
| 251: | if (!IS_CLI) { |
| 252: | return $this->query('ftp_get_option', $user, $c_directive); |
| 253: | } |
| 254: | |
| 255: | if (!$this->user_exists($user)) { |
| 256: | return error('user ' . $user . ' does not exist'); |
| 257: | } |
| 258: | |
| 259: | return $this->_get_option_real($user, $c_directive); |
| 260: | } |
| 261: | |
| 262: | protected function _get_option_real($user, $c_directive) |
| 263: | { |
| 264: | $conf_file = $this->domain_fs_path() . self::VSFTPD_CONF_DIR . '/' . $user; |
| 265: | if (!file_exists($conf_file)) { |
| 266: | warn('no configuration set for user ' . $user); |
| 267: | |
| 268: | return null; |
| 269: | } |
| 270: | $user_conf = file_get_contents($conf_file); |
| 271: | $conf_val = null; |
| 272: | |
| 273: | if (!preg_match('/^\b' . preg_quote($c_directive) . '(?:\s*=\s*)(.+)$/m', $user_conf, $conf_val)) { |
| 274: | return null; |
| 275: | } |
| 276: | |
| 277: | $conf_val = $conf_val[1]; |
| 278: | |
| 279: | return $conf_val; |
| 280: | } |
| 281: | |
| 282: | public function _reload(string $what = null, array $args = []) |
| 283: | { |
| 284: | |
| 285: | if ($what === Ssl_Module::SYS_RHOOK) { |
| 286: | \Opcenter\Ftp\Vsftpd::restart(HTTPD_RELOAD_DELAY); |
| 287: | } |
| 288: | |
| 289: | return true; |
| 290: | } |
| 291: | |
| 292: | public function _delete_user(string $user) |
| 293: | { |
| 294: | if ($this->user_jailed($user)) { |
| 295: | $this->unjail_user($user); |
| 296: | } |
| 297: | $ftp_conf = join(DIRECTORY_SEPARATOR, |
| 298: | array( |
| 299: | $this->domain_fs_path(), |
| 300: | self::VSFTPD_CONF_DIR, |
| 301: | $user |
| 302: | ) |
| 303: | ); |
| 304: | if (file_exists($ftp_conf)) { |
| 305: | unlink($ftp_conf); |
| 306: | } |
| 307: | |
| 308: | return true; |
| 309: | } |
| 310: | |
| 311: | public function user_jailed($user) |
| 312: | { |
| 313: | if (!$this->user_exists($user)) { |
| 314: | return error('user ' . $user . ' does not exist'); |
| 315: | } |
| 316: | |
| 317: | return $this->_user_jailed_real($user); |
| 318: | } |
| 319: | |
| 320: | public function unjail_user($user) |
| 321: | { |
| 322: | if (!IS_CLI) { |
| 323: | return $this->query('ftp_unjail_user', $user); |
| 324: | } |
| 325: | if (!$this->user_exists($user)) { |
| 326: | return error('user ' . $user . ' does not exist'); |
| 327: | } |
| 328: | if (!file_exists($this->domain_fs_path() . self::VSFTPD_CHROOT_FILE)) { |
| 329: | return warn('chroot file ' . self::VSFTPD_CHROOT_FILE . ' not found'); |
| 330: | } |
| 331: | |
| 332: | $fp = fopen($this->domain_fs_path() . self::VSFTPD_CHROOT_FILE, 'r'); |
| 333: | $buffer = []; |
| 334: | for ($seen = false; !feof($fp);) { |
| 335: | $line = trim((string)fgets($fp)); |
| 336: | if (!$line) { |
| 337: | continue; |
| 338: | } else if ($user === $line) { |
| 339: | $seen = true; |
| 340: | continue; |
| 341: | } |
| 342: | $buffer[] = $line; |
| 343: | } |
| 344: | fclose($fp); |
| 345: | |
| 346: | if (!$seen) { |
| 347: | warn("user `%s' not found in jail conf", $user); |
| 348: | } |
| 349: | $prefix = $this->domain_fs_path(); |
| 350: | $path = $prefix . self::VSFTPD_CHROOT_FILE; |
| 351: | $size = file_put_contents($path, join("\n", $buffer) . "\n", LOCK_EX); |
| 352: | |
| 353: | return $size !== false; |
| 354: | } |
| 355: | |
| 356: | public function _create() |
| 357: | { |
| 358: | |
| 359: | $conf = $this->getAuthContext()->getAccount()->new; |
| 360: | $admin = $conf['siteinfo']['admin_user']; |
| 361: | $pam = new Util_Pam($this->getAuthContext()); |
| 362: | if ($this->auth_is_demo() && $pam->check($admin, $this->getPamServiceName())) { |
| 363: | $pam->remove($admin, $this->getPamServiceName()); |
| 364: | } |
| 365: | } |
| 366: | |
| 367: | |
| 368: | |
| 369: | |
| 370: | |
| 371: | |
| 372: | public function enabled(): bool |
| 373: | { |
| 374: | $check = $this->getServiceValue('ftp', 'enabled'); |
| 375: | |
| 376: | if ($this->permission_level & PRIVILEGE_USER) { |
| 377: | $check &= $this->user_permitted($this->username); |
| 378: | } |
| 379: | |
| 380: | return (bool)$check; |
| 381: | } |
| 382: | |
| 383: | public function _verify_conf(\Opcenter\Service\ConfigurationContext $ctx): bool |
| 384: | { |
| 385: | return true; |
| 386: | } |
| 387: | |
| 388: | public function _delete() |
| 389: | { |
| 390: | |
| 391: | } |
| 392: | |
| 393: | public function _edit() |
| 394: | { |
| 395: | } |
| 396: | |
| 397: | public function _create_user(string $user) |
| 398: | { |
| 399: | } |
| 400: | |
| 401: | } |