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_enabled($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_enabled($user)
222: {
223: if (!FTP_ENABLED || !$this->getConfig('ftp', 'enabled')) {
224: return false;
225: }
226:
227: return (new Util_Pam($this->getAuthContext()))->check($user, $this->getPamServiceName());
228: }
229:
230: protected function _user_jailed_real($user)
231: {
232: $chroot_file = $this->domain_fs_path() . self::VSFTPD_CHROOT_FILE;
233: if (!file_exists($chroot_file)) {
234: return false;
235: }
236:
237: return (bool)preg_match('/\b' . $user . '\b/', file_get_contents($chroot_file));
238: }
239:
240: public function has_configuration($user)
241: {
242: $path = $this->domain_fs_path() . self::VSFTPD_CONF_DIR . '/' . $user;
243:
244: return file_exists($path);
245: }
246:
247: public function get_option($user, $c_directive)
248: {
249: if (!IS_CLI) {
250: return $this->query('ftp_get_option', $user, $c_directive);
251: }
252:
253: if (!$this->user_exists($user)) {
254: return error('user ' . $user . ' does not exist');
255: }
256:
257: return $this->_get_option_real($user, $c_directive);
258: }
259:
260: protected function _get_option_real($user, $c_directive)
261: {
262: $conf_file = $this->domain_fs_path() . self::VSFTPD_CONF_DIR . '/' . $user;
263: if (!file_exists($conf_file)) {
264: warn('no configuration set for user ' . $user);
265:
266: return null;
267: }
268: $user_conf = file_get_contents($conf_file);
269: $conf_val = null;
270:
271: if (!preg_match('/^\b' . preg_quote($c_directive) . '(?:\s*=\s*)(.+)$/m', $user_conf, $conf_val)) {
272: return null;
273: }
274:
275: $conf_val = $conf_val[1];
276:
277: return $conf_val;
278: }
279:
280: public function _reload(string $what = null, array $args = [])
281: {
282: // ssl = system ssl
283: if ($what === Ssl_Module::SYS_RHOOK) {
284: \Opcenter\Ftp\Vsftpd::restart(HTTPD_RELOAD_DELAY);
285: }
286:
287: return true;
288: }
289:
290: public function _delete_user(string $user)
291: {
292: if ($this->user_jailed($user)) {
293: $this->unjail_user($user);
294: }
295: $ftp_conf = join(DIRECTORY_SEPARATOR,
296: array(
297: $this->domain_fs_path(),
298: self::VSFTPD_CONF_DIR,
299: $user
300: )
301: );
302: if (file_exists($ftp_conf)) {
303: unlink($ftp_conf);
304: }
305:
306: return true;
307: }
308:
309: public function user_jailed($user)
310: {
311: if (!$this->user_exists($user)) {
312: return error('user ' . $user . ' does not exist');
313: }
314:
315: return $this->_user_jailed_real($user);
316: }
317:
318: public function unjail_user($user)
319: {
320: if (!IS_CLI) {
321: return $this->query('ftp_unjail_user', $user);
322: }
323: if (!$this->user_exists($user)) {
324: return error('user ' . $user . ' does not exist');
325: }
326: if (!file_exists($this->domain_fs_path() . self::VSFTPD_CHROOT_FILE)) {
327: return warn('chroot file ' . self::VSFTPD_CHROOT_FILE . ' not found');
328: }
329:
330: $fp = fopen($this->domain_fs_path() . self::VSFTPD_CHROOT_FILE, 'r');
331: $buffer = [];
332: for ($seen = false; !feof($fp);) {
333: $line = trim((string)fgets($fp));
334: if (!$line) {
335: continue;
336: } else if ($user === $line) {
337: $seen = true;
338: continue;
339: }
340: $buffer[] = $line;
341: }
342: fclose($fp);
343:
344: if (!$seen) {
345: warn("user `%s' not found in jail conf", $user);
346: }
347: $prefix = $this->domain_fs_path();
348: $path = $prefix . self::VSFTPD_CHROOT_FILE;
349: $size = file_put_contents($path, join("\n", $buffer) . "\n", LOCK_EX);
350:
351: return $size !== false;
352: }
353:
354: public function _create()
355: {
356: // stupid thor...
357: $conf = $this->getAuthContext()->getAccount()->new;
358: $admin = $conf['siteinfo']['admin_user'];
359: $pam = new Util_Pam($this->getAuthContext());
360: if ($this->auth_is_demo() && $pam->check($admin, $this->getPamServiceName())) {
361: $pam->remove($admin, $this->getPamServiceName());
362: }
363: }
364:
365: /**
366: * FTP service enabled
367: *
368: * @return bool
369: */
370: public function enabled(): bool
371: {
372: return $this->user_enabled($this->username);
373: }
374:
375: public function _verify_conf(\Opcenter\Service\ConfigurationContext $ctx): bool
376: {
377: return true;
378: }
379:
380: public function _delete()
381: {
382:
383: }
384:
385: public function _edit()
386: {
387: }
388:
389: public function _create_user(string $user)
390: {
391: }
392:
393: }