vsftpd.py (7538B)
1 #!/usr/bin/env python3 2 # -*- coding: utf-8 -*- 3 #*************************************************************************** 4 # _ _ ____ _ 5 # Project ___| | | | _ \| | 6 # / __| | | | |_) | | 7 # | (__| |_| | _ <| |___ 8 # \___|\___/|_| \_\_____| 9 # 10 # Copyright (C) Daniel Stenberg, <daniel@haxx.se>, et al. 11 # 12 # This software is licensed as described in the file COPYING, which 13 # you should have received as part of this distribution. The terms 14 # are also available at https://curl.se/docs/copyright.html. 15 # 16 # You may opt to use, copy, modify, merge, publish, distribute and/or sell 17 # copies of the Software, and permit persons to whom the Software is 18 # furnished to do so, under the terms of the COPYING file. 19 # 20 # This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY 21 # KIND, either express or implied. 22 # 23 # SPDX-License-Identifier: curl 24 # 25 ########################################################################### 26 # 27 import logging 28 import os 29 import re 30 import socket 31 import subprocess 32 import time 33 34 from datetime import datetime, timedelta 35 from typing import List, Dict 36 37 from .curl import CurlClient, ExecResult 38 from .env import Env 39 from .ports import alloc_ports_and_do 40 41 log = logging.getLogger(__name__) 42 43 44 class VsFTPD: 45 46 def __init__(self, env: Env, with_ssl=False, ssl_implicit=False): 47 self.env = env 48 self._cmd = env.vsftpd 49 self._port = 0 50 self._with_ssl = with_ssl 51 self._ssl_implicit = ssl_implicit and with_ssl 52 self._scheme = 'ftps' if self._ssl_implicit else 'ftp' 53 if self._with_ssl: 54 self.name = 'vsftpds' 55 self._port_skey = 'ftps' 56 self._port_specs = { 57 'ftps': socket.SOCK_STREAM, 58 } 59 else: 60 self.name = 'vsftpd' 61 self._port_skey = 'ftp' 62 self._port_specs = { 63 'ftp': socket.SOCK_STREAM, 64 } 65 self._vsftpd_dir = os.path.join(env.gen_dir, self.name) 66 self._run_dir = os.path.join(self._vsftpd_dir, 'run') 67 self._docs_dir = os.path.join(self._vsftpd_dir, 'docs') 68 self._tmp_dir = os.path.join(self._vsftpd_dir, 'tmp') 69 self._conf_file = os.path.join(self._vsftpd_dir, 'test.conf') 70 self._pid_file = os.path.join(self._vsftpd_dir, 'vsftpd.pid') 71 self._error_log = os.path.join(self._vsftpd_dir, 'vsftpd.log') 72 self._process = None 73 74 self.clear_logs() 75 76 @property 77 def domain(self): 78 return self.env.ftp_domain 79 80 @property 81 def docs_dir(self): 82 return self._docs_dir 83 84 @property 85 def port(self) -> int: 86 return self._port 87 88 def clear_logs(self): 89 self._rmf(self._error_log) 90 91 def exists(self): 92 return os.path.exists(self._cmd) 93 94 def is_running(self): 95 if self._process: 96 self._process.poll() 97 return self._process.returncode is None 98 return False 99 100 def start_if_needed(self): 101 if not self.is_running(): 102 return self.start() 103 return True 104 105 def stop(self, wait_dead=True): 106 self._mkpath(self._tmp_dir) 107 if self._process: 108 self._process.terminate() 109 self._process.wait(timeout=2) 110 self._process = None 111 return not wait_dead or self.wait_dead(timeout=timedelta(seconds=5)) 112 return True 113 114 def restart(self): 115 self.stop() 116 return self.start() 117 118 def initial_start(self): 119 120 def startup(ports: Dict[str, int]) -> bool: 121 self._port = ports[self._port_skey] 122 if self.start(): 123 self.env.update_ports(ports) 124 return True 125 self.stop() 126 self._port = 0 127 return False 128 129 return alloc_ports_and_do(self._port_specs, startup, 130 self.env.gen_root, max_tries=3) 131 132 def start(self, wait_live=True): 133 assert self._port > 0 134 self._mkpath(self._tmp_dir) 135 if self._process: 136 self.stop() 137 self._write_config() 138 args = [ 139 self._cmd, 140 f'{self._conf_file}', 141 ] 142 procerr = open(self._error_log, 'a') 143 self._process = subprocess.Popen(args=args, stderr=procerr) 144 if self._process.returncode is not None: 145 return False 146 return not wait_live or self.wait_live(timeout=timedelta(seconds=Env.SERVER_TIMEOUT)) 147 148 def wait_dead(self, timeout: timedelta): 149 curl = CurlClient(env=self.env, run_dir=self._tmp_dir) 150 try_until = datetime.now() + timeout 151 while datetime.now() < try_until: 152 check_url = f'{self._scheme}://{self.domain}:{self.port}/' 153 r = curl.ftp_get(urls=[check_url], extra_args=['-v']) 154 if r.exit_code != 0: 155 return True 156 log.debug(f'waiting for vsftpd to stop responding: {r}') 157 time.sleep(.1) 158 log.debug(f"Server still responding after {timeout}") 159 return False 160 161 def wait_live(self, timeout: timedelta): 162 curl = CurlClient(env=self.env, run_dir=self._tmp_dir) 163 try_until = datetime.now() + timeout 164 while datetime.now() < try_until: 165 check_url = f'{self._scheme}://{self.domain}:{self.port}/' 166 r = curl.ftp_get(urls=[check_url], extra_args=[ 167 '--trace', 'curl-start.trace', '--trace-time' 168 ]) 169 if r.exit_code == 0: 170 return True 171 time.sleep(.1) 172 log.error(f"Server still not responding after {timeout}") 173 return False 174 175 def _rmf(self, path): 176 if os.path.exists(path): 177 return os.remove(path) 178 179 def _mkpath(self, path): 180 if not os.path.exists(path): 181 return os.makedirs(path) 182 183 def _write_config(self): 184 self._mkpath(self._docs_dir) 185 self._mkpath(self._tmp_dir) 186 conf = [ # base server config 187 'listen=YES', 188 'run_as_launching_user=YES', 189 '#listen_address=127.0.0.1', 190 f'listen_port={self.port}', 191 'local_enable=NO', 192 'anonymous_enable=YES', 193 f'anon_root={self._docs_dir}', 194 'dirmessage_enable=YES', 195 'write_enable=YES', 196 'anon_upload_enable=YES', 197 'log_ftp_protocol=YES', 198 'xferlog_enable=YES', 199 'xferlog_std_format=NO', 200 f'vsftpd_log_file={self._error_log}', 201 '\n', 202 ] 203 if self._with_ssl: 204 creds = self.env.get_credentials(self.domain) 205 assert creds # convince pytype this isn't None 206 conf.extend([ 207 'ssl_enable=YES', 208 'debug_ssl=YES', 209 'allow_anon_ssl=YES', 210 f'rsa_cert_file={creds.cert_file}', 211 f'rsa_private_key_file={creds.pkey_file}', 212 # require_ssl_reuse=YES means ctrl and data connection need to use the same session 213 'require_ssl_reuse=NO', 214 ]) 215 if self._ssl_implicit: 216 conf.extend([ 217 'implicit_ssl=YES', 218 ]) 219 with open(self._conf_file, 'w') as fd: 220 fd.write("\n".join(conf)) 221 222 def get_data_ports(self, r: ExecResult) -> List[int]: 223 return [int(m.group(1)) for line in r.trace_lines if 224 (m := re.match(r'.*Connected 2nd connection to .* port (\d+)', line))]