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: | } |