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# 27import inspect 28import logging 29import os 30import subprocess 31from datetime import timedelta, datetime 32from json import JSONEncoder 33import time 34from typing import List, Union, Optional 35 36from .curl import CurlClient, ExecResult 37from .env import Env 38 39 40log = logging.getLogger(__name__) 41 42 43class VsFTPD: 44 45 def __init__(self, env: Env, with_ssl=False): 46 self.env = env 47 self._cmd = env.vsftpd 48 self._scheme = 'ftp' 49 self._with_ssl = with_ssl 50 if self._with_ssl: 51 self._port = self.env.ftps_port 52 name = 'vsftpds' 53 else: 54 self._port = self.env.ftp_port 55 name = 'vsftpd' 56 self._vsftpd_dir = os.path.join(env.gen_dir, name) 57 self._run_dir = os.path.join(self._vsftpd_dir, 'run') 58 self._docs_dir = os.path.join(self._vsftpd_dir, 'docs') 59 self._tmp_dir = os.path.join(self._vsftpd_dir, 'tmp') 60 self._conf_file = os.path.join(self._vsftpd_dir, 'test.conf') 61 self._pid_file = os.path.join(self._vsftpd_dir, 'vsftpd.pid') 62 self._error_log = os.path.join(self._vsftpd_dir, 'vsftpd.log') 63 self._process = None 64 65 self.clear_logs() 66 67 @property 68 def domain(self): 69 return self.env.ftp_domain 70 71 @property 72 def docs_dir(self): 73 return self._docs_dir 74 75 @property 76 def port(self) -> str: 77 return self._port 78 79 def clear_logs(self): 80 self._rmf(self._error_log) 81 82 def exists(self): 83 return os.path.exists(self._cmd) 84 85 def is_running(self): 86 if self._process: 87 self._process.poll() 88 return self._process.returncode is None 89 return False 90 91 def start_if_needed(self): 92 if not self.is_running(): 93 return self.start() 94 return True 95 96 def start(self, wait_live=True): 97 pass 98 99 def stop_if_running(self): 100 if self.is_running(): 101 return self.stop() 102 return True 103 104 def stop(self, wait_dead=True): 105 self._mkpath(self._tmp_dir) 106 if self._process: 107 self._process.terminate() 108 self._process.wait(timeout=2) 109 self._process = None 110 return not wait_dead or self.wait_dead(timeout=timedelta(seconds=5)) 111 return True 112 113 def restart(self): 114 self.stop() 115 return self.start() 116 117 def start(self, wait_live=True): 118 self._mkpath(self._tmp_dir) 119 if self._process: 120 self.stop() 121 self._write_config() 122 args = [ 123 self._cmd, 124 f'{self._conf_file}', 125 ] 126 procerr = open(self._error_log, 'a') 127 self._process = subprocess.Popen(args=args, stderr=procerr) 128 if self._process.returncode is not None: 129 return False 130 return not wait_live or self.wait_live(timeout=timedelta(seconds=5)) 131 132 def wait_dead(self, timeout: timedelta): 133 curl = CurlClient(env=self.env, run_dir=self._tmp_dir) 134 try_until = datetime.now() + timeout 135 while datetime.now() < try_until: 136 check_url = f'{self._scheme}://{self.domain}:{self.port}/' 137 r = curl.ftp_get(urls=[check_url], extra_args=['-v']) 138 if r.exit_code != 0: 139 return True 140 log.debug(f'waiting for vsftpd to stop responding: {r}') 141 time.sleep(.1) 142 log.debug(f"Server still responding after {timeout}") 143 return False 144 145 def wait_live(self, timeout: timedelta): 146 curl = CurlClient(env=self.env, run_dir=self._tmp_dir) 147 try_until = datetime.now() + timeout 148 while datetime.now() < try_until: 149 check_url = f'{self._scheme}://{self.domain}:{self.port}/' 150 r = curl.ftp_get(urls=[check_url], extra_args=[ 151 '--trace', 'curl-start.trace', '--trace-time' 152 ]) 153 if r.exit_code == 0: 154 return True 155 log.debug(f'waiting for vsftpd to become responsive: {r}') 156 time.sleep(.1) 157 log.error(f"Server still not responding after {timeout}") 158 return False 159 160 def _run(self, args, intext=''): 161 env = {} 162 for key, val in os.environ.items(): 163 env[key] = val 164 with open(self._error_log, 'w') as cerr: 165 self._process = subprocess.run(args, stderr=cerr, stdout=cerr, 166 cwd=self._vsftpd_dir, 167 input=intext.encode() if intext else None, 168 env=env) 169 start = datetime.now() 170 return ExecResult(args=args, exit_code=self._process.returncode, 171 duration=datetime.now() - start) 172 173 def _rmf(self, path): 174 if os.path.exists(path): 175 return os.remove(path) 176 177 def _mkpath(self, path): 178 if not os.path.exists(path): 179 return os.makedirs(path) 180 181 def _write_config(self): 182 self._mkpath(self._docs_dir) 183 self._mkpath(self._tmp_dir) 184 conf = [ # base server config 185 f'listen=YES', 186 f'run_as_launching_user=YES', 187 f'#listen_address=127.0.0.1', 188 f'listen_port={self.port}', 189 f'local_enable=NO', 190 f'anonymous_enable=YES', 191 f'anon_root={self._docs_dir}', 192 f'dirmessage_enable=YES', 193 f'write_enable=YES', 194 f'anon_upload_enable=YES', 195 f'log_ftp_protocol=YES', 196 f'xferlog_enable=YES', 197 f'xferlog_std_format=NO', 198 f'vsftpd_log_file={self._error_log}', 199 f'\n', 200 ] 201 if self._with_ssl: 202 creds = self.env.get_credentials(self.domain) 203 conf.extend([ 204 f'ssl_enable=YES', 205 f'debug_ssl=YES', 206 f'allow_anon_ssl=YES', 207 f'rsa_cert_file={creds.cert_file}', 208 f'rsa_private_key_file={creds.pkey_file}', 209 # require_ssl_reuse=YES means ctrl and data connection need to use the same session 210 f'require_ssl_reuse=NO', 211 ]) 212 213 with open(self._conf_file, 'w') as fd: 214 fd.write("\n".join(conf)) 215