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/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()