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: /**
16: * Provides common functionality associated with vsFTPd
17: *
18: * @package core
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: * {{{ void __construct(void)
29: *
30: * @ignore
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; // value already set
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: // make sure configuration is owned by root on v6+ platforms
146: // no more custom patches
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: * Wrapper for backwards compatibility during dev
159: *
160: * @todo yank before 3.0.0 release
161: *
162: * @return string
163: */
164: protected function getPamServiceName(): string
165: {
166: //@xxx temporary backwards compatibility
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: // ssl = system ssl
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: // stupid thor...
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: * FTP service enabled
369: *
370: * @return bool
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: }