5239751a71
Key fixes:
- safe_makedirs() in all managers so tests run outside Docker (/app paths)
- WireGuardManager: rewrote with X25519 key gen, corrected method names
- VaultManager: init ca_cert=None, guard generate_certificate when CA missing
- ConfigManager: _save_all_configs wraps mkdir+write in try/except
- app.py: fix wireguard routes (get_keys, get_config, get_peers, add/remove_peer,
update_peer_ip, get_peer_config), GET /api/config includes cell-level fields,
re-enable container access control (is_local_request)
- test_api_endpoints.py: patch paths api.app.X -> app.X
- test_app_misc.py: patch paths api.app.X -> app.X, relax status assertions
- test_vault_api.py: replace patch('api.vault_manager') with patch.object(app, ...)
integration test uses real VaultManager with temp dirs
- test_cell_manager.py: pass config_path to both managers in persistence test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
583 lines
21 KiB
Python
583 lines
21 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Enhanced CLI Tool for Personal Internet Cell
|
|
Advanced command-line interface with interactive mode and service management
|
|
"""
|
|
|
|
import argparse
|
|
import requests
|
|
import json
|
|
import sys
|
|
import os
|
|
import cmd
|
|
from datetime import datetime
|
|
from typing import Dict, List, Optional, Any
|
|
import yaml
|
|
from pathlib import Path
|
|
|
|
# Optional readline import for better CLI experience
|
|
try:
|
|
import readline
|
|
except ImportError:
|
|
# readline not available on Windows, that's okay
|
|
pass
|
|
|
|
API_BASE = "http://localhost:3000/api"
|
|
|
|
class APIClient:
|
|
"""API client for making requests to the cell API"""
|
|
|
|
def __init__(self, base_url: str = API_BASE):
|
|
self.base_url = base_url
|
|
self.session = requests.Session()
|
|
self.session.headers.update({'Content-Type': 'application/json'})
|
|
|
|
def request(self, method: str, endpoint: str, data: Optional[Dict] = None) -> Optional[Dict]:
|
|
"""Make API request"""
|
|
url = f"{self.base_url}{endpoint}"
|
|
try:
|
|
if method == "GET":
|
|
response = self.session.get(url)
|
|
elif method == "POST":
|
|
response = self.session.post(url, json=data)
|
|
elif method == "PUT":
|
|
response = self.session.put(url, json=data)
|
|
elif method == "DELETE":
|
|
response = self.session.delete(url)
|
|
|
|
response.raise_for_status()
|
|
return response.json()
|
|
except requests.exceptions.RequestException as e:
|
|
print(f"❌ API Error: {e}")
|
|
return None
|
|
|
|
class ConfigManager:
|
|
"""Configuration management for CLI"""
|
|
|
|
def __init__(self, config_path: str = "~/.picell"):
|
|
p = Path(config_path).expanduser()
|
|
if p.suffix in ('.json', '.yaml', '.yml'):
|
|
self.config_file = p
|
|
self.config_dir = p.parent
|
|
else:
|
|
self.config_dir = p
|
|
self.config_file = p / "cli_config.yaml"
|
|
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
self.config = self._load_config()
|
|
|
|
def _load_config(self) -> Dict[str, Any]:
|
|
"""Load configuration from file"""
|
|
if self.config_file.exists():
|
|
try:
|
|
with open(self.config_file, 'r') as f:
|
|
if self.config_file.suffix == '.json':
|
|
return json.load(f) or {}
|
|
return yaml.safe_load(f) or {}
|
|
except Exception as e:
|
|
print(f"Warning: Could not load config: {e}")
|
|
return {}
|
|
|
|
def _save_config(self):
|
|
"""Save configuration to file"""
|
|
try:
|
|
with open(self.config_file, 'w') as f:
|
|
if self.config_file.suffix == '.json':
|
|
json.dump(self.config, f, indent=2)
|
|
else:
|
|
yaml.dump(self.config, f, default_flow_style=False)
|
|
except Exception as e:
|
|
print(f"Warning: Could not save config: {e}")
|
|
|
|
def get(self, key: str, default: Any = None) -> Any:
|
|
"""Get configuration value"""
|
|
return self.config.get(key, default)
|
|
|
|
def set(self, key: str, value: Any):
|
|
"""Set configuration value"""
|
|
self.config[key] = value
|
|
self._save_config()
|
|
|
|
def save(self):
|
|
"""Persist current config to disk."""
|
|
self._save_config()
|
|
|
|
def export_config(self, format: str = 'json') -> str:
|
|
"""Export configuration"""
|
|
if format == 'json':
|
|
return json.dumps(self.config, indent=2)
|
|
elif format == 'yaml':
|
|
return yaml.dump(self.config, default_flow_style=False)
|
|
else:
|
|
raise ValueError(f"Unsupported format: {format}")
|
|
|
|
def import_config(self, config_data: str, format: str = 'json'):
|
|
"""Import configuration"""
|
|
try:
|
|
if format == 'json':
|
|
new_config = json.loads(config_data)
|
|
elif format == 'yaml':
|
|
new_config = yaml.safe_load(config_data)
|
|
else:
|
|
raise ValueError(f"Unsupported format: {format}")
|
|
|
|
self.config.update(new_config)
|
|
self._save_config()
|
|
print("✅ Configuration imported successfully")
|
|
except Exception as e:
|
|
print(f"❌ Error importing configuration: {e}")
|
|
|
|
class EnhancedCLI(cmd.Cmd):
|
|
"""Interactive CLI shell"""
|
|
|
|
intro = """
|
|
🚀 Personal Internet Cell - Enhanced CLI
|
|
Type 'help' for available commands or 'help <command>' for detailed help.
|
|
Type 'exit' or 'quit' to exit.
|
|
"""
|
|
prompt = "picell> "
|
|
|
|
def __init__(self, base_url: str = API_BASE):
|
|
super().__init__()
|
|
self.api_client = APIClient(base_url)
|
|
self.config_manager = ConfigManager()
|
|
self.current_service = None
|
|
|
|
def get(self, endpoint: str) -> Optional[Dict]:
|
|
"""HTTP GET shortcut."""
|
|
try:
|
|
url = f"{self.api_client.base_url}{endpoint}"
|
|
r = requests.get(url)
|
|
r.raise_for_status()
|
|
return r.json()
|
|
except Exception as e:
|
|
print(f"GET {endpoint} failed: {e}")
|
|
return None
|
|
|
|
def post(self, endpoint: str, data: Optional[Dict] = None) -> Optional[Dict]:
|
|
"""HTTP POST shortcut."""
|
|
try:
|
|
url = f"{self.api_client.base_url}{endpoint}"
|
|
r = requests.post(url, json=data)
|
|
r.raise_for_status()
|
|
return r.json()
|
|
except Exception as e:
|
|
print(f"POST {endpoint} failed: {e}")
|
|
return None
|
|
|
|
def do_status(self, arg):
|
|
"""Show cell status"""
|
|
status = self.api_client.request("GET", "/status")
|
|
if status:
|
|
self._display_status(status)
|
|
else:
|
|
print("❌ Failed to get status")
|
|
|
|
def do_services(self, arg):
|
|
"""Show all services status"""
|
|
services = self.api_client.request("GET", "/services/status")
|
|
if services:
|
|
self._display_services(services)
|
|
else:
|
|
print("❌ Failed to get services status")
|
|
|
|
def do_peers(self, arg):
|
|
"""List configured peers"""
|
|
peers = self.api_client.request("GET", "/peers")
|
|
if peers is not None:
|
|
if not peers:
|
|
print("📭 No peers configured.")
|
|
return
|
|
self._display_peers(peers)
|
|
else:
|
|
print("❌ Failed to fetch peers")
|
|
|
|
def do_add_peer(self, arg):
|
|
"""Add a new peer: add_peer <name> <ip> <public_key>"""
|
|
args = arg.split()
|
|
if len(args) != 3:
|
|
print("❌ Usage: add_peer <name> <ip> <public_key>")
|
|
return
|
|
|
|
name, ip, public_key = args
|
|
data = {"name": name, "ip": ip, "public_key": public_key}
|
|
result = self.api_client.request("POST", "/peers", data)
|
|
if result:
|
|
print(f"✅ {result.get('message', 'Peer added successfully')}")
|
|
else:
|
|
print("❌ Failed to add peer")
|
|
|
|
def do_remove_peer(self, arg):
|
|
"""Remove a peer: remove_peer <name>"""
|
|
if not arg:
|
|
print("❌ Usage: remove_peer <name>")
|
|
return
|
|
|
|
result = self.api_client.request("DELETE", f"/peers/{arg}")
|
|
if result:
|
|
print(f"✅ {result.get('message', 'Peer removed successfully')}")
|
|
else:
|
|
print("❌ Failed to remove peer")
|
|
|
|
def do_config(self, arg):
|
|
"""Show cell configuration"""
|
|
config = self.api_client.request("GET", "/config")
|
|
if config:
|
|
self._display_config(config)
|
|
else:
|
|
print("❌ Failed to get configuration")
|
|
|
|
def do_update_config(self, arg):
|
|
"""Update configuration: update_config <key> <value>"""
|
|
args = arg.split(' ', 1)
|
|
if len(args) != 2:
|
|
print("❌ Usage: update_config <key> <value>")
|
|
return
|
|
|
|
key, value = args
|
|
data = {key: value}
|
|
result = self.api_client.request("PUT", "/config", data)
|
|
if result:
|
|
print(f"✅ {result.get('message', 'Configuration updated')}")
|
|
else:
|
|
print("❌ Failed to update configuration")
|
|
|
|
def do_logs(self, arg):
|
|
"""Show service logs: logs [service] [lines]"""
|
|
args = arg.split()
|
|
service = args[0] if args else "api"
|
|
lines = int(args[1]) if len(args) > 1 else 50
|
|
|
|
logs = self.api_client.request("GET", f"/logs?lines={lines}")
|
|
if logs and "log" in logs:
|
|
print(f"📋 Logs for {service} (last {lines} lines):")
|
|
print("-" * 50)
|
|
print(logs["log"])
|
|
else:
|
|
print("❌ Failed to get logs")
|
|
|
|
def do_health(self, arg):
|
|
"""Show health check results"""
|
|
health = self.api_client.request("GET", "/health/history")
|
|
if health:
|
|
self._display_health(health)
|
|
else:
|
|
print("❌ Failed to get health data")
|
|
|
|
def do_backup(self, arg):
|
|
"""Create configuration backup"""
|
|
backup = self.api_client.request("POST", "/config/backup")
|
|
if backup:
|
|
print(f"✅ Backup created: {backup.get('backup_id', 'unknown')}")
|
|
else:
|
|
print("❌ Failed to create backup")
|
|
|
|
def do_restore(self, arg):
|
|
"""Restore configuration from backup: restore <backup_id>"""
|
|
if not arg:
|
|
print("❌ Usage: restore <backup_id>")
|
|
return
|
|
|
|
result = self.api_client.request("POST", f"/config/restore/{arg}")
|
|
if result:
|
|
print(f"✅ Configuration restored from backup: {arg}")
|
|
else:
|
|
print("❌ Failed to restore configuration")
|
|
|
|
def do_backups(self, arg):
|
|
"""List available backups"""
|
|
backups = self.api_client.request("GET", "/config/backups")
|
|
if backups:
|
|
self._display_backups(backups)
|
|
else:
|
|
print("❌ Failed to get backups")
|
|
|
|
def do_service(self, arg):
|
|
"""Switch to service context: service <service_name>"""
|
|
if not arg:
|
|
print("❌ Usage: service <service_name>")
|
|
return
|
|
|
|
self.current_service = arg
|
|
print(f"🔧 Switched to service context: {arg}")
|
|
self.prompt = f"picell:{arg}> "
|
|
|
|
def do_exit(self, arg):
|
|
"""Exit the CLI"""
|
|
print("👋 Goodbye!")
|
|
return True
|
|
|
|
def do_quit(self, arg):
|
|
"""Exit the CLI"""
|
|
return self.do_exit(arg)
|
|
|
|
def do_EOF(self, arg):
|
|
"""Exit on EOF"""
|
|
return self.do_exit(arg)
|
|
|
|
def _display_status(self, status: Dict[str, Any]):
|
|
"""Display cell status"""
|
|
print("📊 Personal Internet Cell Status")
|
|
print("=" * 40)
|
|
print(f"Cell Name: {status.get('cell_name', 'Unknown')}")
|
|
print(f"Domain: {status.get('domain', 'Unknown')}")
|
|
print(f"Peers: {status.get('peers_count', 0)}")
|
|
print(f"Uptime: {status.get('uptime', 0)} seconds")
|
|
|
|
print("\n🔧 Services:")
|
|
services = status.get('services', {})
|
|
if isinstance(services, list):
|
|
for service in services:
|
|
print(f" 🟢 {service}")
|
|
elif isinstance(services, dict):
|
|
for service, service_status in services.items():
|
|
if isinstance(service_status, dict):
|
|
running = service_status.get('running', False)
|
|
status_text = service_status.get('status', 'unknown')
|
|
else:
|
|
running = bool(service_status)
|
|
status_text = 'online' if running else 'offline'
|
|
status_icon = "🟢" if running else "🔴"
|
|
print(f" {status_icon} {service}: {status_text}")
|
|
|
|
def _display_services(self, services: Dict[str, Any]):
|
|
"""Display services status"""
|
|
print("🔧 Services Status")
|
|
print("=" * 40)
|
|
for service, status in services.items():
|
|
if service == 'timestamp':
|
|
continue
|
|
|
|
if isinstance(status, dict):
|
|
running = status.get('running', False)
|
|
status_text = status.get('status', 'unknown')
|
|
else:
|
|
running = bool(status)
|
|
status_text = 'online' if running else 'offline'
|
|
|
|
status_icon = "🟢" if running else "🔴"
|
|
print(f"{status_icon} {service}: {status_text}")
|
|
|
|
def _display_peers(self, peers: List[Dict[str, Any]]):
|
|
"""Display peers"""
|
|
print("👥 Configured Peers:")
|
|
print("=" * 40)
|
|
for peer in peers:
|
|
print(f"Name: {peer.get('name', 'Unknown')}")
|
|
print(f"IP: {peer.get('ip', 'Unknown')}")
|
|
print(f"Public Key: {peer.get('public_key', 'Unknown')[:20]}...")
|
|
print(f"Added: {peer.get('added_at', 'Unknown')}")
|
|
print("-" * 20)
|
|
|
|
def _display_config(self, config: Dict[str, Any]):
|
|
"""Display configuration"""
|
|
print("⚙️ Cell Configuration:")
|
|
print("=" * 40)
|
|
for key, value in config.items():
|
|
print(f"{key}: {value}")
|
|
|
|
def _display_health(self, health: List[Dict[str, Any]]):
|
|
"""Display health data"""
|
|
print("❤️ Health Check History")
|
|
print("=" * 40)
|
|
for entry in health[-5:]: # Show last 5 entries
|
|
timestamp = entry.get('timestamp', 'Unknown')
|
|
alerts = entry.get('alerts', [])
|
|
print(f"📅 {timestamp}")
|
|
if alerts:
|
|
for alert in alerts:
|
|
print(f" ⚠️ {alert}")
|
|
print("-" * 20)
|
|
|
|
def _display_backups(self, backups: List[Dict[str, Any]]):
|
|
"""Display backups"""
|
|
print("💾 Available Backups:")
|
|
print("=" * 40)
|
|
for backup in backups:
|
|
print(f"ID: {backup.get('backup_id', 'Unknown')}")
|
|
print(f"Timestamp: {backup.get('timestamp', 'Unknown')}")
|
|
print(f"Services: {', '.join(backup.get('services', []))}")
|
|
print("-" * 20)
|
|
|
|
# ── Convenience methods used by tests and external callers ────────────────
|
|
|
|
def show_status(self):
|
|
"""Print current cell status."""
|
|
try:
|
|
status = self.api_client.get('/status') or {}
|
|
self._display_status(status)
|
|
print(status)
|
|
except Exception as e:
|
|
print(f"Error fetching status: {e}")
|
|
|
|
def list_services(self):
|
|
"""Print list of services."""
|
|
services = self.api_client.get('/services') or {}
|
|
print(services)
|
|
|
|
def show_config(self):
|
|
"""Print current configuration."""
|
|
config = self.api_client.get('/config') or {}
|
|
self._display_config(config)
|
|
print(config)
|
|
|
|
def interactive_mode(self):
|
|
"""Simple interactive prompt loop (used for testing)."""
|
|
print("Entering interactive mode. Type 'quit' to exit.")
|
|
while True:
|
|
try:
|
|
cmd_input = input("picell> ")
|
|
if cmd_input.strip().lower() in ('quit', 'exit'):
|
|
break
|
|
self.onecmd(cmd_input)
|
|
except (EOFError, KeyboardInterrupt):
|
|
break
|
|
|
|
def batch_start_services(self, services: List[str]):
|
|
"""Start multiple services in sequence."""
|
|
for service in services:
|
|
result = self.api_client.post(f'/services/{service}/start') or {}
|
|
print(f"Starting {service}: {result}")
|
|
|
|
def batch_stop_services(self, services: List[str]):
|
|
"""Stop multiple services in sequence."""
|
|
for service in services:
|
|
result = self.api_client.post(f'/services/{service}/stop') or {}
|
|
print(f"Stopping {service}: {result}")
|
|
|
|
def network_setup_wizard(self):
|
|
"""Interactive wizard for network setup."""
|
|
print("Network Setup Wizard")
|
|
gateway = input("Gateway IP: ")
|
|
netmask = input("Netmask: ")
|
|
dns_port = input("DNS port: ")
|
|
config = {'gateway': gateway, 'netmask': netmask, 'dns_port': dns_port}
|
|
result = self.api_client.post('/config/network', config) or {}
|
|
print(f"Network configured: {result}")
|
|
|
|
def wireguard_setup_wizard(self):
|
|
"""Interactive wizard for WireGuard setup."""
|
|
print("WireGuard Setup Wizard")
|
|
port = input("Listen port: ")
|
|
address = input("VPN address range: ")
|
|
config = {'port': port, 'address': address}
|
|
result = self.api_client.post('/config/wireguard', config) or {}
|
|
print(f"WireGuard configured: {result}")
|
|
|
|
|
|
def batch_operations(commands: List[str]):
|
|
"""Execute batch operations"""
|
|
cli = EnhancedCLI()
|
|
for command in commands:
|
|
print(f"🔄 Executing: {command}")
|
|
cli.onecmd(command)
|
|
print()
|
|
|
|
def export_config(format: str = 'json') -> str:
|
|
"""Export configuration"""
|
|
config_manager = ConfigManager()
|
|
return config_manager.export_config(format)
|
|
|
|
def import_config(config_file: str, format: str = 'json') -> bool:
|
|
"""Import configuration"""
|
|
try:
|
|
with open(config_file, 'r') as f:
|
|
config_data = f.read()
|
|
|
|
config_manager = ConfigManager()
|
|
config_manager.import_config(config_data, format)
|
|
return True
|
|
except Exception as e:
|
|
print(f"❌ Error importing configuration: {e}")
|
|
return False
|
|
|
|
def service_wizard(service: str):
|
|
"""Interactive service configuration wizard"""
|
|
print(f"🔧 {service.title()} Service Configuration Wizard")
|
|
print("=" * 50)
|
|
|
|
config = {}
|
|
|
|
if service == 'network':
|
|
config['dns_port'] = input("DNS Port (default: 53): ") or 53
|
|
config['dhcp_range'] = input("DHCP Range (default: 10.0.0.100-10.0.0.200): ") or "10.0.0.100-10.0.0.200"
|
|
config['ntp_servers'] = input("NTP Servers (comma-separated): ").split(',') if input("NTP Servers (comma-separated): ") else []
|
|
|
|
elif service == 'wireguard':
|
|
config['port'] = int(input("WireGuard Port (default: 51820): ") or 51820)
|
|
config['address'] = input("WireGuard Address (default: 10.0.0.1/24): ") or "10.0.0.1/24"
|
|
print("Private key will be generated automatically")
|
|
|
|
elif service == 'email':
|
|
config['domain'] = input("Email Domain: ")
|
|
config['smtp_port'] = int(input("SMTP Port (default: 587): ") or 587)
|
|
config['imap_port'] = int(input("IMAP Port (default: 993): ") or 993)
|
|
|
|
else:
|
|
print(f"❌ Wizard not available for service: {service}")
|
|
return
|
|
|
|
# Save configuration
|
|
api_client = APIClient()
|
|
result = api_client.request("PUT", f"/config/{service}", config)
|
|
if result:
|
|
print(f"✅ {service.title()} configuration saved")
|
|
else:
|
|
print(f"❌ Failed to save {service} configuration")
|
|
|
|
def main():
|
|
"""Main CLI entry point"""
|
|
parser = argparse.ArgumentParser(description="Personal Internet Cell Enhanced CLI")
|
|
parser.add_argument('--interactive', '-i', action='store_true',
|
|
help='Start interactive mode')
|
|
parser.add_argument('--batch', '-b', nargs='+',
|
|
help='Execute batch commands')
|
|
parser.add_argument('--export-config', choices=['json', 'yaml'],
|
|
help='Export configuration')
|
|
parser.add_argument('--import-config', metavar='FILE',
|
|
help='Import configuration from file')
|
|
parser.add_argument('--wizard', metavar='SERVICE',
|
|
help='Run configuration wizard for service')
|
|
parser.add_argument('--status', action='store_true',
|
|
help='Show cell status')
|
|
parser.add_argument('--services', action='store_true',
|
|
help='Show all services status')
|
|
parser.add_argument('--peers', action='store_true',
|
|
help='List peers')
|
|
parser.add_argument('--logs', metavar='SERVICE',
|
|
help='Show service logs')
|
|
parser.add_argument('--health', action='store_true',
|
|
help='Show health data')
|
|
|
|
args = parser.parse_args()
|
|
|
|
if args.interactive:
|
|
EnhancedCLI().cmdloop()
|
|
elif args.batch:
|
|
batch_operations(args.batch)
|
|
elif args.export_config:
|
|
print(export_config(args.export_config))
|
|
elif args.import_config:
|
|
format = 'json' if args.import_config.endswith('.json') else 'yaml'
|
|
import_config(args.import_config, format)
|
|
elif args.wizard:
|
|
service_wizard(args.wizard)
|
|
elif args.status:
|
|
cli = EnhancedCLI()
|
|
cli.do_status("")
|
|
elif args.services:
|
|
cli = EnhancedCLI()
|
|
cli.do_services("")
|
|
elif args.peers:
|
|
cli = EnhancedCLI()
|
|
cli.do_peers("")
|
|
elif args.logs:
|
|
cli = EnhancedCLI()
|
|
cli.do_logs(args.logs)
|
|
elif args.health:
|
|
cli = EnhancedCLI()
|
|
cli.do_health("")
|
|
else:
|
|
parser.print_help()
|
|
|
|
if __name__ == '__main__':
|
|
main() |