pftp 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pftp/config.py ADDED
@@ -0,0 +1,152 @@
1
+ """Configuration management for PFTP"""
2
+
3
+ from dataclasses import dataclass, asdict, field
4
+ from pathlib import Path
5
+ from typing import Optional, Dict
6
+ import yaml
7
+
8
+ from .constants import (
9
+ DEFAULT_HOST,
10
+ DEFAULT_PORT,
11
+ DEFAULT_DATA_DIR,
12
+ DEFAULT_DOCKER_IMAGE,
13
+ CONTAINER_NAME,
14
+ FTP_PORT,
15
+ FTP_PASSIVE_START,
16
+ FTP_PASSIVE_END,
17
+ SMB_PORT,
18
+ SMB_NETBIOS_PORT,
19
+ )
20
+
21
+
22
+ @dataclass
23
+ class Config:
24
+ """PFTP configuration v2.0 with multi-protocol support"""
25
+
26
+ # Server settings
27
+ host: str = DEFAULT_HOST
28
+ port: int = DEFAULT_PORT
29
+
30
+ # v2.0 Protocol configurations
31
+ protocols: Dict = field(default_factory=lambda: {
32
+ 'http': {'enabled': True, 'port': DEFAULT_PORT},
33
+ 'ftp': {'enabled': True, 'port': FTP_PORT, 'passive_start': FTP_PASSIVE_START, 'passive_end': FTP_PASSIVE_END},
34
+ 'smb': {'enabled': True, 'port': SMB_PORT, 'netbios_port': SMB_NETBIOS_PORT}
35
+ })
36
+
37
+ # Authentication
38
+ auth_enabled: bool = False
39
+ auth_username: Optional[str] = None
40
+ auth_password_hash: Optional[str] = None
41
+
42
+ # Docker and directories
43
+ docker_image: str = DEFAULT_DOCKER_IMAGE
44
+ data_dir: Path = DEFAULT_DATA_DIR
45
+ tools_dir: Optional[Path] = None
46
+ uploads_dir: Optional[Path] = None
47
+ ignore_dirs: str = ".git,__pycache__,.vscode"
48
+ debug: bool = False
49
+
50
+ # Docker restart policy: 'no', 'always', 'unless-stopped', 'on-failure'
51
+ restart_policy: str = "unless-stopped"
52
+
53
+ def __post_init__(self):
54
+ """Initialize paths"""
55
+ self.data_dir = Path(self.data_dir).expanduser()
56
+ if not self.tools_dir:
57
+ self.tools_dir = self.data_dir / "tools"
58
+ if not self.uploads_dir:
59
+ self.uploads_dir = self.data_dir / "uploads"
60
+
61
+ @classmethod
62
+ def load(cls, config_path: Path) -> 'Config':
63
+ """Load configuration from YAML file with v1.0 to v2.0 migration"""
64
+ if not config_path.exists():
65
+ return cls()
66
+
67
+ with open(config_path, 'r') as f:
68
+ data = yaml.safe_load(f) or {}
69
+
70
+ version = data.get('version', '1.0')
71
+
72
+ # v1.0 config - migrate to v2.0
73
+ if version == '1.0':
74
+ http_port = data.get('server', {}).get('port', DEFAULT_PORT)
75
+ config = cls(
76
+ host=data.get('server', {}).get('host', DEFAULT_HOST),
77
+ port=http_port,
78
+ docker_image=data.get('docker', {}).get('image', DEFAULT_DOCKER_IMAGE),
79
+ data_dir=Path(data.get('directories', {}).get('data_dir', DEFAULT_DATA_DIR)),
80
+ ignore_dirs=data.get('advanced', {}).get('ignore_dirs', ".git,__pycache__,.vscode"),
81
+ debug=data.get('advanced', {}).get('debug', False)
82
+ )
83
+ # Update HTTP port to match legacy config
84
+ config.protocols['http']['port'] = http_port
85
+ # Auto-save as v2.0
86
+ config.save(config_path)
87
+ return config
88
+
89
+ # v2.0 config
90
+ tools_dir_str = data.get('directories', {}).get('tools_dir')
91
+ uploads_dir_str = data.get('directories', {}).get('uploads_dir')
92
+
93
+ return cls(
94
+ host=data.get('server', {}).get('host', DEFAULT_HOST),
95
+ port=data.get('server', {}).get('port', DEFAULT_PORT),
96
+ protocols=data.get('protocols', cls().protocols),
97
+ auth_enabled=data.get('authentication', {}).get('enabled', False),
98
+ auth_username=data.get('authentication', {}).get('username'),
99
+ auth_password_hash=data.get('authentication', {}).get('password_hash'),
100
+ docker_image=data.get('docker', {}).get('image', DEFAULT_DOCKER_IMAGE),
101
+ data_dir=Path(data.get('directories', {}).get('data_dir', DEFAULT_DATA_DIR)),
102
+ tools_dir=Path(tools_dir_str) if tools_dir_str else None,
103
+ uploads_dir=Path(uploads_dir_str) if uploads_dir_str else None,
104
+ ignore_dirs=data.get('advanced', {}).get('ignore_dirs', ".git,__pycache__,.vscode"),
105
+ debug=data.get('advanced', {}).get('debug', False),
106
+ restart_policy=data.get('docker', {}).get('restart_policy', 'unless-stopped')
107
+ )
108
+
109
+ def save(self, config_path: Path):
110
+ """Save configuration to YAML file in v2.0 format"""
111
+ config_path.parent.mkdir(parents=True, exist_ok=True)
112
+
113
+ data = {
114
+ 'version': '2.0',
115
+ 'server': {
116
+ 'host': self.host,
117
+ 'port': self.port
118
+ },
119
+ 'protocols': self.protocols,
120
+ 'authentication': {
121
+ 'enabled': self.auth_enabled,
122
+ 'username': self.auth_username,
123
+ 'password_hash': self.auth_password_hash
124
+ },
125
+ 'docker': {
126
+ 'image': self.docker_image,
127
+ 'container_name': CONTAINER_NAME,
128
+ 'restart_policy': self.restart_policy
129
+ },
130
+ 'directories': {
131
+ 'data_dir': str(self.data_dir),
132
+ 'tools_dir': str(self.tools_dir),
133
+ 'uploads_dir': str(self.uploads_dir),
134
+ 'permissions': {
135
+ 'tools': 'ro',
136
+ 'uploads': 'rw'
137
+ }
138
+ },
139
+ 'advanced': {
140
+ 'ignore_dirs': self.ignore_dirs,
141
+ 'debug': self.debug
142
+ }
143
+ }
144
+
145
+ with open(config_path, 'w') as f:
146
+ yaml.dump(data, f, default_flow_style=False)
147
+
148
+ def merge_cli_args(self, **kwargs):
149
+ """Merge CLI arguments into configuration"""
150
+ for key, value in kwargs.items():
151
+ if value is not None and hasattr(self, key):
152
+ setattr(self, key, value)
pftp/constants.py ADDED
@@ -0,0 +1,32 @@
1
+ """Constants and default values for PFTP"""
2
+
3
+ from pathlib import Path
4
+
5
+ # Default configuration values
6
+ DEFAULT_PORT = 1234
7
+ DEFAULT_HOST = "0.0.0.0"
8
+ DEFAULT_DATA_DIR = Path.home() / ".pftp"
9
+ DEFAULT_DOCKER_IMAGE = "ahmadalawneh3/pftp:latest"
10
+
11
+ # Protocol ports
12
+ FTP_PORT = 21
13
+ FTP_PASSIVE_START = 60000
14
+ FTP_PASSIVE_END = 60100
15
+ SMB_PORT = 445
16
+ SMB_NETBIOS_PORT = 139
17
+
18
+ # Container and config settings
19
+ CONTAINER_NAME = "pftp"
20
+ CONFIG_FILE = "config.yaml"
21
+ CONFIG_DIR = "config"
22
+ TOOLS_DIR = "tools"
23
+ UPLOADS_DIR = "uploads"
24
+
25
+ # Docker environment variables
26
+ ENV_PROTOCOL = "PROTOCOL"
27
+ ENV_HOST = "HOST"
28
+ ENV_PORT = "PORT"
29
+ ENV_DEBUG = "DEBUG"
30
+ ENV_UPLOAD_FOLDER = "UPLOAD_FOLDER"
31
+ ENV_TOOLS_FOLDER = "TOOLS_FOLDER"
32
+ ENV_IGNORE_DIRS = "IGNORE_DIRS"
pftp/docker_manager.py ADDED
@@ -0,0 +1,295 @@
1
+ """Docker container management for PFTP"""
2
+
3
+ import click
4
+ import docker
5
+ from docker.errors import DockerException, ImageNotFound, NotFound
6
+ from typing import Dict, Optional
7
+
8
+ from .config import Config
9
+ from .constants import CONTAINER_NAME
10
+
11
+
12
+ class DockerManager:
13
+ """Manage PFTP Docker container"""
14
+
15
+ def __init__(self, config: Config):
16
+ """Initialize Docker manager
17
+
18
+ Args:
19
+ config: PFTP configuration
20
+ """
21
+ self.config = config
22
+ try:
23
+ self.client = docker.from_env()
24
+ except DockerException as e:
25
+ click.echo(f"Error: Cannot connect to Docker daemon. Is Docker running?", err=True)
26
+ click.echo(f"Details: {e}", err=True)
27
+ raise
28
+
29
+ def pull_image(self, image_name: str) -> bool:
30
+ """Pull Docker image from registry
31
+
32
+ Args:
33
+ image_name: Full image name (e.g., 'ahmadalawneh3/pftp:latest')
34
+
35
+ Returns:
36
+ True if successful, False otherwise
37
+ """
38
+ try:
39
+ click.echo(f"Pulling image {image_name}...")
40
+ self.client.images.pull(image_name)
41
+ click.echo(f"✓ Successfully pulled {image_name}")
42
+ return True
43
+ except DockerException as e:
44
+ click.echo(f"Error pulling image: {e}", err=True)
45
+ return False
46
+
47
+ def is_running(self) -> bool:
48
+ """Check if container is running
49
+
50
+ Returns:
51
+ True if container exists and is running
52
+ """
53
+ try:
54
+ container = self.client.containers.get(CONTAINER_NAME)
55
+ return container.status == 'running'
56
+ except NotFound:
57
+ return False
58
+
59
+ def start_container(self) -> bool:
60
+ """Start PFTP container
61
+
62
+ Returns:
63
+ True if successful, False otherwise
64
+ """
65
+ # Check if container exists
66
+ try:
67
+ container = self.client.containers.get(CONTAINER_NAME)
68
+ if container.status == 'running':
69
+ click.echo(f"Container '{CONTAINER_NAME}' is already running")
70
+ return True
71
+ # Container exists but not running - start it
72
+ container.start()
73
+ click.echo(f"✓ Started container '{CONTAINER_NAME}'")
74
+ return True
75
+ except NotFound:
76
+ # Create new container
77
+ return self._create_and_start()
78
+
79
+ def _create_and_start(self) -> bool:
80
+ """Create and start new container
81
+
82
+ Returns:
83
+ True if successful, False otherwise
84
+ """
85
+ try:
86
+ # Prepare volume mappings
87
+ volumes = {
88
+ str(self.config.tools_dir): {'bind': '/app/data/tools', 'mode': 'rw'},
89
+ str(self.config.uploads_dir): {'bind': '/app/data/uploads', 'mode': 'rw'},
90
+ }
91
+
92
+ # Prepare environment variables
93
+ environment = {
94
+ # General
95
+ 'PROTOCOL': 'http',
96
+ 'HOST': self.config.host,
97
+ 'PORT': str(self.config.port),
98
+ 'DEBUG': 'false',
99
+ 'UPLOAD_FOLDER': 'data/uploads',
100
+ 'TOOLS_FOLDER': 'data/tools',
101
+ 'IGNORE_DIRS': '.git,__pycache__,.vscode',
102
+
103
+ # HTTP
104
+ 'HTTP_ENABLED': str(self.config.protocols.get('http', {}).get('enabled', True)).lower(),
105
+ 'HTTP_PORT': str(self.config.protocols.get('http', {}).get('port', 1234)),
106
+
107
+ # FTP
108
+ 'FTP_ENABLED': str(self.config.protocols.get('ftp', {}).get('enabled', True)).lower(),
109
+ 'FTP_PORT': str(self.config.protocols.get('ftp', {}).get('port', 21)),
110
+ 'FTP_PASSIVE_START': str(self.config.protocols.get('ftp', {}).get('passive_start', 60000)),
111
+ 'FTP_PASSIVE_END': str(self.config.protocols.get('ftp', {}).get('passive_end', 60100)),
112
+
113
+ # SMB
114
+ 'SMB_ENABLED': str(self.config.protocols.get('smb', {}).get('enabled', True)).lower(),
115
+ 'SMB_PORT': str(self.config.protocols.get('smb', {}).get('port', 445)),
116
+ 'SMB_NETBIOS_PORT': str(self.config.protocols.get('smb', {}).get('netbios_port', 139)),
117
+
118
+ # Authentication
119
+ 'AUTH_ENABLED': str(self.config.auth_enabled).lower(),
120
+ 'AUTH_USERNAME': self.config.auth_username or '',
121
+ 'AUTH_PASSWORD_HASH': self.config.auth_password_hash or '',
122
+ }
123
+
124
+ # Prepare restart policy
125
+ restart_policy_name = self.config.restart_policy or 'unless-stopped'
126
+ restart_policy = {'Name': restart_policy_name}
127
+ if restart_policy_name == 'on-failure':
128
+ restart_policy['MaximumRetryCount'] = 5
129
+
130
+ # Create and start container
131
+ container = self.client.containers.run(
132
+ self.config.docker_image,
133
+ name=CONTAINER_NAME,
134
+ volumes=volumes,
135
+ environment=environment,
136
+ network_mode='host', # Required for IP detection
137
+ restart_policy=restart_policy,
138
+ detach=True,
139
+ stdin_open=True,
140
+ tty=True
141
+ )
142
+
143
+ click.echo(click.style(f"✓ Container created and started: {container.short_id}", fg='green'))
144
+
145
+ # Try to get actual IPs
146
+ try:
147
+ import subprocess
148
+ import re
149
+ result = subprocess.run(['ip', 'addr', 'show'],
150
+ capture_output=True, text=True, timeout=5)
151
+ if result.returncode == 0:
152
+ ips = []
153
+ current_interface = None
154
+
155
+ for line in result.stdout.split('\n'):
156
+ if_match = re.match(r'^\d+:\s+(\S+):', line)
157
+ if if_match:
158
+ current_interface = if_match.group(1)
159
+
160
+ ip_match = re.search(r'inet\s+(\d+\.\d+\.\d+\.\d+)', line)
161
+ if ip_match and current_interface:
162
+ ip = ip_match.group(1)
163
+ if ip != '127.0.0.1':
164
+ # Priority system:
165
+ # 0 = tun interfaces (VPN/pentest)
166
+ # 1 = eth interfaces (ethernet)
167
+ # 2 = wlan interfaces (wifi)
168
+ # 3 = other interfaces
169
+ if current_interface.startswith('tun'):
170
+ priority = 0
171
+ elif current_interface.startswith('eth'):
172
+ priority = 1
173
+ elif current_interface.startswith('wlan'):
174
+ priority = 2
175
+ else:
176
+ priority = 3
177
+
178
+ ips.append((priority, ip, current_interface))
179
+
180
+ ips.sort()
181
+ ip_list = [ip for _, ip, _ in ips]
182
+
183
+ if ip_list:
184
+ click.echo(click.style(f"✓ Server running at:", fg='green', bold=True))
185
+ for ip in ip_list:
186
+ click.echo(f" {click.style(f'http://{ip}:{self.config.port}', fg='cyan', bold=True)}")
187
+ else:
188
+ click.echo(click.style(f"✓ Server running on http://<your-ip>:{self.config.port}", fg='green'))
189
+ else:
190
+ click.echo(click.style(f"✓ Server running on http://<your-ip>:{self.config.port}", fg='green'))
191
+ except:
192
+ click.echo(click.style(f"✓ Server running on http://<your-ip>:{self.config.port}", fg='green'))
193
+
194
+ return True
195
+
196
+ except ImageNotFound:
197
+ click.echo(f"Error: Docker image '{self.config.docker_image}' not found", err=True)
198
+ click.echo("Run 'pftp install' to pull the image", err=True)
199
+ return False
200
+ except DockerException as e:
201
+ click.echo(f"Error starting container: {e}", err=True)
202
+ return False
203
+
204
+ def stop_container(self) -> bool:
205
+ """Stop container
206
+
207
+ Returns:
208
+ True if successful, False otherwise
209
+ """
210
+ try:
211
+ container = self.client.containers.get(CONTAINER_NAME)
212
+ container.stop(timeout=10)
213
+ click.echo(f"✓ Container '{CONTAINER_NAME}' stopped")
214
+ return True
215
+ except NotFound:
216
+ click.echo(f"Container '{CONTAINER_NAME}' not found")
217
+ return False
218
+ except DockerException as e:
219
+ click.echo(f"Error stopping container: {e}", err=True)
220
+ return False
221
+
222
+ def remove_container(self) -> bool:
223
+ """Remove container
224
+
225
+ Returns:
226
+ True if successful, False otherwise
227
+ """
228
+ try:
229
+ container = self.client.containers.get(CONTAINER_NAME)
230
+ container.remove(force=True)
231
+ click.echo(f"✓ Container '{CONTAINER_NAME}' removed")
232
+ return True
233
+ except NotFound:
234
+ # Already removed
235
+ return True
236
+ except DockerException as e:
237
+ click.echo(f"Error removing container: {e}", err=True)
238
+ return False
239
+
240
+ def get_logs(self, follow: bool = True, lines: int = 50):
241
+ """Get container logs
242
+
243
+ Args:
244
+ follow: Stream logs (like tail -f)
245
+ lines: Number of lines to show
246
+ """
247
+ try:
248
+ container = self.client.containers.get(CONTAINER_NAME)
249
+ if follow:
250
+ click.echo(click.style(f"Following logs from '{CONTAINER_NAME}' (Ctrl+C to stop)...", fg='cyan'))
251
+ buffer = b''
252
+ for chunk in container.logs(stream=True, follow=True, tail=lines):
253
+ if isinstance(chunk, bytes):
254
+ buffer += chunk
255
+ else:
256
+ buffer += chunk.encode('utf-8')
257
+ while b'\n' in buffer:
258
+ line, buffer = buffer.split(b'\n', 1)
259
+ click.echo(line.decode('utf-8', errors='replace').rstrip())
260
+ if buffer:
261
+ click.echo(buffer.decode('utf-8', errors='replace').rstrip())
262
+ else:
263
+ # Get logs as bytes, decode to string
264
+ logs_bytes = container.logs(tail=lines)
265
+ if isinstance(logs_bytes, bytes):
266
+ click.echo(logs_bytes.decode('utf-8', errors='replace'))
267
+ else:
268
+ click.echo(str(logs_bytes))
269
+ except NotFound:
270
+ click.echo(click.style(f"Container '{CONTAINER_NAME}' not found", fg='red'), err=True)
271
+ except KeyboardInterrupt:
272
+ click.echo(click.style("\n✓ Stopped following logs", fg='green'))
273
+ except DockerException as e:
274
+ click.echo(click.style(f"Error getting logs: {e}", fg='red'), err=True)
275
+
276
+ def get_status(self) -> Optional[Dict]:
277
+ """Get container status information
278
+
279
+ Returns:
280
+ Dictionary with container info or None if not found
281
+ """
282
+ try:
283
+ container = self.client.containers.get(CONTAINER_NAME)
284
+ return {
285
+ 'status': container.status,
286
+ 'id': container.short_id,
287
+ 'image': container.image.tags[0] if container.image.tags else 'unknown',
288
+ 'created': container.attrs['Created'],
289
+ 'ports': container.attrs.get('NetworkSettings', {}).get('Ports', {})
290
+ }
291
+ except NotFound:
292
+ return {'status': 'not_found'}
293
+ except DockerException as e:
294
+ click.echo(f"Error getting status: {e}", err=True)
295
+ return None
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: pftp
3
+ Version: 2.0.0
4
+ Summary: PFTP - Pentest File Transfer Protocols - CLI Tool
5
+ Author-email: Ahmad Alawneh <a.3alawneh@gmail.com>
6
+ License: MIT
7
+ Keywords: pentest,file-transfer,security,hacking,ctf,ftp,CTF
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Information Technology
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.8
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Topic :: Security
17
+ Requires-Python: >=3.8
18
+ Description-Content-Type: text/markdown
19
+ Requires-Dist: click>=8.0
20
+ Requires-Dist: docker>=6.0
21
+ Requires-Dist: pyyaml>=6.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: build; extra == "dev"
24
+ Requires-Dist: twine; extra == "dev"
25
+
26
+ # PFTP CLI
27
+
28
+ CLI tool for managing the PFTP (Pentest File Transfer Protocols) server.
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pipx install pftp
34
+ ```
35
+
36
+ Or from source:
37
+
38
+ ```bash
39
+ cd cli
40
+ pipx install .
41
+ ```
42
+
43
+ ## Quick Start
44
+
45
+ ```bash
46
+ # Install and configure
47
+ pftp install
48
+
49
+ # Start the server
50
+ pftp start
51
+
52
+ # Check status
53
+ pftp status
54
+
55
+ # Add tools
56
+ pftp add-tool /path/to/linpeas.sh
57
+
58
+ # View logs
59
+ pftp logs
60
+
61
+ # Stop the server
62
+ pftp stop
63
+ ```
64
+
65
+ ## Commands
66
+
67
+ - `pftp install` - Install and configure pftp
68
+ - `pftp configure` - Reconfigure settings
69
+ - `pftp start` - Start the server
70
+ - `pftp stop` - Stop the server
71
+ - `pftp restart` - Restart the server
72
+ - `pftp status` - Show status and configuration
73
+ - `pftp logs` - View server logs
74
+ - `pftp update` - Update to latest Docker image
75
+ - `pftp remove` - Uninstall pftp
76
+ - `pftp add-tool` - Add files to tools directory
77
+ - `pftp version` - Show version
78
+
79
+ ## Requirements
80
+
81
+ - Python 3.8+
82
+ - Docker
83
+
84
+ ## License
85
+
86
+ MIT
@@ -0,0 +1,11 @@
1
+ pftp/__init__.py,sha256=V9qkxhFeWzkx2PC5yjNuNREk-LwtdRWT0DDbKoOp6TQ,173
2
+ pftp/__main__.py,sha256=OgFKe0aksr0dwPtmMSbvtSRrRaPrhWrtLmLhjIWdLGA,108
3
+ pftp/cli.py,sha256=EjaOnWCERNMkKj3shEHI2DVBYxuJOGEsizL51Djrn5M,31422
4
+ pftp/config.py,sha256=9NBYpcd2xL021JHAymi_FANvodJfgOf8QEMZixEYfRE,5642
5
+ pftp/constants.py,sha256=X9F7MdIP0FKVAY1GkKrAWOz08KxDr1i4KBtFRHGd7Sk,758
6
+ pftp/docker_manager.py,sha256=q6L5wd52g3EbtlvC40ThsNb0zkgkCpO2W9fjVtU7u00,11812
7
+ pftp-2.0.0.dist-info/METADATA,sha256=RYlrFffAcq7KgBN2CU4G8k_XuC0kJh2tebGdGxSLojc,1816
8
+ pftp-2.0.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
9
+ pftp-2.0.0.dist-info/entry_points.txt,sha256=Hp7IlQLGb30u4zTq3huSYls15Ecy9vw8tAxu5qf-79U,38
10
+ pftp-2.0.0.dist-info/top_level.txt,sha256=Dn7MXqEDhB-cK4jiVjMlh0Xu1pq1yuO3_BTNwyd7TSk,5
11
+ pftp-2.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pftp = pftp.cli:cli
@@ -0,0 +1 @@
1
+ pftp