├── preview.png ├── ftpServer.ico ├── .gitignore ├── .vscode └── launch.json ├── LICENSE ├── file_version_info.txt ├── mypyftpdlib ├── __init__.py ├── prefork.py ├── __main__.py ├── log.py ├── servers.py ├── filesystems.py ├── authorizers.py └── ioloop.py ├── README.md ├── UserList.py ├── Settings.py ├── myUtils.py └── ftpServer.py /preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jark006/FtpServer/HEAD/preview.png -------------------------------------------------------------------------------- /ftpServer.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jark006/FtpServer/HEAD/ftpServer.ico -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | dist 3 | *.spec 4 | env 5 | venv 6 | output 7 | nuitkaHelp.txt 8 | FtpServer.json 9 | *.csv 10 | *.crt 11 | *.key 12 | __pycache__ 13 | mypyftpdlib/__pycache__ 14 | ftpServer.onefile-build 15 | ftpServer.build 16 | ftpServer.dist 17 | ftpServer.installer 18 | ftpServer.exe -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // 使用 IntelliSense 了解相关属性。 3 | // 悬停以查看现有属性的描述。 4 | // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python 调试程序: 当前文件", 9 | "type": "debugpy", 10 | "request": "launch", 11 | "program": "${file}", 12 | "console": "integratedTerminal" 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 JARK006 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /file_version_info.txt: -------------------------------------------------------------------------------- 1 | # UTF-8 2 | # 3 | # For more details about fixed file info 'ffi' see: 4 | # http://msdn.microsoft.com/en-us/library/ms646997.aspx 5 | VSVersionInfo( 6 | ffi=FixedFileInfo( 7 | # filevers and prodvers should be always a tuple with four items: (1, 2, 3, 4) 8 | # Set not needed items to zero 0. 9 | filevers=(1,24, 0, 0), 10 | prodvers=(1,24, 0, 0), 11 | # Contains a bitmask that specifies the valid bits 'flags'r 12 | mask=0x3f, 13 | # Contains a bitmask that specifies the Boolean attributes of the file. 14 | flags=0x0, 15 | # The operating system for which this file was designed. 16 | # 0x4 - NT and there is no need to change it. 17 | OS=0x40004, 18 | # The general type of file. 19 | # 0x1 - the file is an application. 20 | fileType=0x0, 21 | # The function of the file. 22 | # 0x0 - the function is not defined for this fileType 23 | subtype=0x0, 24 | # Creation date and time stamp. 25 | date=(0, 0) 26 | ), 27 | kids=[ 28 | StringFileInfo( 29 | [ 30 | StringTable( 31 | '080404b0', 32 | [StringStruct('CompanyName', 'Github@JARK006'), 33 | StringStruct('FileDescription', 'FtpServer Github@JARK006'), 34 | StringStruct('FileVersion', '1.24.0.0'), 35 | StringStruct('InternalName', 'FtpServer'), 36 | StringStruct('LegalCopyright', 'Copyright (C) 2023-2026'), 37 | StringStruct('OriginalFilename', 'FtpServer.exe'), 38 | StringStruct('ProductName', 'FtpServer'), 39 | StringStruct('ProductVersion', '1.24.0.0')]) 40 | ]), 41 | VarFileInfo([VarStruct('Translation', [2052, 1200])]) 42 | ] 43 | ) -------------------------------------------------------------------------------- /mypyftpdlib/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2007 Giampaolo Rodola' . 2 | # Use of this source code is governed by MIT license that can be 3 | # found in the LICENSE file. 4 | 5 | 6 | """ 7 | pyftpdlib: RFC-959 asynchronous FTP server. 8 | 9 | pyftpdlib implements a fully functioning asynchronous FTP server as 10 | defined in RFC-959. A hierarchy of classes outlined below implement 11 | the backend functionality for the FTPd: 12 | 13 | [pyftpdlib.ftpservers.FTPServer] 14 | accepts connections and dispatches them to a handler 15 | 16 | [pyftpdlib.handlers.FTPHandler] 17 | a class representing the server-protocol-interpreter 18 | (server-PI, see RFC-959). Each time a new connection occurs 19 | FTPServer will create a new FTPHandler instance to handle the 20 | current PI session. 21 | 22 | [pyftpdlib.handlers.ActiveDTP] 23 | [pyftpdlib.handlers.PassiveDTP] 24 | base classes for active/passive-DTP backends. 25 | 26 | [pyftpdlib.handlers.DTPHandler] 27 | this class handles processing of data transfer operations (server-DTP, 28 | see RFC-959). 29 | 30 | [pyftpdlib.authorizers.DummyAuthorizer] 31 | an "authorizer" is a class handling FTPd authentications and 32 | permissions. It is used inside FTPHandler class to verify user 33 | passwords, to get user's home directory and to get permissions 34 | when a filesystem read/write occurs. "DummyAuthorizer" is the 35 | base authorizer class providing a platform independent interface 36 | for managing virtual users. 37 | 38 | [pyftpdlib.filesystems.AbstractedFS] 39 | class used to interact with the file system, providing a high level, 40 | cross-platform interface compatible with both Windows and UNIX style 41 | filesystems. 42 | 43 | Usage example: 44 | 45 | >>> from pyftpdlib.authorizers import DummyAuthorizer 46 | >>> from pyftpdlib.handlers import FTPHandler 47 | >>> from pyftpdlib.servers import FTPServer 48 | >>> 49 | >>> authorizer = DummyAuthorizer() 50 | >>> authorizer.add_user("user", "12345", "/home/giampaolo", perm="elradfmwMT") 51 | >>> authorizer.add_anonymous("/home/nobody") 52 | >>> 53 | >>> handler = FTPHandler 54 | >>> handler.authorizer = authorizer 55 | >>> 56 | >>> server = FTPServer(("127.0.0.1", 21), handler) 57 | >>> server.serve_forever() 58 | [I 13-02-19 10:55:42] >>> starting FTP server on 127.0.0.1:21 <<< 59 | [I 13-02-19 10:55:42] poller: 60 | [I 13-02-19 10:55:42] masquerade (NAT) address: None 61 | [I 13-02-19 10:55:42] passive ports: None 62 | [I 13-02-19 10:55:42] use sendfile(2): True 63 | [I 13-02-19 10:55:45] 127.0.0.1:34178-[] FTP session opened (connect) 64 | [I 13-02-19 10:55:48] 127.0.0.1:34178-[user] USER 'user' logged in. 65 | [I 13-02-19 10:56:27] 127.0.0.1:34179-[user] RETR /home/giampaolo/.vimrc 66 | completed=1 bytes=1700 seconds=0.001 67 | [I 13-02-19 10:56:39] 127.0.0.1:34179-[user] FTP session closed (disconnect). 68 | 69 | """ 70 | 71 | 72 | __ver__ = '2.0.1' 73 | __author__ = "Giampaolo Rodola' " 74 | __web__ = 'https://github.com/giampaolo/pyftpdlib/' 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🖥️ FTP文件服务器 2 | 3 | [![Version](https://img.shields.io/github/v/release/jark006/ftpServer)](https://github.com/jark006/FtpServer/releases/latest) ![Download](https://img.shields.io/github/downloads/jark006/FtpServer/total) [![Stars](https://img.shields.io/github/stars/jark006/ftpServer)](https://github.com/jark006/FtpServer/stargazers) [![License](https://img.shields.io/github/license/jark006/ftpServer)](https://github.com/jark006/FtpServer/blob/main/LICENSE) ![Platform](https://img.shields.io/badge/OS-Windows%2010/11%2064%20bit-00adef.svg) 4 | 5 | 一键开启FTP文件服务器,方便其他设备通过网络传输、管理文件,支持IPv4/IPv6、多用户、FTPS。 6 | 7 | ![](preview.png) 8 | 9 | ## 🔐 FTPS 配置 10 | 11 | *本软件默认使用 FTP 明文传输数据,如果数据比较敏感,或者网络环境不安全,则可以按以下步骤开启 FTPS 加密传输数据。* 12 | 13 | 在 `Linux` 或 `MinGW64` 终端使用 `openssl` (命令如下,需填入一些简单信息: 地区/名字/Email等)生成SSL证书文件(ftpServer.key和ftpServer.crt), `不要重命名`文件为其他名称。 14 | 15 | ```sh 16 | openssl req -x509 -newkey rsa:2048 -keyout ftpServer.key -out ftpServer.crt -nodes -days 36500 17 | ``` 18 | 19 | 直接将 `ftpServer.key` 和 `ftpServer.crt` 放到程序所在目录, 开启服务时若存在这两个文件, 则启用加密传输 `FTPS [TLS/SSL显式加密, TLSv1.3]`。 20 | 21 | Windows文件管理器对 `显式FTPS` 支持不佳, 推荐使用开源软件 `WinSCP` 客户端, 对 FTPS 支持比较好。 22 | 23 | 开启 `FTPS 加密传输` 后, 会影响传输性能, 最大传输速度会降到 `50MiB/s` 左右。若对网络安全没那么高要求, 不建议加密。 24 | 25 | --- 26 | 27 | ## 👥 多用户配置 28 | 29 | *一般单人使用时,只需在软件主页面设置用户名和密码即可。如果需要开放给多人使用,可以按以下步骤建立多个用户,分配不同的读写权限和根目录。* 30 | 31 | 在主程序所在目录新建文件 `FtpServerUserList.csv` ,使用 `Excel` 或文本编辑器(需熟悉csv文件格式)编辑,一行一个配置: 32 | 33 | 1. 第一列:用户名,限定英文大小写/数字。 34 | 2. 第二列:密码,限定英文大小写/数字/符号。 35 | 3. 第三列:权限,详细配置如下。 36 | 4. 第四列:根目录路径。 37 | 38 | **📇 样例** 39 | 40 | | | | | | 41 | | --------- | ------ | ---------- | ------------ | 42 | | JARK006 | 123456 | readonly | D:\Downloads | 43 | | JARK007 | 456789 | readwrite | D:\Data | 44 | | JARK008 | abc123 | 只读 | D:\FtpRoot | 45 | | JARK009 | abc456 | elradfmwMT | D:\FtpRoot | 46 | | anonymous | | elr | D:\FtpRoot | 47 | | ... | | | | 48 | 49 | 注: anonymous 是匿名用户,允许不设密码,其他用户必须设置密码。 50 | 51 | 📜 详细权限配置: 52 | 53 | 1. 使用 `readonly` 或 `只读` 设置为 `只读权限`。 54 | 1. 使用 `readwrite` 或 `读写` 设置为 `读写权限`。 55 | 1. 使用 `自定义` 权限设置, 从以下权限挑选自行组合(注意大小写)。 56 | 57 | 参考链接:https://pyftpdlib.readthedocs.io/en/latest/api.html#pyftpdlib.authorizers.DummyAuthorizer.add_user 58 | 59 | 📄 读取权限: 60 | 61 | - `e` : 更改目录 (CWD 命令) 62 | - `l` : 列出文件 (LIST、NLST、STAT、MLSD、MLST、SIZE、MDTM 命令) 63 | - `r` : 从服务器检索文件 (RETR 命令) 64 | 65 | 📝 写入权限: 66 | 67 | - `a` : 将数据附加到现有文件 (APPE 命令) 68 | - `d` : 删除文件或目录 (DELE、RMD 命令) 69 | - `f` : 重命名文件或目录 (RNFR、RNTO 命令) 70 | - `m` : 创建目录 (MKD 命令) 71 | - `w` : 将文件存储到服务器 (STOR、STOU 命令) 72 | - `M` : 更改文件模式 (SITE CHMOD 命令) 73 | - `T` : 更新文件上次修改时间 (MFMT 命令) 74 | 75 | **📌 其他** 76 | 77 | 1. 若读取到有效配置,则自动 `禁用` 主页面的用户/密码设置。 78 | 2. 密码不要出现英文逗号 `,` 字符,以免和csv文本格式冲突。 79 | 3. 若不需要多用户配置,可将配置文件 `删除` 或 `重命名` 为其他名称。 80 | 4. 配置文件可以是UTF-8或GBK编码。 81 | 82 | --- 83 | 84 | ## 🧩 使用到的库 85 | 86 | 1. [pyftpdlib](https://github.com/giampaolo/pyftpdlib) 87 | 2. [tkinter](https://docs.python.org/3/library/tkinter.html) 88 | 3. [pystray](https://github.com/moses-palmer/pystray) 89 | 4. [Pillow](https://github.com/python-pillow/Pillow) 90 | -------------------------------------------------------------------------------- /mypyftpdlib/prefork.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2007 Giampaolo Rodola' . 2 | # Use of this source code is governed by MIT license that can be 3 | # found in the LICENSE file. 4 | 5 | """Process utils.""" 6 | 7 | import os 8 | import random 9 | import sys 10 | import time 11 | from binascii import hexlify 12 | 13 | 14 | try: 15 | import multiprocessing 16 | except ImportError: 17 | multiprocessing = None 18 | 19 | from .log import logger 20 | 21 | 22 | _task_id = None 23 | 24 | 25 | def cpu_count(): 26 | """Returns the number of processors on this machine.""" 27 | if multiprocessing is None: 28 | return 1 29 | try: 30 | return multiprocessing.cpu_count() 31 | except NotImplementedError: 32 | pass 33 | try: 34 | return os.sysconf("SC_NPROCESSORS_CONF") 35 | except (AttributeError, ValueError): 36 | pass 37 | return 1 38 | 39 | 40 | def _reseed_random(): 41 | 42 | # If os.urandom is available, this method does the same thing as 43 | # random.seed. If os.urandom is not available, we mix in the pid in 44 | # addition to a timestamp. 45 | try: 46 | seed = int(hexlify(os.urandom(16)), 16) 47 | except NotImplementedError: 48 | seed = int(time.time() * 1000) ^ os.getpid() 49 | random.seed(seed) 50 | 51 | 52 | def fork_processes(number, max_restarts=100): 53 | """Starts multiple worker processes. 54 | 55 | If *number* is None or <= 0, we detect the number of cores available 56 | on this machine and fork that number of child processes. 57 | If *number* is given and > 0, we fork that specific number of 58 | sub-processes. 59 | 60 | Since we use processes and not threads, there is no shared memory 61 | between any server code. 62 | 63 | In each child process, *fork_processes* returns its *task id*, a 64 | number between 0 and *number*. Processes that exit abnormally 65 | (due to a signal or non-zero exit status) are restarted with the 66 | same id (up to *max_restarts* times). In the parent process, 67 | *fork_processes* returns None if all child processes have exited 68 | normally, but will otherwise only exit by throwing an exception. 69 | """ 70 | assert _task_id is None 71 | if number is None or number <= 0: 72 | number = cpu_count() 73 | logger.info("starting %d pre-fork processes", number) 74 | children = {} 75 | 76 | def start_child(i): 77 | pid = os.fork() 78 | if pid == 0: 79 | # child process 80 | _reseed_random() 81 | global _task_id 82 | _task_id = i 83 | return i 84 | else: 85 | children[pid] = i 86 | return None 87 | 88 | for i in range(number): 89 | id = start_child(i) 90 | if id is not None: 91 | return id 92 | num_restarts = 0 93 | while children: 94 | try: 95 | pid, status = os.wait() 96 | except InterruptedError: 97 | continue 98 | if pid not in children: 99 | continue 100 | id = children.pop(pid) 101 | if os.WIFSIGNALED(status): 102 | logger.warning( 103 | "child %d (pid %d) killed by signal %d, restarting", 104 | id, 105 | pid, 106 | os.WTERMSIG(status), 107 | ) 108 | elif os.WEXITSTATUS(status) != 0: 109 | logger.warning( 110 | "child %d (pid %d) exited with status %d, restarting", 111 | id, 112 | pid, 113 | os.WEXITSTATUS(status), 114 | ) 115 | else: 116 | logger.info("child %d (pid %d) exited normally", id, pid) 117 | continue 118 | num_restarts += 1 119 | if num_restarts > max_restarts: 120 | raise RuntimeError("Too many child restarts, giving up") 121 | new_id = start_child(id) 122 | if new_id is not None: 123 | return new_id 124 | # All child processes exited cleanly, so exit the master process 125 | # instead of just returning to right after the call to 126 | # fork_processes (which will probably just start up another IOLoop 127 | # unless the caller checks the return value). 128 | sys.exit(0) 129 | -------------------------------------------------------------------------------- /mypyftpdlib/__main__.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2007 Giampaolo Rodola' . 2 | # Use of this source code is governed by MIT license that can be 3 | # found in the LICENSE file. 4 | 5 | """ 6 | Start a stand alone anonymous FTP server from the command line as in: 7 | 8 | $ python3 -m pyftpdlib 9 | """ 10 | 11 | import argparse 12 | import logging 13 | import os 14 | import sys 15 | 16 | from . import __ver__ 17 | from .authorizers import DummyAuthorizer 18 | from .handlers import FTPHandler 19 | from .log import config_logging 20 | from .servers import FTPServer 21 | 22 | 23 | def main(args=None): 24 | """Start a stand alone anonymous FTP server.""" 25 | usage = "python3 -m pyftpdlib [options]" 26 | parser = argparse.ArgumentParser( 27 | usage=usage, 28 | description=main.__doc__, 29 | ) 30 | parser.add_argument( 31 | '-i', 32 | '--interface', 33 | default=None, 34 | metavar="ADDRESS", 35 | help="specify the interface to run on (default all interfaces)", 36 | ) 37 | parser.add_argument( 38 | '-p', 39 | '--port', 40 | type=int, 41 | default=2121, 42 | metavar="PORT", 43 | help="specify port number to run on (default 2121)", 44 | ) 45 | parser.add_argument( 46 | '-w', 47 | '--write', 48 | action="store_true", 49 | default=False, 50 | help="grants write access for logged in user (default read-only)", 51 | ) 52 | parser.add_argument( 53 | '-d', 54 | '--directory', 55 | default=os.getcwd(), 56 | metavar="FOLDER", 57 | help="specify the directory to share (default current directory)", 58 | ) 59 | parser.add_argument( 60 | '-n', 61 | '--nat-address', 62 | default=None, 63 | metavar="ADDRESS", 64 | help="the NAT address to use for passive connections", 65 | ) 66 | parser.add_argument( 67 | '-r', 68 | '--range', 69 | default=None, 70 | metavar="FROM-TO", 71 | help=( 72 | "the range of TCP ports to use for passive " 73 | "connections (e.g. -r 8000-9000)" 74 | ), 75 | ) 76 | parser.add_argument( 77 | '-D', '--debug', action='store_true', help="enable DEBUG logging level" 78 | ) 79 | parser.add_argument( 80 | '-v', 81 | '--version', 82 | action='store_true', 83 | help="print pyftpdlib version and exit", 84 | ) 85 | parser.add_argument( 86 | '-V', 87 | '--verbose', 88 | action='store_true', 89 | help="activate a more verbose logging", 90 | ) 91 | parser.add_argument( 92 | '-u', 93 | '--username', 94 | type=str, 95 | default=None, 96 | help=( 97 | "specify username to login with (anonymous login " 98 | "will be disabled and password required " 99 | "if supplied)" 100 | ), 101 | ) 102 | parser.add_argument( 103 | '-P', 104 | '--password', 105 | type=str, 106 | default=None, 107 | help=( 108 | "specify a password to login with (username required to be useful)" 109 | ), 110 | ) 111 | 112 | options = parser.parse_args(args=args) 113 | if options.version: 114 | sys.exit(f"pyftpdlib {__ver__}") 115 | if options.debug: 116 | config_logging(level=logging.DEBUG) 117 | 118 | passive_ports = None 119 | if options.range: 120 | try: 121 | start, stop = options.range.split('-') 122 | start = int(start) 123 | stop = int(stop) 124 | except ValueError: 125 | parser.error('invalid argument passed to -r option') 126 | else: 127 | passive_ports = list(range(start, stop + 1)) 128 | # On recent Windows versions, if address is not specified and IPv6 129 | # is installed the socket will listen on IPv6 by default; in this 130 | # case we force IPv4 instead. 131 | if os.name in ('nt', 'ce') and not options.interface: 132 | options.interface = '0.0.0.0' 133 | 134 | authorizer = DummyAuthorizer() 135 | perm = "elradfmwMT" if options.write else "elr" 136 | if options.username: 137 | if not options.password: 138 | parser.error( 139 | "if username (-u) is supplied, password ('-P') is required" 140 | ) 141 | authorizer.add_user( 142 | options.username, options.password, options.directory, perm=perm 143 | ) 144 | else: 145 | authorizer.add_anonymous(options.directory, perm=perm) 146 | 147 | handler = FTPHandler 148 | handler.authorizer = authorizer 149 | handler.masquerade_address = options.nat_address 150 | handler.passive_ports = passive_ports 151 | 152 | ftpd = FTPServer((options.interface, options.port), FTPHandler) 153 | # On Windows specify a timeout for the underlying select() so 154 | # that the server can be interrupted with CTRL + C. 155 | try: 156 | ftpd.serve_forever(timeout=2 if os.name == 'nt' else None) 157 | finally: 158 | ftpd.close_all() 159 | if args: # only used in unit tests 160 | return ftpd 161 | 162 | 163 | if __name__ == '__main__': 164 | main() 165 | -------------------------------------------------------------------------------- /UserList.py: -------------------------------------------------------------------------------- 1 | import Settings 2 | import os 3 | import sys 4 | 5 | 6 | class UserNode: 7 | def __init__(self, userName: str, password: str, perm: str, path: str) -> None: 8 | self.userName = userName 9 | self.password = password 10 | self.perm = perm 11 | self.path = path 12 | 13 | 14 | def permTranslate(perm: str) -> str: 15 | if perm == "elr": 16 | return "只读" 17 | elif perm == "elradfmwMT": 18 | return "读写" 19 | else: 20 | return perm 21 | 22 | 23 | def permConvert(perm: str) -> str: 24 | """ 25 | Link: https://pyftpdlib.readthedocs.io/en/latest/api.html#pyftpdlib.authorizers.DummyAuthorizer.add_user 26 | 读取权限: 27 | - "e" = 更改目录 (CWD 命令) 28 | - "l" = 列出文件 (LIST、NLST、STAT、MLSD、MLST、SIZE、MDTM 命令) 29 | - "r" = 从服务器检索文件 (RETR 命令) 30 | 31 | 写入权限: 32 | - "a" = 将数据附加到现有文件 (APPE 命令) 33 | - "d" = 删除文件或目录 (DELE、RMD 命令) 34 | - "f" = 重命名文件或目录 (RNFR、RNTO 命令) 35 | - "m" = 创建目录 (MKD 命令) 36 | - "w" = 将文件存储到服务器 (STOR、STOU 命令) 37 | - "M" = 更改文件模式 (SITE CHMOD 命令) 38 | - "T" = 更新文件上次修改时间 (MFMT 命令) 39 | """ 40 | 41 | if perm.lower() == "readonly" or perm == "只读": 42 | return "elr" 43 | elif perm.lower() == "readwrite" or perm == "读写": 44 | return "elradfmwMT" 45 | else: 46 | charSet = set() 47 | for c in perm: 48 | if c not in "elradfmwMT": 49 | continue 50 | if c not in charSet: 51 | charSet.add(c) 52 | if len(charSet) == 0: 53 | return "elr" 54 | else: 55 | return "".join(charSet) 56 | 57 | 58 | class UserList: 59 | def __init__(self) -> None: 60 | self.appDirectory = str(os.path.dirname(sys.argv[0])).replace("\\", "/") 61 | if ( 62 | len(self.appDirectory) > 2 63 | and self.appDirectory[0].islower() 64 | and self.appDirectory[1] == ":" 65 | ): 66 | self.appDirectory = self.appDirectory[0].upper() + self.appDirectory[1:] 67 | 68 | self.userListCsvPath = os.path.join(self.appDirectory, "FtpServerUserList.csv") 69 | 70 | self.userList: list[UserNode] = list() 71 | self.userNameSet: set[str] = set() 72 | self.load() 73 | 74 | def readAllLines(self) -> list[str]: 75 | for encoding in ['utf-8-sig', 'gbk']: 76 | try: 77 | with open(self.userListCsvPath, 'r', encoding=encoding) as file: 78 | return file.read().splitlines() 79 | except: 80 | continue 81 | print(f"无法使用UTF-8或GBK编码读取文件 {self.userListCsvPath}") 82 | return [""] 83 | 84 | def load(self): 85 | self.userList.clear() 86 | self.userNameSet.clear() 87 | 88 | if not os.path.exists(self.userListCsvPath): 89 | return 90 | 91 | try: 92 | allLines = self.readAllLines() 93 | 94 | for line in allLines: 95 | if len(line.strip()) == 0: 96 | continue 97 | item = line.split(",") 98 | if len(item) < 4: 99 | print(f"解析错误 [{line}]") 100 | continue 101 | if ( 102 | len(item[0].strip()) > 0 103 | and len(item[1].strip()) > 0 104 | and len(item[3].strip()) > 0 105 | ): 106 | if item[0].strip() in self.userNameSet: 107 | print( 108 | f"发现重复的用户名条目 [{item[0].strip()}], 已跳过此内容 [{line}]" 109 | ) 110 | elif not os.path.exists(item[3].strip()): 111 | print( 112 | f"该用户名条目 [{item[0].strip()}] 的路径不存在或无访问权限 [{item[3].strip()}] 已跳过此内容 [{line}]" 113 | ) 114 | elif item[0].strip() != "anonymous" and len(item[2].strip()) == 0: 115 | print( 116 | f"该用户名条目 [{item[0].strip()}] 没有密码(只有匿名用户 anonymous 可以不设密码),已跳过此内容 [{line}]" 117 | ) 118 | else: 119 | self.userNameSet.add(item[0].strip()) 120 | self.userList.append( 121 | UserNode( 122 | item[0].strip(), 123 | Settings.Settings.encry2sha256(item[1].strip()), 124 | permConvert(item[2].strip()), 125 | item[3].strip().replace('"', ""), 126 | ) 127 | ) 128 | else: 129 | print(f"解析错误 [{line}]") 130 | continue 131 | 132 | except Exception as e: 133 | print(f"用户列表文件读取异常: {self.userListCsvPath}\n{e}") 134 | return 135 | 136 | def print(self): 137 | if len(self.userList) == 0: 138 | print("用户列表空白") 139 | else: 140 | print(f"主页面的用户/密码设置将会忽略,现将使用以下{len(self.userList)}条用户配置:") 141 | for userItem in self.userList: 142 | print( 143 | f"[{userItem.userName}] [******] [{permTranslate(userItem.perm)}] [{userItem.path}]" 144 | ) 145 | print('') 146 | 147 | def isEmpty(self) -> bool: 148 | return len(self.userList) == 0 149 | -------------------------------------------------------------------------------- /Settings.py: -------------------------------------------------------------------------------- 1 | import os, sys, json, hashlib 2 | from typing import Any 3 | 4 | class Settings: 5 | encryPasswordPrefix = "ENCRY" 6 | defaultUserName = "JARK006" 7 | defaultUserPassword = "123456" 8 | 9 | def __init__(self) -> None: 10 | self.appDirectory = str(os.path.dirname(os.path.abspath(sys.argv[0]))).replace("\\", "/") 11 | if ( 12 | len(self.appDirectory) > 2 13 | and self.appDirectory[0].islower() 14 | and self.appDirectory[1] == ":" 15 | ): 16 | self.appDirectory = self.appDirectory[0].upper() + self.appDirectory[1:] 17 | 18 | self.savePath = os.path.join(self.appDirectory, "ftpServer.json") 19 | 20 | self.directoryList: list[str] = [self.appDirectory] 21 | self.userName: str = Settings.defaultUserName 22 | self.userPassword: str = self.encry2sha256(Settings.defaultUserPassword) 23 | self.IPv4Port: int = 21 24 | self.IPv6Port: int = 0 25 | self.isGBK: bool = True 26 | self.isReadOnly: bool = True 27 | self.isAutoStartServer: bool = False 28 | self.load() 29 | 30 | @staticmethod 31 | def encry2sha256(input_string: str) -> str: 32 | if len(input_string) == 0: 33 | return "" 34 | 35 | salt = "JARK006_FTP_SERVER_SALT" 36 | sha256_hash = hashlib.sha256() 37 | sha256_hash.update((input_string + salt).encode("utf-8")) 38 | return Settings.encryPasswordPrefix + sha256_hash.hexdigest().upper() 39 | 40 | def load(self): 41 | if not os.path.exists(self.savePath): 42 | return 43 | 44 | try: 45 | with open(self.savePath, "r") as file: 46 | variables = json.load(file) 47 | 48 | if "rootDirectory" in variables: # old version: v1.11 and lower 49 | self.directoryList = [variables["rootDirectory"]] 50 | self.userName = variables["userName"] 51 | self.userPassword = variables["userPassword"] 52 | self.IPv4Port = int(variables["ipv4Port"]) # 旧版是小写 "ip" 53 | self.IPv6Port = int(variables["ipv6Port"]) 54 | self.isGBK = variables["isGBK"] == "1" 55 | self.isReadOnly = variables["isReadOnly"] == "1" 56 | self.isAutoStartServer = variables["isAutoStartServer"] == "1" 57 | else: 58 | self.directoryList = variables["directoryList"] 59 | self.userName = variables["userName"] 60 | self.userPassword = variables["userPassword"] 61 | self.IPv4Port = variables["IPv4Port"] 62 | self.IPv6Port = variables["IPv6Port"] 63 | self.isGBK = variables["isGBK"] 64 | self.isReadOnly = variables["isReadOnly"] 65 | self.isAutoStartServer = variables["isAutoStartServer"] 66 | 67 | # 检查变量类型 68 | if not type(self.directoryList) is list: 69 | self.directoryList = [self.appDirectory] 70 | print(f"directoryList 类型错误,已恢复默认:[{self.appDirectory}]") 71 | if not type(self.userName) is str: 72 | self.userName = Settings.defaultUserName 73 | print(f"userName 类型错误,已恢复默认:{self.userName}") 74 | if not type(self.userPassword) is str: 75 | self.userPassword = self.encry2sha256(Settings.defaultUserPassword) 76 | print(f"userPassword 类型错误,已恢复默认:{self.userPassword}") 77 | if not type(self.IPv4Port) is int: 78 | self.IPv4Port = 21 79 | print(f"IPv4Port 类型错误,已恢复默认:{self.IPv4Port}") 80 | if not type(self.IPv6Port) is int: 81 | self.IPv6Port = 0 82 | print(f"IPv6Port 类型错误,已恢复默认:{self.IPv6Port}") 83 | if not type(self.isGBK) is bool: 84 | self.isGBK = True 85 | print(f"isGBK 类型错误,已恢复默认:{self.isGBK}") 86 | if not type(self.isReadOnly) is bool: 87 | self.isReadOnly = True 88 | print(f"isReadOnly 类型错误,已恢复默认:{self.isReadOnly}") 89 | if not type(self.isAutoStartServer) is bool: 90 | self.isAutoStartServer = False 91 | print( 92 | f"isAutoStartServer 类型错误,已恢复默认:{self.isAutoStartServer}" 93 | ) 94 | 95 | # 旧版(v1.12及以下) 存储的是明文密码 96 | if len(self.userPassword) > 0 and not self.userPassword.startswith( 97 | Settings.encryPasswordPrefix 98 | ): 99 | self.userPassword = self.encry2sha256(self.userPassword) 100 | self.save() 101 | 102 | except Exception as e: 103 | print(f"设置文件读取异常: {self.savePath}\n{e}") 104 | return 105 | 106 | def save(self): 107 | """保存前确保调用 updateSettingVars() 或其他函数进行参数检查""" 108 | 109 | while len(self.directoryList) > 20: 110 | self.directoryList.pop() 111 | 112 | variables: dict[str, Any] = { 113 | "directoryList": self.directoryList, 114 | "userName": self.userName, 115 | "userPassword": self.userPassword, 116 | "IPv4Port": self.IPv4Port, 117 | "IPv6Port": self.IPv6Port, 118 | "isGBK": self.isGBK, 119 | "isReadOnly": self.isReadOnly, 120 | "isAutoStartServer": self.isAutoStartServer, 121 | } 122 | try: 123 | with open(self.savePath, "w") as file: 124 | json.dump(variables, file) 125 | except Exception as e: 126 | print(f"设置文件保存异常: {self.savePath}\n{e}") 127 | return 128 | -------------------------------------------------------------------------------- /myUtils.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import io 3 | from PIL import Image, ImageTk 4 | 5 | class iconObj(): 6 | """ 7 | import base64 8 | with open(r"ico64x64.ico", "rb") as f: 9 | iconBase64Str = base64.b64encode(f.read()) 10 | print(iconBase64Str) 11 | """ 12 | 13 | def __init__(self): 14 | iconBase64Str = b"AAABAAEAQEAAAAAAIAATEQAAFgAAAIlQTkcNChoKAAAADUlIRFIAAABAAAAAQAgGAAAAqmlx3gAAENpJREFUeJzdm3twXOV1wH/f3bsvPVZvvyTZxrZsbAMmxk6wg8dxEkjaaUMHEpiWMjFtqWfaZiZkaEibYVz6mISWhElD2hIS4A9CUl550AAtSSixgSZ2TGwwfr+QV5YlWY+VtO97T/8492pXq11pZeMJ5MysVnvvPd8953zn+Z3vM1QEMWAEERP7Fk2pPJ2W4RoRNmJYiaHDQAwhhMFUHucigiAYsgIJhNMIB4zhVVfYGbXpTvwZQxgjE7yUgWkJb3pQGlLCGoEbgC0izMUQBUIGgoD1G2PeB0EAVyAHZBFSxnAWeMnAM1HD3qFtZqQSennit4tV386yrPBpEW4y0EGACAaQos+7CUzRRwCHtMBpA0+GLB4djXOUe4xbDm0ybJdQZAEbBP4a2IQhBoDrsWwq4L07QCYmxvJodBjF4ue43Jdp4VVuMtlihMmMbJdQaAHXAncbYR0WAVzkXc50JVBhWBhcHDHsBv4h28OL3FMQQoGp7WKFF7BZXO41hnWebQvvPcZLQXkQRITdxuKuTA8v++ZgqYeEUDvLgDuNYe1vEfPgewWDMYa1wJ0er4AYC4w0PSgNxmUrwiYsApQwb2bxKX3z+X4qcTKb56cIwSKAsMm4bG16UBrAiGG7WJG5fNAN8IgxLPVsftKYs3X4PvKFBopiImYaywDG09uKzwqChRHhmCX8SfoMO+2G5TSkR7nRCB3lxGmAiFW9LTgCOQELCM4Cr9I4viqGDAQqDCZAzoWMo0IImEI0nAQevhE6xOKGWAdv2sk0Cy3YQoAwTmH2jUdEQxCuXwCNQf0900zEk/C/A9ARgqvbIBqA0uBbyke5MXuS8NN+GMlDgw0faYP2mvI4rkB3Cg6MwUhWP06Z9wAGF/F43ZIK8oht5dkkMNeUzrwBx4WGMHx2PSys09+B6WZVYGcc9r0G72+DL26AxogSWGwWUiRIy5QZT+CVOOx6FQazUB+CratgY7tyVYojQCoPvePwei88fhR2D0JyStozIQYE5lp5NtkibDQQLefzxSOwMQKxsP5OZpWh8wF/vJqQfgOk85BzpsezjL6/MVoex7agLQpza2FlC6yfD197HZ7phrHSsT3bMBAVYaONsBJDaFKeVwJ5V4k/l4J/+xX0J/Wl5eQQT+qs/bIf/um1ggn4JtUYhj9cCZe2wXgWfnAIft0H+ZLBepKQyKkmiuh9ERjPeTj9SpcxEAvCpS2wfgEsboDVbfC5tTCUgRd6ISuTGDMe4SGElTaGTiA4k3EbYDwPT52GYwkIB5SgUvCd1+E8nOguvNigjqq9Fq5drL/zrjL/nVOQcidL3xHIo87UV05jpuJYqIZEjsPaJrh7HVy1ALqa4eYueGUQ+tIQNEUTpv8EMXTaRohhsKZnv4CXctW2XFPZFAzqhPJFNmiMCiDlTnaKjhTG9Jn1wSqrj+VxhvOwox++ewiWNENLFNbOh7YI9KWYOjhYRohZWNizKWmtGT6mimerGXMmgkqfD1lq7/99FuJj+kxjGDY0QW1gsiMGwGCwsO0q3jUJpOQz07PlcC9kzOlwRGAsD8Mp/W0BdQEvfyg/sLGrfF8BwwtD5dTTD3G/KTCAbTRUg5raqKMmUwlmJQABsg44DqSZ6gMMGh0udgVVtg4QnZRlNbCwQS8NZ+H/hmDcgWAFpz0rAQQtWFSvgiiNAsZobO5Lq7O7mEJwvbCYl4JvcwUuqYWblmhOIAIH+2Aw4yFV0IKqBOCr+9woPH6tF39LCLIt6B6DP/oJnBpVAZ1vwjQdGKAmCC1hqHEh4F1sCcOfLoM/WA4RG86MwgunYCxXMIlyMCsNCBiYF5l63RdAzlEbvJhuIGzDtYugq1Ft2zLQXANLmmBpI9QGYWAcHn0Tvt8NSadcBCxAVQLwVX0sB88dgURGhTHhfVHz6Et7EjfvvDM03osiNmxeBJuL7vkamnHg8Dl48hA8dATOZqdnHqoVgPc9nIWvvAUnKqi4KzCanyycdxpc0Wpv3CnE9pwL8RF4ow9+cBL2jmgVWU12NysTcEXz/HMZCFWw8Uo1+4WCABgYy8DXd8OTpyHtZYIuan7pvNYPeamcRZbCrPMA28sBbDO1zp8g9CKCKzCQhBMJGHcLs+zXCgEzOw2ctQDOJ2t7p8G21AQdU7Bx8YibLV2zFsC7Afy475vghUxEVVXgbzNUXQYXf78TcD4mdDHMrioB+B61Ws86ExhvTW82450PTjUwowAESOfAdfT7QmdABLI5yOZ1PKfSwuUF4lQLFZ2giIaT8Rz88LAuSceT+vu8Mj3RWUw78OLbcDoBozk4mCisB04Z8nxwZgkm/OD0rFgG6m1l2vEyvQspciyjKzS2pUJMOZB1p2fkfHCqhRnDoJ/9+XChmZ4rkCgSYtm+QBmckSLzqwanWphRAAYtdHyQiT9FD5QDKT9DLgXzsqYxpdLLtlXmfpEZTLSzqqBh0rgz3McRcEvUzZ8Bd5oXWGV6eQaoD0BtCIYzatvlepFMvUzOLb8CZXkrUHkBKXGO5WgohWkFYIA6G+qDYAf0muNq0ZF3dWGi0mJDxtHq0a/YXNSOb12sLa6v74M3RiZz6v/rijYzilW+IQhRzxf5D+ZdGM2qP5gT0ZVh/56ICnh4hk5WWQH4BIcMXNsGt65UIlxPdV/r0aXn67tUQH6HxoeAgf2D8KU3YCDj9RNdbVx8ejWEDXx0HlzVWITnNT2C3vL29+Na0oJqzW2XwIc6vIaJ61WjwLf3wxtj8I/roDWkUcoyWiscHFIa+jOVF2qm1QDLQGctrG6Fx/brrH5qOaxoUOGsaYPdZ7Q6C1gqoHAArmyDVc26euNmVJqLa2HbKljWBGfHYN38wpqe7xfmRLWjc3JMO8NDWVVx24JlDTC/Bp46AmeT0FEPt66Gjho4nobLWmH/GdjZo+/93UWwssmjIU3FlZFpBSCijI058J1uSOVgc2fBiQ3ldIHkVALqgtqhbQnDF66AJq+R6biwuA7+8lJY0QR7e3XZ6kcnYUcfpERnpysGf7NGcX56SlU7YE12koNZ+Fm/tuZWZuBGbz+AoD7i8DA83wt1IXhfG7TWTsddFQLwwTJQa+sCpG/zBi8vyEF7CD7eARkXWmvg8lb4Ra/6ikW18IXLVSv+Yx+cGoPPrIFPLdX7Owfg8hhsvRRiIXhwH3zriOYbtik0Nm0LFsZgWxcMprUT3BbxNmIYxf1QBzRHlN7VLTCcmzlcTu8Ejc5gLAh3rFApd9RBz0hBCDlR+17ZBita9OWHR+DhQ6oxH23Xju1Db8F/ntTxgvvgjivh3k3w9gh0xuDcODz8JjzZDfF0YXHDFX3HYEa70xlHx0jmoHtcm63vy6tPyriwYT70jqkG9af12nkLYDoQdIbm1cDJEXjgoNqbXzuczUAW+J9eXSaPZ6ApAivqYONcdXauQENUHZoFLI3B1a2wf0w70XlHs76wgb39yuTuYb0mwNw4/HmXCv6fX4dWG+5cC/Nj8NwxOJPU1ljIqpxvVOUDEjm4/7AytrhRiXVE1e3edZDIKrH+FpqorTP2+d1qNmtb4OYm1YSFDapJA6Pw0F748RlY3wTXLYQtS+BjXbromczA8WF45hiELfi9SzQcb1ig7wkYaI2qg8458NmQ7jfIu7pMvqEdMjnVrvsPwEC2fCSoell8PKcqXdzydl2wBBbHYE8vjGa0BbW6RZ3g/ChsXQab2xW3JwFPH4TXBuGPF6tn7zsG3xuBl85CVy1smaebHdrqoLUejqbA5ODlbmgKaZSpDWo0Wd0M8VHoG4cljbCrB549omaSzKum9CT1u5IvsCnKJEvBeAVQrQ23dOrAc2rg7Kg6pXNpeOwIfGKJrsPvOafh7u/XKgEDKfjRMYgPQ0tEZ95x4OpmuHwOZPJw21K1Vz+yJLLw8ml4rk+9/nAG2qOaE8yNwoI6bYLEovD8KXjkIAzkYNsKWNOivctjQ9CTUuGNO4WGbhkrEBuXPKbyHgHxYvvGdmWgKVJoSuYE3hqFzSnYPAf2DsCyOljUAI/sh96UqvO6NljfCdFgIbtcUKfe/IZoYQZEoDGkydP34lr+ttfD56+ALZ0QCaizPDkCL57WDREHEtr9efwEnB6FD7TBzaugLqKbNPafg7/7BewfLWUMQcjbYkgYaMRrs5Uyb1vKxPa96gO+8n7VGRFNUkayul/nusUa4j6xGI6PwH/16Lq9Czx8Ap6MK5eWwO+3w22r4at74Of9et1F9yPeshA+cokyawXU9F44AZ01cHWnOtaOANzYBJ+8FL62G3YNw5c3aNsuK5oHzIlAPAHPHIaTybLa74ohYSN0Y6jFECjWEUEdYH1Qc+3uMVXZjFNQpYBRB/nYSbimA+7+gLbNPrcTTo17vQNRTYgnQRxYVAfr2+D4ELzQo0ILBDS01dkwnIbeUX0nqCm9MgjXJSB4Bv52D7yd0N1g/34NNIfUy7dF4NnD8M3j6oTvWqmJ0Mv9mg+EirvZBhByCN0WhgNA1jOACRG4ogMvqIOBMfW0/o6tYin5VeHAuGaByRwkXMV1RD8G9eTLG+EzK2FpAzx9VAuV+mBhXa7OhmWNhfhvPIL8XWYiGm0yeXWqqbwKTtDCJ5nXe/mifUgy1bD97f9ZDAdsY3hVhA8bQ2ySOxQvbrdoshILarYVDRZKzKAFm1rV7lY0w/MnoKsJ/nUjPHUYno1rw7TWhjUNcPsq3V/wxFH4YVxn5eNz4WBSFzyujMGqVrXbdH7yAqhlYHkTfOkq1bLmKMyLFu7HwvDJ5XDlHI1EK5rg7bHymzC9jZIpY3jVdm12WDnOIswtfiZo4JKIPvx6P9y+RDcgLoxpATSWh6gFt3Tpfrxvvgk/6YXl9fAXl8HtlykRPz4Bv7MQrpqnUeEb++C5HjiX00Lm+i64o8HLKNFQ+dgRjQYBU9gfOJaDU8MaDgeSqpnNEZ15x9PAI/3wq7NepejoIaKy22MEjKHPNey0oy7dGYuXcFiBRRg9Z2Ec4M0EPPBreH0YPtgCDSGtth47Dp1haDwMuwdg15CWnGkH4ik49kvY0qpV25DXrPzuIXi2R7OzrKiAB9Lwxd1weZ3SdSSlCdRgWj24P/MpB549CS/3aA4xkoPmMBxJwBtDmuQ8sAeOJuDQuDruvYOqeWNeUeUZt2BhcMhgeClqeFu3y8/nGtfwcLnt8iFvFsKWeua8aKHi/x7PK+MBa/J2lYjnAwKW2nYyrzG5eD1P8GoD70LGW1co3WdkUIdqgIwfgYzS4LjaIwx56wn5Irr9UD2hBGW2yxvQ43FJ4S7gr7Co97WgyGQmanZDIalwPUKsifELBJduhgyUec5/dgLP+1FBayee93EmO/XJm6bconsTQxgMLqMYvlEDXx7aZkYsEDO0zYyIxaMYduBO7DSX4gEC3iz4DrB4VoqJ8Yn1V6cMOsNWmedKhSEVmC9mvBineENlsXBLhTFxycXBsEMCPKpnCb0jMwDZOEeB+0TYU6QBQsnApYxWgtLnpnv2fKFKWpQXPTS1B7gv280RvWWksKR5j3EzPbyCxT0CuxAcjCJeJPovNmiyqzw4AruwuCfTwyvFByinpglPSCg8wkYc7+CkRT3w3j046TIK7CDAv2QaZjo46YN/dNZlqwg3GUP7e/LorBA3hidmd3S2CBrul8ZMlCsEbkD4sBjmYIgaPWzwrjo8jSEn/uFp6MfwMyM8HU6xb+QOM1wJfRriyxyft9gksAFhFdBphBgWs95x/g6C4JIXQwLoBu/4vMuOao/P/z/UZZZP+0utlAAAAABJRU5ErkJggg==" 15 | iconBytes = base64.b64decode(iconBase64Str) 16 | 17 | self.iconImageTk = ImageTk.PhotoImage(data=iconBytes) 18 | self.strayIconImage = Image.open(io.BytesIO(iconBytes)) 19 | -------------------------------------------------------------------------------- /mypyftpdlib/log.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2007 Giampaolo Rodola' . 2 | # Use of this source code is governed by MIT license that can be 3 | # found in the LICENSE file. 4 | 5 | """ 6 | Logging support for pyftpdlib, inspired from Tornado's 7 | (https://www.tornadoweb.org/). 8 | 9 | This is not supposed to be imported/used directly. 10 | Instead you should use logging.basicConfig before serve_forever(). 11 | """ 12 | 13 | import logging 14 | import re 15 | import sys 16 | import time 17 | 18 | 19 | try: 20 | import curses 21 | except ImportError: 22 | curses = None 23 | 24 | 25 | # default logger 26 | logger = logging.getLogger('pyftpdlib') 27 | 28 | 29 | def _stderr_supports_color(): 30 | color = False 31 | if curses is not None and sys.stderr.isatty(): 32 | try: 33 | curses.setupterm() 34 | if curses.tigetnum("colors") > 0: 35 | color = True 36 | except Exception: # noqa 37 | pass 38 | return color 39 | 40 | 41 | # configurable options 42 | LEVEL = logging.INFO 43 | PREFIX = '[%(levelname)1.1s %(asctime)s]' 44 | PREFIX_MPROC = '[%(levelname)1.1s %(asctime)s %(process)s]' 45 | COLOURED = _stderr_supports_color() 46 | TIME_FORMAT = "%H:%M:%S" 47 | 48 | 49 | # taken and adapted from Tornado 50 | class LogFormatter(logging.Formatter): 51 | """Log formatter used in pyftpdlib. 52 | Key features of this formatter are: 53 | 54 | * Color support when logging to a terminal that supports it. 55 | * Timestamps on every log line. 56 | * Robust against str/bytes encoding problems. 57 | """ 58 | 59 | PREFIX = PREFIX 60 | 61 | def __init__(self, *args, **kwargs): 62 | logging.Formatter.__init__(self, *args, **kwargs) 63 | self._coloured = COLOURED and _stderr_supports_color() 64 | if self._coloured: 65 | curses.setupterm() 66 | # The curses module has some str/bytes confusion in 67 | # python3. Until version 3.2.3, most methods return 68 | # bytes, but only accept strings. In addition, we want to 69 | # output these strings with the logging module, which 70 | # works with unicode strings. The explicit calls to 71 | # str() below are harmless in python2 but will do the 72 | # right conversion in python 3. 73 | fg_color = ( 74 | curses.tigetstr("setaf") or curses.tigetstr("setf") or "" 75 | ) 76 | self._colors = { 77 | # blues 78 | logging.DEBUG: str(curses.tparm(fg_color, 4), "ascii"), 79 | # green 80 | logging.INFO: str(curses.tparm(fg_color, 2), "ascii"), 81 | # yellow 82 | logging.WARNING: str(curses.tparm(fg_color, 3), "ascii"), 83 | # red 84 | logging.ERROR: str(curses.tparm(fg_color, 1), "ascii"), 85 | } 86 | self._normal = str(curses.tigetstr("sgr0"), "ascii") 87 | 88 | def format(self, record): 89 | try: 90 | record.message = record.getMessage() 91 | except Exception as err: 92 | record.message = f"Bad message ({err!r}): {record.__dict__!r}" 93 | 94 | record.asctime = time.strftime( 95 | TIME_FORMAT, self.converter(record.created) 96 | ) 97 | prefix = self.PREFIX % record.__dict__ 98 | if self._coloured: 99 | prefix = ( 100 | self._colors.get(record.levelno, self._normal) 101 | + prefix 102 | + self._normal 103 | ) 104 | 105 | # Encoding notes: The logging module prefers to work with character 106 | # strings, but only enforces that log messages are instances of 107 | # basestring. In python 2, non-ASCII bytestrings will make 108 | # their way through the logging framework until they blow up with 109 | # an unhelpful decoding error (with this formatter it happens 110 | # when we attach the prefix, but there are other opportunities for 111 | # exceptions further along in the framework). 112 | # 113 | # If a byte string makes it this far, convert it to unicode to 114 | # ensure it will make it out to the logs. Use repr() as a fallback 115 | # to ensure that all byte strings can be converted successfully, 116 | # but don't do it by default so we don't add extra quotes to ASCII 117 | # bytestrings. This is a bit of a hacky place to do this, but 118 | # it's worth it since the encoding errors that would otherwise 119 | # result are so useless (and tornado is fond of using utf8-encoded 120 | # byte strings wherever possible). 121 | try: 122 | message = str(record.message) 123 | except UnicodeDecodeError: 124 | message = repr(record.message) 125 | 126 | formatted = prefix + " " + message 127 | if record.exc_info and not record.exc_text: 128 | record.exc_text = self.formatException(record.exc_info) 129 | if record.exc_text: 130 | formatted = formatted.rstrip() + "\n" + record.exc_text 131 | return formatted.replace("\n", "\n ") 132 | 133 | 134 | def debug(s, inst=None): 135 | s = "[debug] " + s 136 | if inst is not None: 137 | s += f" ({inst!r})" 138 | logger.debug(s) 139 | 140 | 141 | def is_logging_configured(): 142 | if logging.getLogger('pyftpdlib').handlers: 143 | return True 144 | return bool(logging.root.handlers) 145 | 146 | 147 | # TODO: write tests 148 | 149 | 150 | def config_logging(level=LEVEL, prefix=PREFIX, other_loggers=None): 151 | # Speedup logging by preventing certain internal log record info to 152 | # be unnecessarily fetched. This results in about 28% speedup. See: 153 | # * https://docs.python.org/3/howto/logging.html#optimization 154 | # * https://docs.python.org/3/library/logging.html#logrecord-attributes 155 | # * https://stackoverflow.com/a/38924153/376587 156 | key_names = set( 157 | re.findall( 158 | r'(?. 2 | # Use of this source code is governed by MIT license that can be 3 | # found in the LICENSE file. 4 | 5 | """ 6 | This module contains the main FTPServer class which listens on a 7 | host:port and dispatches the incoming connections to a handler. 8 | The concurrency is handled asynchronously by the main process thread, 9 | meaning the handler cannot block otherwise the whole server will hang. 10 | 11 | Other than that we have 2 subclasses changing the asynchronous concurrency 12 | model using multiple threads or processes. 13 | 14 | You might be interested in these in case your code contains blocking 15 | parts which cannot be adapted to the base async model or if the 16 | underlying filesystem is particularly slow, see: 17 | 18 | https://github.com/giampaolo/pyftpdlib/issues/197 19 | https://github.com/giampaolo/pyftpdlib/issues/212 20 | 21 | Two classes are provided: 22 | 23 | - ThreadingFTPServer 24 | - MultiprocessFTPServer 25 | 26 | ...spawning a new thread or process every time a client connects. 27 | 28 | The main thread will be async-based and be used only to accept new 29 | connections. 30 | Every time a new connection comes in that will be dispatched to a 31 | separate thread/process which internally will run its own IO loop. 32 | This way the handler handling that connections will be free to block 33 | without hanging the whole FTP server. 34 | """ 35 | 36 | import errno 37 | import os 38 | import select 39 | import signal 40 | import sys 41 | import threading 42 | import time 43 | import traceback 44 | 45 | from .ioloop import Acceptor 46 | from .log import PREFIX 47 | from .log import PREFIX_MPROC 48 | from .log import config_logging 49 | from .log import debug 50 | from .log import is_logging_configured 51 | from .log import logger 52 | from .prefork import fork_processes 53 | 54 | 55 | __all__ = ['FTPServer', 'ThreadedFTPServer'] 56 | _BSD = 'bsd' in sys.platform 57 | 58 | 59 | # =================================================================== 60 | # --- base class 61 | # =================================================================== 62 | 63 | 64 | class FTPServer(Acceptor): 65 | """Creates a socket listening on
, dispatching the requests 66 | to a (typically FTPHandler class). 67 | 68 | Depending on the type of address specified IPv4 or IPv6 connections 69 | (or both, depending from the underlying system) will be accepted. 70 | 71 | All relevant session information is stored in class attributes 72 | described below. 73 | 74 | - (int) max_cons: 75 | number of maximum simultaneous connections accepted (defaults 76 | to 512). Can be set to 0 for unlimited but it is recommended 77 | to always have a limit to avoid running out of file descriptors 78 | (DoS). 79 | 80 | - (int) max_cons_per_ip: 81 | number of maximum connections accepted for the same IP address 82 | (defaults to 0 == unlimited). 83 | """ 84 | 85 | max_cons = 512 86 | max_cons_per_ip = 0 87 | 88 | def __init__(self, address_or_socket, handler, ioloop=None, backlog=100): 89 | """Creates a socket listening on 'address' dispatching 90 | connections to a 'handler'. 91 | 92 | - (tuple) address_or_socket: the (host, port) pair on which 93 | the command channel will listen for incoming connections or 94 | an existent socket object. 95 | 96 | - (instance) handler: the handler class to use. 97 | 98 | - (instance) ioloop: a pyftpdlib.ioloop.IOLoop instance 99 | 100 | - (int) backlog: the maximum number of queued connections 101 | passed to listen(). If a connection request arrives when 102 | the queue is full the client may raise ECONNRESET. 103 | Defaults to 5. 104 | """ 105 | Acceptor.__init__(self, ioloop=ioloop) 106 | self.handler = handler 107 | self.backlog = backlog 108 | self.ip_map = [] 109 | # in case of FTPS class not properly configured we want errors 110 | # to be raised here rather than later, when client connects 111 | if hasattr(handler, 'get_ssl_context'): 112 | handler.get_ssl_context() 113 | if callable(getattr(address_or_socket, 'listen', None)): 114 | sock = address_or_socket 115 | sock.setblocking(0) 116 | self.set_socket(sock) 117 | else: 118 | self.bind_af_unspecified(address_or_socket) 119 | self.listen(backlog) 120 | 121 | def __enter__(self): 122 | return self 123 | 124 | def __exit__(self, *args): 125 | self.close_all() 126 | 127 | @property 128 | def address(self): 129 | """The address this server is listening on as a (ip, port) tuple.""" 130 | return self.socket.getsockname()[:2] 131 | 132 | def _map_len(self): 133 | return len(self.ioloop.socket_map) 134 | 135 | def _accept_new_cons(self): 136 | """Return True if the server is willing to accept new connections.""" 137 | if not self.max_cons: 138 | return True 139 | else: 140 | return self._map_len() <= self.max_cons 141 | 142 | def _log_start(self, prefork=False): 143 | def get_fqname(obj): 144 | try: 145 | return obj.__module__ + "." + obj.__class__.__name__ 146 | except AttributeError: 147 | try: 148 | return obj.__module__ + "." + obj.__name__ 149 | except AttributeError: 150 | return str(obj) 151 | 152 | if not is_logging_configured(): 153 | # If we get to this point it means the user hasn't 154 | # configured any logger. We want logging to be on 155 | # by default (stderr). 156 | config_logging(prefix=PREFIX_MPROC if prefork else PREFIX) 157 | 158 | if self.handler.passive_ports: 159 | pasv_ports = "%s->%s" % ( # noqa: UP031 160 | self.handler.passive_ports[0], 161 | self.handler.passive_ports[-1], 162 | ) 163 | else: 164 | pasv_ports = None 165 | model = 'prefork + ' if prefork else '' 166 | if 'ThreadedFTPServer' in __all__ and issubclass( 167 | self.__class__, ThreadedFTPServer 168 | ): 169 | model += 'multi-thread' 170 | elif 'MultiprocessFTPServer' in __all__ and issubclass( 171 | self.__class__, MultiprocessFTPServer 172 | ): 173 | model += 'multi-process' 174 | elif issubclass(self.__class__, FTPServer): 175 | model += 'async' 176 | else: 177 | model += 'unknown (custom class)' 178 | # logger.info("concurrency model: " + model) 179 | # logger.info( 180 | # "masquerade (NAT) address: %s", self.handler.masquerade_address 181 | # ) 182 | # logger.info("passive ports: %s", pasv_ports) 183 | logger.debug("poller: %r", get_fqname(self.ioloop)) 184 | logger.debug("authorizer: %r", get_fqname(self.handler.authorizer)) 185 | if os.name == 'posix': 186 | logger.debug("use sendfile(2): %s", self.handler.use_sendfile) 187 | logger.debug("handler: %r", get_fqname(self.handler)) 188 | logger.debug("max connections: %s", self.max_cons or "unlimited") 189 | logger.debug( 190 | "max connections per ip: %s", self.max_cons_per_ip or "unlimited" 191 | ) 192 | logger.debug("timeout: %s", self.handler.timeout or "unlimited") 193 | logger.debug("banner: %r", self.handler.banner) 194 | logger.debug("max login attempts: %r", self.handler.max_login_attempts) 195 | if getattr(self.handler, 'certfile', None): 196 | logger.debug("SSL certfile: %r", self.handler.certfile) 197 | if getattr(self.handler, 'keyfile', None): 198 | logger.debug("SSL keyfile: %r", self.handler.keyfile) 199 | 200 | def serve_forever( 201 | self, timeout=None, blocking=True, handle_exit=True, worker_processes=1 202 | ): 203 | """Start serving. 204 | 205 | - (float) timeout: the timeout passed to the underlying IO 206 | loop expressed in seconds. 207 | 208 | - (bool) blocking: if False loop once and then return the 209 | timeout of the next scheduled call next to expire soonest 210 | (if any). 211 | 212 | - (bool) handle_exit: when True catches KeyboardInterrupt and 213 | SystemExit exceptions (generally caused by SIGTERM / SIGINT 214 | signals) and gracefully exits after cleaning up resources. 215 | Also, logs server start and stop. 216 | 217 | - (int) worker_processes: pre-fork a certain number of child 218 | processes before starting. 219 | Each child process will keep using a 1-thread, async 220 | concurrency model, handling multiple concurrent connections. 221 | If the number is None or <= 0 the number of usable cores 222 | available on this machine is detected and used. 223 | It is a good idea to use this option in case the app risks 224 | blocking for too long on a single function call (e.g. 225 | hard-disk is slow, long DB query on auth etc.). 226 | By splitting the work load over multiple processes the delay 227 | introduced by a blocking function call is amortized and divided 228 | by the number of worker processes. 229 | """ 230 | log = handle_exit and blocking 231 | 232 | if worker_processes != 1 and os.name == 'posix': 233 | if not blocking: 234 | raise ValueError( 235 | "'worker_processes' and 'blocking' are mutually exclusive" 236 | ) 237 | if log: 238 | self._log_start(prefork=True) 239 | fork_processes(worker_processes) 240 | elif log: 241 | self._log_start() 242 | 243 | proto = "FTP+SSL" if hasattr(self.handler, 'ssl_protocol') else "FTP" 244 | logger.info( 245 | ">>> starting %s server on %s:%s, pid=%i <<<" 246 | % (proto, self.address[0], self.address[1], os.getpid()) 247 | ) 248 | 249 | if handle_exit: 250 | try: 251 | self.ioloop.loop(timeout, blocking) 252 | except (KeyboardInterrupt, SystemExit): 253 | logger.info("received interrupt signal") 254 | if blocking: 255 | if log: 256 | logger.info( 257 | ">>> shutting down FTP server, %s socket(s), pid=%i " 258 | "<<<", 259 | self._map_len(), 260 | os.getpid(), 261 | ) 262 | self.close_all() 263 | else: 264 | self.ioloop.loop(timeout, blocking) 265 | 266 | def handle_accepted(self, sock, addr): 267 | """Called when remote client initiates a connection.""" 268 | handler = None 269 | ip = None 270 | try: 271 | handler = self.handler(sock, self, ioloop=self.ioloop) 272 | if not handler.connected: 273 | return 274 | 275 | ip = addr[0] 276 | self.ip_map.append(ip) 277 | 278 | # For performance and security reasons we should always set a 279 | # limit for the number of file descriptors that socket_map 280 | # should contain. When we're running out of such limit we'll 281 | # use the last available channel for sending a 421 response 282 | # to the client before disconnecting it. 283 | if not self._accept_new_cons(): 284 | handler.handle_max_cons() 285 | return 286 | 287 | # accept only a limited number of connections from the same 288 | # source address. 289 | if self.max_cons_per_ip: 290 | if self.ip_map.count(ip) > self.max_cons_per_ip: 291 | handler.handle_max_cons_per_ip() 292 | return 293 | 294 | try: 295 | handler.handle() 296 | except Exception: 297 | handler.handle_error() 298 | else: 299 | return handler 300 | except Exception: 301 | # This is supposed to be an application bug that should 302 | # be fixed. We do not want to tear down the server though 303 | # (DoS). We just log the exception, hoping that someone 304 | # will eventually file a bug. References: 305 | # - https://github.com/giampaolo/pyftpdlib/issues/143 306 | # - https://github.com/giampaolo/pyftpdlib/issues/166 307 | # - https://groups.google.com/forum/#!topic/pyftpdlib/h7pPybzAx14 308 | logger.error(traceback.format_exc()) 309 | if handler is not None: 310 | handler.close() 311 | elif ip is not None and ip in self.ip_map: 312 | self.ip_map.remove(ip) 313 | 314 | def handle_error(self): 315 | """Called to handle any uncaught exceptions.""" 316 | try: 317 | raise # noqa: PLE0704 318 | except Exception: 319 | logger.error(traceback.format_exc()) 320 | self.close() 321 | 322 | def close_all(self): 323 | """Stop serving and also disconnects all currently connected 324 | clients. 325 | """ 326 | return self.ioloop.close() 327 | 328 | 329 | # =================================================================== 330 | # --- extra implementations 331 | # =================================================================== 332 | 333 | 334 | class _SpawnerBase(FTPServer): 335 | """Base class shared by multiple threads/process dispatcher. 336 | Not supposed to be used. 337 | """ 338 | 339 | # How many seconds to wait when join()ing parent's threads 340 | # or processes. 341 | join_timeout = 5 342 | # How often thread/process finished tasks should be cleaned up. 343 | refresh_interval = 5 344 | _lock = None 345 | _exit = None 346 | 347 | def __init__(self, address_or_socket, handler, ioloop=None, backlog=100): 348 | FTPServer.__init__( 349 | self, address_or_socket, handler, ioloop=ioloop, backlog=backlog 350 | ) 351 | self._active_tasks = [] 352 | self._active_tasks_idler = self.ioloop.call_every( 353 | self.refresh_interval, 354 | self._refresh_tasks, 355 | _errback=self.handle_error, 356 | ) 357 | 358 | def _start_task(self, *args, **kwargs): 359 | raise NotImplementedError('must be implemented in subclass') 360 | 361 | def _map_len(self): 362 | if len(self._active_tasks) >= self.max_cons: 363 | # Since refresh()ing is a potentially expensive operation 364 | # (O(N)) do it only if we're exceeding max connections 365 | # limit. Other than in here, tasks are refreshed every 10 366 | # seconds anyway. 367 | self._refresh_tasks() 368 | return len(self._active_tasks) 369 | 370 | def _refresh_tasks(self): 371 | """join() terminated tasks and update internal _tasks list. 372 | This gets called every X secs. 373 | """ 374 | if self._active_tasks: 375 | logger.debug( 376 | f"refreshing tasks ({len(self._active_tasks)} join()" 377 | " potentials)" 378 | ) 379 | with self._lock: 380 | new = [] 381 | for t in self._active_tasks: 382 | if not t.is_alive(): 383 | self._join_task(t) 384 | else: 385 | new.append(t) 386 | 387 | self._active_tasks = new 388 | 389 | def _loop(self, handler): 390 | """Serve handler's IO loop in a separate thread or process.""" 391 | with self.ioloop.factory() as ioloop: 392 | handler.ioloop = ioloop 393 | try: 394 | handler.add_channel() 395 | except OSError as err: 396 | if err.errno == errno.EBADF: 397 | # we might get here in case the other end quickly 398 | # disconnected (see test_quick_connect()) 399 | debug( 400 | "call: %s._loop(); add_channel() returned EBADF", self 401 | ) 402 | return 403 | else: 404 | raise 405 | 406 | # Here we localize variable access to minimize overhead. 407 | poll = ioloop.poll 408 | sched_poll = ioloop.sched.poll 409 | poll_timeout = getattr(self, 'poll_timeout', None) 410 | soonest_timeout = poll_timeout 411 | 412 | while ( 413 | ioloop.socket_map or ioloop.sched._tasks 414 | ) and not self._exit.is_set(): 415 | try: 416 | if ioloop.socket_map: 417 | poll(timeout=soonest_timeout) 418 | if ioloop.sched._tasks: 419 | soonest_timeout = sched_poll() 420 | # Handle the case where socket_map is empty but some 421 | # cancelled scheduled calls are still around causing 422 | # this while loop to hog CPU resources. 423 | # In theory this should never happen as all the sched 424 | # functions are supposed to be cancel()ed on close() 425 | # but by using threads we can incur into 426 | # synchronization issues such as this one. 427 | # https://github.com/giampaolo/pyftpdlib/issues/245 428 | if not ioloop.socket_map: 429 | # get rid of cancel()led calls 430 | ioloop.sched.reheapify() 431 | soonest_timeout = sched_poll() 432 | if soonest_timeout: 433 | time.sleep(min(soonest_timeout, 1)) 434 | else: 435 | soonest_timeout = None 436 | except (KeyboardInterrupt, SystemExit): 437 | # note: these two exceptions are raised in all sub 438 | # processes 439 | self._exit.set() 440 | except OSError as err: 441 | # on Windows we can get WSAENOTSOCK if the client 442 | # rapidly connect and disconnects 443 | if os.name == 'nt' and err.winerror == 10038: 444 | for fd in list(ioloop.socket_map.keys()): 445 | try: 446 | select.select([fd], [], [], 0) 447 | except OSError: 448 | try: 449 | logger.info( 450 | "discarding broken socket %r", 451 | ioloop.socket_map[fd], 452 | ) 453 | del ioloop.socket_map[fd] 454 | except KeyError: 455 | # dict changed during iteration 456 | pass 457 | else: 458 | raise 459 | else: 460 | if poll_timeout: 461 | if ( 462 | soonest_timeout is None 463 | or soonest_timeout > poll_timeout 464 | ): 465 | soonest_timeout = poll_timeout 466 | 467 | def handle_accepted(self, sock, addr): 468 | handler = FTPServer.handle_accepted(self, sock, addr) 469 | if handler is not None: 470 | # unregister the handler from the main IOLoop used by the 471 | # main thread to accept connections 472 | self.ioloop.unregister(handler._fileno) 473 | 474 | t = self._start_task( 475 | target=self._loop, args=(handler,), name='ftpd' 476 | ) 477 | t.name = repr(addr) 478 | t.start() 479 | 480 | # it is a different process so free resources here 481 | if hasattr(t, 'pid'): 482 | handler.close() 483 | 484 | with self._lock: 485 | # add the new task 486 | self._active_tasks.append(t) 487 | 488 | def _log_start(self): 489 | FTPServer._log_start(self) 490 | 491 | def serve_forever(self, timeout=1.0, blocking=True, handle_exit=True): 492 | self._exit.clear() 493 | if handle_exit: 494 | log = handle_exit and blocking 495 | if log: 496 | self._log_start() 497 | try: 498 | self.ioloop.loop(timeout, blocking) 499 | except (KeyboardInterrupt, SystemExit): 500 | pass 501 | if blocking: 502 | if log: 503 | logger.info( 504 | ">>> 已关闭 FTP 服务器 (%s个连接) <<<", 505 | self._map_len(), 506 | ) 507 | self.close_all() 508 | else: 509 | self.ioloop.loop(timeout, blocking) 510 | 511 | def _terminate_task(self, t): 512 | if hasattr(t, 'terminate'): 513 | logger.debug(f"terminate()ing task {t!r}") 514 | try: 515 | if not _BSD: 516 | t.terminate() 517 | else: 518 | # XXX - On FreeBSD using SIGTERM doesn't work 519 | # as the process hangs on kqueue.control() or 520 | # select.select(). Use SIGKILL instead. 521 | os.kill(t.pid, signal.SIGKILL) 522 | except ProcessLookupError: 523 | pass 524 | 525 | def _join_task(self, t): 526 | logger.debug(f"join()ing task {t!r}") 527 | t.join(self.join_timeout) 528 | if t.is_alive(): 529 | logger.warning( 530 | "task %r remained alive after %r secs", t, self.join_timeout 531 | ) 532 | 533 | def close_all(self): 534 | self._active_tasks_idler.cancel() 535 | # this must be set after getting active tasks as it causes 536 | # thread objects to get out of the list too soon 537 | self._exit.set() 538 | 539 | with self._lock: 540 | for t in self._active_tasks: 541 | self._terminate_task(t) 542 | for t in self._active_tasks: 543 | self._join_task(t) 544 | del self._active_tasks[:] 545 | 546 | FTPServer.close_all(self) 547 | 548 | 549 | class ThreadedFTPServer(_SpawnerBase): 550 | """A modified version of base FTPServer class which spawns a 551 | thread every time a new connection is established. 552 | """ 553 | 554 | # The timeout passed to thread's IOLoop.poll() call on every 555 | # loop. Necessary since threads ignore KeyboardInterrupt. 556 | poll_timeout = 1.0 557 | _lock = threading.Lock() 558 | _exit = threading.Event() 559 | 560 | def _start_task(self, *args, **kwargs): 561 | return threading.Thread(*args, **kwargs) 562 | 563 | 564 | if os.name == 'posix': 565 | try: 566 | import multiprocessing 567 | 568 | multiprocessing.Lock() 569 | except Exception: # noqa 570 | # see https://github.com/giampaolo/pyftpdlib/issues/496 571 | pass 572 | else: 573 | __all__ += ['MultiprocessFTPServer'] 574 | 575 | class MultiprocessFTPServer(_SpawnerBase): 576 | """A modified version of base FTPServer class which spawns a 577 | process every time a new connection is established. 578 | """ 579 | 580 | _lock = multiprocessing.Lock() 581 | _exit = multiprocessing.Event() 582 | 583 | def _start_task(self, *args, **kwargs): 584 | return multiprocessing.Process(*args, **kwargs) 585 | -------------------------------------------------------------------------------- /mypyftpdlib/filesystems.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2007 Giampaolo Rodola' . 2 | # Use of this source code is governed by MIT license that can be 3 | # found in the LICENSE file. 4 | 5 | import os 6 | import stat 7 | import tempfile 8 | import time 9 | 10 | 11 | try: 12 | import grp 13 | import pwd 14 | except ImportError: 15 | pwd = grp = None 16 | 17 | 18 | __all__ = ['AbstractedFS', 'FilesystemError'] 19 | 20 | 21 | _months_map = { 22 | 1: 'Jan', 23 | 2: 'Feb', 24 | 3: 'Mar', 25 | 4: 'Apr', 26 | 5: 'May', 27 | 6: 'Jun', 28 | 7: 'Jul', 29 | 8: 'Aug', 30 | 9: 'Sep', 31 | 10: 'Oct', 32 | 11: 'Nov', 33 | 12: 'Dec', 34 | } 35 | 36 | 37 | def _memoize(fun): 38 | """A simple memoize decorator for functions supporting (hashable) 39 | positional arguments. 40 | """ 41 | 42 | def wrapper(*args, **kwargs): 43 | key = (args, frozenset(sorted(kwargs.items()))) 44 | try: 45 | return cache[key] 46 | except KeyError: 47 | ret = cache[key] = fun(*args, **kwargs) 48 | return ret 49 | 50 | cache = {} 51 | return wrapper 52 | 53 | 54 | # =================================================================== 55 | # --- custom exceptions 56 | # =================================================================== 57 | 58 | 59 | class FilesystemError(Exception): 60 | """Custom class for filesystem-related exceptions. 61 | You can raise this from an AbstractedFS subclass in order to 62 | send a customized error string to the client. 63 | """ 64 | 65 | 66 | # =================================================================== 67 | # --- base class 68 | # =================================================================== 69 | 70 | 71 | class AbstractedFS: 72 | """A class used to interact with the file system, providing a 73 | cross-platform interface compatible with both Windows and 74 | UNIX style filesystems where all paths use "/" separator. 75 | 76 | AbstractedFS distinguishes between "real" filesystem paths and 77 | "virtual" ftp paths emulating a UNIX chroot jail where the user 78 | can not escape its home directory (example: real "/home/user" 79 | path will be seen as "/" by the client) 80 | 81 | It also provides some utility methods and wraps around all os.* 82 | calls involving operations against the filesystem like creating 83 | files or removing directories. 84 | 85 | FilesystemError exception can be raised from within any of 86 | the methods below in order to send a customized error string 87 | to the client. 88 | """ 89 | 90 | def __init__(self, root, cmd_channel): 91 | """ 92 | - (str) root: the user "real" home directory (e.g. '/home/user') 93 | - (instance) cmd_channel: the FTPHandler class instance. 94 | """ 95 | # Set initial current working directory. 96 | # By default initial cwd is set to "/" to emulate a chroot jail. 97 | # If a different behavior is desired (e.g. initial cwd = root, 98 | # to reflect the real filesystem) users overriding this class 99 | # are responsible to set _cwd attribute as necessary. 100 | self._cwd = '/' 101 | self._root = root 102 | self.cmd_channel = cmd_channel 103 | 104 | @property 105 | def root(self): 106 | """The user home directory.""" 107 | return self._root 108 | 109 | @property 110 | def cwd(self): 111 | """The user current working directory.""" 112 | return self._cwd 113 | 114 | @root.setter 115 | def root(self, path): 116 | self._root = path 117 | 118 | @cwd.setter 119 | def cwd(self, path): 120 | self._cwd = path 121 | 122 | # --- Pathname / conversion utilities 123 | 124 | @staticmethod 125 | def _isabs(path, _windows=os.name == "nt"): 126 | # Windows + Python 3.13: isabs() changed so that a path 127 | # starting with "/" is no longer considered absolute. 128 | # https://github.com/python/cpython/issues/44626 129 | # https://github.com/python/cpython/pull/113829/ 130 | if _windows and path.startswith("/"): 131 | return True 132 | return os.path.isabs(path) 133 | 134 | def ftpnorm(self, ftppath): 135 | """Normalize a "virtual" ftp pathname (typically the raw string 136 | coming from client) depending on the current working directory. 137 | 138 | Example (having "/foo" as current working directory): 139 | >>> ftpnorm('bar') 140 | '/foo/bar' 141 | 142 | Note: directory separators are system independent ("/"). 143 | Pathname returned is always absolutized. 144 | """ 145 | if self._isabs(ftppath): 146 | p = os.path.normpath(ftppath) 147 | else: 148 | p = os.path.normpath(os.path.join(self.cwd, ftppath)) 149 | # normalize string in a standard web-path notation having '/' 150 | # as separator. 151 | if os.sep == "\\": 152 | p = p.replace("\\", "/") 153 | # os.path.normpath supports UNC paths (e.g. "//a/b/c") but we 154 | # don't need them. In case we get an UNC path we collapse 155 | # redundant separators appearing at the beginning of the string 156 | while p[:2] == '//': 157 | p = p[1:] 158 | # Anti path traversal: don't trust user input, in the event 159 | # that self.cwd is not absolute, return "/" as a safety measure. 160 | # This is for extra protection, maybe not really necessary. 161 | if not self._isabs(p): 162 | p = "/" 163 | return p 164 | 165 | def ftp2fs(self, ftppath): 166 | """Translate a "virtual" ftp pathname (typically the raw string 167 | coming from client) into equivalent absolute "real" filesystem 168 | pathname. 169 | 170 | Example (having "/home/user" as root directory): 171 | >>> ftp2fs("foo") 172 | '/home/user/foo' 173 | 174 | Note: directory separators are system dependent. 175 | """ 176 | # as far as I know, it should always be path traversal safe... 177 | if os.path.normpath(self.root) == os.sep: 178 | return os.path.normpath(self.ftpnorm(ftppath)) 179 | else: 180 | p = self.ftpnorm(ftppath)[1:] 181 | return os.path.normpath(os.path.join(self.root, p)) 182 | 183 | def fs2ftp(self, fspath): 184 | """Translate a "real" filesystem pathname into equivalent 185 | absolute "virtual" ftp pathname depending on the user's 186 | root directory. 187 | 188 | Example (having "/home/user" as root directory): 189 | >>> fs2ftp("/home/user/foo") 190 | '/foo' 191 | 192 | As for ftpnorm, directory separators are system independent 193 | ("/") and pathname returned is always absolutized. 194 | 195 | On invalid pathnames escaping from user's root directory 196 | (e.g. "/home" when root is "/home/user") always return "/". 197 | """ 198 | if self._isabs(fspath): 199 | p = os.path.normpath(fspath) 200 | else: 201 | p = os.path.normpath(os.path.join(self.root, fspath)) 202 | if not self.validpath(p): 203 | return '/' 204 | p = p.replace(os.sep, "/") 205 | p = p[len(self.root) :] 206 | if not p.startswith('/'): 207 | p = '/' + p 208 | return p 209 | 210 | def validpath(self, path): 211 | """Check whether the path belongs to user's home directory. 212 | Expected argument is a "real" filesystem pathname. 213 | 214 | If path is a symbolic link it is resolved to check its real 215 | destination. 216 | 217 | Pathnames escaping from user's root directory are considered 218 | not valid. 219 | """ 220 | root = self.realpath(self.root) 221 | path = self.realpath(path) 222 | if not root.endswith(os.sep): 223 | root += os.sep 224 | if not path.endswith(os.sep): 225 | path += os.sep 226 | return path[0 : len(root)] == root 227 | 228 | # --- Wrapper methods around open() and tempfile.mkstemp 229 | 230 | def open(self, filename, mode): 231 | """Open a file returning its handler.""" 232 | return open(filename, mode) 233 | 234 | def mkstemp(self, suffix='', prefix='', dir=None, mode='wb'): 235 | """A wrap around tempfile.mkstemp creating a file with a unique 236 | name. Unlike mkstemp it returns an object with a file-like 237 | interface. 238 | """ 239 | 240 | class FileWrapper: 241 | 242 | def __init__(self, fd, name): 243 | self.file = fd 244 | self.name = name 245 | 246 | def __getattr__(self, attr): 247 | return getattr(self.file, attr) 248 | 249 | text = 'b' not in mode 250 | # max number of tries to find out a unique file name 251 | tempfile.TMP_MAX = 50 252 | fd, name = tempfile.mkstemp(suffix, prefix, dir, text=text) 253 | file = os.fdopen(fd, mode) 254 | return FileWrapper(file, name) 255 | 256 | # --- Wrapper methods around os.* calls 257 | 258 | def chdir(self, path): 259 | """Change the current directory. If this method is overridden 260 | it is vital that `cwd` attribute gets set. 261 | """ 262 | # note: process cwd will be reset by the caller 263 | os.chdir(path) 264 | self.cwd = self.fs2ftp(path) 265 | 266 | def mkdir(self, path): 267 | """Create the specified directory.""" 268 | os.mkdir(path) 269 | 270 | def listdir(self, path): 271 | """List the content of a directory.""" 272 | return os.listdir(path) 273 | 274 | def listdirinfo(self, path): 275 | """List the content of a directory.""" 276 | return os.listdir(path) 277 | 278 | def rmdir(self, path): 279 | """Remove the specified directory.""" 280 | os.rmdir(path) 281 | 282 | def remove(self, path): 283 | """Remove the specified file.""" 284 | os.remove(path) 285 | 286 | def rename(self, src, dst): 287 | """Rename the specified src file to the dst filename.""" 288 | os.rename(src, dst) 289 | 290 | def chmod(self, path, mode): 291 | """Change file/directory mode.""" 292 | if not hasattr(os, 'chmod'): 293 | raise NotImplementedError 294 | os.chmod(path, mode) 295 | 296 | def stat(self, path): 297 | """Perform a stat() system call on the given path.""" 298 | return os.stat(path) 299 | 300 | def utime(self, path, timeval): 301 | """Perform a utime() call on the given path.""" 302 | # utime expects a int/float (atime, mtime) in seconds 303 | # thus, setting both access and modify time to timeval 304 | return os.utime(path, (timeval, timeval)) 305 | 306 | if hasattr(os, 'lstat'): 307 | 308 | def lstat(self, path): 309 | """Like stat but does not follow symbolic links.""" 310 | return os.lstat(path) 311 | 312 | else: 313 | lstat = stat 314 | 315 | if hasattr(os, 'readlink'): 316 | 317 | def readlink(self, path): 318 | """Return a string representing the path to which a 319 | symbolic link points. 320 | """ 321 | return os.readlink(path) 322 | 323 | # --- Wrapper methods around os.path.* calls 324 | 325 | def isfile(self, path): 326 | """Return True if path is a file.""" 327 | return os.path.isfile(path) 328 | 329 | def islink(self, path): 330 | """Return True if path is a symbolic link.""" 331 | return os.path.islink(path) 332 | 333 | def isdir(self, path): 334 | """Return True if path is a directory.""" 335 | return os.path.isdir(path) 336 | 337 | def getsize(self, path): 338 | """Return the size of the specified file in bytes.""" 339 | return os.path.getsize(path) 340 | 341 | def getmtime(self, path): 342 | """Return the last modified time as a number of seconds since 343 | the epoch.""" 344 | return os.path.getmtime(path) 345 | 346 | def realpath(self, path): 347 | """Return the canonical version of path eliminating any 348 | symbolic links encountered in the path (if they are 349 | supported by the operating system). 350 | """ 351 | return os.path.realpath(path) 352 | 353 | def lexists(self, path): 354 | """Return True if path refers to an existing path, including 355 | a broken or circular symbolic link. 356 | """ 357 | return os.path.lexists(path) 358 | 359 | if pwd is not None: 360 | 361 | def get_user_by_uid(self, uid): 362 | """Return the username associated with user id. 363 | If this can't be determined return raw uid instead. 364 | On Windows just return "owner". 365 | """ 366 | try: 367 | return pwd.getpwuid(uid).pw_name 368 | except KeyError: 369 | return uid 370 | 371 | else: 372 | 373 | def get_user_by_uid(self, uid): 374 | return "owner" 375 | 376 | if grp is not None: 377 | 378 | def get_group_by_gid(self, gid): 379 | """Return the group name associated with group id. 380 | If this can't be determined return raw gid instead. 381 | On Windows just return "group". 382 | """ 383 | try: 384 | return grp.getgrgid(gid).gr_name 385 | except KeyError: 386 | return gid 387 | 388 | else: 389 | 390 | def get_group_by_gid(self, gid): 391 | return "group" 392 | 393 | # --- Listing utilities 394 | 395 | def format_list(self, basedir, listing, ignore_err=True): 396 | """Return an iterator object that yields the entries of given 397 | directory emulating the "/bin/ls -lA" UNIX command output. 398 | 399 | - (str) basedir: the absolute dirname. 400 | - (list) listing: the names of the entries in basedir 401 | - (bool) ignore_err: when False raise exception if os.lstat() 402 | call fails. 403 | 404 | On platforms which do not support the pwd and grp modules (such 405 | as Windows), ownership is printed as "owner" and "group" as a 406 | default, and number of hard links is always "1". On UNIX 407 | systems, the actual owner, group, and number of links are 408 | printed. 409 | 410 | This is how output appears to client: 411 | 412 | -rw-rw-rw- 1 owner group 7045120 Sep 02 3:47 music.mp3 413 | drwxrwxrwx 1 owner group 0 Aug 31 18:50 e-books 414 | -rw-rw-rw- 1 owner group 380 Sep 02 3:40 module.py 415 | """ 416 | 417 | @_memoize 418 | def get_user_by_uid(uid): 419 | return self.get_user_by_uid(uid) 420 | 421 | @_memoize 422 | def get_group_by_gid(gid): 423 | return self.get_group_by_gid(gid) 424 | 425 | if self.cmd_channel.use_gmt_times: 426 | timefunc = time.gmtime 427 | else: 428 | timefunc = time.localtime 429 | SIX_MONTHS = 180 * 24 * 60 * 60 430 | readlink = getattr(self, 'readlink', None) 431 | now = time.time() 432 | for basename in listing: 433 | file = os.path.join(basedir, basename) 434 | try: 435 | st = self.lstat(file) 436 | except (OSError, FilesystemError): 437 | if ignore_err: 438 | continue 439 | raise 440 | 441 | perms = stat.filemode(st.st_mode) # permissions 442 | nlinks = st.st_nlink # number of links to inode 443 | if not nlinks: # non-posix system, let's use a bogus value 444 | nlinks = 1 445 | size = st.st_size # file size 446 | uname = get_user_by_uid(st.st_uid) 447 | gname = get_group_by_gid(st.st_gid) 448 | mtime = timefunc(st.st_mtime) 449 | # if modification time > 6 months shows "month year" 450 | # else "month hh:mm"; this matches proftpd format, see: 451 | # https://github.com/giampaolo/pyftpdlib/issues/187 452 | fmtstr = '%d %Y' if now - st.st_mtime > SIX_MONTHS else '%d %H:%M' 453 | try: 454 | mtimestr = "%s %s" % ( # noqa: UP031 455 | _months_map[mtime.tm_mon], 456 | time.strftime(fmtstr, mtime), 457 | ) 458 | except ValueError: 459 | # It could be raised if last mtime happens to be too 460 | # old (prior to year 1900) in which case we return 461 | # the current time as last mtime. 462 | mtime = timefunc() 463 | mtimestr = "%s %s" % ( # noqa: UP031 464 | _months_map[mtime.tm_mon], 465 | time.strftime("%d %H:%M", mtime), 466 | ) 467 | 468 | # same as stat.S_ISLNK(st.st_mode) but slighlty faster 469 | islink = (st.st_mode & 61440) == stat.S_IFLNK 470 | if islink and readlink is not None: 471 | # if the file is a symlink, resolve it, e.g. 472 | # "symlink -> realfile" 473 | try: 474 | basename = basename + " -> " + readlink(file) 475 | except (OSError, FilesystemError): 476 | if not ignore_err: 477 | raise 478 | 479 | # formatting is matched with proftpd ls output 480 | line = "%s %3s %-8s %-8s %8s %s %s\r\n" % ( 481 | perms, 482 | nlinks, 483 | uname, 484 | gname, 485 | size, 486 | mtimestr, 487 | basename, 488 | ) 489 | yield line.encode( 490 | self.cmd_channel.encoding, self.cmd_channel.unicode_errors 491 | ) 492 | 493 | def format_mlsx(self, basedir, listing, perms, facts, ignore_err=True): 494 | """Return an iterator object that yields the entries of a given 495 | directory or of a single file in a form suitable with MLSD and 496 | MLST commands. 497 | 498 | Every entry includes a list of "facts" referring the listed 499 | element. See RFC-3659, chapter 7, to see what every single 500 | fact stands for. 501 | 502 | - (str) basedir: the absolute dirname. 503 | - (list) listing: the names of the entries in basedir 504 | - (str) perms: the string referencing the user permissions. 505 | - (str) facts: the list of "facts" to be returned. 506 | - (bool) ignore_err: when False raise exception if os.stat() 507 | call fails. 508 | 509 | Note that "facts" returned may change depending on the platform 510 | and on what user specified by using the OPTS command. 511 | 512 | This is how output could appear to the client issuing 513 | a MLSD request: 514 | 515 | type=file;size=156;perm=r;modify=20071029155301;unique=8012; music.mp3 516 | type=dir;size=0;perm=el;modify=20071127230206;unique=801e33; ebooks 517 | type=file;size=211;perm=r;modify=20071103093626;unique=192; module.py 518 | """ 519 | if self.cmd_channel.use_gmt_times: 520 | timefunc = time.gmtime 521 | else: 522 | timefunc = time.localtime 523 | permdir = ''.join([x for x in perms if x not in 'arw']) 524 | permfile = ''.join([x for x in perms if x not in 'celmp']) 525 | if ('w' in perms) or ('a' in perms) or ('f' in perms): 526 | permdir += 'c' 527 | if 'd' in perms: 528 | permdir += 'p' 529 | show_type = 'type' in facts 530 | show_perm = 'perm' in facts 531 | show_size = 'size' in facts 532 | show_modify = 'modify' in facts 533 | show_create = 'create' in facts 534 | show_mode = 'unix.mode' in facts 535 | show_uid = 'unix.uid' in facts 536 | show_gid = 'unix.gid' in facts 537 | show_unique = 'unique' in facts 538 | for basename in listing: 539 | retfacts = {} 540 | file = os.path.join(basedir, basename) 541 | # in order to properly implement 'unique' fact (RFC-3659, 542 | # chapter 7.5.2) we are supposed to follow symlinks, hence 543 | # use os.stat() instead of os.lstat() 544 | try: 545 | st = self.stat(file) 546 | except (OSError, FilesystemError): 547 | if ignore_err: 548 | continue 549 | raise 550 | # type + perm 551 | # same as stat.S_ISDIR(st.st_mode) but slightly faster 552 | isdir = (st.st_mode & 61440) == stat.S_IFDIR 553 | if isdir: 554 | if show_type: 555 | if basename == '.': 556 | retfacts['type'] = 'cdir' 557 | elif basename == '..': 558 | retfacts['type'] = 'pdir' 559 | else: 560 | retfacts['type'] = 'dir' 561 | if show_perm: 562 | retfacts['perm'] = permdir 563 | else: 564 | if show_type: 565 | retfacts['type'] = 'file' 566 | if show_perm: 567 | retfacts['perm'] = permfile 568 | if show_size: 569 | retfacts['size'] = st.st_size # file size 570 | # last modification time 571 | if show_modify: 572 | try: 573 | retfacts['modify'] = time.strftime( 574 | "%Y%m%d%H%M%S", timefunc(st.st_mtime) 575 | ) 576 | # it could be raised if last mtime happens to be too old 577 | # (prior to year 1900) 578 | except ValueError: 579 | pass 580 | if show_create: 581 | # on Windows we can provide also the creation time 582 | try: 583 | retfacts['create'] = time.strftime( 584 | "%Y%m%d%H%M%S", timefunc(st.st_ctime) 585 | ) 586 | except ValueError: 587 | pass 588 | # UNIX only 589 | if show_mode: 590 | retfacts['unix.mode'] = oct(st.st_mode & 511) 591 | if show_uid: 592 | retfacts['unix.uid'] = st.st_uid 593 | if show_gid: 594 | retfacts['unix.gid'] = st.st_gid 595 | 596 | # We provide unique fact (see RFC-3659, chapter 7.5.2) on 597 | # posix platforms only; we get it by mixing st_dev and 598 | # st_ino values which should be enough for granting an 599 | # uniqueness for the file listed. 600 | # The same approach is used by pure-ftpd. 601 | # Implementors who want to provide unique fact on other 602 | # platforms should use some platform-specific method (e.g. 603 | # on Windows NTFS filesystems MTF records could be used). 604 | if show_unique: 605 | retfacts['unique'] = f"{st.st_dev:x}g{st.st_ino:x}" 606 | 607 | # facts can be in any order but we sort them by name 608 | factstring = "".join( 609 | [f"{x}={retfacts[x]};" for x in sorted(retfacts.keys())] 610 | ) 611 | line = f"{factstring} {basename}\r\n" 612 | yield line.encode( 613 | self.cmd_channel.encoding, self.cmd_channel.unicode_errors 614 | ) 615 | 616 | 617 | # =================================================================== 618 | # --- platform specific implementation 619 | # =================================================================== 620 | 621 | if os.name == 'posix': 622 | __all__ += ['UnixFilesystem'] 623 | 624 | class UnixFilesystem(AbstractedFS): 625 | """Represents the real UNIX filesystem. 626 | 627 | Differently from AbstractedFS the client will login into 628 | /home/ and will be able to escape its home directory 629 | and navigate the real filesystem. 630 | """ 631 | 632 | def __init__(self, root, cmd_channel): 633 | AbstractedFS.__init__(self, root, cmd_channel) 634 | # initial cwd was set to "/" to emulate a chroot jail 635 | self.cwd = root 636 | 637 | def ftp2fs(self, ftppath): 638 | return self.ftpnorm(ftppath) 639 | 640 | def fs2ftp(self, fspath): 641 | return fspath 642 | 643 | def validpath(self, path): 644 | # validpath was used to check symlinks escaping user home 645 | # directory; this is no longer necessary. 646 | return True 647 | -------------------------------------------------------------------------------- /ftpServer.py: -------------------------------------------------------------------------------- 1 | r""" 2 | FTP Server with GUI Interface 3 | 一个带图形界面的FTP服务器 4 | 5 | Author: JARK006 6 | Email: jark006@qq.com 7 | Github: https://github.com/jark006 8 | Project: https://github.com/jark006/FtpServer 9 | License: MIT License 10 | Copyright (c) 2023-2026 JARK006 11 | 12 | # 打包工具 13 | pip install pyinstaller nuitka 14 | 15 | # 第三方库需求 16 | pip install Pillow pypiwin32 pystray pyopenssl pyasynchat 17 | 18 | # 在终端中生成SSL证书 (ftpServer.key, ftpServer.crt 有效期100年) 放到程序所在目录则自动启用 FTPS [TLS/SSL显式加密, TLSv1.3] 19 | openssl req -x509 -newkey rsa:2048 -keyout ftpServer.key -out ftpServer.crt -nodes -days 36500 20 | 21 | # 打包 单文件 隐藏终端窗口 以下三选一 (第一条和第二条是同一个,第一条执行过一次产生ftpServer.spec后,以后只需执行第二条) 22 | pyinstaller.exe -F -w .\ftpServer.py -i .\ftpServer.ico --version-file .\file_version_info.txt 23 | pyinstaller.exe .\ftpServer.spec 24 | python -m nuitka .\ftpServer.py --windows-icon-from-ico=.\ftpServer.ico --standalone --lto=yes --enable-plugin=tk-inter --windows-console-mode=disable --company-name=JARK006 --product-name=ftpServer --file-version=1.24.0.0 --product-version=1.24.0.0 --file-description="FtpServer Github@JARK006" --copyright="Copyright (C) 2025 Github@JARK006" 25 | 26 | """ 27 | 28 | # 标准库导入 29 | import os 30 | import queue 31 | import socket 32 | import sys 33 | import threading 34 | import time 35 | import ctypes 36 | import functools 37 | 38 | # GUI相关导入 39 | import tkinter as tk 40 | import webbrowser 41 | from tkinter import ttk, scrolledtext, filedialog, messagebox, font 42 | 43 | # 第三方库导入 44 | import pystray 45 | import win32clipboard 46 | import win32con 47 | 48 | # 本地模块导入 49 | import Settings 50 | import UserList 51 | import myUtils 52 | 53 | # 汉化 pyftpdlib 模块导入 54 | from mypyftpdlib.authorizers import DummyAuthorizer 55 | from mypyftpdlib.handlers import FTPHandler, TLS_FTPHandler 56 | from mypyftpdlib.servers import ThreadedFTPServer 57 | 58 | appLabel = "FTP文件服务器" 59 | appVersion = "v1.24" 60 | appAuthor = "JARK006" 61 | githubLink = "https://github.com/jark006/FtpServer" 62 | releaseLink = "https://github.com/jark006/FtpServer/releases" 63 | quarkLink = "https://pan.quark.cn/s/fb740c256653" 64 | baiduLink = "https://pan.baidu.com/s/1955qjdrnPtxhNhtksjqvfg?pwd=6666" 65 | windowsTitle = f"{appLabel} {appVersion}" 66 | tipsTitle = "若用户名空白则默认匿名访问(anonymous)。若中文乱码则需更换编码方式, 再重启服务。若无需开启IPv6只需将其端口留空即可, IPv4同理。请设置完后再开启服务。若需FTPS或多用户配置, 请点击“帮助”按钮查看使用说明。以下为本机所有IP地址(含所有物理网卡/虚拟网卡), 右键可复制。\n" 67 | 68 | logMsg = queue.Queue() 69 | logThreadrunning: bool = True 70 | 71 | permReadOnly: str = "elr" 72 | permReadWrite: str = "elradfmwMT" 73 | 74 | isIPv4Supported: bool = False 75 | isIPv6Supported: bool = False 76 | isIPv4ThreadRunning: bool = False 77 | isIPv6ThreadRunning: bool = False 78 | 79 | certFilePath = os.path.join(os.path.dirname(sys.argv[0]), "ftpServer.crt") 80 | keyFilePath = os.path.join(os.path.dirname(sys.argv[0]), "ftpServer.key") 81 | 82 | ScaleFactor = 100 83 | 84 | 85 | def scale(n: int) -> int: 86 | global ScaleFactor 87 | return int(n * ScaleFactor / 100) 88 | 89 | 90 | def showHelp(): 91 | global window 92 | global iconImage 93 | global uiFont 94 | helpTips = """以下是 安全加密连接FTPS 和 多用户配置 说明, 普通用户一般不需要。 95 | 96 | ==== FTPS 配置 ==== 97 | 98 | 本软件默认使用 FTP 明文传输数据,如果数据比较敏感,或者网络环境不安全,则可以按以下步骤开启 FTPS 加密传输数据。 99 | 100 | 在 "Linux" 或 "MinGW64" 终端使用 "openssl" (命令如下,需填入一些简单信息: 地区/名字/Email等)生成SSL证书文件(ftpServer.key和ftpServer.crt), "不要重命名"文件为其他名称。 101 | 102 | openssl req -x509 -newkey rsa:2048 -keyout ftpServer.key -out ftpServer.crt -nodes -days 36500 103 | 104 | 直接将 ftpServer.key 和 ftpServer.crt 放到程序所在目录, 开启服务时若存在这两个文件, 则启用加密传输 "FTPS [TLS/SSL显式加密, TLSv1.3]"。 105 | Windows文件管理器对 显式FTPS 支持不佳, 推荐使用开源软件 "WinSCP" FTP客户端, 对 FTPS 支持比较好。 106 | 开启 "FTPS 加密传输" 后, 会影响传输性能, 最大传输速度会降到 50MiB/s 左右。若对网络安全没那么高要求, 不建议加密。 107 | 108 | 109 | ==== 多用户配置 ==== 110 | 111 | 一般单人使用时,只需在软件主页面设置用户名和密码即可。如果需要开放给多人使用,可以按以下步骤建立多个用户,分配不同的读写权限和根目录。 112 | 113 | 在主程序所在目录新建文件 "FtpServerUserList.csv" , 使用 "Excel"或文本编辑器(需熟悉csv文件格式)编辑, 一行一个配置: 114 | 第一列: 用户名, 限定英文大小写/数字。 115 | 第二列: 密码, 限定英文大小写/数字/符号。 116 | 第三列: 权限, 详细配置如下。 117 | 第四列: 根目录路径。 118 | 119 | 例如: 120 | | JARK006 | 123456 | readonly | D:/Downloads | 121 | | JARK007 | 456789 | readwrite | D:/Data | 122 | | JARK008 | abc123 | 只读 | D:/FtpRoot | 123 | | JARK009 | abc456 | elr | D:/FtpRoot | 124 | | anonymous | | elr | D:/FtpRoot | 125 | | ... | | | | 126 | 注: anonymous 是匿名用户, 允许不设密码, 其他用户必须设置密码。 127 | 128 | 权限配置: 129 | 使用 "readonly" 或 "只读" 设置为 "只读权限"。 130 | 使用 "readwrite" 或 "读写" 设置为 "读写权限"。 131 | 使用 "自定义" 权限设置, 从以下权限挑选自行组合(注意大小写)。 132 | 133 | 参考链接: https://pyftpdlib.readthedocs.io/en/latest/api.html#pyftpdlib.authorizers.DummyAuthorizer.add_user 134 | 135 | 读取权限: 136 | "e" = 更改目录 (CWD 命令) 137 | "l" = 列出文件 (LIST、NLST、STAT、MLSD、MLST、SIZE、MDTM 命令) 138 | "r" = 从服务器检索文件 (RETR 命令) 139 | 140 | 写入权限: 141 | "a" = 将数据附加到现有文件 (APPE 命令) 142 | "d" = 删除文件或目录 (DELE、RMD 命令) 143 | "f" = 重命名文件或目录 (RNFR、RNTO 命令) 144 | "m" = 创建目录 (MKD 命令) 145 | "w" = 将文件存储到服务器 (STOR、STOU 命令) 146 | "M" = 更改文件模式 (SITE CHMOD 命令) 147 | "T" = 更新文件上次修改时间 (MFMT 命令) 148 | 149 | 其他: 150 | 1. 若读取到有效配置, 则自动 "禁用"主页面的用户/密码设置。 151 | 2. 密码不要出现英文逗号 "," 字符, 以免和csv文本格式冲突。 152 | 3. 若临时不需多用户配置, 可将配置文件 "删除" 或 "重命名" 为其他名称。 153 | 4. 配置文件可以是UTF-8或GBK编码。 154 | """ 155 | 156 | helpWindows = tk.Toplevel(window) 157 | helpWindows.geometry(f"{scale(600)}x{scale(500)}") 158 | helpWindows.minsize(scale(600), scale(500)) 159 | helpWindows.title("帮助") 160 | helpWindows.iconphoto(False, iconImage) # type: ignore 161 | helpTextWidget = scrolledtext.ScrolledText( 162 | helpWindows, bg="#dddddd", wrap=tk.CHAR, font=uiFont, width=0, height=0 163 | ) 164 | helpTextWidget.insert(tk.INSERT, helpTips) 165 | helpTextWidget.configure(state=tk.DISABLED) 166 | helpTextWidget.pack(fill=tk.BOTH, expand=True) 167 | 168 | menu = tk.Menu(window, tearoff=False) 169 | menu.add_command( 170 | label="复制", 171 | command=lambda event=None: helpTextWidget.event_generate("<>"), 172 | ) 173 | helpTextWidget.bind( 174 | "", lambda event: menu.post(event.x_root, event.y_root) 175 | ) 176 | 177 | 178 | def showAbout(): 179 | global window 180 | global iconImage 181 | 182 | aboutWindows = tk.Toplevel(window) 183 | # 自动跟随内容调节大小,适配大字体 184 | # aboutWindows.geometry(f"{scale(400)}x{scale(200)}") 185 | aboutWindows.resizable(False, False) 186 | aboutWindows.minsize(scale(400), scale(200)) 187 | aboutWindows.title("关于") 188 | aboutWindows.iconphoto(False, iconImage) # type: ignore 189 | 190 | headerFrame = ttk.Frame(aboutWindows) 191 | headerFrame.pack(fill=tk.X) 192 | headerFrame.grid_columnconfigure(1, weight=1) 193 | 194 | tk.Label(headerFrame, image=iconImage, width=scale(100), height=scale(100)).grid( 195 | row=0, column=0, rowspan=2 196 | ) 197 | tk.Label( 198 | headerFrame, 199 | text=f"{appLabel} {appVersion}", 200 | font=font.Font(font=("Consolas", scale(12))), 201 | ).grid(row=0, column=1, sticky=tk.S) 202 | 203 | tk.Label(headerFrame, text=f"开发者: {appAuthor}").grid(row=1, column=1) 204 | 205 | linksFrame = ttk.Frame(aboutWindows) 206 | linksFrame.pack(fill=tk.X, padx=scale(20), pady=(0, scale(20))) 207 | 208 | tk.Label(linksFrame, text="Github").grid(row=0, column=0) 209 | tk.Label(linksFrame, text="Release").grid(row=1, column=0) 210 | tk.Label(linksFrame, text="夸克网盘").grid(row=2, column=0) 211 | tk.Label(linksFrame, text="百度云盘").grid(row=3, column=0) 212 | 213 | label1 = ttk.Label(linksFrame, text=githubLink, foreground="blue") 214 | label1.bind("", lambda event: webbrowser.open(githubLink)) 215 | label1.grid(row=0, column=1, sticky=tk.W) 216 | 217 | label2 = ttk.Label(linksFrame, text=releaseLink, foreground="blue") 218 | label2.bind("", lambda event: webbrowser.open(releaseLink)) 219 | label2.grid(row=1, column=1, sticky=tk.W) 220 | 221 | label3 = ttk.Label(linksFrame, text=quarkLink, foreground="blue") 222 | label3.bind("", lambda event: webbrowser.open(quarkLink)) 223 | label3.grid(row=2, column=1, sticky=tk.W) 224 | 225 | baiduLinkTmp = baiduLink[:30] + "... 提取码: 6666" 226 | label4 = ttk.Label(linksFrame, text=baiduLinkTmp, foreground="blue") 227 | label4.bind("", lambda event: webbrowser.open(baiduLink)) 228 | label4.grid(row=3, column=1, sticky=tk.W) 229 | 230 | 231 | def deleteCurrentComboboxItem(): 232 | global settings 233 | global directoryCombobox 234 | 235 | currentDirectoryList = list(directoryCombobox["value"]) 236 | 237 | if len(currentDirectoryList) <= 1: 238 | settings.directoryList = [settings.appDirectory] 239 | directoryCombobox["value"] = tuple(settings.directoryList) 240 | directoryCombobox.current(0) 241 | print("目录列表已清空, 默认恢复到程序所在目录") 242 | return 243 | 244 | currentValue = directoryCombobox.get() 245 | 246 | if currentValue in currentDirectoryList: 247 | currentIdx = directoryCombobox.current(None) 248 | currentDirectoryList.remove(currentValue) 249 | settings.directoryList = currentDirectoryList 250 | directoryCombobox["value"] = tuple(currentDirectoryList) 251 | if currentIdx >= len(currentDirectoryList): 252 | directoryCombobox.current(len(currentDirectoryList) - 1) 253 | else: 254 | directoryCombobox.current(currentIdx) 255 | else: 256 | directoryCombobox.current(0) 257 | 258 | 259 | def updateSettingVars(): 260 | global settings 261 | global directoryCombobox 262 | global userNameVar 263 | global userPasswordVar 264 | global IPv4PortVar 265 | global IPv6PortVar 266 | global isReadOnlyVar 267 | global isGBKVar 268 | global isAutoStartServerVar 269 | global isIPv4Supported 270 | global isIPv6Supported 271 | 272 | settings.directoryList = list(directoryCombobox["value"]) 273 | if len(settings.directoryList) > 0: 274 | directory = directoryCombobox.get() 275 | if directory in settings.directoryList: 276 | settings.directoryList.remove(directory) 277 | settings.directoryList.insert(0, directory) 278 | else: 279 | settings.directoryList = [settings.appDirectory] 280 | 281 | directoryCombobox["value"] = tuple(settings.directoryList) 282 | directoryCombobox.current(0) 283 | 284 | settings.userName = userNameVar.get() 285 | settings.isGBK = isGBKVar.get() 286 | settings.isReadOnly = isReadOnlyVar.get() 287 | settings.isAutoStartServer = isAutoStartServerVar.get() 288 | 289 | passwordTmp = userPasswordVar.get() 290 | if len(passwordTmp) == 0: 291 | settings.userPassword = "" 292 | elif passwordTmp == "******": 293 | pass 294 | else: 295 | settings.userPassword = Settings.Settings.encry2sha256(passwordTmp) 296 | userPasswordVar.set("******") 297 | 298 | try: 299 | IPv4PortInt = 0 if IPv4PortVar.get() == "" else int(IPv4PortVar.get()) 300 | if 0 <= IPv4PortInt and IPv4PortInt < 65536: 301 | settings.IPv4Port = IPv4PortInt 302 | else: 303 | raise 304 | except: 305 | tips: str = ( 306 | f"当前 IPv4 端口值: [ {IPv4PortVar.get()} ] 异常, 正常范围: 1 ~ 65535, 已重设为: 21" 307 | ) 308 | messagebox.showwarning("IPv4 端口值异常", tips) 309 | print(tips) 310 | settings.IPv4Port = 21 311 | IPv4PortVar.set("21") 312 | 313 | try: 314 | IPv6PortInt = 0 if IPv6PortVar.get() == "" else int(IPv6PortVar.get()) 315 | if 0 <= IPv6PortInt and IPv6PortInt < 65536: 316 | settings.IPv6Port = IPv6PortInt 317 | else: 318 | raise 319 | except: 320 | tips: str = ( 321 | f"当前 IPv6 端口值: [ {IPv6PortVar.get()} ] 异常, 正常范围: 1 ~ 65535, 已重设为: 21" 322 | ) 323 | messagebox.showwarning("IPv6 端口值异常", tips) 324 | print(tips) 325 | settings.IPv6Port = 21 326 | IPv6PortVar.set("21") 327 | 328 | 329 | class myStdout: # 重定向输出 330 | def __init__(self): 331 | sys.stdout = self 332 | sys.stderr = self 333 | 334 | def write(self, info): 335 | logMsg.put(info) 336 | 337 | def flush(self): 338 | pass 339 | 340 | 341 | def copyToClipboard(text: str): 342 | if len(text) > 0: 343 | win32clipboard.OpenClipboard() 344 | win32clipboard.EmptyClipboard() 345 | win32clipboard.SetClipboardData(win32con.CF_UNICODETEXT, text) 346 | win32clipboard.CloseClipboard() 347 | 348 | 349 | def ip_into_int(ip_str: str) -> int: 350 | return functools.reduce(lambda x, y: (x << 8) + y, map(int, ip_str.split("."))) 351 | 352 | 353 | # https://blog.mimvp.com/article/32438.html 354 | def is_internal_ip(ip_str: str) -> bool: 355 | if ip_str.startswith("169.254."): 356 | return True 357 | 358 | ip_int = ip_into_int(ip_str) 359 | net_A = 10 # ip_into_int("10.255.255.255") >> 24 360 | net_B = 2753 # ip_into_int("172.31.255.255") >> 20 361 | net_C = 49320 # ip_into_int("192.168.255.255") >> 16 362 | net_ISP = 43518 # ip_into_int("100.127.255.255") >> 22 363 | net_DHCP = 401 # ip_into_int("169.254.255.255") >> 16 364 | return ( 365 | ip_int >> 24 == net_A 366 | or ip_int >> 20 == net_B 367 | or ip_int >> 16 == net_C 368 | or ip_int >> 22 == net_ISP 369 | or ip_int >> 16 == net_DHCP 370 | ) 371 | 372 | 373 | def startServer(): 374 | global settings 375 | global userList 376 | global userNameEntry 377 | global userPasswordEntry 378 | global serverThreadV4 379 | global serverThreadV6 380 | global isIPv4Supported 381 | global isIPv6Supported 382 | global isIPv4ThreadRunning 383 | global isIPv6ThreadRunning 384 | global tipsTextWidget 385 | global tipsTextWidgetRightClickMenu 386 | 387 | if isIPv4ThreadRunning: 388 | print("[FTP IPv4] 正在运行") 389 | return 390 | if isIPv6ThreadRunning: 391 | print("[FTP IPv6] 正在运行") 392 | return 393 | 394 | updateSettingVars() 395 | 396 | if not os.path.exists(settings.directoryList[0]): 397 | tips: str = ( 398 | f"路径: [ {settings.directoryList[0]} ]异常!请检查路径是否正确或者有没有读取权限。" 399 | ) 400 | messagebox.showerror("路径异常", tips) 401 | print(tips) 402 | return 403 | 404 | userList.load() 405 | if userList.isEmpty(): 406 | userNameEntry.configure(state=tk.NORMAL) 407 | userPasswordEntry.configure(state=tk.NORMAL) 408 | if len(settings.userName) > 0 and len(settings.userPassword) == 0: 409 | tips: str = "!!! 请设置密码再启动服务 !!!" 410 | messagebox.showerror("密码异常", tips) 411 | print(tips) 412 | return 413 | if ( 414 | settings.userName == "anonymous" or len(settings.userName) == 0 415 | ) and settings.isReadOnly == False: 416 | print( 417 | "警告:当前允许【匿名用户】登录,且拥有【写入、修改】文件权限,请谨慎对待。" 418 | ) 419 | print( 420 | "若是安全的内网环境可忽略以上警告,否则【匿名用户】应当选择【只读】权限。" 421 | ) 422 | else: 423 | userNameEntry.configure(state=tk.DISABLED) 424 | userPasswordEntry.configure(state=tk.DISABLED) 425 | 426 | tipsStr, ftpUrlList = getTipsAndUrlList() 427 | 428 | if len(ftpUrlList) == 0: 429 | tips: str = "!!! 本机没有检测到网络IP, 请检查端口设置或网络连接, 或稍后重试 !!!" 430 | messagebox.showerror("网络或端口设置异常", tips) 431 | print(tips) 432 | return 433 | 434 | settings.save() 435 | 436 | tipsTextWidget.configure(state=tk.NORMAL) 437 | tipsTextWidget.delete("0.0", tk.END) 438 | tipsTextWidget.insert(tk.INSERT, tipsStr) 439 | tipsTextWidget.configure(state=tk.DISABLED) 440 | 441 | tipsTextWidgetRightClickMenu.delete(0, tk.END) 442 | for url in ftpUrlList: 443 | tipsTextWidgetRightClickMenu.add_command( 444 | label=f"复制 {url}", command=lambda url=url: copyToClipboard(url) 445 | ) 446 | 447 | try: 448 | hasStartServer: bool = False 449 | if isIPv4Supported and settings.IPv4Port > 0: 450 | serverThreadV4 = threading.Thread(target=serverThreadFun, args=("IPv4",)) 451 | serverThreadV4.start() 452 | hasStartServer = True 453 | 454 | if isIPv6Supported and settings.IPv6Port > 0: 455 | serverThreadV6 = threading.Thread(target=serverThreadFun, args=("IPv6",)) 456 | serverThreadV6.start() 457 | hasStartServer = True 458 | 459 | if not hasStartServer: 460 | tips: str = "!!! 未检测到有效端口, 服务无法启动, 请检查端口设置是否正确 !!!" 461 | messagebox.showerror("端口异常", tips) 462 | print(tips) 463 | return 464 | 465 | except Exception as e: 466 | tips: str = f"!!! 发生异常, 无法启动线程 !!!\n{e}" 467 | messagebox.showerror("启动异常", tips) 468 | print(tips) 469 | return 470 | 471 | if userList.isEmpty(): 472 | print( 473 | "\n用户: {}\n密码: {}\n权限: {}\n编码: {}\n目录: {}\n".format( 474 | ( 475 | settings.userName 476 | if len(settings.userName) > 0 477 | else "匿名访问(anonymous)" 478 | ), 479 | ("******" if len(settings.userPassword) > 0 else "无"), 480 | ("只读" if settings.isReadOnly else "读写"), 481 | ("GBK" if settings.isGBK else "UTF-8"), 482 | settings.directoryList[0], 483 | ) 484 | ) 485 | else: 486 | userList.print() 487 | print(f"编码: {'GBK' if settings.isGBK else 'UTF-8'}\n") 488 | 489 | 490 | def serverThreadFun(IP_Family: str): 491 | global serverV4 492 | global isIPv4ThreadRunning 493 | global serverV6 494 | global isIPv6ThreadRunning 495 | global certFilePath 496 | global keyFilePath 497 | 498 | authorizer = DummyAuthorizer() 499 | 500 | if userList.isEmpty(): 501 | if len(settings.userName) > 0: 502 | authorizer.add_user( 503 | settings.userName, 504 | settings.userPassword, 505 | settings.directoryList[0], 506 | perm=permReadOnly if settings.isReadOnly else permReadWrite, 507 | ) 508 | else: 509 | authorizer.add_anonymous( 510 | settings.directoryList[0], 511 | perm=permReadOnly if settings.isReadOnly else permReadWrite, 512 | ) 513 | else: 514 | for userItem in userList.userList: 515 | authorizer.add_user( 516 | userItem.userName, 517 | userItem.password, 518 | userItem.path, 519 | perm=userItem.perm, 520 | ) 521 | 522 | if os.path.exists(certFilePath) and os.path.exists(keyFilePath): 523 | handler = TLS_FTPHandler 524 | handler.certfile = certFilePath # type: ignore 525 | handler.keyfile = keyFilePath # type: ignore 526 | handler.tls_control_required = True 527 | handler.tls_data_required = True 528 | print( 529 | "[FTP IPv4] 已加载 SSL 证书文件, 默认开启 FTPS [TLS/SSL显式加密, TLSv1.3]" 530 | ) 531 | else: 532 | handler = FTPHandler 533 | 534 | handler.authorizer = authorizer 535 | handler.encoding = "gbk" if settings.isGBK else "utf8" 536 | if IP_Family == "IPv4": 537 | serverV4 = ThreadedFTPServer(("0.0.0.0", settings.IPv4Port), handler) 538 | print("[FTP IPv4] 开始运行") 539 | isIPv4ThreadRunning = True 540 | serverV4.serve_forever() 541 | isIPv4ThreadRunning = False 542 | print("[FTP IPv4] 已关闭") 543 | else: 544 | serverV6 = ThreadedFTPServer(("::", settings.IPv6Port), handler) 545 | print("[FTP IPv6] 开始运行") 546 | isIPv6ThreadRunning = True 547 | serverV6.serve_forever() 548 | isIPv6ThreadRunning = False 549 | print("[FTP IPv6] 已关闭") 550 | 551 | 552 | def closeServer(): 553 | global serverV4 554 | global serverV6 555 | global serverThreadV4 556 | global serverThreadV6 557 | global isIPv4ThreadRunning 558 | global isIPv6ThreadRunning 559 | global isIPv4Supported 560 | global isIPv6Supported 561 | 562 | if isIPv4Supported and settings.IPv4Port > 0: 563 | if isIPv4ThreadRunning: 564 | print("[FTP IPv4] 正在关闭...") 565 | serverV4.close_all() # 注意: 这也会关闭serverV6的所有连接 566 | serverThreadV4.join() 567 | print("[FTP IPv4] 线程已关闭") 568 | 569 | if isIPv6Supported and settings.IPv6Port > 0: 570 | if isIPv6ThreadRunning: 571 | print("[FTP IPv6] 正在关闭...") 572 | serverV6.close_all() 573 | serverThreadV6.join() 574 | print("[FTP IPv6] 线程已关闭") 575 | 576 | 577 | def pickDirectory(): 578 | global directoryCombobox 579 | global settings 580 | 581 | directory = filedialog.askdirectory() 582 | if len(directory) == 0: 583 | return 584 | 585 | if os.path.exists(directory): 586 | if directory in settings.directoryList: 587 | settings.directoryList.remove(directory) 588 | settings.directoryList.insert(0, directory) 589 | else: 590 | settings.directoryList.insert(0, directory) 591 | 592 | directoryCombobox["value"] = tuple(settings.directoryList) 593 | directoryCombobox.current(0) 594 | else: 595 | tips: str = f"路径不存在或无访问权限: [ {directory} ]" 596 | messagebox.showerror("路径异常", tips) 597 | print(tips) 598 | 599 | 600 | def showWindow(): 601 | global window 602 | window.deiconify() 603 | 604 | 605 | def hideWindow(): 606 | global window 607 | window.withdraw() 608 | 609 | 610 | def handleExit(strayIcon: pystray._base.Icon): 611 | global window 612 | global logThreadrunning 613 | global logThread 614 | 615 | updateSettingVars() 616 | settings.save() 617 | 618 | closeServer() 619 | strayIcon.stop() 620 | 621 | print("等待日志线程退出...") 622 | logThreadrunning = False 623 | logThread.join() 624 | 625 | window.destroy() 626 | exit(0) 627 | 628 | 629 | def logThreadFun(): 630 | global logThreadrunning 631 | global loggingWidget 632 | 633 | logMsgBackup = [] 634 | while logThreadrunning: 635 | if logMsg.empty(): 636 | time.sleep(0.1) 637 | continue 638 | 639 | logInfo = "" 640 | while not logMsg.empty(): 641 | logInfo += logMsg.get() 642 | 643 | logMsgBackup.append(logInfo) 644 | if len(logMsgBackup) > 500: 645 | loggingWidget.configure(state=tk.NORMAL) 646 | loggingWidget.delete(0.0, tk.END) 647 | loggingWidget.configure(state=tk.DISABLED) 648 | 649 | logMsgBackup = logMsgBackup[-20:] 650 | logInfo = "" 651 | for tmp in logMsgBackup: 652 | logInfo += tmp 653 | 654 | loggingWidget.configure(state=tk.NORMAL) 655 | loggingWidget.insert(tk.END, logInfo) 656 | loggingWidget.see(tk.END) 657 | loggingWidget.configure(state=tk.DISABLED) 658 | 659 | 660 | def getTipsAndUrlList(): 661 | global isIPv4Supported 662 | global isIPv6Supported 663 | global tipsTitle 664 | 665 | addrs = socket.getaddrinfo(socket.gethostname(), None) 666 | 667 | IPv4IPstr = "" 668 | IPv6IPstr = "" 669 | IPv4FtpUrlList = [] 670 | IPv6FtpUrlList = [] 671 | ipStrSet = set() # 少数用户存在多个相同IP的情况,避免重复添加 672 | 673 | for item in addrs: 674 | ipStr = str(item[4][0]) 675 | 676 | if ipStr in ipStrSet: 677 | continue 678 | ipStrSet.add(ipStr) 679 | 680 | if (settings.IPv6Port > 0) and (":" in ipStr): # IPv6 681 | fullUrl = f"ftp://[{ipStr}]" + ( 682 | "" if settings.IPv6Port == 21 else (f":{settings.IPv6Port}") 683 | ) 684 | IPv6FtpUrlList.append(fullUrl) 685 | if ipStr.startswith(("fe8", "fe9", "fea", "feb", "fd")): 686 | IPv6IPstr += f"\n[IPv6 局域网] {fullUrl}" 687 | elif ipStr[:4] == "240e": 688 | IPv6IPstr += f"\n[IPv6 电信公网] {fullUrl}" 689 | elif ipStr[:4] == "2408": 690 | IPv6IPstr += f"\n[IPv6 联通公网] {fullUrl}" 691 | elif ipStr[:4] == "2409": 692 | IPv6IPstr += f"\n[IPv6 移动铁通公网] {fullUrl}" 693 | else: 694 | IPv6IPstr += f"\n[IPv6 公网] {fullUrl}" 695 | elif (settings.IPv4Port > 0) and ("." in ipStr): # IPv4 696 | fullUrl = f"ftp://{ipStr}" + ( 697 | "" if settings.IPv4Port == 21 else (f":{settings.IPv4Port}") 698 | ) 699 | IPv4FtpUrlList.append(fullUrl) 700 | if is_internal_ip(ipStr): 701 | IPv4IPstr += f"\n[IPv4 局域网] {fullUrl}" 702 | elif ipStr.startswith("198.18."): 703 | IPv4IPstr += f"\n[IPv4 TUN代理] {fullUrl}" 704 | else: 705 | IPv4IPstr += f"\n[IPv4 公网] {fullUrl}" 706 | 707 | isIPv4Supported = len(IPv4FtpUrlList) > 0 708 | isIPv6Supported = len(IPv6FtpUrlList) > 0 709 | 710 | ftpUrlList = IPv4FtpUrlList + IPv6FtpUrlList 711 | tipsStr = tipsTitle + IPv4IPstr + IPv6IPstr 712 | return tipsStr, ftpUrlList 713 | 714 | 715 | def main(): 716 | global ScaleFactor 717 | global iconImage 718 | global uiFont 719 | global settings 720 | global userList 721 | global window 722 | global loggingWidget 723 | global logThread 724 | global tipsTextWidget 725 | global tipsTextWidgetRightClickMenu 726 | global directoryCombobox 727 | global userNameEntry 728 | global userPasswordEntry 729 | 730 | global userNameVar 731 | global userPasswordVar 732 | global IPv4PortVar 733 | global IPv6PortVar 734 | global isReadOnlyVar 735 | global isGBKVar 736 | global isAutoStartServerVar 737 | 738 | # 告诉操作系统使用程序自身的dpi适配 739 | ctypes.windll.shcore.SetProcessDpiAwareness(2) 740 | 741 | mystd = myStdout() # 实例化重定向类 742 | logThread = threading.Thread(target=logThreadFun) 743 | logThread.start() 744 | 745 | window = tk.Tk() # 实例化tk对象 746 | ScaleFactor = window.tk.call("tk", "scaling") * 75 747 | uiFont = font.Font( 748 | family="Consolas", size=font.nametofont("TkTextFont").cget("size") 749 | ) 750 | style = ttk.Style(window) 751 | style.configure("TButton", width=-5, padding=(scale(8), scale(2))) 752 | style.configure("TEntry", padding=(scale(2), scale(3))) 753 | style.configure("TCombobox", padding=(scale(2), scale(3))) 754 | window.geometry(f"{scale(600)}x{scale(500)}") 755 | window.minsize(scale(600), scale(500)) 756 | 757 | ftpIcon = myUtils.iconObj() # 创建主窗口后才能初始化图标 758 | 759 | window.title(windowsTitle) 760 | iconImage = ftpIcon.iconImageTk 761 | window.iconphoto(False, iconImage) # type: ignore 762 | window.protocol("WM_DELETE_WINDOW", hideWindow) 763 | 764 | strayMenu = ( 765 | pystray.MenuItem("显示", showWindow, default=True), 766 | pystray.MenuItem("退出", handleExit), 767 | ) 768 | strayIcon = pystray.Icon("icon", ftpIcon.strayIconImage, windowsTitle, strayMenu) 769 | threading.Thread(target=strayIcon.run, daemon=True).start() 770 | 771 | ttk.Sizegrip(window).place(relx=1, rely=1, anchor=tk.SE) 772 | 773 | frame1 = ttk.Frame(window) 774 | frame1.pack(fill=tk.X, padx=scale(10), pady=(scale(10), scale(5))) 775 | 776 | startButton = ttk.Button(frame1, text="开启", command=startServer) 777 | startButton.pack(side=tk.LEFT, padx=(0, scale(10))) 778 | ttk.Button(frame1, text="关闭", command=closeServer).pack( 779 | side=tk.LEFT, padx=(0, scale(10)) 780 | ) 781 | 782 | ttk.Button(frame1, text="选择目录", command=pickDirectory).pack( 783 | side=tk.LEFT, padx=(0, scale(10)) 784 | ) 785 | 786 | directoryCombobox = ttk.Combobox(frame1, width=0) 787 | directoryCombobox.pack(side=tk.LEFT, fill=tk.X, expand=True) 788 | 789 | ttk.Button(frame1, text="X", command=deleteCurrentComboboxItem, width=0).pack( 790 | side=tk.LEFT, padx=(0, scale(10)) 791 | ) 792 | 793 | ttk.Button(frame1, text="帮助", command=showHelp, width=-4).pack( 794 | side=tk.LEFT, padx=(0, scale(5)) 795 | ) 796 | 797 | ttk.Button(frame1, text="关于", command=showAbout, width=-4).pack(side=tk.LEFT) 798 | 799 | frame2 = ttk.Frame(window) 800 | frame2.pack(fill=tk.X, padx=scale(10), pady=(0, scale(10))) 801 | 802 | userFrame = ttk.Frame(frame2) 803 | userFrame.pack(side=tk.LEFT, padx=(0, scale(10)), fill=tk.Y) 804 | 805 | ttk.Label(userFrame, text="用户").grid( 806 | row=0, column=0, pady=(0, scale(5)), padx=(0, scale(5)) 807 | ) 808 | userNameVar = tk.StringVar() 809 | userNameEntry = ttk.Entry(userFrame, textvariable=userNameVar, width=20) 810 | userNameEntry.grid(row=0, column=1, sticky=tk.EW, pady=(0, scale(5))) 811 | 812 | ttk.Label(userFrame, text="密码").grid(row=1, column=0, padx=(0, scale(5))) 813 | userPasswordVar = tk.StringVar() 814 | userPasswordEntry = ttk.Entry( 815 | userFrame, textvariable=userPasswordVar, width=20, show="*" 816 | ) 817 | userPasswordEntry.grid(row=1, column=1, sticky=tk.EW) 818 | 819 | portFrame = ttk.Frame(frame2) 820 | portFrame.pack(side=tk.LEFT, padx=(0, scale(10)), fill=tk.Y) 821 | 822 | ttk.Label(portFrame, text="IPv4端口").grid( 823 | row=0, column=0, pady=(0, scale(5)), padx=(0, scale(5)) 824 | ) 825 | IPv4PortVar = tk.StringVar() 826 | ttk.Entry(portFrame, textvariable=IPv4PortVar, width=6).grid( 827 | row=0, column=1, pady=(0, scale(5)) 828 | ) 829 | 830 | ttk.Label(portFrame, text="IPv6端口").grid(row=1, column=0, padx=(0, scale(5))) 831 | IPv6PortVar = tk.StringVar() 832 | ttk.Entry(portFrame, textvariable=IPv6PortVar, width=6).grid(row=1, column=1) 833 | 834 | encodingFrame = ttk.Frame(frame2) 835 | encodingFrame.pack(side=tk.LEFT, padx=(0, scale(10)), fill=tk.Y) 836 | encodingFrame.grid_rowconfigure((0, 1), weight=1) 837 | 838 | isGBKVar = tk.BooleanVar() 839 | ttk.Radiobutton( 840 | encodingFrame, text="UTF-8 编码", variable=isGBKVar, value=False 841 | ).grid(row=0, column=0, sticky=tk.EW, pady=(0, scale(5))) 842 | ttk.Radiobutton(encodingFrame, text="GBK 编码", variable=isGBKVar, value=True).grid( 843 | row=1, column=0, sticky=tk.EW 844 | ) 845 | 846 | permissionFrame = ttk.Frame(frame2) 847 | permissionFrame.pack(side=tk.LEFT, padx=(0, scale(10)), fill=tk.Y) 848 | permissionFrame.grid_rowconfigure((0, 1), weight=1) 849 | 850 | isReadOnlyVar = tk.BooleanVar() 851 | ttk.Radiobutton( 852 | permissionFrame, text="读写", variable=isReadOnlyVar, value=False 853 | ).grid(row=0, column=0, sticky=tk.EW, pady=(0, scale(5))) 854 | ttk.Radiobutton( 855 | permissionFrame, text="只读", variable=isReadOnlyVar, value=True 856 | ).grid(row=1, column=0, sticky=tk.EW) 857 | 858 | isAutoStartServerVar = tk.BooleanVar() 859 | ttk.Checkbutton( 860 | frame2, 861 | text="下次打开软件后自动\n隐藏窗口并启动服务", 862 | variable=isAutoStartServerVar, 863 | onvalue=True, 864 | offvalue=False, 865 | ).pack(side=tk.LEFT) 866 | 867 | tipsTextWidget = scrolledtext.ScrolledText( 868 | window, bg="#dddddd", wrap=tk.CHAR, font=uiFont, height=10, width=0 869 | ) 870 | tipsTextWidget.pack(fill=tk.BOTH, expand=False, padx=scale(10), pady=(0, scale(10))) 871 | 872 | loggingWidget = scrolledtext.ScrolledText( 873 | window, bg="#dddddd", wrap=tk.CHAR, font=uiFont, height=0, width=0 874 | ) 875 | loggingWidget.pack(fill=tk.BOTH, expand=True, padx=scale(10), pady=(0, scale(10))) 876 | loggingWidget.configure(state=tk.DISABLED) 877 | 878 | settings = Settings.Settings() 879 | userList = UserList.UserList() 880 | if not userList.isEmpty(): 881 | userList.print() 882 | userNameEntry.configure(state=tk.DISABLED) 883 | userPasswordEntry.configure(state=tk.DISABLED) 884 | 885 | directoryCombobox["value"] = tuple(settings.directoryList) 886 | directoryCombobox.current(0) 887 | 888 | userNameVar.set(settings.userName) 889 | userPasswordVar.set("******" if len(settings.userPassword) > 0 else "") 890 | IPv4PortVar.set("" if settings.IPv4Port == 0 else str(settings.IPv4Port)) 891 | IPv6PortVar.set("" if settings.IPv6Port == 0 else str(settings.IPv6Port)) 892 | isGBKVar.set(settings.isGBK) 893 | isReadOnlyVar.set(settings.isReadOnly) 894 | isAutoStartServerVar.set(settings.isAutoStartServer) 895 | 896 | tipsStr, ftpUrlList = getTipsAndUrlList() 897 | tipsTextWidget.insert(tk.INSERT, tipsStr) 898 | tipsTextWidget.configure(state=tk.DISABLED) 899 | 900 | tipsTextWidgetRightClickMenu = tk.Menu(window, tearoff=False) 901 | for url in ftpUrlList: 902 | tipsTextWidgetRightClickMenu.add_command( 903 | label=f"复制 {url}", command=lambda url=url: copyToClipboard(url) 904 | ) 905 | 906 | tipsTextWidget.bind( 907 | "", 908 | lambda event: tipsTextWidgetRightClickMenu.post(event.x_root, event.y_root), 909 | ) 910 | 911 | if settings.isAutoStartServer: 912 | startButton.invoke() 913 | window.withdraw() 914 | 915 | if os.path.exists(certFilePath) and os.path.exists(keyFilePath): 916 | print("检测到 SSL 证书文件, 默认使用 FTPS [TLS/SSL显式加密, TLSv1.3]") 917 | 918 | window.mainloop() 919 | 920 | 921 | if __name__ == "__main__": 922 | main() 923 | -------------------------------------------------------------------------------- /mypyftpdlib/authorizers.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2007 Giampaolo Rodola' . 2 | # Use of this source code is governed by MIT license that can be 3 | # found in the LICENSE file. 4 | 5 | """An "authorizer" is a class handling authentications and permissions 6 | of the FTP server. It is used by pyftpdlib.handlers.FTPHandler 7 | class for: 8 | 9 | - verifying user password 10 | - getting user home directory 11 | - checking user permissions when a filesystem read/write event occurs 12 | - changing user when accessing the filesystem 13 | 14 | DummyAuthorizer is the main class which handles virtual users. 15 | 16 | UnixAuthorizer and WindowsAuthorizer are platform specific and 17 | interact with UNIX and Windows password database. 18 | """ 19 | 20 | 21 | import os 22 | import warnings 23 | 24 | 25 | __all__ = [ 26 | 'DummyAuthorizer', 27 | # 'BaseUnixAuthorizer', 'UnixAuthorizer', 28 | # 'BaseWindowsAuthorizer', 'WindowsAuthorizer', 29 | ] 30 | 31 | 32 | # =================================================================== 33 | # --- exceptions 34 | # =================================================================== 35 | 36 | 37 | class AuthorizerError(Exception): 38 | """Base class for authorizer exceptions.""" 39 | 40 | 41 | class AuthenticationFailed(Exception): 42 | """Exception raised when authentication fails for any reason.""" 43 | 44 | 45 | # =================================================================== 46 | # --- base class 47 | # =================================================================== 48 | 49 | 50 | class DummyAuthorizer: 51 | """Basic "dummy" authorizer class, suitable for subclassing to 52 | create your own custom authorizers. 53 | 54 | An "authorizer" is a class handling authentications and permissions 55 | of the FTP server. It is used inside FTPHandler class for verifying 56 | user's password, getting users home directory, checking user 57 | permissions when a file read/write event occurs and changing user 58 | before accessing the filesystem. 59 | 60 | DummyAuthorizer is the base authorizer, providing a platform 61 | independent interface for managing "virtual" FTP users. System 62 | dependent authorizers can by written by subclassing this base 63 | class and overriding appropriate methods as necessary. 64 | """ 65 | 66 | read_perms = "elr" 67 | write_perms = "adfmwMT" 68 | 69 | def __init__(self): 70 | self.user_table = {} 71 | 72 | def add_user( 73 | self, 74 | username, 75 | password, 76 | homedir, 77 | perm='elr', 78 | msg_login="Login successful.", 79 | msg_quit="Goodbye.", 80 | ): 81 | """Add a user to the virtual users table. 82 | 83 | AuthorizerError exceptions raised on error conditions such as 84 | invalid permissions, missing home directory or duplicate usernames. 85 | 86 | Optional perm argument is a string referencing the user's 87 | permissions explained below: 88 | 89 | Read permissions: 90 | - "e" = change directory (CWD command) 91 | - "l" = list files (LIST, NLST, STAT, MLSD, MLST, SIZE, MDTM commands) 92 | - "r" = retrieve file from the server (RETR command) 93 | 94 | Write permissions: 95 | - "a" = append data to an existing file (APPE command) 96 | - "d" = delete file or directory (DELE, RMD commands) 97 | - "f" = rename file or directory (RNFR, RNTO commands) 98 | - "m" = create directory (MKD command) 99 | - "w" = store a file to the server (STOR, STOU commands) 100 | - "M" = change file mode (SITE CHMOD command) 101 | - "T" = update file last modified time (MFMT command) 102 | 103 | Optional msg_login and msg_quit arguments can be specified to 104 | provide customized response strings when user log-in and quit. 105 | """ 106 | if self.has_user(username): 107 | raise ValueError(f'user {username!r} already exists') 108 | if not os.path.isdir(homedir): 109 | raise ValueError(f'no such directory: {homedir!r}') 110 | homedir = os.path.realpath(homedir) 111 | self._check_permissions(username, perm) 112 | dic = { 113 | 'pwd': str(password), 114 | 'home': homedir, 115 | 'perm': perm, 116 | 'operms': {}, 117 | 'msg_login': str(msg_login), 118 | 'msg_quit': str(msg_quit), 119 | } 120 | self.user_table[username] = dic 121 | 122 | def add_anonymous(self, homedir, **kwargs): 123 | """Add an anonymous user to the virtual users table. 124 | 125 | AuthorizerError exception raised on error conditions such as 126 | invalid permissions, missing home directory, or duplicate 127 | anonymous users. 128 | 129 | The keyword arguments in kwargs are the same expected by 130 | add_user method: "perm", "msg_login" and "msg_quit". 131 | 132 | The optional "perm" keyword argument is a string defaulting to 133 | "elr" referencing "read-only" anonymous user's permissions. 134 | 135 | Using write permission values ("adfmwM") results in a 136 | RuntimeWarning. 137 | """ 138 | DummyAuthorizer.add_user(self, 'anonymous', '', homedir, **kwargs) 139 | 140 | def remove_user(self, username): 141 | """Remove a user from the virtual users table.""" 142 | del self.user_table[username] 143 | 144 | def override_perm(self, username, directory, perm, recursive=False): 145 | """Override permissions for a given directory.""" 146 | self._check_permissions(username, perm) 147 | if not os.path.isdir(directory): 148 | raise ValueError(f'no such directory: {directory!r}') 149 | directory = os.path.normcase(os.path.realpath(directory)) 150 | home = os.path.normcase(self.get_home_dir(username)) 151 | if directory == home: 152 | raise ValueError("can't override home directory permissions") 153 | if not self._issubpath(directory, home): 154 | raise ValueError("path escapes user home directory") 155 | self.user_table[username]['operms'][directory] = perm, recursive 156 | 157 | def validate_authentication(self, username, password, handler): 158 | """Raises AuthenticationFailed if supplied username and 159 | password don't match the stored credentials, else return 160 | None. 161 | """ 162 | msg = "Authentication failed." 163 | if not self.has_user(username): 164 | if username == 'anonymous': 165 | msg = "Anonymous access not allowed." 166 | raise AuthenticationFailed(msg) 167 | if username != 'anonymous': 168 | if self.user_table[username]['pwd'] != password: 169 | raise AuthenticationFailed(msg) 170 | 171 | def get_home_dir(self, username): 172 | """Return the user's home directory. 173 | Since this is called during authentication (PASS), 174 | AuthenticationFailed can be freely raised by subclasses in case 175 | the provided username no longer exists. 176 | """ 177 | return self.user_table[username]['home'] 178 | 179 | def impersonate_user(self, username, password): 180 | """Impersonate another user (noop). 181 | 182 | It is always called before accessing the filesystem. 183 | By default it does nothing. The subclass overriding this 184 | method is expected to provide a mechanism to change the 185 | current user. 186 | """ 187 | 188 | def terminate_impersonation(self, username): 189 | """Terminate impersonation (noop). 190 | 191 | It is always called after having accessed the filesystem. 192 | By default it does nothing. The subclass overriding this 193 | method is expected to provide a mechanism to switch back 194 | to the original user. 195 | """ 196 | 197 | def has_user(self, username): 198 | """Whether the username exists in the virtual users table.""" 199 | return username in self.user_table 200 | 201 | def has_perm(self, username, perm, path=None): 202 | """Whether the user has permission over path (an absolute 203 | pathname of a file or a directory). 204 | 205 | Expected perm argument is one of the following letters: 206 | "elradfmwMT". 207 | """ 208 | if path is None: 209 | return perm in self.user_table[username]['perm'] 210 | 211 | path = os.path.normcase(path) 212 | for dir in self.user_table[username]['operms']: 213 | operm, recursive = self.user_table[username]['operms'][dir] 214 | if self._issubpath(path, dir): 215 | if recursive: 216 | return perm in operm 217 | if path == dir or ( 218 | os.path.dirname(path) == dir and not os.path.isdir(path) 219 | ): 220 | return perm in operm 221 | 222 | return perm in self.user_table[username]['perm'] 223 | 224 | def get_perms(self, username): 225 | """Return current user permissions.""" 226 | return self.user_table[username]['perm'] 227 | 228 | def get_msg_login(self, username): 229 | """Return the user's login message.""" 230 | return self.user_table[username]['msg_login'] 231 | 232 | def get_msg_quit(self, username): 233 | """Return the user's quitting message.""" 234 | try: 235 | return self.user_table[username]['msg_quit'] 236 | except KeyError: 237 | return "Goodbye." 238 | 239 | def _check_permissions(self, username, perm): 240 | warned = 0 241 | for p in perm: 242 | if p not in self.read_perms + self.write_perms: 243 | raise ValueError(f'no such permission {p!r}') 244 | if ( 245 | username == 'anonymous' 246 | and p in self.write_perms 247 | and not warned 248 | ): 249 | warned = 1 250 | 251 | def _issubpath(self, a, b): 252 | """Return True if a is a sub-path of b or if the paths are equal.""" 253 | p1 = a.rstrip(os.sep).split(os.sep) 254 | p2 = b.rstrip(os.sep).split(os.sep) 255 | return p1[: len(p2)] == p2 256 | 257 | 258 | def replace_anonymous(callable): 259 | """A decorator to replace anonymous user string passed to authorizer 260 | methods as first argument with the actual user used to handle 261 | anonymous sessions. 262 | """ 263 | 264 | def wrapper(self, username, *args, **kwargs): 265 | if username == 'anonymous': 266 | username = self.anonymous_user or username 267 | return callable(self, username, *args, **kwargs) 268 | 269 | return wrapper 270 | 271 | 272 | # =================================================================== 273 | # --- platform specific authorizers 274 | # =================================================================== 275 | 276 | 277 | class _Base: 278 | """Methods common to both Unix and Windows authorizers. 279 | Not supposed to be used directly. 280 | """ 281 | 282 | msg_no_such_user = "Authentication failed." 283 | msg_wrong_password = "Authentication failed." 284 | msg_anon_not_allowed = "Anonymous access not allowed." 285 | msg_invalid_shell = "User %s doesn't have a valid shell." 286 | msg_rejected_user = "User %s is not allowed to login." 287 | 288 | def __init__(self): 289 | """Check for errors in the constructor.""" 290 | if self.rejected_users and self.allowed_users: 291 | raise AuthorizerError( 292 | "rejected_users and allowed_users options " 293 | "are mutually exclusive" 294 | ) 295 | 296 | users = self._get_system_users() 297 | for user in self.allowed_users or self.rejected_users: 298 | if user == 'anonymous': 299 | raise AuthorizerError('invalid username "anonymous"') 300 | if user not in users: 301 | raise AuthorizerError(f'unknown user {user}') 302 | 303 | if self.anonymous_user is not None: 304 | if not self.has_user(self.anonymous_user): 305 | raise AuthorizerError(f'no such user {self.anonymous_user}') 306 | home = self.get_home_dir(self.anonymous_user) 307 | if not os.path.isdir(home): 308 | raise AuthorizerError( 309 | f'no valid home set for user {self.anonymous_user}' 310 | ) 311 | 312 | def override_user( 313 | self, 314 | username, 315 | password=None, 316 | homedir=None, 317 | perm=None, 318 | msg_login=None, 319 | msg_quit=None, 320 | ): 321 | """Overrides the options specified in the class constructor 322 | for a specific user. 323 | """ 324 | if ( 325 | not password 326 | and not homedir 327 | and not perm 328 | and not msg_login 329 | and not msg_quit 330 | ): 331 | raise AuthorizerError( 332 | "at least one keyword argument must be specified" 333 | ) 334 | if self.allowed_users and username not in self.allowed_users: 335 | raise AuthorizerError(f'{username} is not an allowed user') 336 | if self.rejected_users and username in self.rejected_users: 337 | raise AuthorizerError(f'{username} is not an allowed user') 338 | if username == "anonymous" and password: 339 | raise AuthorizerError("can't assign password to anonymous user") 340 | if not self.has_user(username): 341 | raise AuthorizerError(f'no such user {username}') 342 | 343 | if username in self._dummy_authorizer.user_table: 344 | # re-set parameters 345 | del self._dummy_authorizer.user_table[username] 346 | self._dummy_authorizer.add_user( 347 | username, 348 | password or "", 349 | homedir or os.getcwd(), 350 | perm or "", 351 | msg_login or "", 352 | msg_quit or "", 353 | ) 354 | if homedir is None: 355 | self._dummy_authorizer.user_table[username]['home'] = "" 356 | 357 | def get_msg_login(self, username): 358 | return self._get_key(username, 'msg_login') or self.msg_login 359 | 360 | def get_msg_quit(self, username): 361 | return self._get_key(username, 'msg_quit') or self.msg_quit 362 | 363 | def get_perms(self, username): 364 | overridden_perms = self._get_key(username, 'perm') 365 | if overridden_perms: 366 | return overridden_perms 367 | if username == 'anonymous': 368 | return 'elr' 369 | return self.global_perm 370 | 371 | def has_perm(self, username, perm, path=None): 372 | return perm in self.get_perms(username) 373 | 374 | def _get_key(self, username, key): 375 | if self._dummy_authorizer.has_user(username): 376 | return self._dummy_authorizer.user_table[username][key] 377 | 378 | def _is_rejected_user(self, username): 379 | """Return True if the user has been black listed via 380 | allowed_users or rejected_users options. 381 | """ 382 | if self.allowed_users and username not in self.allowed_users: 383 | return True 384 | return bool(self.rejected_users and username in self.rejected_users) 385 | 386 | 387 | # =================================================================== 388 | # --- UNIX 389 | # =================================================================== 390 | 391 | try: 392 | with warnings.catch_warnings(): 393 | warnings.simplefilter("ignore") 394 | import crypt 395 | import pwd 396 | import spwd 397 | except ImportError: 398 | pass 399 | else: 400 | __all__ += ['BaseUnixAuthorizer', 'UnixAuthorizer'] 401 | 402 | # the uid/gid the server runs under 403 | PROCESS_UID = os.getuid() 404 | PROCESS_GID = os.getgid() 405 | 406 | class BaseUnixAuthorizer: 407 | """An authorizer compatible with Unix user account and password 408 | database. 409 | This class should not be used directly unless for subclassing. 410 | Use higher-level UnixAuthorizer class instead. 411 | """ 412 | 413 | def __init__(self, anonymous_user=None): 414 | if os.geteuid() != 0 or not spwd.getspall(): 415 | raise AuthorizerError("super user privileges are required") 416 | self.anonymous_user = anonymous_user 417 | 418 | if self.anonymous_user is not None: 419 | try: 420 | pwd.getpwnam(self.anonymous_user).pw_dir # noqa 421 | except KeyError: 422 | raise AuthorizerError(f'no such user {anonymous_user}') 423 | 424 | # --- overridden / private API 425 | 426 | def validate_authentication(self, username, password, handler): 427 | """Authenticates against shadow password db; raises 428 | AuthenticationFailed in case of failed authentication. 429 | """ 430 | if username == "anonymous": 431 | if self.anonymous_user is None: 432 | raise AuthenticationFailed(self.msg_anon_not_allowed) 433 | else: 434 | try: 435 | pw1 = spwd.getspnam(username).sp_pwd 436 | pw2 = crypt.crypt(password, pw1) 437 | except KeyError: # no such username 438 | raise AuthenticationFailed(self.msg_no_such_user) 439 | else: 440 | if pw1 != pw2: 441 | raise AuthenticationFailed(self.msg_wrong_password) 442 | 443 | @replace_anonymous 444 | def impersonate_user(self, username, password): 445 | """Change process effective user/group ids to reflect 446 | logged in user. 447 | """ 448 | try: 449 | pwdstruct = pwd.getpwnam(username) 450 | except KeyError: 451 | raise AuthorizerError(self.msg_no_such_user) 452 | else: 453 | os.setegid(pwdstruct.pw_gid) 454 | os.seteuid(pwdstruct.pw_uid) 455 | 456 | def terminate_impersonation(self, username): 457 | """Revert process effective user/group IDs.""" 458 | os.setegid(PROCESS_GID) 459 | os.seteuid(PROCESS_UID) 460 | 461 | @replace_anonymous 462 | def has_user(self, username): 463 | """Return True if user exists on the Unix system. 464 | If the user has been black listed via allowed_users or 465 | rejected_users options always return False. 466 | """ 467 | return username in self._get_system_users() 468 | 469 | @replace_anonymous 470 | def get_home_dir(self, username): 471 | """Return user home directory.""" 472 | try: 473 | return pwd.getpwnam(username).pw_dir 474 | except KeyError: 475 | raise AuthorizerError(self.msg_no_such_user) 476 | 477 | @staticmethod 478 | def _get_system_users(): 479 | """Return all users defined on the UNIX system.""" 480 | return [entry.pw_name for entry in pwd.getpwall()] 481 | 482 | def get_msg_login(self, username): 483 | return "Login successful." 484 | 485 | def get_msg_quit(self, username): 486 | return "Goodbye." 487 | 488 | def get_perms(self, username): 489 | return "elradfmwMT" 490 | 491 | def has_perm(self, username, perm, path=None): 492 | return perm in self.get_perms(username) 493 | 494 | class UnixAuthorizer(_Base, BaseUnixAuthorizer): 495 | """A wrapper on top of BaseUnixAuthorizer providing options 496 | to specify what users should be allowed to login, per-user 497 | options, etc. 498 | 499 | Example usages: 500 | 501 | >>> from pyftpdlib.authorizers import UnixAuthorizer 502 | >>> # accept all except root 503 | >>> auth = UnixAuthorizer(rejected_users=["root"]) 504 | >>> 505 | >>> # accept some users only 506 | >>> auth = UnixAuthorizer(allowed_users=["matt", "jay"]) 507 | >>> 508 | >>> # accept everybody and don't care if they have not a valid shell 509 | >>> auth = UnixAuthorizer(require_valid_shell=False) 510 | >>> 511 | >>> # set specific options for a user 512 | >>> auth.override_user("matt", password="foo", perm="elr") 513 | """ 514 | 515 | # --- public API 516 | 517 | def __init__( 518 | self, 519 | global_perm="elradfmwMT", 520 | allowed_users=None, 521 | rejected_users=None, 522 | require_valid_shell=True, 523 | anonymous_user=None, 524 | msg_login="Login successful.", 525 | msg_quit="Goodbye.", 526 | ): 527 | """Parameters: 528 | 529 | - (string) global_perm: 530 | a series of letters referencing the users permissions; 531 | defaults to "elradfmwMT" which means full read and write 532 | access for everybody (except anonymous). 533 | 534 | - (list) allowed_users: 535 | a list of users which are accepted for authenticating 536 | against the FTP server; defaults to [] (no restrictions). 537 | 538 | - (list) rejected_users: 539 | a list of users which are not accepted for authenticating 540 | against the FTP server; defaults to [] (no restrictions). 541 | 542 | - (bool) require_valid_shell: 543 | Deny access for those users which do not have a valid shell 544 | binary listed in /etc/shells. 545 | If /etc/shells cannot be found this is a no-op. 546 | Anonymous user is not subject to this option, and is free 547 | to not have a valid shell defined. 548 | Defaults to True (a valid shell is required for login). 549 | 550 | - (string) anonymous_user: 551 | specify it if you intend to provide anonymous access. 552 | The value expected is a string representing the system user 553 | to use for managing anonymous sessions; defaults to None 554 | (anonymous access disabled). 555 | 556 | - (string) msg_login: 557 | the string sent when client logs in. 558 | 559 | - (string) msg_quit: 560 | the string sent when client quits. 561 | """ 562 | BaseUnixAuthorizer.__init__(self, anonymous_user) 563 | if allowed_users is None: 564 | allowed_users = [] 565 | if rejected_users is None: 566 | rejected_users = [] 567 | self.global_perm = global_perm 568 | self.allowed_users = allowed_users 569 | self.rejected_users = rejected_users 570 | self.anonymous_user = anonymous_user 571 | self.require_valid_shell = require_valid_shell 572 | self.msg_login = msg_login 573 | self.msg_quit = msg_quit 574 | 575 | self._dummy_authorizer = DummyAuthorizer() 576 | self._dummy_authorizer._check_permissions('', global_perm) 577 | _Base.__init__(self) 578 | if require_valid_shell: 579 | for username in self.allowed_users: 580 | if not self._has_valid_shell(username): 581 | raise AuthorizerError( 582 | f"user {username} has not a valid shell" 583 | ) 584 | 585 | def override_user( 586 | self, 587 | username, 588 | password=None, 589 | homedir=None, 590 | perm=None, 591 | msg_login=None, 592 | msg_quit=None, 593 | ): 594 | """Overrides the options specified in the class constructor 595 | for a specific user. 596 | """ 597 | if self.require_valid_shell and username != 'anonymous': 598 | if not self._has_valid_shell(username): 599 | raise AuthorizerError(self.msg_invalid_shell % username) 600 | _Base.override_user( 601 | self, username, password, homedir, perm, msg_login, msg_quit 602 | ) 603 | 604 | # --- overridden / private API 605 | 606 | def validate_authentication(self, username, password, handler): 607 | if username == "anonymous": 608 | if self.anonymous_user is None: 609 | raise AuthenticationFailed(self.msg_anon_not_allowed) 610 | return 611 | if self._is_rejected_user(username): 612 | raise AuthenticationFailed(self.msg_rejected_user % username) 613 | overridden_password = self._get_key(username, 'pwd') 614 | if overridden_password: 615 | if overridden_password != password: 616 | raise AuthenticationFailed(self.msg_wrong_password) 617 | else: 618 | BaseUnixAuthorizer.validate_authentication( 619 | self, username, password, handler 620 | ) 621 | if self.require_valid_shell and username != 'anonymous': 622 | if not self._has_valid_shell(username): 623 | raise AuthenticationFailed( 624 | self.msg_invalid_shell % username 625 | ) 626 | 627 | @replace_anonymous 628 | def has_user(self, username): 629 | if self._is_rejected_user(username): 630 | return False 631 | return username in self._get_system_users() 632 | 633 | @replace_anonymous 634 | def get_home_dir(self, username): 635 | overridden_home = self._get_key(username, 'home') 636 | if overridden_home: 637 | return overridden_home 638 | return BaseUnixAuthorizer.get_home_dir(self, username) 639 | 640 | @staticmethod 641 | def _has_valid_shell(username): 642 | """Return True if the user has a valid shell binary listed 643 | in /etc/shells. If /etc/shells can't be found return True. 644 | """ 645 | try: 646 | file = open('/etc/shells') 647 | except FileNotFoundError: 648 | return True 649 | else: 650 | with file: 651 | try: 652 | shell = pwd.getpwnam(username).pw_shell 653 | except KeyError: # invalid user 654 | return False 655 | for line in file: 656 | if line.startswith('#'): 657 | continue 658 | line = line.strip() 659 | if line == shell: 660 | return True 661 | return False 662 | 663 | 664 | # =================================================================== 665 | # --- Windows 666 | # =================================================================== 667 | 668 | # Note: requires pywin32 extension 669 | try: 670 | import pywintypes 671 | import win32api 672 | import win32con 673 | import win32net 674 | import win32security 675 | except ImportError: 676 | pass 677 | else: # pragma: no cover 678 | import winreg 679 | 680 | __all__ += ['BaseWindowsAuthorizer', 'WindowsAuthorizer'] 681 | 682 | class BaseWindowsAuthorizer: 683 | """An authorizer compatible with Windows user account and 684 | password database. 685 | This class should not be used directly unless for subclassing. 686 | Use higher-level WinowsAuthorizer class instead. 687 | """ 688 | 689 | def __init__(self, anonymous_user=None, anonymous_password=None): 690 | # actually try to impersonate the user 691 | self.anonymous_user = anonymous_user 692 | self.anonymous_password = anonymous_password 693 | if self.anonymous_user is not None: 694 | self.impersonate_user( 695 | self.anonymous_user, self.anonymous_password 696 | ) 697 | self.terminate_impersonation(None) 698 | 699 | def validate_authentication(self, username, password, handler): 700 | if username == "anonymous": 701 | if self.anonymous_user is None: 702 | raise AuthenticationFailed(self.msg_anon_not_allowed) 703 | return 704 | try: 705 | win32security.LogonUser( 706 | username, 707 | None, 708 | password, 709 | win32con.LOGON32_LOGON_INTERACTIVE, 710 | win32con.LOGON32_PROVIDER_DEFAULT, 711 | ) 712 | except pywintypes.error: 713 | raise AuthenticationFailed(self.msg_wrong_password) 714 | 715 | @replace_anonymous 716 | def impersonate_user(self, username, password): 717 | """Impersonate the security context of another user.""" 718 | handler = win32security.LogonUser( 719 | username, 720 | None, 721 | password, 722 | win32con.LOGON32_LOGON_INTERACTIVE, 723 | win32con.LOGON32_PROVIDER_DEFAULT, 724 | ) 725 | win32security.ImpersonateLoggedOnUser(handler) 726 | handler.Close() 727 | 728 | def terminate_impersonation(self, username): 729 | """Terminate the impersonation of another user.""" 730 | win32security.RevertToSelf() 731 | 732 | @replace_anonymous 733 | def has_user(self, username): 734 | return username in self._get_system_users() 735 | 736 | @replace_anonymous 737 | def get_home_dir(self, username): 738 | """Return the user's profile directory, the closest thing 739 | to a user home directory we have on Windows. 740 | """ 741 | try: 742 | sid = win32security.ConvertSidToStringSid( 743 | win32security.LookupAccountName(None, username)[0] 744 | ) 745 | except pywintypes.error as err: 746 | raise AuthorizerError(err) 747 | path = r"SOFTWARE\Microsoft\Windows NT" 748 | path += r"\CurrentVersion\ProfileList" + "\\" + sid 749 | try: 750 | key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path) 751 | except OSError: 752 | raise AuthorizerError( 753 | f"No profile directory defined for user {username}" 754 | ) 755 | value = winreg.QueryValueEx(key, "ProfileImagePath")[0] 756 | home = win32api.ExpandEnvironmentStrings(value) 757 | return home 758 | 759 | @classmethod 760 | def _get_system_users(cls): 761 | """Return all users defined on the Windows system.""" 762 | # XXX - Does Windows allow usernames with chars outside of 763 | # ASCII set? In that case we need to convert this to unicode. 764 | return [ 765 | entry['name'] for entry in win32net.NetUserEnum(None, 0)[0] 766 | ] 767 | 768 | def get_msg_login(self, username): 769 | return "Login successful." 770 | 771 | def get_msg_quit(self, username): 772 | return "Goodbye." 773 | 774 | def get_perms(self, username): 775 | return "elradfmwMT" 776 | 777 | def has_perm(self, username, perm, path=None): 778 | return perm in self.get_perms(username) 779 | 780 | class WindowsAuthorizer(_Base, BaseWindowsAuthorizer): 781 | """A wrapper on top of BaseWindowsAuthorizer providing options 782 | to specify what users should be allowed to login, per-user 783 | options, etc. 784 | 785 | Example usages: 786 | 787 | >>> from pyftpdlib.authorizers import WindowsAuthorizer 788 | >>> # accept all except Administrator 789 | >>> auth = WindowsAuthorizer(rejected_users=["Administrator"]) 790 | >>> 791 | >>> # accept some users only 792 | >>> auth = WindowsAuthorizer(allowed_users=["matt", "jay"]) 793 | >>> 794 | >>> # set specific options for a user 795 | >>> auth.override_user("matt", password="foo", perm="elr") 796 | """ 797 | 798 | # --- public API 799 | 800 | def __init__( 801 | self, 802 | global_perm="elradfmwMT", 803 | allowed_users=None, 804 | rejected_users=None, 805 | anonymous_user=None, 806 | anonymous_password=None, 807 | msg_login="Login successful.", 808 | msg_quit="Goodbye.", 809 | ): 810 | """Parameters: 811 | 812 | - (string) global_perm: 813 | a series of letters referencing the users permissions; 814 | defaults to "elradfmwMT" which means full read and write 815 | access for everybody (except anonymous). 816 | 817 | - (list) allowed_users: 818 | a list of users which are accepted for authenticating 819 | against the FTP server; defaults to [] (no restrictions). 820 | 821 | - (list) rejected_users: 822 | a list of users which are not accepted for authenticating 823 | against the FTP server; defaults to [] (no restrictions). 824 | 825 | - (string) anonymous_user: 826 | specify it if you intend to provide anonymous access. 827 | The value expected is a string representing the system user 828 | to use for managing anonymous sessions. 829 | As for IIS, it is recommended to use Guest account. 830 | The common practice is to first enable the Guest user, which 831 | is disabled by default and then assign an empty password. 832 | Defaults to None (anonymous access disabled). 833 | 834 | - (string) anonymous_password: 835 | the password of the user who has been chosen to manage the 836 | anonymous sessions. Defaults to None (empty password). 837 | 838 | - (string) msg_login: 839 | the string sent when client logs in. 840 | 841 | - (string) msg_quit: 842 | the string sent when client quits. 843 | """ 844 | if allowed_users is None: 845 | allowed_users = [] 846 | if rejected_users is None: 847 | rejected_users = [] 848 | self.global_perm = global_perm 849 | self.allowed_users = allowed_users 850 | self.rejected_users = rejected_users 851 | self.anonymous_user = anonymous_user 852 | self.anonymous_password = anonymous_password 853 | self.msg_login = msg_login 854 | self.msg_quit = msg_quit 855 | self._dummy_authorizer = DummyAuthorizer() 856 | self._dummy_authorizer._check_permissions('', global_perm) 857 | _Base.__init__(self) 858 | # actually try to impersonate the user 859 | if self.anonymous_user is not None: 860 | self.impersonate_user( 861 | self.anonymous_user, self.anonymous_password 862 | ) 863 | self.terminate_impersonation(None) 864 | 865 | def override_user( 866 | self, 867 | username, 868 | password=None, 869 | homedir=None, 870 | perm=None, 871 | msg_login=None, 872 | msg_quit=None, 873 | ): 874 | """Overrides the options specified in the class constructor 875 | for a specific user. 876 | """ 877 | _Base.override_user( 878 | self, username, password, homedir, perm, msg_login, msg_quit 879 | ) 880 | 881 | # --- overridden / private API 882 | 883 | def validate_authentication(self, username, password, handler): 884 | """Authenticates against Windows user database; return 885 | True on success. 886 | """ 887 | if username == "anonymous": 888 | if self.anonymous_user is None: 889 | raise AuthenticationFailed(self.msg_anon_not_allowed) 890 | return 891 | if self.allowed_users and username not in self.allowed_users: 892 | raise AuthenticationFailed(self.msg_rejected_user % username) 893 | if self.rejected_users and username in self.rejected_users: 894 | raise AuthenticationFailed(self.msg_rejected_user % username) 895 | 896 | overridden_password = self._get_key(username, 'pwd') 897 | if overridden_password: 898 | if overridden_password != password: 899 | raise AuthenticationFailed(self.msg_wrong_password) 900 | else: 901 | BaseWindowsAuthorizer.validate_authentication( 902 | self, username, password, handler 903 | ) 904 | 905 | def impersonate_user(self, username, password): 906 | """Impersonate the security context of another user.""" 907 | if username == "anonymous": 908 | username = self.anonymous_user or "" 909 | password = self.anonymous_password or "" 910 | BaseWindowsAuthorizer.impersonate_user(self, username, password) 911 | 912 | @replace_anonymous 913 | def has_user(self, username): 914 | if self._is_rejected_user(username): 915 | return False 916 | return username in self._get_system_users() 917 | 918 | @replace_anonymous 919 | def get_home_dir(self, username): 920 | overridden_home = self._get_key(username, 'home') 921 | if overridden_home: 922 | home = overridden_home 923 | else: 924 | home = BaseWindowsAuthorizer.get_home_dir(self, username) 925 | return home 926 | -------------------------------------------------------------------------------- /mypyftpdlib/ioloop.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2007 Giampaolo Rodola' . 2 | # Use of this source code is governed by MIT license that can be 3 | # found in the LICENSE file. 4 | 5 | """ 6 | A specialized IO loop on top of asyncore adding support for epoll() 7 | on Linux and kqueue() and OSX/BSD, dramatically increasing performances 8 | offered by base asyncore module. 9 | 10 | poll() and select() loops are also reimplemented and are an order of 11 | magnitude faster as they support fd un/registration and modification. 12 | 13 | This module is not supposed to be used directly unless you want to 14 | include a new dispatcher which runs within the main FTP server loop, 15 | in which case: 16 | __________________________________________________________________ 17 | | | | 18 | | INSTEAD OF | ...USE: | 19 | |______________________|___________________________________________| 20 | | | | 21 | | asyncore.dispacher | Acceptor (for servers) | 22 | | asyncore.dispacher | Connector (for clients) | 23 | | asynchat.async_chat | AsyncChat (for a full duplex connection ) | 24 | | asyncore.loop | FTPServer.server_forever() | 25 | |______________________|___________________________________________| 26 | 27 | asyncore.dispatcher_with_send is not supported, same for "map" argument 28 | for asyncore.loop and asyncore.dispatcher and asynchat.async_chat 29 | constructors. 30 | 31 | Follows a server example: 32 | 33 | import socket 34 | from pyftpdlib.ioloop import IOLoop, Acceptor, AsyncChat 35 | 36 | class Handler(AsyncChat): 37 | 38 | def __init__(self, sock): 39 | AsyncChat.__init__(self, sock) 40 | self.push('200 hello\r\n') 41 | self.close_when_done() 42 | 43 | class Server(Acceptor): 44 | 45 | def __init__(self, host, port): 46 | Acceptor.__init__(self) 47 | self.create_socket(socket.AF_INET, socket.SOCK_STREAM) 48 | self.set_reuse_addr() 49 | self.bind((host, port)) 50 | self.listen(5) 51 | 52 | def handle_accepted(self, sock, addr): 53 | Handler(sock) 54 | 55 | server = Server('localhost', 2121) 56 | IOLoop.instance().loop() 57 | """ 58 | 59 | import errno 60 | import heapq 61 | import os 62 | import select 63 | import socket 64 | import sys 65 | import threading 66 | import time 67 | import traceback 68 | import warnings 69 | 70 | from .log import config_logging 71 | from .log import debug 72 | from .log import is_logging_configured 73 | from .log import logger 74 | 75 | 76 | with warnings.catch_warnings(): 77 | warnings.simplefilter('ignore', DeprecationWarning) 78 | import asynchat 79 | import asyncore 80 | 81 | timer = getattr(time, 'monotonic', time.time) 82 | _read = asyncore.read 83 | _write = asyncore.write 84 | 85 | # These errnos indicate that a connection has been abruptly terminated. 86 | _ERRNOS_DISCONNECTED = { 87 | errno.ECONNRESET, 88 | errno.ENOTCONN, 89 | errno.ESHUTDOWN, 90 | errno.ECONNABORTED, 91 | errno.EPIPE, 92 | errno.EBADF, 93 | errno.ETIMEDOUT, 94 | } 95 | if hasattr(errno, "WSAECONNRESET"): 96 | _ERRNOS_DISCONNECTED.add(errno.WSAECONNRESET) 97 | if hasattr(errno, "WSAECONNABORTED"): 98 | _ERRNOS_DISCONNECTED.add(errno.WSAECONNABORTED) 99 | 100 | # These errnos indicate that a non-blocking operation must be retried 101 | # at a later time. 102 | _ERRNOS_RETRY = {errno.EAGAIN, errno.EWOULDBLOCK} 103 | if hasattr(errno, "WSAEWOULDBLOCK"): 104 | _ERRNOS_RETRY.add(errno.WSAEWOULDBLOCK) 105 | 106 | 107 | class RetryError(Exception): 108 | pass 109 | 110 | 111 | # =================================================================== 112 | # --- scheduler 113 | # =================================================================== 114 | 115 | 116 | class _Scheduler: 117 | """Run the scheduled functions due to expire soonest (if any).""" 118 | 119 | def __init__(self): 120 | # the heap used for the scheduled tasks 121 | self._tasks = [] 122 | self._cancellations = 0 123 | 124 | def poll(self): 125 | """Run the scheduled functions due to expire soonest and 126 | return the timeout of the next one (if any, else None). 127 | """ 128 | now = timer() 129 | calls = [] 130 | while self._tasks: 131 | if now < self._tasks[0].timeout: 132 | break 133 | call = heapq.heappop(self._tasks) 134 | if call.cancelled: 135 | self._cancellations -= 1 136 | else: 137 | calls.append(call) 138 | 139 | for call in calls: 140 | if call._repush: 141 | heapq.heappush(self._tasks, call) 142 | call._repush = False 143 | continue 144 | try: 145 | call.call() 146 | except Exception: 147 | logger.error(traceback.format_exc()) 148 | 149 | # remove cancelled tasks and re-heapify the queue if the 150 | # number of cancelled tasks is more than the half of the 151 | # entire queue 152 | if self._cancellations > 512 and self._cancellations > ( 153 | len(self._tasks) >> 1 154 | ): 155 | debug(f"re-heapifying {self._cancellations} cancelled tasks") 156 | self.reheapify() 157 | 158 | try: 159 | return max(0, self._tasks[0].timeout - now) 160 | except IndexError: 161 | pass 162 | 163 | def register(self, what): 164 | """Register a _CallLater instance.""" 165 | heapq.heappush(self._tasks, what) 166 | 167 | def unregister(self, what): 168 | """Unregister a _CallLater instance. 169 | The actual unregistration will happen at a later time though. 170 | """ 171 | self._cancellations += 1 172 | 173 | def reheapify(self): 174 | """Get rid of cancelled calls and reinitialize the internal heap.""" 175 | self._cancellations = 0 176 | self._tasks = [x for x in self._tasks if not x.cancelled] 177 | heapq.heapify(self._tasks) 178 | 179 | def close(self): 180 | for x in self._tasks: 181 | try: 182 | if not x.cancelled: 183 | x.cancel() 184 | except Exception: 185 | logger.error(traceback.format_exc()) 186 | del self._tasks[:] 187 | self._cancellations = 0 188 | 189 | 190 | class _CallLater: 191 | """Container object which instance is returned by ioloop.call_later().""" 192 | 193 | __slots__ = ( 194 | '_args', 195 | '_delay', 196 | '_errback', 197 | '_kwargs', 198 | '_repush', 199 | '_sched', 200 | '_target', 201 | 'cancelled', 202 | 'timeout', 203 | ) 204 | 205 | def __init__(self, seconds, target, *args, **kwargs): 206 | assert callable(target), f"{target} is not callable" 207 | assert ( 208 | sys.maxsize >= seconds >= 0 209 | ), f"{seconds} is not greater than or equal to 0 seconds" 210 | self._delay = seconds 211 | self._target = target 212 | self._args = args 213 | self._kwargs = kwargs 214 | self._errback = kwargs.pop('_errback', None) 215 | self._sched = kwargs.pop('_scheduler') 216 | self._repush = False 217 | # seconds from the epoch at which to call the function 218 | if not seconds: 219 | self.timeout = 0 220 | else: 221 | self.timeout = timer() + self._delay 222 | self.cancelled = False 223 | self._sched.register(self) 224 | 225 | def __lt__(self, other): 226 | return self.timeout < other.timeout 227 | 228 | def __le__(self, other): 229 | return self.timeout <= other.timeout 230 | 231 | def __repr__(self): 232 | if self._target is None: 233 | sig = object.__repr__(self) 234 | else: 235 | sig = repr(self._target) 236 | sig += ' args=%s, kwargs=%s, cancelled=%s, secs=%s' % ( # noqa: UP031 237 | self._args or '[]', 238 | self._kwargs or '{}', 239 | self.cancelled, 240 | self._delay, 241 | ) 242 | return f'<{sig}>' 243 | 244 | __str__ = __repr__ 245 | 246 | def _post_call(self, exc): 247 | if not self.cancelled: 248 | self.cancel() 249 | 250 | def call(self): 251 | """Call this scheduled function.""" 252 | assert not self.cancelled, "already cancelled" 253 | exc = None 254 | try: 255 | self._target(*self._args, **self._kwargs) 256 | except Exception as _: 257 | exc = _ 258 | if self._errback is not None: 259 | self._errback() 260 | else: 261 | raise 262 | finally: 263 | self._post_call(exc) 264 | 265 | def reset(self): 266 | """Reschedule this call resetting the current countdown.""" 267 | assert not self.cancelled, "already cancelled" 268 | self.timeout = timer() + self._delay 269 | self._repush = True 270 | 271 | def cancel(self): 272 | """Unschedule this call.""" 273 | if not self.cancelled: 274 | self.cancelled = True 275 | self._target = self._args = self._kwargs = self._errback = None 276 | self._sched.unregister(self) 277 | 278 | 279 | class _CallEvery(_CallLater): 280 | """Container object which instance is returned by IOLoop.call_every().""" 281 | 282 | def _post_call(self, exc): 283 | if not self.cancelled: 284 | if exc: 285 | self.cancel() 286 | else: 287 | self.timeout = timer() + self._delay 288 | self._sched.register(self) 289 | 290 | 291 | class _IOLoop: 292 | """Base class which will later be referred as IOLoop.""" 293 | 294 | READ = 1 295 | WRITE = 2 296 | _instance = None 297 | _lock = threading.Lock() 298 | _started_once = False 299 | 300 | def __init__(self): 301 | self.socket_map = {} 302 | self.sched = _Scheduler() 303 | 304 | def __enter__(self): 305 | return self 306 | 307 | def __exit__(self, *args): 308 | self.close() 309 | 310 | def __repr__(self): 311 | status = [self.__class__.__module__ + "." + self.__class__.__name__] 312 | status.append( 313 | f"(fds={len(self.socket_map)}, tasks={len(self.sched._tasks)})" 314 | ) 315 | return '<%s at %#x>' % (' '.join(status), id(self)) # noqa: UP031 316 | 317 | __str__ = __repr__ 318 | 319 | @classmethod 320 | def instance(cls): 321 | """Return a global IOLoop instance.""" 322 | if cls._instance is None: 323 | with cls._lock: 324 | if cls._instance is None: 325 | cls._instance = cls() 326 | return cls._instance 327 | 328 | @classmethod 329 | def factory(cls): 330 | """Constructs a new IOLoop instance.""" 331 | return cls() 332 | 333 | def register(self, fd, instance, events): 334 | """Register a fd, handled by instance for the given events.""" 335 | raise NotImplementedError('must be implemented in subclass') 336 | 337 | def unregister(self, fd): 338 | """Register fd.""" 339 | raise NotImplementedError('must be implemented in subclass') 340 | 341 | def modify(self, fd, events): 342 | """Changes the events assigned for fd.""" 343 | raise NotImplementedError('must be implemented in subclass') 344 | 345 | def poll(self, timeout): 346 | """Poll once. The subclass overriding this method is supposed 347 | to poll over the registered handlers and the scheduled functions 348 | and then return. 349 | """ 350 | raise NotImplementedError('must be implemented in subclass') 351 | 352 | def loop(self, timeout=None, blocking=True): 353 | """Start the asynchronous IO loop. 354 | 355 | - (float) timeout: the timeout passed to the underlying 356 | multiplex syscall (select(), epoll() etc.). 357 | 358 | - (bool) blocking: if True poll repeatedly, as long as there 359 | are registered handlers and/or scheduled functions. 360 | If False poll only once and return the timeout of the next 361 | scheduled call (if any, else None). 362 | """ 363 | if not _IOLoop._started_once: 364 | _IOLoop._started_once = True 365 | if not is_logging_configured(): 366 | # If we get to this point it means the user hasn't 367 | # configured logging. We want to log by default so 368 | # we configure logging ourselves so that it will 369 | # print to stderr. 370 | config_logging() 371 | 372 | if blocking: 373 | # localize variable access to minimize overhead 374 | poll = self.poll 375 | socket_map = self.socket_map 376 | sched_poll = self.sched.poll 377 | 378 | if timeout is not None: 379 | while socket_map: 380 | poll(timeout) 381 | sched_poll() 382 | else: 383 | soonest_timeout = None 384 | while socket_map: 385 | poll(soonest_timeout) 386 | soonest_timeout = sched_poll() 387 | else: 388 | sched = self.sched 389 | if self.socket_map: 390 | self.poll(timeout) 391 | if sched._tasks: 392 | return sched.poll() 393 | 394 | def call_later(self, seconds, target, *args, **kwargs): 395 | """Calls a function at a later time. 396 | It can be used to asynchronously schedule a call within the polling 397 | loop without blocking it. The instance returned is an object that 398 | can be used to cancel or reschedule the call. 399 | 400 | - (int) seconds: the number of seconds to wait 401 | - (obj) target: the callable object to call later 402 | - args: the arguments to call it with 403 | - kwargs: the keyword arguments to call it with; a special 404 | '_errback' parameter can be passed: it is a callable 405 | called in case target function raises an exception. 406 | """ 407 | kwargs['_scheduler'] = self.sched 408 | return _CallLater(seconds, target, *args, **kwargs) 409 | 410 | def call_every(self, seconds, target, *args, **kwargs): 411 | """Schedules the given callback to be called periodically.""" 412 | kwargs['_scheduler'] = self.sched 413 | return _CallEvery(seconds, target, *args, **kwargs) 414 | 415 | def close(self): 416 | """Closes the IOLoop, freeing any resources used.""" 417 | debug("closing IOLoop", self) 418 | self.__class__._instance = None 419 | 420 | # free connections 421 | instances = sorted(self.socket_map.values(), key=lambda x: x._fileno) 422 | for inst in instances: 423 | try: 424 | inst.close() 425 | except OSError as err: 426 | if err.errno != errno.EBADF: 427 | logger.error(traceback.format_exc()) 428 | except Exception: 429 | logger.error(traceback.format_exc()) 430 | self.socket_map.clear() 431 | 432 | # free scheduled functions 433 | self.sched.close() 434 | 435 | 436 | # =================================================================== 437 | # --- select() - POSIX / Windows 438 | # =================================================================== 439 | 440 | 441 | class Select(_IOLoop): 442 | """select()-based poller.""" 443 | 444 | def __init__(self): 445 | _IOLoop.__init__(self) 446 | self._r = [] 447 | self._w = [] 448 | 449 | def register(self, fd, instance, events): 450 | if fd not in self.socket_map: 451 | self.socket_map[fd] = instance 452 | if events & self.READ: 453 | self._r.append(fd) 454 | if events & self.WRITE: 455 | self._w.append(fd) 456 | 457 | def unregister(self, fd): 458 | try: 459 | del self.socket_map[fd] 460 | except KeyError: 461 | debug("call: unregister(); fd was no longer in socket_map", self) 462 | for ls in (self._r, self._w): 463 | try: 464 | ls.remove(fd) 465 | except ValueError: 466 | pass 467 | 468 | def modify(self, fd, events): 469 | inst = self.socket_map.get(fd) 470 | if inst is not None: 471 | self.unregister(fd) 472 | self.register(fd, inst, events) 473 | else: 474 | debug("call: modify(); fd was no longer in socket_map", self) 475 | 476 | def poll(self, timeout): 477 | try: 478 | r, w, _ = select.select(self._r, self._w, [], timeout) 479 | except InterruptedError: 480 | return 481 | 482 | smap_get = self.socket_map.get 483 | for fd in r: 484 | obj = smap_get(fd) 485 | if obj is None or not obj.readable(): 486 | continue 487 | _read(obj) 488 | for fd in w: 489 | obj = smap_get(fd) 490 | if obj is None or not obj.writable(): 491 | continue 492 | _write(obj) 493 | 494 | 495 | # =================================================================== 496 | # --- poll() / epoll() 497 | # =================================================================== 498 | 499 | 500 | class _BasePollEpoll(_IOLoop): 501 | """This is common to both poll() (UNIX), epoll() (Linux) and 502 | /dev/poll (Solaris) implementations which share almost the same 503 | interface. 504 | Not supposed to be used directly. 505 | """ 506 | 507 | def __init__(self): 508 | _IOLoop.__init__(self) 509 | self._poller = self._poller() 510 | 511 | def register(self, fd, instance, events): 512 | try: 513 | self._poller.register(fd, events) 514 | except FileExistsError: 515 | debug("call: register(); poller raised EEXIST; ignored", self) 516 | self.socket_map[fd] = instance 517 | 518 | def unregister(self, fd): 519 | try: 520 | del self.socket_map[fd] 521 | except KeyError: 522 | debug("call: unregister(); fd was no longer in socket_map", self) 523 | else: 524 | try: 525 | self._poller.unregister(fd) 526 | except OSError as err: 527 | if err.errno in (errno.ENOENT, errno.EBADF): 528 | debug( 529 | f"call: unregister(); poller returned {err!r};" 530 | " ignoring it", 531 | self, 532 | ) 533 | else: 534 | raise 535 | 536 | def modify(self, fd, events): 537 | try: 538 | self._poller.modify(fd, events) 539 | except FileNotFoundError: 540 | if fd in self.socket_map: 541 | # XXX - see: 542 | # https://github.com/giampaolo/pyftpdlib/issues/329 543 | instance = self.socket_map[fd] 544 | self.register(fd, instance, events) 545 | else: 546 | raise 547 | 548 | def poll(self, timeout): 549 | if timeout is None: 550 | timeout = -1 # -1 waits indefinitely 551 | try: 552 | events = self._poller.poll(timeout) 553 | except InterruptedError: 554 | return 555 | # localize variable access to minimize overhead 556 | smap_get = self.socket_map.get 557 | for fd, event in events: 558 | inst = smap_get(fd) 559 | if inst is None: 560 | continue 561 | if event & self._ERROR and not event & self.READ: 562 | inst.handle_close() 563 | else: 564 | if event & self.READ and inst.readable(): 565 | _read(inst) 566 | if event & self.WRITE and inst.writable(): 567 | _write(inst) 568 | 569 | 570 | # =================================================================== 571 | # --- poll() - POSIX 572 | # =================================================================== 573 | 574 | if hasattr(select, 'poll'): 575 | 576 | class Poll(_BasePollEpoll): 577 | """poll() based poller.""" 578 | 579 | READ = select.POLLIN 580 | WRITE = select.POLLOUT 581 | _ERROR = select.POLLERR | select.POLLHUP | select.POLLNVAL 582 | _poller = select.poll 583 | 584 | def modify(self, fd, events): 585 | inst = self.socket_map[fd] 586 | self.unregister(fd) 587 | self.register(fd, inst, events) 588 | 589 | def poll(self, timeout): 590 | # poll() timeout is expressed in milliseconds 591 | if timeout is not None: 592 | timeout = int(timeout * 1000) 593 | _BasePollEpoll.poll(self, timeout) 594 | 595 | 596 | # =================================================================== 597 | # --- /dev/poll - Solaris (introduced in python 3.3) 598 | # =================================================================== 599 | 600 | if hasattr(select, 'devpoll'): # pragma: no cover 601 | 602 | class DevPoll(_BasePollEpoll): 603 | """/dev/poll based poller (introduced in python 3.3).""" 604 | 605 | READ = select.POLLIN 606 | WRITE = select.POLLOUT 607 | _ERROR = select.POLLERR | select.POLLHUP | select.POLLNVAL 608 | _poller = select.devpoll 609 | 610 | # introduced in python 3.4 611 | if hasattr(select.devpoll, 'fileno'): 612 | 613 | def fileno(self): 614 | """Return devpoll() fd.""" 615 | return self._poller.fileno() 616 | 617 | def modify(self, fd, events): 618 | inst = self.socket_map[fd] 619 | self.unregister(fd) 620 | self.register(fd, inst, events) 621 | 622 | def poll(self, timeout): 623 | # /dev/poll timeout is expressed in milliseconds 624 | if timeout is not None: 625 | timeout = int(timeout * 1000) 626 | _BasePollEpoll.poll(self, timeout) 627 | 628 | # introduced in python 3.4 629 | if hasattr(select.devpoll, 'close'): 630 | 631 | def close(self): 632 | _IOLoop.close(self) 633 | self._poller.close() 634 | 635 | 636 | # =================================================================== 637 | # --- epoll() - Linux 638 | # =================================================================== 639 | 640 | if hasattr(select, 'epoll'): 641 | 642 | class Epoll(_BasePollEpoll): 643 | """epoll() based poller.""" 644 | 645 | READ = select.EPOLLIN 646 | WRITE = select.EPOLLOUT 647 | _ERROR = select.EPOLLERR | select.EPOLLHUP 648 | _poller = select.epoll 649 | 650 | def fileno(self): 651 | """Return epoll() fd.""" 652 | return self._poller.fileno() 653 | 654 | def close(self): 655 | _IOLoop.close(self) 656 | self._poller.close() 657 | 658 | 659 | # =================================================================== 660 | # --- kqueue() - BSD / OSX 661 | # =================================================================== 662 | 663 | if hasattr(select, 'kqueue'): # pragma: no cover 664 | 665 | class Kqueue(_IOLoop): 666 | """kqueue() based poller.""" 667 | 668 | def __init__(self): 669 | _IOLoop.__init__(self) 670 | self._kqueue = select.kqueue() 671 | self._active = {} 672 | 673 | def fileno(self): 674 | """Return kqueue() fd.""" 675 | return self._kqueue.fileno() 676 | 677 | def close(self): 678 | _IOLoop.close(self) 679 | self._kqueue.close() 680 | 681 | def register(self, fd, instance, events): 682 | self.socket_map[fd] = instance 683 | try: 684 | self._control(fd, events, select.KQ_EV_ADD) 685 | except FileExistsError: 686 | debug("call: register(); poller raised EEXIST; ignored", self) 687 | self._active[fd] = events 688 | 689 | def unregister(self, fd): 690 | try: 691 | del self.socket_map[fd] 692 | events = self._active.pop(fd) 693 | except KeyError: 694 | pass 695 | else: 696 | try: 697 | self._control(fd, events, select.KQ_EV_DELETE) 698 | except OSError as err: 699 | if err.errno in (errno.ENOENT, errno.EBADF): 700 | debug( 701 | f"call: unregister(); poller returned {err!r};" 702 | " ignoring it", 703 | self, 704 | ) 705 | else: 706 | raise 707 | 708 | def modify(self, fd, events): 709 | instance = self.socket_map[fd] 710 | self.unregister(fd) 711 | self.register(fd, instance, events) 712 | 713 | def _control(self, fd, events, flags): 714 | kevents = [] 715 | if events & self.WRITE: 716 | kevents.append( 717 | select.kevent( 718 | fd, filter=select.KQ_FILTER_WRITE, flags=flags 719 | ) 720 | ) 721 | if events & self.READ or not kevents: 722 | # always read when there is not a write 723 | kevents.append( 724 | select.kevent( 725 | fd, filter=select.KQ_FILTER_READ, flags=flags 726 | ) 727 | ) 728 | # even though control() takes a list, it seems to return 729 | # EINVAL on Mac OS X (10.6) when there is more than one 730 | # event in the list 731 | for kevent in kevents: 732 | self._kqueue.control([kevent], 0) 733 | 734 | # localize variable access to minimize overhead 735 | def poll( 736 | self, 737 | timeout, 738 | _len=len, 739 | _READ=select.KQ_FILTER_READ, 740 | _WRITE=select.KQ_FILTER_WRITE, 741 | _EOF=select.KQ_EV_EOF, 742 | _ERROR=select.KQ_EV_ERROR, 743 | ): 744 | try: 745 | kevents = self._kqueue.control( 746 | None, _len(self.socket_map), timeout 747 | ) 748 | except InterruptedError: 749 | return 750 | for kevent in kevents: 751 | inst = self.socket_map.get(kevent.ident) 752 | if inst is None: 753 | continue 754 | if kevent.filter == _READ and inst.readable(): 755 | _read(inst) 756 | if kevent.filter == _WRITE: 757 | if kevent.flags & _EOF: 758 | # If an asynchronous connection is refused, 759 | # kqueue returns a write event with the EOF 760 | # flag set. 761 | # Note that for read events, EOF may be returned 762 | # before all data has been consumed from the 763 | # socket buffer, so we only check for EOF on 764 | # write events. 765 | inst.handle_close() 766 | elif inst.writable(): 767 | _write(inst) 768 | if kevent.flags & _ERROR: 769 | inst.handle_close() 770 | 771 | 772 | # =================================================================== 773 | # --- choose the better poller for this platform 774 | # =================================================================== 775 | 776 | if hasattr(select, 'epoll'): # epoll() - Linux 777 | IOLoop = Epoll 778 | elif hasattr(select, 'kqueue'): # kqueue() - BSD / OSX 779 | IOLoop = Kqueue 780 | elif hasattr(select, 'devpoll'): # /dev/poll - Solaris 781 | IOLoop = DevPoll 782 | elif hasattr(select, 'poll'): # poll() - POSIX 783 | IOLoop = Poll 784 | else: # select() - POSIX and Windows 785 | IOLoop = Select 786 | 787 | 788 | # =================================================================== 789 | # --- asyncore dispatchers 790 | # =================================================================== 791 | 792 | # these are overridden in order to register() and unregister() 793 | # file descriptors against the new pollers 794 | 795 | 796 | class AsyncChat(asynchat.async_chat): 797 | """Same as asynchat.async_chat, only working with the new IO poller 798 | and being more clever in avoid registering for read events when 799 | it shouldn't. 800 | """ 801 | 802 | def __init__(self, sock=None, ioloop=None): 803 | self.ioloop = ioloop or IOLoop.instance() 804 | self._wanted_io_events = self.ioloop.READ 805 | self._current_io_events = self.ioloop.READ 806 | self._closed = False 807 | self._closing = False 808 | self._fileno = sock.fileno() if sock else None 809 | self._tasks = [] 810 | asynchat.async_chat.__init__(self, sock) 811 | 812 | # --- IO loop related methods 813 | 814 | def add_channel(self, map=None, events=None): 815 | assert self._fileno, repr(self._fileno) 816 | events = events if events is not None else self.ioloop.READ 817 | self.ioloop.register(self._fileno, self, events) 818 | self._wanted_io_events = events 819 | self._current_io_events = events 820 | 821 | def del_channel(self, map=None): 822 | if self._fileno is not None: 823 | self.ioloop.unregister(self._fileno) 824 | 825 | def modify_ioloop_events(self, events, logdebug=False): 826 | if not self._closed: 827 | assert self._fileno, repr(self._fileno) 828 | if self._fileno not in self.ioloop.socket_map: 829 | debug( 830 | "call: modify_ioloop_events(), fd was no longer in " 831 | "socket_map, had to register() it again", 832 | inst=self, 833 | ) 834 | self.add_channel(events=events) 835 | elif events != self._current_io_events: 836 | if logdebug: 837 | if events == self.ioloop.READ: 838 | ev = "R" 839 | elif events == self.ioloop.WRITE: 840 | ev = "W" 841 | elif events == self.ioloop.READ | self.ioloop.WRITE: 842 | ev = "RW" 843 | else: 844 | ev = events 845 | debug( 846 | f"call: IOLoop.modify(); setting {ev!r} IO events", 847 | self, 848 | ) 849 | self.ioloop.modify(self._fileno, events) 850 | self._current_io_events = events 851 | else: 852 | debug( 853 | "call: modify_ioloop_events(), handler had already been " 854 | "close()d, skipping modify()", 855 | inst=self, 856 | ) 857 | 858 | # --- utils 859 | 860 | def call_later(self, seconds, target, *args, **kwargs): 861 | """Same as self.ioloop.call_later but also cancel()s the 862 | scheduled function on close(). 863 | """ 864 | if '_errback' not in kwargs and hasattr(self, 'handle_error'): 865 | kwargs['_errback'] = self.handle_error 866 | callback = self.ioloop.call_later(seconds, target, *args, **kwargs) 867 | self._tasks.append(callback) 868 | return callback 869 | 870 | # --- overridden asynchat methods 871 | 872 | def connect(self, addr): 873 | self.modify_ioloop_events(self.ioloop.WRITE) 874 | asynchat.async_chat.connect(self, addr) 875 | 876 | def connect_af_unspecified(self, addr, source_address=None): 877 | """Same as connect() but guesses address family from addr. 878 | Return the address family just determined. 879 | """ 880 | assert self.socket is None 881 | host, port = addr 882 | err = "getaddrinfo() returned an empty list" 883 | info = socket.getaddrinfo( 884 | host, 885 | port, 886 | socket.AF_UNSPEC, 887 | socket.SOCK_STREAM, 888 | 0, 889 | socket.AI_PASSIVE, 890 | ) 891 | for res in info: 892 | self.socket = None 893 | af, socktype, _proto, _canonname, _sa = res 894 | try: 895 | self.create_socket(af, socktype) 896 | if source_address: 897 | if source_address[0].startswith('::ffff:'): 898 | # In this scenario, the server has an IPv6 socket, but 899 | # the remote client is using IPv4 and its address is 900 | # represented as an IPv4-mapped IPv6 address which 901 | # looks like this ::ffff:151.12.5.65, see: 902 | # https://en.wikipedia.org/wiki/IPv6\ 903 | # IPv4-mapped_addresses 904 | # https://datatracker.ietf.org/doc/html/rfc3493.html#section-3.7 905 | # We truncate the first bytes to make it look like a 906 | # common IPv4 address. 907 | source_address = ( 908 | source_address[0][7:], 909 | source_address[1], 910 | ) 911 | self.bind(source_address) 912 | self.connect((host, port)) 913 | except OSError as _: 914 | err = _ 915 | if self.socket is not None: 916 | self.socket.close() 917 | self.del_channel() 918 | self.socket = None 919 | continue 920 | break 921 | if self.socket is None: 922 | self.del_channel() 923 | raise OSError(err) 924 | return af 925 | 926 | # send() and recv() overridden as a fix around various bugs: 927 | # - https://bugs.python.org/issue1736101 928 | # - https://github.com/giampaolo/pyftpdlib/issues/104 929 | # - https://github.com/giampaolo/pyftpdlib/issues/109 930 | 931 | def send(self, data): 932 | try: 933 | return self.socket.send(data) 934 | except OSError as err: 935 | debug(f"call: send(), err: {err}", inst=self) 936 | if err.errno in _ERRNOS_RETRY: 937 | return 0 938 | elif err.errno in _ERRNOS_DISCONNECTED: 939 | self.handle_close() 940 | return 0 941 | else: 942 | raise 943 | 944 | def recv(self, buffer_size): 945 | try: 946 | data = self.socket.recv(buffer_size) 947 | except OSError as err: 948 | debug(f"call: recv(), err: {err}", inst=self) 949 | if err.errno in _ERRNOS_DISCONNECTED: 950 | self.handle_close() 951 | return b'' 952 | elif err.errno in _ERRNOS_RETRY: 953 | raise RetryError 954 | else: 955 | raise 956 | else: 957 | if not data: 958 | # a closed connection is indicated by signaling 959 | # a read condition, and having recv() return 0. 960 | self.handle_close() 961 | return b'' 962 | else: 963 | return data 964 | 965 | def handle_read(self): 966 | try: 967 | asynchat.async_chat.handle_read(self) 968 | except RetryError: 969 | # This can be raised by (the overridden) recv(). 970 | pass 971 | 972 | def initiate_send(self): 973 | asynchat.async_chat.initiate_send(self) 974 | if not self._closed: 975 | # if there's still data to send we want to be ready 976 | # for writing, else we're only interested in reading 977 | if not self.producer_fifo: 978 | wanted = self.ioloop.READ 979 | else: 980 | # In FTPHandler, we also want to listen for user input 981 | # hence the READ. DTPHandler has its own initiate_send() 982 | # which will either READ or WRITE. 983 | wanted = self.ioloop.READ | self.ioloop.WRITE 984 | if self._wanted_io_events != wanted: 985 | self.ioloop.modify(self._fileno, wanted) 986 | self._wanted_io_events = wanted 987 | else: 988 | debug( 989 | "call: initiate_send(); called with no connection", inst=self 990 | ) 991 | 992 | def close_when_done(self): 993 | if len(self.producer_fifo) == 0: 994 | self.handle_close() 995 | else: 996 | self._closing = True 997 | asynchat.async_chat.close_when_done(self) 998 | 999 | def close(self): 1000 | if not self._closed: 1001 | self._closed = True 1002 | try: 1003 | asynchat.async_chat.close(self) 1004 | finally: 1005 | for fun in self._tasks: 1006 | try: 1007 | fun.cancel() 1008 | except Exception: 1009 | logger.error(traceback.format_exc()) 1010 | self._tasks = [] 1011 | self._closed = True 1012 | self._closing = False 1013 | self.connected = False 1014 | 1015 | 1016 | class Connector(AsyncChat): 1017 | """Same as base AsyncChat and supposed to be used for 1018 | clients. 1019 | """ 1020 | 1021 | def add_channel(self, map=None, events=None): 1022 | AsyncChat.add_channel(self, map=map, events=self.ioloop.WRITE) 1023 | 1024 | 1025 | class Acceptor(AsyncChat): 1026 | """Same as base AsyncChat and supposed to be used to 1027 | accept new connections. 1028 | """ 1029 | 1030 | def add_channel(self, map=None, events=None): 1031 | AsyncChat.add_channel(self, map=map, events=self.ioloop.READ) 1032 | 1033 | def bind_af_unspecified(self, addr): 1034 | """Same as bind() but guesses address family from addr. 1035 | Return the address family just determined. 1036 | """ 1037 | assert self.socket is None 1038 | host, port = addr 1039 | if not host: 1040 | # When using bind() "" is a symbolic name meaning all 1041 | # available interfaces. People might not know we're 1042 | # using getaddrinfo() internally, which uses None 1043 | # instead of "", so we'll make the conversion for them. 1044 | host = None 1045 | err = "getaddrinfo() returned an empty list" 1046 | info = socket.getaddrinfo( 1047 | host, 1048 | port, 1049 | socket.AF_UNSPEC, 1050 | socket.SOCK_STREAM, 1051 | 0, 1052 | socket.AI_PASSIVE, 1053 | ) 1054 | for res in info: 1055 | self.socket = None 1056 | self.del_channel() 1057 | af, socktype, _proto, _canonname, sa = res 1058 | try: 1059 | self.create_socket(af, socktype) 1060 | self.set_reuse_addr() 1061 | self.bind(sa) 1062 | except OSError as _: 1063 | err = _ 1064 | if self.socket is not None: 1065 | self.socket.close() 1066 | self.del_channel() 1067 | self.socket = None 1068 | continue 1069 | break 1070 | if self.socket is None: 1071 | self.del_channel() 1072 | raise OSError(err) 1073 | return af 1074 | 1075 | def listen(self, num): 1076 | AsyncChat.listen(self, num) 1077 | # XXX - this seems to be necessary, otherwise kqueue.control() 1078 | # won't return listening fd events 1079 | try: 1080 | if isinstance(self.ioloop, Kqueue): 1081 | self.ioloop.modify(self._fileno, self.ioloop.READ) 1082 | except NameError: 1083 | pass 1084 | 1085 | def handle_accept(self): 1086 | try: 1087 | sock, addr = self.accept() 1088 | except TypeError: 1089 | # sometimes accept() might return None, see: 1090 | # https://github.com/giampaolo/pyftpdlib/issues/91 1091 | debug("call: handle_accept(); accept() returned None", self) 1092 | return 1093 | except OSError as err: 1094 | # ECONNABORTED might be thrown on *BSD, see: 1095 | # https://github.com/giampaolo/pyftpdlib/issues/105 1096 | if err.errno != errno.ECONNABORTED: 1097 | raise 1098 | else: 1099 | debug( 1100 | "call: handle_accept(); accept() returned ECONNABORTED", 1101 | self, 1102 | ) 1103 | else: 1104 | # sometimes addr == None instead of (ip, port) (see issue 104) 1105 | if addr is not None: 1106 | self.handle_accepted(sock, addr) 1107 | 1108 | def handle_accepted(self, sock, addr): 1109 | sock.close() 1110 | self.log_info('unhandled accepted event', 'warning') 1111 | 1112 | # overridden for convenience; avoid to reuse address on Windows 1113 | if (os.name in ('nt', 'ce')) or (sys.platform == 'cygwin'): 1114 | 1115 | def set_reuse_addr(self): 1116 | pass 1117 | --------------------------------------------------------------------------------