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/__init__.py +7 -0
- pftp/__main__.py +6 -0
- pftp/cli.py +782 -0
- pftp/config.py +152 -0
- pftp/constants.py +32 -0
- pftp/docker_manager.py +295 -0
- pftp-2.0.0.dist-info/METADATA +86 -0
- pftp-2.0.0.dist-info/RECORD +11 -0
- pftp-2.0.0.dist-info/WHEEL +5 -0
- pftp-2.0.0.dist-info/entry_points.txt +2 -0
- pftp-2.0.0.dist-info/top_level.txt +1 -0
pftp/cli.py
ADDED
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
"""PFTP CLI commands"""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .config import Config
|
|
11
|
+
from .docker_manager import DockerManager
|
|
12
|
+
from .constants import (
|
|
13
|
+
DEFAULT_DATA_DIR,
|
|
14
|
+
DEFAULT_PORT,
|
|
15
|
+
CONFIG_FILE,
|
|
16
|
+
CONFIG_DIR,
|
|
17
|
+
TOOLS_DIR,
|
|
18
|
+
UPLOADS_DIR,
|
|
19
|
+
FTP_PORT,
|
|
20
|
+
SMB_PORT,
|
|
21
|
+
SMB_NETBIOS_PORT,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_config_path(data_dir: Path = None) -> Path:
|
|
26
|
+
"""Get configuration file path"""
|
|
27
|
+
if data_dir is None:
|
|
28
|
+
data_dir = DEFAULT_DATA_DIR
|
|
29
|
+
return Path(data_dir) / CONFIG_DIR / CONFIG_FILE
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def hash_password(password: str) -> str:
|
|
33
|
+
"""Return password as-is for multi-protocol compatibility.
|
|
34
|
+
|
|
35
|
+
FTP and SMB protocols need the plaintext password to compute
|
|
36
|
+
their own authentication hashes (NTLM, etc). Bcrypt is one-way
|
|
37
|
+
and incompatible with these protocols.
|
|
38
|
+
"""
|
|
39
|
+
return password
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_local_ips():
|
|
43
|
+
"""Get local IP addresses (prioritize tun/VPN and eth interfaces)"""
|
|
44
|
+
try:
|
|
45
|
+
result = subprocess.run(['ip', 'addr', 'show'],
|
|
46
|
+
capture_output=True, text=True, timeout=5)
|
|
47
|
+
if result.returncode != 0:
|
|
48
|
+
return []
|
|
49
|
+
|
|
50
|
+
ips = []
|
|
51
|
+
current_interface = None
|
|
52
|
+
|
|
53
|
+
for line in result.stdout.split('\n'):
|
|
54
|
+
# Match interface name
|
|
55
|
+
if_match = re.match(r'^\d+:\s+(\S+):', line)
|
|
56
|
+
if if_match:
|
|
57
|
+
current_interface = if_match.group(1)
|
|
58
|
+
|
|
59
|
+
# Match IPv4 address
|
|
60
|
+
ip_match = re.search(r'inet\s+(\d+\.\d+\.\d+\.\d+)', line)
|
|
61
|
+
if ip_match and current_interface:
|
|
62
|
+
ip = ip_match.group(1)
|
|
63
|
+
# Skip localhost
|
|
64
|
+
if ip != '127.0.0.1':
|
|
65
|
+
# Priority system:
|
|
66
|
+
# 0 = tun interfaces (VPN/pentest)
|
|
67
|
+
# 1 = eth interfaces (ethernet)
|
|
68
|
+
# 2 = wlan interfaces (wifi)
|
|
69
|
+
# 3 = other interfaces
|
|
70
|
+
if current_interface.startswith('tun'):
|
|
71
|
+
priority = 0
|
|
72
|
+
elif current_interface.startswith('eth'):
|
|
73
|
+
priority = 1
|
|
74
|
+
elif current_interface.startswith('wlan'):
|
|
75
|
+
priority = 2
|
|
76
|
+
else:
|
|
77
|
+
priority = 3
|
|
78
|
+
|
|
79
|
+
ips.append((priority, ip, current_interface))
|
|
80
|
+
|
|
81
|
+
# Sort by priority and return IPs
|
|
82
|
+
ips.sort()
|
|
83
|
+
return [ip for _, ip, _ in ips]
|
|
84
|
+
except Exception:
|
|
85
|
+
return []
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@click.group()
|
|
89
|
+
@click.version_option(version=__version__)
|
|
90
|
+
def cli():
|
|
91
|
+
"""PFTP - Pentest File Transfer Protocols
|
|
92
|
+
|
|
93
|
+
A CLI tool for managing file transfer servers during penetration testing.
|
|
94
|
+
"""
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@cli.command()
|
|
99
|
+
@click.option('--yes', '-y', is_flag=True, help='Skip prompts, use defaults')
|
|
100
|
+
@click.option('--data-dir', type=click.Path(), help='Data directory')
|
|
101
|
+
@click.option('--tools-dir', type=click.Path(), help='Custom tools directory')
|
|
102
|
+
@click.option('--uploads-dir', type=click.Path(), help='Custom uploads directory')
|
|
103
|
+
@click.option('--http-port', type=int, help='HTTP server port')
|
|
104
|
+
@click.option('--ftp-port', type=int, help='FTP server port')
|
|
105
|
+
@click.option('--enable-ftp/--disable-ftp', default=None, help='Enable/disable FTP')
|
|
106
|
+
@click.option('--enable-smb/--disable-smb', default=None, help='Enable/disable SMB')
|
|
107
|
+
@click.option('--skip-pull', is_flag=True, help='Skip pulling Docker image')
|
|
108
|
+
def install(yes, data_dir, tools_dir, uploads_dir, http_port, ftp_port,
|
|
109
|
+
enable_ftp, enable_smb, skip_pull):
|
|
110
|
+
"""Install and configure pftp"""
|
|
111
|
+
|
|
112
|
+
click.echo(click.style("=== PFTP Installation ===\n", fg='cyan', bold=True))
|
|
113
|
+
|
|
114
|
+
# Data directory
|
|
115
|
+
if not data_dir:
|
|
116
|
+
if yes:
|
|
117
|
+
data_dir = str(DEFAULT_DATA_DIR)
|
|
118
|
+
else:
|
|
119
|
+
data_dir = click.prompt('Data directory',
|
|
120
|
+
default=str(DEFAULT_DATA_DIR),
|
|
121
|
+
type=click.Path())
|
|
122
|
+
|
|
123
|
+
data_dir = Path(data_dir).expanduser()
|
|
124
|
+
|
|
125
|
+
# Interactive configuration (skip if --yes)
|
|
126
|
+
if not yes:
|
|
127
|
+
click.echo(f"\n{click.style('Protocol Configuration:', fg='yellow', bold=True)}")
|
|
128
|
+
|
|
129
|
+
# HTTP configuration
|
|
130
|
+
if http_port is None:
|
|
131
|
+
http_port = click.prompt('HTTP port', default=DEFAULT_PORT, type=int)
|
|
132
|
+
|
|
133
|
+
# FTP configuration
|
|
134
|
+
if enable_ftp is None:
|
|
135
|
+
enable_ftp = click.confirm('Enable FTP server', default=True)
|
|
136
|
+
|
|
137
|
+
if enable_ftp and ftp_port is None:
|
|
138
|
+
ftp_port = click.prompt('FTP port', default=FTP_PORT, type=int)
|
|
139
|
+
|
|
140
|
+
# SMB configuration
|
|
141
|
+
if enable_smb is None:
|
|
142
|
+
enable_smb = click.confirm('Enable SMB server', default=False)
|
|
143
|
+
|
|
144
|
+
# Directory configuration
|
|
145
|
+
click.echo(f"\n{click.style('Directory Configuration:', fg='yellow', bold=True)}")
|
|
146
|
+
|
|
147
|
+
if tools_dir is None:
|
|
148
|
+
if click.confirm('Use custom tools directory', default=False):
|
|
149
|
+
tools_dir = click.prompt('Tools directory path', type=click.Path())
|
|
150
|
+
|
|
151
|
+
if uploads_dir is None:
|
|
152
|
+
if click.confirm('Use custom uploads directory', default=False):
|
|
153
|
+
uploads_dir = click.prompt('Uploads directory path', type=click.Path())
|
|
154
|
+
else:
|
|
155
|
+
# Use defaults for --yes mode
|
|
156
|
+
if http_port is None:
|
|
157
|
+
http_port = DEFAULT_PORT
|
|
158
|
+
if enable_ftp is None:
|
|
159
|
+
enable_ftp = True
|
|
160
|
+
if ftp_port is None:
|
|
161
|
+
ftp_port = FTP_PORT
|
|
162
|
+
if enable_smb is None:
|
|
163
|
+
enable_smb = False
|
|
164
|
+
|
|
165
|
+
# Set up directory paths
|
|
166
|
+
if tools_dir:
|
|
167
|
+
tools_dir = Path(tools_dir).expanduser()
|
|
168
|
+
else:
|
|
169
|
+
tools_dir = data_dir / TOOLS_DIR
|
|
170
|
+
|
|
171
|
+
if uploads_dir:
|
|
172
|
+
uploads_dir = Path(uploads_dir).expanduser()
|
|
173
|
+
else:
|
|
174
|
+
uploads_dir = data_dir / UPLOADS_DIR
|
|
175
|
+
|
|
176
|
+
config_dir = data_dir / CONFIG_DIR
|
|
177
|
+
|
|
178
|
+
# Create directories
|
|
179
|
+
click.echo(f"\n{click.style('Creating directories...', fg='cyan')}")
|
|
180
|
+
tools_dir.mkdir(parents=True, exist_ok=True)
|
|
181
|
+
uploads_dir.mkdir(parents=True, exist_ok=True)
|
|
182
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
183
|
+
click.echo(f"✓ Created {data_dir}")
|
|
184
|
+
if tools_dir != data_dir / TOOLS_DIR:
|
|
185
|
+
click.echo(f"✓ Tools directory: {tools_dir}")
|
|
186
|
+
if uploads_dir != data_dir / UPLOADS_DIR:
|
|
187
|
+
click.echo(f"✓ Uploads directory: {uploads_dir}")
|
|
188
|
+
|
|
189
|
+
# Create configuration
|
|
190
|
+
config = Config(
|
|
191
|
+
port=http_port,
|
|
192
|
+
data_dir=data_dir,
|
|
193
|
+
tools_dir=tools_dir if tools_dir != data_dir / TOOLS_DIR else None,
|
|
194
|
+
uploads_dir=uploads_dir if uploads_dir != data_dir / UPLOADS_DIR else None,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# Configure protocols
|
|
198
|
+
config.protocols['http']['port'] = http_port
|
|
199
|
+
config.protocols['ftp']['enabled'] = enable_ftp
|
|
200
|
+
config.protocols['ftp']['port'] = ftp_port if ftp_port else FTP_PORT
|
|
201
|
+
config.protocols['smb']['enabled'] = enable_smb
|
|
202
|
+
|
|
203
|
+
config_path = get_config_path(data_dir)
|
|
204
|
+
config.save(config_path)
|
|
205
|
+
click.echo(f"✓ Configuration saved to {config_path}")
|
|
206
|
+
|
|
207
|
+
# Pull Docker image
|
|
208
|
+
if not skip_pull:
|
|
209
|
+
click.echo(f"\nPulling Docker image...")
|
|
210
|
+
try:
|
|
211
|
+
dm = DockerManager(config)
|
|
212
|
+
dm.pull_image(config.docker_image)
|
|
213
|
+
except Exception as e:
|
|
214
|
+
click.echo(f"Warning: Could not pull image: {e}", err=True)
|
|
215
|
+
click.echo("You can pull it later with: pftp update")
|
|
216
|
+
|
|
217
|
+
click.echo(click.style(f"\n✓ Installation complete!", fg='green', bold=True))
|
|
218
|
+
click.echo(f"\n{click.style('Next steps:', fg='cyan', bold=True)}")
|
|
219
|
+
click.echo(f" 1. Add tools to {click.style(str(tools_dir), fg='yellow')}")
|
|
220
|
+
click.echo(f" 2. Run: {click.style('pftp start', fg='green')}")
|
|
221
|
+
|
|
222
|
+
# Show actual IPs if available
|
|
223
|
+
ips = get_local_ips()
|
|
224
|
+
if ips:
|
|
225
|
+
click.echo(f" 3. Access the web UI at:")
|
|
226
|
+
for ip in ips:
|
|
227
|
+
click.echo(f" {click.style(f'http://{ip}:{http_port}', fg='cyan', bold=True)}")
|
|
228
|
+
else:
|
|
229
|
+
click.echo(f" 3. Access the web UI at {click.style(f'http://<your-ip>:{http_port}', fg='cyan')}")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@cli.command()
|
|
233
|
+
@click.option('--enable-http/--disable-http', default=None, help='Enable/disable HTTP server')
|
|
234
|
+
@click.option('--enable-ftp/--disable-ftp', default=None, help='Enable/disable FTP server')
|
|
235
|
+
@click.option('--enable-smb/--disable-smb', default=None, help='Enable/disable SMB server')
|
|
236
|
+
@click.option('--http-port', type=int, help='HTTP server port')
|
|
237
|
+
@click.option('--ftp-port', type=int, help='FTP server port')
|
|
238
|
+
@click.option('--smb-port', type=int, help='SMB server port')
|
|
239
|
+
@click.option('--auth/--no-auth', default=None, help='Enable/disable authentication')
|
|
240
|
+
@click.option('--auth-username', help='Authentication username')
|
|
241
|
+
@click.option('--auth-password', help='Authentication password')
|
|
242
|
+
@click.option('--tools-dir', help='Tools directory path')
|
|
243
|
+
@click.option('--uploads-dir', help='Uploads directory path')
|
|
244
|
+
@click.option('--restart-policy', type=click.Choice(['no', 'always', 'unless-stopped', 'on-failure']),
|
|
245
|
+
help='Docker restart policy')
|
|
246
|
+
def configure(enable_http, enable_ftp, enable_smb, http_port, ftp_port, smb_port,
|
|
247
|
+
auth, auth_username, auth_password, tools_dir, uploads_dir,
|
|
248
|
+
restart_policy):
|
|
249
|
+
"""Reconfigure pftp settings (interactive if no options provided)"""
|
|
250
|
+
|
|
251
|
+
config_path = get_config_path()
|
|
252
|
+
if not config_path.exists():
|
|
253
|
+
click.echo("Error: pftp is not installed. Run 'pftp install' first.", err=True)
|
|
254
|
+
return
|
|
255
|
+
|
|
256
|
+
# Load current config
|
|
257
|
+
config = Config.load(config_path)
|
|
258
|
+
|
|
259
|
+
# Check if any CLI flags were provided
|
|
260
|
+
has_flags = any([
|
|
261
|
+
enable_http is not None, enable_ftp is not None, enable_smb is not None,
|
|
262
|
+
http_port is not None, ftp_port is not None, smb_port is not None,
|
|
263
|
+
auth is not None, auth_username is not None, auth_password is not None,
|
|
264
|
+
tools_dir is not None, uploads_dir is not None, restart_policy is not None
|
|
265
|
+
])
|
|
266
|
+
|
|
267
|
+
# If no flags provided, run interactive mode
|
|
268
|
+
if not has_flags:
|
|
269
|
+
click.echo(click.style("=== Current Configuration ===", fg='cyan', bold=True))
|
|
270
|
+
click.echo(f"\n{click.style('General:', fg='yellow', bold=True)}")
|
|
271
|
+
click.echo(f" Data directory: {config.data_dir}")
|
|
272
|
+
click.echo(f" Tools directory: {config.tools_dir}")
|
|
273
|
+
click.echo(f" Uploads directory: {config.uploads_dir}")
|
|
274
|
+
click.echo(f" Docker image: {config.docker_image}")
|
|
275
|
+
|
|
276
|
+
click.echo(f"\n{click.style('Protocols:', fg='yellow', bold=True)}")
|
|
277
|
+
for proto_name, proto_config in config.protocols.items():
|
|
278
|
+
status = click.style('✓ Enabled', fg='green') if proto_config.get('enabled') else click.style('✗ Disabled', fg='red')
|
|
279
|
+
click.echo(f" {proto_name.upper()}: {status}")
|
|
280
|
+
click.echo(f" Port: {proto_config.get('port')}")
|
|
281
|
+
if proto_name == 'ftp':
|
|
282
|
+
click.echo(f" Passive ports: {proto_config.get('passive_start')}-{proto_config.get('passive_end')}")
|
|
283
|
+
elif proto_name == 'smb':
|
|
284
|
+
click.echo(f" NetBIOS port: {proto_config.get('netbios_port')}")
|
|
285
|
+
|
|
286
|
+
click.echo(f"\n{click.style('Authentication:', fg='yellow', bold=True)}")
|
|
287
|
+
if config.auth_enabled:
|
|
288
|
+
click.echo(f" Status: {click.style('✓ Enabled', fg='green')}")
|
|
289
|
+
click.echo(f" Username: {config.auth_username}")
|
|
290
|
+
else:
|
|
291
|
+
click.echo(f" Status: {click.style('✗ Disabled', fg='red')}")
|
|
292
|
+
|
|
293
|
+
click.echo(f"\n{click.style('Docker:', fg='yellow', bold=True)}")
|
|
294
|
+
policy_desc = {
|
|
295
|
+
'no': 'Never restart',
|
|
296
|
+
'always': 'Always restart',
|
|
297
|
+
'unless-stopped': 'Restart unless stopped manually',
|
|
298
|
+
'on-failure': 'Restart on failure only'
|
|
299
|
+
}
|
|
300
|
+
click.echo(f" Restart policy: {config.restart_policy} ({policy_desc.get(config.restart_policy, '')})")
|
|
301
|
+
|
|
302
|
+
click.echo(f"\n{click.style('=== Reconfigure Settings ===', fg='cyan', bold=True)}")
|
|
303
|
+
click.echo(click.style("Press Ctrl+C to cancel at any time\n", fg='yellow'))
|
|
304
|
+
|
|
305
|
+
try:
|
|
306
|
+
# Protocol configuration
|
|
307
|
+
click.echo(click.style('Protocol Settings:', fg='yellow', bold=True))
|
|
308
|
+
config.protocols['http']['enabled'] = click.confirm('Enable HTTP', default=config.protocols['http']['enabled'])
|
|
309
|
+
if config.protocols['http']['enabled']:
|
|
310
|
+
config.protocols['http']['port'] = click.prompt('HTTP port',
|
|
311
|
+
default=config.protocols['http']['port'], type=int)
|
|
312
|
+
config.port = config.protocols['http']['port'] # Update legacy
|
|
313
|
+
|
|
314
|
+
config.protocols['ftp']['enabled'] = click.confirm('Enable FTP', default=config.protocols['ftp']['enabled'])
|
|
315
|
+
if config.protocols['ftp']['enabled']:
|
|
316
|
+
config.protocols['ftp']['port'] = click.prompt('FTP port',
|
|
317
|
+
default=config.protocols['ftp']['port'], type=int)
|
|
318
|
+
|
|
319
|
+
config.protocols['smb']['enabled'] = click.confirm('Enable SMB', default=config.protocols['smb']['enabled'])
|
|
320
|
+
if config.protocols['smb']['enabled']:
|
|
321
|
+
config.protocols['smb']['port'] = click.prompt('SMB port',
|
|
322
|
+
default=config.protocols['smb']['port'], type=int)
|
|
323
|
+
|
|
324
|
+
# Authentication
|
|
325
|
+
click.echo(f"\n{click.style('Authentication Settings:', fg='yellow', bold=True)}")
|
|
326
|
+
config.auth_enabled = click.confirm('Enable authentication', default=config.auth_enabled)
|
|
327
|
+
if config.auth_enabled:
|
|
328
|
+
config.auth_username = click.prompt('Username',
|
|
329
|
+
default=config.auth_username or 'admin')
|
|
330
|
+
new_password = click.prompt('Password (leave empty to keep current)',
|
|
331
|
+
default='', hide_input=True, show_default=False)
|
|
332
|
+
if new_password:
|
|
333
|
+
config.auth_password_hash = hash_password(new_password)
|
|
334
|
+
click.echo(click.style(' ✓ Password set', fg='green'))
|
|
335
|
+
|
|
336
|
+
# Directory configuration
|
|
337
|
+
click.echo(f"\n{click.style('Directory Settings:', fg='yellow', bold=True)}")
|
|
338
|
+
if click.confirm('Configure custom tools directory', default=False):
|
|
339
|
+
from pathlib import Path
|
|
340
|
+
tools_path = click.prompt('Tools directory path',
|
|
341
|
+
default=str(config.tools_dir), type=str)
|
|
342
|
+
config.tools_dir = Path(tools_path)
|
|
343
|
+
config.tools_dir.mkdir(parents=True, exist_ok=True)
|
|
344
|
+
|
|
345
|
+
if click.confirm('Configure custom uploads directory', default=False):
|
|
346
|
+
from pathlib import Path
|
|
347
|
+
uploads_path = click.prompt('Uploads directory path',
|
|
348
|
+
default=str(config.uploads_dir), type=str)
|
|
349
|
+
config.uploads_dir = Path(uploads_path)
|
|
350
|
+
config.uploads_dir.mkdir(parents=True, exist_ok=True)
|
|
351
|
+
|
|
352
|
+
# Docker settings
|
|
353
|
+
click.echo(f"\n{click.style('Docker Settings:', fg='yellow', bold=True)}")
|
|
354
|
+
if click.confirm('Configure restart policy', default=False):
|
|
355
|
+
click.echo(" Options:")
|
|
356
|
+
click.echo(" no - Never restart automatically")
|
|
357
|
+
click.echo(" always - Always restart (even after reboot)")
|
|
358
|
+
click.echo(" unless-stopped - Restart unless manually stopped (default)")
|
|
359
|
+
click.echo(" on-failure - Restart only if container exits with error")
|
|
360
|
+
config.restart_policy = click.prompt('Restart policy',
|
|
361
|
+
type=click.Choice(['no', 'always', 'unless-stopped', 'on-failure']),
|
|
362
|
+
default=config.restart_policy)
|
|
363
|
+
|
|
364
|
+
config.save(config_path)
|
|
365
|
+
click.echo(click.style(f"\n✓ Configuration updated", fg='green', bold=True))
|
|
366
|
+
|
|
367
|
+
# Check if we're on Windows (PowerShell doesn't support &&)
|
|
368
|
+
import platform
|
|
369
|
+
if platform.system() == 'Windows':
|
|
370
|
+
click.echo(f"Directory changes require container recreation:")
|
|
371
|
+
click.echo(f" 1. {click.style('pftp remove', fg='cyan')}")
|
|
372
|
+
click.echo(f" 2. {click.style('pftp start', fg='cyan')}")
|
|
373
|
+
else:
|
|
374
|
+
click.echo(f"Run '{click.style('pftp remove && pftp start', fg='cyan')}' to recreate container")
|
|
375
|
+
|
|
376
|
+
click.echo(f"For other changes, run '{click.style('pftp restart', fg='cyan')}'")
|
|
377
|
+
|
|
378
|
+
except click.Abort:
|
|
379
|
+
click.echo(click.style("\n✗ Configuration cancelled", fg='yellow'))
|
|
380
|
+
return
|
|
381
|
+
|
|
382
|
+
else:
|
|
383
|
+
# Non-interactive mode with CLI flags
|
|
384
|
+
click.echo("=== Reconfigure PFTP ===\n")
|
|
385
|
+
|
|
386
|
+
# Protocol enable/disable flags
|
|
387
|
+
if enable_http is not None:
|
|
388
|
+
config.protocols['http']['enabled'] = enable_http
|
|
389
|
+
click.echo(f"{'Enabled' if enable_http else 'Disabled'} HTTP protocol")
|
|
390
|
+
|
|
391
|
+
if enable_ftp is not None:
|
|
392
|
+
config.protocols['ftp']['enabled'] = enable_ftp
|
|
393
|
+
click.echo(f"{'Enabled' if enable_ftp else 'Disabled'} FTP protocol")
|
|
394
|
+
|
|
395
|
+
if enable_smb is not None:
|
|
396
|
+
config.protocols['smb']['enabled'] = enable_smb
|
|
397
|
+
click.echo(f"{'Enabled' if enable_smb else 'Disabled'} SMB protocol")
|
|
398
|
+
|
|
399
|
+
# Port configuration
|
|
400
|
+
if http_port is not None:
|
|
401
|
+
config.protocols['http']['port'] = http_port
|
|
402
|
+
config.port = http_port # Update legacy port
|
|
403
|
+
click.echo(f"Set HTTP port to {http_port}")
|
|
404
|
+
|
|
405
|
+
if ftp_port is not None:
|
|
406
|
+
config.protocols['ftp']['port'] = ftp_port
|
|
407
|
+
click.echo(f"Set FTP port to {ftp_port}")
|
|
408
|
+
|
|
409
|
+
if smb_port is not None:
|
|
410
|
+
config.protocols['smb']['port'] = smb_port
|
|
411
|
+
click.echo(f"Set SMB port to {smb_port}")
|
|
412
|
+
|
|
413
|
+
# Authentication
|
|
414
|
+
if auth is not None:
|
|
415
|
+
config.auth_enabled = auth
|
|
416
|
+
click.echo(f"{'Enabled' if auth else 'Disabled'} authentication")
|
|
417
|
+
|
|
418
|
+
if auth_username is not None:
|
|
419
|
+
config.auth_username = auth_username
|
|
420
|
+
click.echo(f"Set authentication username to {auth_username}")
|
|
421
|
+
|
|
422
|
+
if auth_password is not None:
|
|
423
|
+
config.auth_password_hash = hash_password(auth_password)
|
|
424
|
+
click.echo(f"Set authentication password")
|
|
425
|
+
|
|
426
|
+
# Directory configuration
|
|
427
|
+
dirs_changed = False
|
|
428
|
+
if tools_dir is not None:
|
|
429
|
+
from pathlib import Path
|
|
430
|
+
config.tools_dir = Path(tools_dir)
|
|
431
|
+
config.tools_dir.mkdir(parents=True, exist_ok=True)
|
|
432
|
+
click.echo(f"Set tools directory to {tools_dir}")
|
|
433
|
+
dirs_changed = True
|
|
434
|
+
|
|
435
|
+
if uploads_dir is not None:
|
|
436
|
+
from pathlib import Path
|
|
437
|
+
config.uploads_dir = Path(uploads_dir)
|
|
438
|
+
config.uploads_dir.mkdir(parents=True, exist_ok=True)
|
|
439
|
+
click.echo(f"Set uploads directory to {uploads_dir}")
|
|
440
|
+
dirs_changed = True
|
|
441
|
+
|
|
442
|
+
# Docker settings
|
|
443
|
+
if restart_policy is not None:
|
|
444
|
+
config.restart_policy = restart_policy
|
|
445
|
+
click.echo(f"Set restart policy to {restart_policy}")
|
|
446
|
+
|
|
447
|
+
config.save(config_path)
|
|
448
|
+
|
|
449
|
+
click.echo(click.style(f"\n✓ Configuration updated", fg='green', bold=True))
|
|
450
|
+
if dirs_changed:
|
|
451
|
+
import platform
|
|
452
|
+
if platform.system() == 'Windows':
|
|
453
|
+
click.echo(f"Directory changes require container recreation:")
|
|
454
|
+
click.echo(f" 1. {click.style('pftp remove', fg='cyan')}")
|
|
455
|
+
click.echo(f" 2. {click.style('pftp start', fg='cyan')}")
|
|
456
|
+
else:
|
|
457
|
+
click.echo(f"Directory changes require container recreation:")
|
|
458
|
+
click.echo(f" Run '{click.style('pftp remove && pftp start', fg='cyan')}'")
|
|
459
|
+
else:
|
|
460
|
+
click.echo(f"Run '{click.style('pftp restart', fg='cyan')}' to apply changes")
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
@cli.command()
|
|
464
|
+
@click.option('--detach', '-d', is_flag=True, default=True, help='Run in background')
|
|
465
|
+
@click.option('--port', type=int, help='Override config port')
|
|
466
|
+
@click.option('--foreground', is_flag=True, help='Run in foreground (show logs)')
|
|
467
|
+
def start(detach, port, foreground):
|
|
468
|
+
"""Start pftp server"""
|
|
469
|
+
|
|
470
|
+
config_path = get_config_path()
|
|
471
|
+
if not config_path.exists():
|
|
472
|
+
click.echo("Error: pftp is not installed. Run 'pftp install' first.", err=True)
|
|
473
|
+
return
|
|
474
|
+
|
|
475
|
+
config = Config.load(config_path)
|
|
476
|
+
|
|
477
|
+
# Override port if specified
|
|
478
|
+
if port:
|
|
479
|
+
config.port = port
|
|
480
|
+
|
|
481
|
+
# Ensure directories exist
|
|
482
|
+
config.tools_dir.mkdir(parents=True, exist_ok=True)
|
|
483
|
+
config.uploads_dir.mkdir(parents=True, exist_ok=True)
|
|
484
|
+
|
|
485
|
+
try:
|
|
486
|
+
dm = DockerManager(config)
|
|
487
|
+
|
|
488
|
+
if dm.is_running():
|
|
489
|
+
click.echo("pftp is already running")
|
|
490
|
+
click.echo("Run 'pftp status' for details")
|
|
491
|
+
return
|
|
492
|
+
|
|
493
|
+
if dm.start_container():
|
|
494
|
+
if foreground:
|
|
495
|
+
dm.get_logs(follow=True)
|
|
496
|
+
else:
|
|
497
|
+
click.echo("Failed to start container", err=True)
|
|
498
|
+
except Exception as e:
|
|
499
|
+
click.echo(f"Error: {e}", err=True)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
@cli.command()
|
|
503
|
+
def stop():
|
|
504
|
+
"""Stop pftp server"""
|
|
505
|
+
|
|
506
|
+
config_path = get_config_path()
|
|
507
|
+
if not config_path.exists():
|
|
508
|
+
click.echo("Error: pftp is not installed. Run 'pftp install' first.", err=True)
|
|
509
|
+
return
|
|
510
|
+
|
|
511
|
+
config = Config.load(config_path)
|
|
512
|
+
|
|
513
|
+
try:
|
|
514
|
+
dm = DockerManager(config)
|
|
515
|
+
dm.stop_container()
|
|
516
|
+
except Exception as e:
|
|
517
|
+
click.echo(f"Error: {e}", err=True)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
@cli.command()
|
|
521
|
+
def restart():
|
|
522
|
+
"""Restart pftp server"""
|
|
523
|
+
|
|
524
|
+
config_path = get_config_path()
|
|
525
|
+
if not config_path.exists():
|
|
526
|
+
click.echo(click.style("Error: pftp is not installed. Run 'pftp install' first.", fg='red'), err=True)
|
|
527
|
+
return
|
|
528
|
+
|
|
529
|
+
config = Config.load(config_path)
|
|
530
|
+
|
|
531
|
+
try:
|
|
532
|
+
dm = DockerManager(config)
|
|
533
|
+
|
|
534
|
+
click.echo(click.style("Stopping container...", fg='yellow'))
|
|
535
|
+
dm.stop_container()
|
|
536
|
+
|
|
537
|
+
click.echo(click.style("Removing old container...", fg='yellow'))
|
|
538
|
+
dm.remove_container()
|
|
539
|
+
|
|
540
|
+
click.echo(click.style("Starting container with new configuration...", fg='yellow'))
|
|
541
|
+
dm.start_container()
|
|
542
|
+
except Exception as e:
|
|
543
|
+
click.echo(click.style(f"Error: {e}", fg='red'), err=True)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
@cli.command()
|
|
547
|
+
def status():
|
|
548
|
+
"""Show pftp status and configuration"""
|
|
549
|
+
|
|
550
|
+
config_path = get_config_path()
|
|
551
|
+
if not config_path.exists():
|
|
552
|
+
click.echo(click.style("pftp is not installed", fg='yellow'))
|
|
553
|
+
click.echo(f"Run '{click.style('pftp install', fg='green')}' to get started")
|
|
554
|
+
return
|
|
555
|
+
|
|
556
|
+
config = Config.load(config_path)
|
|
557
|
+
|
|
558
|
+
click.echo(click.style("=== PFTP Status ===\n", fg='cyan', bold=True))
|
|
559
|
+
|
|
560
|
+
try:
|
|
561
|
+
dm = DockerManager(config)
|
|
562
|
+
status_info = dm.get_status()
|
|
563
|
+
|
|
564
|
+
if status_info and status_info.get('status') != 'not_found':
|
|
565
|
+
status_color = 'green' if status_info['status'] == 'running' else 'yellow'
|
|
566
|
+
click.echo(f"Status: {click.style(status_info['status'], fg=status_color, bold=True)}")
|
|
567
|
+
click.echo(f"Container ID: {click.style(status_info['id'], fg='cyan')}")
|
|
568
|
+
click.echo(f"Image: {click.style(status_info['image'], fg='cyan')}")
|
|
569
|
+
|
|
570
|
+
if status_info['status'] == 'running':
|
|
571
|
+
ips = get_local_ips()
|
|
572
|
+
if ips:
|
|
573
|
+
click.echo(f"\n{click.style('Server URLs:', fg='green', bold=True)}")
|
|
574
|
+
for ip in ips:
|
|
575
|
+
click.echo(f" {click.style(f'http://{ip}:{config.port}', fg='cyan', bold=True)}")
|
|
576
|
+
else:
|
|
577
|
+
click.echo(f"\nServer: {click.style(f'http://<your-ip>:{config.port}', fg='cyan')}")
|
|
578
|
+
else:
|
|
579
|
+
click.echo(f"Status: {click.style('not running', fg='red')}")
|
|
580
|
+
|
|
581
|
+
click.echo(f"\n{click.style('=== Configuration ===', fg='cyan', bold=True)}")
|
|
582
|
+
|
|
583
|
+
# Show protocol status
|
|
584
|
+
click.echo(f"\n{click.style('Protocols:', fg='yellow', bold=True)}")
|
|
585
|
+
for proto_name, proto_config in config.protocols.items():
|
|
586
|
+
enabled = proto_config.get('enabled', True)
|
|
587
|
+
status_icon = click.style('✓', fg='green') if enabled else click.style('✗', fg='red')
|
|
588
|
+
port = proto_config.get('port')
|
|
589
|
+
|
|
590
|
+
if enabled and status_info and status_info.get('status') == 'running':
|
|
591
|
+
ips = get_local_ips()
|
|
592
|
+
if ips and ips[0]:
|
|
593
|
+
url = f"{proto_name}://{ips[0]}:{port}"
|
|
594
|
+
click.echo(f" {status_icon} {proto_name.upper()}: {click.style(url, fg='cyan', bold=True)}")
|
|
595
|
+
else:
|
|
596
|
+
click.echo(f" {status_icon} {proto_name.upper()}: Port {port}")
|
|
597
|
+
else:
|
|
598
|
+
state = "Enabled" if enabled else "Disabled"
|
|
599
|
+
click.echo(f" {status_icon} {proto_name.upper()}: {state} (Port {port})")
|
|
600
|
+
|
|
601
|
+
# Show authentication status
|
|
602
|
+
click.echo(f"\n{click.style('Authentication:', fg='yellow', bold=True)}")
|
|
603
|
+
if config.auth_enabled:
|
|
604
|
+
click.echo(f" {click.style('✓', fg='green')} Enabled (User: {config.auth_username})")
|
|
605
|
+
else:
|
|
606
|
+
click.echo(f" {click.style('✗', fg='red')} Disabled")
|
|
607
|
+
|
|
608
|
+
# Show directories
|
|
609
|
+
click.echo(f"\n{click.style('Directories:', fg='yellow', bold=True)}")
|
|
610
|
+
click.echo(f" Data: {click.style(str(config.data_dir), fg='cyan')}")
|
|
611
|
+
click.echo(f" Tools: {click.style(str(config.tools_dir), fg='cyan')}")
|
|
612
|
+
click.echo(f" Uploads: {click.style(str(config.uploads_dir), fg='cyan')}")
|
|
613
|
+
|
|
614
|
+
# Show Docker settings
|
|
615
|
+
click.echo(f"\n{click.style('Docker:', fg='yellow', bold=True)}")
|
|
616
|
+
policy_desc = {
|
|
617
|
+
'no': 'Never restart',
|
|
618
|
+
'always': 'Always restart',
|
|
619
|
+
'unless-stopped': 'Restart unless stopped',
|
|
620
|
+
'on-failure': 'Restart on failure'
|
|
621
|
+
}
|
|
622
|
+
policy = config.restart_policy or 'unless-stopped'
|
|
623
|
+
click.echo(f" Restart policy: {click.style(policy, fg='cyan')} ({policy_desc.get(policy, '')})")
|
|
624
|
+
|
|
625
|
+
except Exception as e:
|
|
626
|
+
click.echo(click.style(f"Error: {e}", fg='red'), err=True)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
@cli.command()
|
|
630
|
+
@click.option('--follow', '-f', is_flag=True, default=True, help='Follow log output')
|
|
631
|
+
@click.option('--lines', '-n', type=int, default=50, help='Number of lines to show')
|
|
632
|
+
def logs(follow, lines):
|
|
633
|
+
"""View pftp server logs"""
|
|
634
|
+
|
|
635
|
+
config_path = get_config_path()
|
|
636
|
+
if not config_path.exists():
|
|
637
|
+
click.echo("Error: pftp is not installed. Run 'pftp install' first.", err=True)
|
|
638
|
+
return
|
|
639
|
+
|
|
640
|
+
config = Config.load(config_path)
|
|
641
|
+
|
|
642
|
+
try:
|
|
643
|
+
dm = DockerManager(config)
|
|
644
|
+
dm.get_logs(follow=follow, lines=lines)
|
|
645
|
+
except Exception as e:
|
|
646
|
+
click.echo(f"Error: {e}", err=True)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
@cli.command()
|
|
650
|
+
@click.option('--restart', is_flag=True, help='Automatically restart after update')
|
|
651
|
+
def update(restart):
|
|
652
|
+
"""Update to latest Docker image"""
|
|
653
|
+
|
|
654
|
+
config_path = get_config_path()
|
|
655
|
+
if not config_path.exists():
|
|
656
|
+
click.echo("Error: pftp is not installed. Run 'pftp install' first.", err=True)
|
|
657
|
+
return
|
|
658
|
+
|
|
659
|
+
config = Config.load(config_path)
|
|
660
|
+
|
|
661
|
+
try:
|
|
662
|
+
dm = DockerManager(config)
|
|
663
|
+
|
|
664
|
+
# Check if running
|
|
665
|
+
was_running = dm.is_running()
|
|
666
|
+
|
|
667
|
+
# Pull latest image
|
|
668
|
+
if dm.pull_image(config.docker_image):
|
|
669
|
+
if was_running and restart:
|
|
670
|
+
click.echo("\nRestarting with new image...")
|
|
671
|
+
dm.stop_container()
|
|
672
|
+
dm.start_container()
|
|
673
|
+
elif was_running:
|
|
674
|
+
click.echo("\nRun 'pftp restart' to use the new image")
|
|
675
|
+
else:
|
|
676
|
+
click.echo("Update failed", err=True)
|
|
677
|
+
|
|
678
|
+
except Exception as e:
|
|
679
|
+
click.echo(f"Error: {e}", err=True)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
@cli.command()
|
|
683
|
+
@click.option('--keep-data', is_flag=True, default=True, help='Keep data directories')
|
|
684
|
+
@click.option('--purge', is_flag=True, help='Remove all data including tools and uploads')
|
|
685
|
+
def remove(keep_data, purge):
|
|
686
|
+
"""Uninstall pftp"""
|
|
687
|
+
|
|
688
|
+
config_path = get_config_path()
|
|
689
|
+
if not config_path.exists():
|
|
690
|
+
click.echo("pftp is not installed")
|
|
691
|
+
return
|
|
692
|
+
|
|
693
|
+
config = Config.load(config_path)
|
|
694
|
+
|
|
695
|
+
if purge:
|
|
696
|
+
keep_data = False
|
|
697
|
+
|
|
698
|
+
click.echo("=== Uninstalling PFTP ===\n")
|
|
699
|
+
|
|
700
|
+
try:
|
|
701
|
+
dm = DockerManager(config)
|
|
702
|
+
|
|
703
|
+
# Stop and remove container
|
|
704
|
+
if dm.is_running():
|
|
705
|
+
click.echo("Stopping container...")
|
|
706
|
+
dm.stop_container()
|
|
707
|
+
|
|
708
|
+
click.echo("Removing container...")
|
|
709
|
+
dm.remove_container()
|
|
710
|
+
|
|
711
|
+
# Remove data if requested
|
|
712
|
+
if not keep_data:
|
|
713
|
+
if click.confirm(f"Remove all data from {config.data_dir}?", default=False):
|
|
714
|
+
shutil.rmtree(config.data_dir)
|
|
715
|
+
click.echo(f"✓ Removed {config.data_dir}")
|
|
716
|
+
else:
|
|
717
|
+
click.echo(f"✓ Data preserved in {config.data_dir}")
|
|
718
|
+
|
|
719
|
+
click.echo("\n✓ pftp uninstalled")
|
|
720
|
+
|
|
721
|
+
except Exception as e:
|
|
722
|
+
click.echo(f"Error: {e}", err=True)
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
@cli.command()
|
|
726
|
+
@click.argument('source', type=click.Path(exists=True))
|
|
727
|
+
@click.option('--category', help='Subdirectory name in tools/')
|
|
728
|
+
@click.option('--recursive', '-r', is_flag=True, help='Copy directories recursively')
|
|
729
|
+
def add_tool(source, category, recursive):
|
|
730
|
+
"""Add file or directory to tools"""
|
|
731
|
+
|
|
732
|
+
config_path = get_config_path()
|
|
733
|
+
if not config_path.exists():
|
|
734
|
+
click.echo(click.style("Error: pftp is not installed. Run 'pftp install' first.", fg='red'), err=True)
|
|
735
|
+
return
|
|
736
|
+
|
|
737
|
+
config = Config.load(config_path)
|
|
738
|
+
source_path = Path(source).resolve()
|
|
739
|
+
|
|
740
|
+
# Determine destination
|
|
741
|
+
if category:
|
|
742
|
+
dest_dir = config.tools_dir / category
|
|
743
|
+
else:
|
|
744
|
+
dest_dir = config.tools_dir
|
|
745
|
+
|
|
746
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
747
|
+
|
|
748
|
+
# Handle file or directory
|
|
749
|
+
if source_path.is_file():
|
|
750
|
+
dest_file = dest_dir / source_path.name
|
|
751
|
+
shutil.copy2(source_path, dest_file)
|
|
752
|
+
click.echo(click.style(f"✓ Copied: {source_path.name} → {dest_dir}", fg='green'))
|
|
753
|
+
|
|
754
|
+
elif source_path.is_dir():
|
|
755
|
+
if not recursive:
|
|
756
|
+
click.echo(click.style("Error: Use --recursive to copy directories", fg='red'), err=True)
|
|
757
|
+
return
|
|
758
|
+
|
|
759
|
+
dest_subdir = dest_dir / source_path.name
|
|
760
|
+
shutil.copytree(source_path, dest_subdir, dirs_exist_ok=True)
|
|
761
|
+
click.echo(click.style(f"✓ Copied directory: {source_path.name} → {dest_dir}", fg='green'))
|
|
762
|
+
else:
|
|
763
|
+
click.echo(click.style(f"Error: {source} is not a file or directory", fg='red'), err=True)
|
|
764
|
+
return
|
|
765
|
+
|
|
766
|
+
# Notify if container is running
|
|
767
|
+
try:
|
|
768
|
+
dm = DockerManager(config)
|
|
769
|
+
if dm.is_running():
|
|
770
|
+
click.echo(click.style("✓ Container is running - new tools are immediately available", fg='green'))
|
|
771
|
+
except:
|
|
772
|
+
pass
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
@cli.command()
|
|
776
|
+
def version():
|
|
777
|
+
"""Show pftp version"""
|
|
778
|
+
click.echo(f"pftp version {__version__}")
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
if __name__ == '__main__':
|
|
782
|
+
cli()
|