diff --git a/backend/app.py b/backend/app.py index 8d044c8..ea1c0ab 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,26 +1,27 @@ -from flask import Flask, jsonify, request +"""Main application entry point - refactored modular architecture.""" +from flask import Flask from flask_cors import CORS -from flask_socketio import SocketIO, emit, disconnect -import docker -import os -import sys -import logging -import threading -import select -from datetime import datetime, timedelta +from flask_socketio import SocketIO -# Configure logging -logging.basicConfig( - level=logging.DEBUG, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - handlers=[ - logging.StreamHandler(sys.stdout) - ] -) -logger = logging.getLogger(__name__) +from config import logger +from routes.login import login_bp +from routes.logout import logout_bp +from routes.health import health_bp +from routes.containers.list import list_bp +from routes.containers.exec import exec_bp +from routes.containers.start import start_bp +from routes.containers.stop import stop_bp +from routes.containers.restart import restart_bp +from routes.containers.remove import remove_bp +from handlers.terminal.register import register_terminal_handlers +from utils.diagnostics.docker_env import diagnose_docker_environment +from utils.docker_client import get_docker_client +# Initialize Flask app app = Flask(__name__) CORS(app, resources={r"/*": {"origins": "*"}}) + +# Initialize SocketIO socketio = SocketIO( app, cors_allowed_origins="*", @@ -31,590 +32,20 @@ socketio = SocketIO( engineio_logger=True ) -# Simple in-memory session storage (in production, use proper session management) -sessions = {} -# Track working directory per session -session_workdirs = {} +# Register blueprints +app.register_blueprint(login_bp) +app.register_blueprint(logout_bp) +app.register_blueprint(health_bp) +app.register_blueprint(list_bp) +app.register_blueprint(exec_bp) +app.register_blueprint(start_bp) +app.register_blueprint(stop_bp) +app.register_blueprint(restart_bp) +app.register_blueprint(remove_bp) + +# Register WebSocket handlers +register_terminal_handlers(socketio) -# Default credentials (should be environment variables in production) -ADMIN_USERNAME = os.getenv('ADMIN_USERNAME', 'admin') -ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD', 'admin123') - -def diagnose_docker_environment(): - """Diagnose Docker environment and configuration""" - logger.info("=== Docker Environment Diagnosis ===") - - # Check environment variables - docker_host = os.getenv('DOCKER_HOST', 'Not set') - docker_cert_path = os.getenv('DOCKER_CERT_PATH', 'Not set') - docker_tls_verify = os.getenv('DOCKER_TLS_VERIFY', 'Not set') - - logger.info(f"DOCKER_HOST: {docker_host}") - logger.info(f"DOCKER_CERT_PATH: {docker_cert_path}") - logger.info(f"DOCKER_TLS_VERIFY: {docker_tls_verify}") - - # Check what's in /var/run - logger.info("Checking /var/run directory contents:") - try: - if os.path.exists('/var/run'): - var_run_contents = os.listdir('/var/run') - logger.info(f" /var/run contains: {var_run_contents}") - - # Check for any Docker-related files - docker_related = [f for f in var_run_contents if 'docker' in f.lower()] - if docker_related: - logger.info(f" Docker-related files/dirs found: {docker_related}") - else: - logger.warning(" /var/run directory doesn't exist") - except Exception as e: - logger.error(f" Error reading /var/run: {e}") - - # Check Docker socket - socket_path = '/var/run/docker.sock' - logger.info(f"Checking Docker socket at {socket_path}") - - if os.path.exists(socket_path): - logger.info(f"✓ Docker socket exists at {socket_path}") - - # Check permissions - import stat - st = os.stat(socket_path) - logger.info(f" Socket permissions: {oct(st.st_mode)}") - logger.info(f" Socket owner UID: {st.st_uid}") - logger.info(f" Socket owner GID: {st.st_gid}") - - # Check if readable/writable - readable = os.access(socket_path, os.R_OK) - writable = os.access(socket_path, os.W_OK) - logger.info(f" Readable: {readable}") - logger.info(f" Writable: {writable}") - - if not (readable and writable): - logger.warning(f"⚠ Socket exists but lacks proper permissions!") - else: - logger.error(f"✗ Docker socket NOT found at {socket_path}") - logger.error(f" This means the Docker socket mount is NOT configured in CapRover") - logger.error(f" The serviceUpdateOverride in captain-definition may not be applied") - - # Check current user - import pwd - try: - current_uid = os.getuid() - current_gid = os.getgid() - user_info = pwd.getpwuid(current_uid) - logger.info(f"Current user: {user_info.pw_name} (UID: {current_uid}, GID: {current_gid})") - - # Check groups - import grp - groups = os.getgroups() - logger.info(f"User groups (GIDs): {groups}") - - for gid in groups: - try: - group_info = grp.getgrgid(gid) - logger.info(f" - {group_info.gr_name} (GID: {gid})") - except: - logger.info(f" - Unknown group (GID: {gid})") - except Exception as e: - logger.error(f"Error checking user info: {e}") - - logger.info("=== End Diagnosis ===") - -def get_docker_client(): - """Get Docker client with enhanced error reporting""" - try: - logger.info("Attempting to connect to Docker...") - - # Try default connection first - try: - client = docker.from_env() - # Test the connection - client.ping() - logger.info("✓ Successfully connected to Docker using docker.from_env()") - return client - except Exception as e: - logger.warning(f"docker.from_env() failed: {e}") - - # Try explicit Unix socket connection - try: - logger.info("Trying explicit Unix socket connection...") - client = docker.DockerClient(base_url='unix:///var/run/docker.sock') - client.ping() - logger.info("✓ Successfully connected to Docker using Unix socket") - return client - except Exception as e: - logger.warning(f"Unix socket connection failed: {e}") - - # If all fails, run diagnostics and return None - logger.error("All Docker connection attempts failed!") - diagnose_docker_environment() - return None - - except Exception as e: - logger.error(f"Unexpected error in get_docker_client: {e}", exc_info=True) - return None - -def format_uptime(created_at): - """Format container uptime""" - created = datetime.fromisoformat(created_at.replace('Z', '+00:00')) - now = datetime.now(created.tzinfo) - delta = now - created - - days = delta.days - hours = delta.seconds // 3600 - minutes = (delta.seconds % 3600) // 60 - - if days > 0: - return f"{days}d {hours}h" - elif hours > 0: - return f"{hours}h {minutes}m" - else: - return f"{minutes}m" - -@app.route('/api/auth/login', methods=['POST']) -def login(): - """Authenticate user""" - data = request.get_json() - username = data.get('username') - password = data.get('password') - - if username == ADMIN_USERNAME and password == ADMIN_PASSWORD: - # Create a simple session token (in production, use JWT or proper session management) - session_token = f"session_{username}_{datetime.now().timestamp()}" - sessions[session_token] = { - 'username': username, - 'created_at': datetime.now() - } - return jsonify({ - 'success': True, - 'token': session_token, - 'username': username - }) - - return jsonify({ - 'success': False, - 'message': 'Invalid credentials' - }), 401 - -@app.route('/api/auth/logout', methods=['POST']) -def logout(): - """Logout user""" - auth_header = request.headers.get('Authorization') - if auth_header and auth_header.startswith('Bearer '): - token = auth_header.split(' ')[1] - if token in sessions: - del sessions[token] - - return jsonify({'success': True}) - -@app.route('/api/containers', methods=['GET']) -def get_containers(): - """Get list of all containers""" - auth_header = request.headers.get('Authorization') - if not auth_header or not auth_header.startswith('Bearer '): - return jsonify({'error': 'Unauthorized'}), 401 - - token = auth_header.split(' ')[1] - if token not in sessions: - return jsonify({'error': 'Invalid session'}), 401 - - client = get_docker_client() - if not client: - return jsonify({'error': 'Cannot connect to Docker'}), 500 - - try: - containers = client.containers.list(all=True) - container_list = [] - - for container in containers: - container_list.append({ - 'id': container.short_id, - 'name': container.name, - 'image': container.image.tags[0] if container.image.tags else 'unknown', - 'status': container.status, - 'uptime': format_uptime(container.attrs['Created']) if container.status == 'running' else 'N/A' - }) - - return jsonify({'containers': container_list}) - except Exception as e: - return jsonify({'error': str(e)}), 500 - -@app.route('/api/containers//exec', methods=['POST']) -def exec_container(container_id): - """Execute command in container""" - auth_header = request.headers.get('Authorization') - if not auth_header or not auth_header.startswith('Bearer '): - return jsonify({'error': 'Unauthorized'}), 401 - - token = auth_header.split(' ')[1] - if token not in sessions: - return jsonify({'error': 'Invalid session'}), 401 - - data = request.get_json() - user_command = data.get('command', 'echo "No command provided"') - - client = get_docker_client() - if not client: - return jsonify({'error': 'Cannot connect to Docker'}), 500 - - try: - container = client.containers.get(container_id) - - # Get or initialize session working directory - session_key = f"{token}_{container_id}" - if session_key not in session_workdirs: - # Get container's default working directory or use root - session_workdirs[session_key] = '/' - - current_workdir = session_workdirs[session_key] - - # Check if this is a cd command - cd_match = user_command.strip() - is_cd_command = cd_match.startswith('cd ') - - # If it's a cd command, handle it specially - if is_cd_command: - target_dir = cd_match[3:].strip() or '~' - # Resolve the new directory and update session - resolve_command = f'cd "{current_workdir}" && cd {target_dir} && pwd' - bash_command = [ - '/bin/bash', - '-c', - f'export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; {resolve_command}' - ] - else: - # Regular command - execute in current working directory - bash_command = [ - '/bin/bash', - '-c', - f'export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; cd "{current_workdir}" && {user_command}; echo "::WORKDIR::$(pwd)"' - ] - - # Try bash first, fallback to sh if bash doesn't exist - try: - exec_instance = container.exec_run( - bash_command, - stdout=True, - stderr=True, - stdin=False, - tty=True, - environment={'TERM': 'xterm-256color', 'LANG': 'C.UTF-8'} - ) - except Exception as bash_error: - logger.warning(f"Bash execution failed, trying sh: {bash_error}") - # Fallback to sh - if is_cd_command: - target_dir = cd_match[3:].strip() or '~' - resolve_command = f'cd "{current_workdir}" && cd {target_dir} && pwd' - sh_command = ['/bin/sh', '-c', f'export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; {resolve_command}'] - else: - sh_command = ['/bin/sh', '-c', f'export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; cd "{current_workdir}" && {user_command}; echo "::WORKDIR::$(pwd)"'] - - exec_instance = container.exec_run( - sh_command, - stdout=True, - stderr=True, - stdin=False, - tty=True, - environment={'TERM': 'xterm-256color', 'LANG': 'C.UTF-8'} - ) - - # Decode output with error handling - output = '' - if exec_instance.output: - try: - output = exec_instance.output.decode('utf-8') - except UnicodeDecodeError: - # Try latin-1 as fallback - output = exec_instance.output.decode('latin-1', errors='replace') - - # Extract and update working directory from output - new_workdir = current_workdir - if is_cd_command: - # For cd commands, the output is the new pwd - new_workdir = output.strip() - session_workdirs[session_key] = new_workdir - output = '' # Don't show the pwd output for cd - else: - # Extract workdir marker from output - if '::WORKDIR::' in output: - parts = output.rsplit('::WORKDIR::', 1) - output = parts[0] - new_workdir = parts[1].strip() - session_workdirs[session_key] = new_workdir - - return jsonify({ - 'output': output, - 'exit_code': exec_instance.exit_code, - 'workdir': new_workdir - }) - except Exception as e: - logger.error(f"Error executing command: {e}", exc_info=True) - return jsonify({'error': str(e)}), 500 - -@app.route('/api/containers//start', methods=['POST']) -def start_container(container_id): - """Start a stopped container""" - auth_header = request.headers.get('Authorization') - if not auth_header or not auth_header.startswith('Bearer '): - return jsonify({'error': 'Unauthorized'}), 401 - - token = auth_header.split(' ')[1] - if token not in sessions: - return jsonify({'error': 'Invalid session'}), 401 - - client = get_docker_client() - if not client: - return jsonify({'error': 'Cannot connect to Docker'}), 500 - - try: - container = client.containers.get(container_id) - container.start() - logger.info(f"Started container {container_id}") - return jsonify({'success': True, 'message': f'Container {container_id} started'}) - except Exception as e: - logger.error(f"Error starting container: {e}", exc_info=True) - return jsonify({'error': str(e)}), 500 - -@app.route('/api/containers//stop', methods=['POST']) -def stop_container(container_id): - """Stop a running container""" - auth_header = request.headers.get('Authorization') - if not auth_header or not auth_header.startswith('Bearer '): - return jsonify({'error': 'Unauthorized'}), 401 - - token = auth_header.split(' ')[1] - if token not in sessions: - return jsonify({'error': 'Invalid session'}), 401 - - client = get_docker_client() - if not client: - return jsonify({'error': 'Cannot connect to Docker'}), 500 - - try: - container = client.containers.get(container_id) - container.stop() - logger.info(f"Stopped container {container_id}") - return jsonify({'success': True, 'message': f'Container {container_id} stopped'}) - except Exception as e: - logger.error(f"Error stopping container: {e}", exc_info=True) - return jsonify({'error': str(e)}), 500 - -@app.route('/api/containers//restart', methods=['POST']) -def restart_container(container_id): - """Restart a container""" - auth_header = request.headers.get('Authorization') - if not auth_header or not auth_header.startswith('Bearer '): - return jsonify({'error': 'Unauthorized'}), 401 - - token = auth_header.split(' ')[1] - if token not in sessions: - return jsonify({'error': 'Invalid session'}), 401 - - client = get_docker_client() - if not client: - return jsonify({'error': 'Cannot connect to Docker'}), 500 - - try: - container = client.containers.get(container_id) - container.restart() - logger.info(f"Restarted container {container_id}") - return jsonify({'success': True, 'message': f'Container {container_id} restarted'}) - except Exception as e: - logger.error(f"Error restarting container: {e}", exc_info=True) - return jsonify({'error': str(e)}), 500 - -@app.route('/api/containers/', methods=['DELETE']) -def remove_container(container_id): - """Remove a container""" - auth_header = request.headers.get('Authorization') - if not auth_header or not auth_header.startswith('Bearer '): - return jsonify({'error': 'Unauthorized'}), 401 - - token = auth_header.split(' ')[1] - if token not in sessions: - return jsonify({'error': 'Invalid session'}), 401 - - client = get_docker_client() - if not client: - return jsonify({'error': 'Cannot connect to Docker'}), 500 - - try: - container = client.containers.get(container_id) - # Force remove (including if running) - container.remove(force=True) - logger.info(f"Removed container {container_id}") - return jsonify({'success': True, 'message': f'Container {container_id} removed'}) - except Exception as e: - logger.error(f"Error removing container: {e}", exc_info=True) - return jsonify({'error': str(e)}), 500 - -@app.route('/api/health', methods=['GET']) -def health(): - """Health check endpoint""" - return jsonify({'status': 'healthy'}) - -# WebSocket handlers for interactive terminal -active_terminals = {} - -@socketio.on('connect', namespace='/terminal') -def handle_connect(): - """Handle WebSocket connection""" - logger.info(f"Client connected to terminal WebSocket: {request.sid}") - -@socketio.on('disconnect', namespace='/terminal') -def handle_disconnect(): - """Handle WebSocket disconnection""" - logger.info(f"Client disconnected from terminal WebSocket: {request.sid}") - # Clean up any active terminal sessions - if request.sid in active_terminals: - try: - exec_instance = active_terminals[request.sid]['exec'] - # Try to stop the exec instance - if hasattr(exec_instance, 'kill'): - exec_instance.kill() - except: - pass - del active_terminals[request.sid] - -@socketio.on('start_terminal', namespace='/terminal') -def handle_start_terminal(data): - """Start an interactive terminal session""" - try: - container_id = data.get('container_id') - token = data.get('token') - cols = data.get('cols', 80) - rows = data.get('rows', 24) - - # Validate token - if not token or token not in sessions: - emit('error', {'error': 'Unauthorized'}) - disconnect() - return - - # Get Docker client and container - client = get_docker_client() - if not client: - emit('error', {'error': 'Cannot connect to Docker'}) - return - - container = client.containers.get(container_id) - - # Create an interactive bash session with PTY - exec_instance = container.exec_run( - ['/bin/bash'], - stdin=True, - stdout=True, - stderr=True, - tty=True, - socket=True, - environment={ - 'TERM': 'xterm-256color', - 'COLUMNS': str(cols), - 'LINES': str(rows), - 'LANG': 'C.UTF-8' - } - ) - - # Store the exec instance - active_terminals[request.sid] = { - 'exec': exec_instance, - 'container_id': container_id - } - - # Capture request.sid before starting thread to avoid context issues - sid = request.sid - - # Start a thread to read from the container and send to client - def read_output(): - sock = exec_instance.output - try: - while True: - # Check if socket is still connected - if sid not in active_terminals: - break - - try: - # Read data from container - data = sock.recv(4096) - if not data: - break - - # Send to client - try: - decoded_data = data.decode('utf-8') - except UnicodeDecodeError: - decoded_data = data.decode('latin-1', errors='replace') - - socketio.emit('output', {'data': decoded_data}, - namespace='/terminal', room=sid) - except Exception as e: - logger.error(f"Error reading from container: {e}") - break - finally: - # Clean up - if sid in active_terminals: - del active_terminals[sid] - try: - sock.close() - except: - pass - socketio.emit('exit', {'code': 0}, - namespace='/terminal', room=sid) - - # Start the output reader thread - output_thread = threading.Thread(target=read_output, daemon=True) - output_thread.start() - - emit('started', {'message': 'Terminal started'}) - - except Exception as e: - logger.error(f"Error starting terminal: {e}", exc_info=True) - emit('error', {'error': str(e)}) - -@socketio.on('input', namespace='/terminal') -def handle_input(data): - """Handle input from the client""" - try: - if request.sid not in active_terminals: - emit('error', {'error': 'No active terminal session'}) - return - - terminal_data = active_terminals[request.sid] - exec_instance = terminal_data['exec'] - input_data = data.get('data', '') - - # Send input to the container - sock = exec_instance.output - # Access the underlying socket for sendall method - if hasattr(sock, '_sock'): - sock._sock.sendall(input_data.encode('utf-8')) - else: - # Fallback for direct socket objects - sock.sendall(input_data.encode('utf-8')) - - except Exception as e: - logger.error(f"Error sending input: {e}", exc_info=True) - emit('error', {'error': str(e)}) - -@socketio.on('resize', namespace='/terminal') -def handle_resize(data): - """Handle terminal resize""" - try: - cols = data.get('cols', 80) - rows = data.get('rows', 24) - - if request.sid in active_terminals: - terminal_data = active_terminals[request.sid] - exec_instance = terminal_data['exec'] - - # Note: Docker exec_run doesn't support resizing after creation - # This is a limitation of the Docker API - # We acknowledge the resize but can't actually resize the PTY - logger.info(f"Terminal resize requested: {cols}x{rows}") - - except Exception as e: - logger.error(f"Error resizing terminal: {e}", exc_info=True) if __name__ == '__main__': # Run diagnostics on startup diff --git a/backend/app_new.py b/backend/app_new.py new file mode 100644 index 0000000..af41d7f --- /dev/null +++ b/backend/app_new.py @@ -0,0 +1,49 @@ +"""Main application entry point.""" +from flask import Flask +from flask_cors import CORS +from flask_socketio import SocketIO + +from config import logger +from routes.auth import auth_bp +from routes.containers import containers_bp +from routes.health import health_bp +from handlers.terminal import register_terminal_handlers +from utils.docker_client import diagnose_docker_environment, get_docker_client + +# Initialize Flask app +app = Flask(__name__) +CORS(app, resources={r"/*": {"origins": "*"}}) + +# Initialize SocketIO +socketio = SocketIO( + app, + cors_allowed_origins="*", + async_mode='threading', + ping_timeout=60, + ping_interval=25, + logger=True, + engineio_logger=True +) + +# Register blueprints +app.register_blueprint(auth_bp) +app.register_blueprint(containers_bp) +app.register_blueprint(health_bp) + +# Register WebSocket handlers +register_terminal_handlers(socketio) + + +if __name__ == '__main__': + # Run diagnostics on startup + logger.info("Backend server starting...") + diagnose_docker_environment() + + # Try to get Docker client and log result + test_client = get_docker_client() + if test_client: + logger.info("✓ Docker connection verified on startup") + else: + logger.error("✗ Docker connection FAILED on startup - check logs above for details") + + socketio.run(app, host='0.0.0.0', port=5000, debug=True, allow_unsafe_werkzeug=True) diff --git a/backend/app_old.py b/backend/app_old.py new file mode 100644 index 0000000..8d044c8 --- /dev/null +++ b/backend/app_old.py @@ -0,0 +1,631 @@ +from flask import Flask, jsonify, request +from flask_cors import CORS +from flask_socketio import SocketIO, emit, disconnect +import docker +import os +import sys +import logging +import threading +import select +from datetime import datetime, timedelta + +# Configure logging +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.StreamHandler(sys.stdout) + ] +) +logger = logging.getLogger(__name__) + +app = Flask(__name__) +CORS(app, resources={r"/*": {"origins": "*"}}) +socketio = SocketIO( + app, + cors_allowed_origins="*", + async_mode='threading', + ping_timeout=60, + ping_interval=25, + logger=True, + engineio_logger=True +) + +# Simple in-memory session storage (in production, use proper session management) +sessions = {} +# Track working directory per session +session_workdirs = {} + +# Default credentials (should be environment variables in production) +ADMIN_USERNAME = os.getenv('ADMIN_USERNAME', 'admin') +ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD', 'admin123') + +def diagnose_docker_environment(): + """Diagnose Docker environment and configuration""" + logger.info("=== Docker Environment Diagnosis ===") + + # Check environment variables + docker_host = os.getenv('DOCKER_HOST', 'Not set') + docker_cert_path = os.getenv('DOCKER_CERT_PATH', 'Not set') + docker_tls_verify = os.getenv('DOCKER_TLS_VERIFY', 'Not set') + + logger.info(f"DOCKER_HOST: {docker_host}") + logger.info(f"DOCKER_CERT_PATH: {docker_cert_path}") + logger.info(f"DOCKER_TLS_VERIFY: {docker_tls_verify}") + + # Check what's in /var/run + logger.info("Checking /var/run directory contents:") + try: + if os.path.exists('/var/run'): + var_run_contents = os.listdir('/var/run') + logger.info(f" /var/run contains: {var_run_contents}") + + # Check for any Docker-related files + docker_related = [f for f in var_run_contents if 'docker' in f.lower()] + if docker_related: + logger.info(f" Docker-related files/dirs found: {docker_related}") + else: + logger.warning(" /var/run directory doesn't exist") + except Exception as e: + logger.error(f" Error reading /var/run: {e}") + + # Check Docker socket + socket_path = '/var/run/docker.sock' + logger.info(f"Checking Docker socket at {socket_path}") + + if os.path.exists(socket_path): + logger.info(f"✓ Docker socket exists at {socket_path}") + + # Check permissions + import stat + st = os.stat(socket_path) + logger.info(f" Socket permissions: {oct(st.st_mode)}") + logger.info(f" Socket owner UID: {st.st_uid}") + logger.info(f" Socket owner GID: {st.st_gid}") + + # Check if readable/writable + readable = os.access(socket_path, os.R_OK) + writable = os.access(socket_path, os.W_OK) + logger.info(f" Readable: {readable}") + logger.info(f" Writable: {writable}") + + if not (readable and writable): + logger.warning(f"⚠ Socket exists but lacks proper permissions!") + else: + logger.error(f"✗ Docker socket NOT found at {socket_path}") + logger.error(f" This means the Docker socket mount is NOT configured in CapRover") + logger.error(f" The serviceUpdateOverride in captain-definition may not be applied") + + # Check current user + import pwd + try: + current_uid = os.getuid() + current_gid = os.getgid() + user_info = pwd.getpwuid(current_uid) + logger.info(f"Current user: {user_info.pw_name} (UID: {current_uid}, GID: {current_gid})") + + # Check groups + import grp + groups = os.getgroups() + logger.info(f"User groups (GIDs): {groups}") + + for gid in groups: + try: + group_info = grp.getgrgid(gid) + logger.info(f" - {group_info.gr_name} (GID: {gid})") + except: + logger.info(f" - Unknown group (GID: {gid})") + except Exception as e: + logger.error(f"Error checking user info: {e}") + + logger.info("=== End Diagnosis ===") + +def get_docker_client(): + """Get Docker client with enhanced error reporting""" + try: + logger.info("Attempting to connect to Docker...") + + # Try default connection first + try: + client = docker.from_env() + # Test the connection + client.ping() + logger.info("✓ Successfully connected to Docker using docker.from_env()") + return client + except Exception as e: + logger.warning(f"docker.from_env() failed: {e}") + + # Try explicit Unix socket connection + try: + logger.info("Trying explicit Unix socket connection...") + client = docker.DockerClient(base_url='unix:///var/run/docker.sock') + client.ping() + logger.info("✓ Successfully connected to Docker using Unix socket") + return client + except Exception as e: + logger.warning(f"Unix socket connection failed: {e}") + + # If all fails, run diagnostics and return None + logger.error("All Docker connection attempts failed!") + diagnose_docker_environment() + return None + + except Exception as e: + logger.error(f"Unexpected error in get_docker_client: {e}", exc_info=True) + return None + +def format_uptime(created_at): + """Format container uptime""" + created = datetime.fromisoformat(created_at.replace('Z', '+00:00')) + now = datetime.now(created.tzinfo) + delta = now - created + + days = delta.days + hours = delta.seconds // 3600 + minutes = (delta.seconds % 3600) // 60 + + if days > 0: + return f"{days}d {hours}h" + elif hours > 0: + return f"{hours}h {minutes}m" + else: + return f"{minutes}m" + +@app.route('/api/auth/login', methods=['POST']) +def login(): + """Authenticate user""" + data = request.get_json() + username = data.get('username') + password = data.get('password') + + if username == ADMIN_USERNAME and password == ADMIN_PASSWORD: + # Create a simple session token (in production, use JWT or proper session management) + session_token = f"session_{username}_{datetime.now().timestamp()}" + sessions[session_token] = { + 'username': username, + 'created_at': datetime.now() + } + return jsonify({ + 'success': True, + 'token': session_token, + 'username': username + }) + + return jsonify({ + 'success': False, + 'message': 'Invalid credentials' + }), 401 + +@app.route('/api/auth/logout', methods=['POST']) +def logout(): + """Logout user""" + auth_header = request.headers.get('Authorization') + if auth_header and auth_header.startswith('Bearer '): + token = auth_header.split(' ')[1] + if token in sessions: + del sessions[token] + + return jsonify({'success': True}) + +@app.route('/api/containers', methods=['GET']) +def get_containers(): + """Get list of all containers""" + auth_header = request.headers.get('Authorization') + if not auth_header or not auth_header.startswith('Bearer '): + return jsonify({'error': 'Unauthorized'}), 401 + + token = auth_header.split(' ')[1] + if token not in sessions: + return jsonify({'error': 'Invalid session'}), 401 + + client = get_docker_client() + if not client: + return jsonify({'error': 'Cannot connect to Docker'}), 500 + + try: + containers = client.containers.list(all=True) + container_list = [] + + for container in containers: + container_list.append({ + 'id': container.short_id, + 'name': container.name, + 'image': container.image.tags[0] if container.image.tags else 'unknown', + 'status': container.status, + 'uptime': format_uptime(container.attrs['Created']) if container.status == 'running' else 'N/A' + }) + + return jsonify({'containers': container_list}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@app.route('/api/containers//exec', methods=['POST']) +def exec_container(container_id): + """Execute command in container""" + auth_header = request.headers.get('Authorization') + if not auth_header or not auth_header.startswith('Bearer '): + return jsonify({'error': 'Unauthorized'}), 401 + + token = auth_header.split(' ')[1] + if token not in sessions: + return jsonify({'error': 'Invalid session'}), 401 + + data = request.get_json() + user_command = data.get('command', 'echo "No command provided"') + + client = get_docker_client() + if not client: + return jsonify({'error': 'Cannot connect to Docker'}), 500 + + try: + container = client.containers.get(container_id) + + # Get or initialize session working directory + session_key = f"{token}_{container_id}" + if session_key not in session_workdirs: + # Get container's default working directory or use root + session_workdirs[session_key] = '/' + + current_workdir = session_workdirs[session_key] + + # Check if this is a cd command + cd_match = user_command.strip() + is_cd_command = cd_match.startswith('cd ') + + # If it's a cd command, handle it specially + if is_cd_command: + target_dir = cd_match[3:].strip() or '~' + # Resolve the new directory and update session + resolve_command = f'cd "{current_workdir}" && cd {target_dir} && pwd' + bash_command = [ + '/bin/bash', + '-c', + f'export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; {resolve_command}' + ] + else: + # Regular command - execute in current working directory + bash_command = [ + '/bin/bash', + '-c', + f'export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; cd "{current_workdir}" && {user_command}; echo "::WORKDIR::$(pwd)"' + ] + + # Try bash first, fallback to sh if bash doesn't exist + try: + exec_instance = container.exec_run( + bash_command, + stdout=True, + stderr=True, + stdin=False, + tty=True, + environment={'TERM': 'xterm-256color', 'LANG': 'C.UTF-8'} + ) + except Exception as bash_error: + logger.warning(f"Bash execution failed, trying sh: {bash_error}") + # Fallback to sh + if is_cd_command: + target_dir = cd_match[3:].strip() or '~' + resolve_command = f'cd "{current_workdir}" && cd {target_dir} && pwd' + sh_command = ['/bin/sh', '-c', f'export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; {resolve_command}'] + else: + sh_command = ['/bin/sh', '-c', f'export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; cd "{current_workdir}" && {user_command}; echo "::WORKDIR::$(pwd)"'] + + exec_instance = container.exec_run( + sh_command, + stdout=True, + stderr=True, + stdin=False, + tty=True, + environment={'TERM': 'xterm-256color', 'LANG': 'C.UTF-8'} + ) + + # Decode output with error handling + output = '' + if exec_instance.output: + try: + output = exec_instance.output.decode('utf-8') + except UnicodeDecodeError: + # Try latin-1 as fallback + output = exec_instance.output.decode('latin-1', errors='replace') + + # Extract and update working directory from output + new_workdir = current_workdir + if is_cd_command: + # For cd commands, the output is the new pwd + new_workdir = output.strip() + session_workdirs[session_key] = new_workdir + output = '' # Don't show the pwd output for cd + else: + # Extract workdir marker from output + if '::WORKDIR::' in output: + parts = output.rsplit('::WORKDIR::', 1) + output = parts[0] + new_workdir = parts[1].strip() + session_workdirs[session_key] = new_workdir + + return jsonify({ + 'output': output, + 'exit_code': exec_instance.exit_code, + 'workdir': new_workdir + }) + except Exception as e: + logger.error(f"Error executing command: {e}", exc_info=True) + return jsonify({'error': str(e)}), 500 + +@app.route('/api/containers//start', methods=['POST']) +def start_container(container_id): + """Start a stopped container""" + auth_header = request.headers.get('Authorization') + if not auth_header or not auth_header.startswith('Bearer '): + return jsonify({'error': 'Unauthorized'}), 401 + + token = auth_header.split(' ')[1] + if token not in sessions: + return jsonify({'error': 'Invalid session'}), 401 + + client = get_docker_client() + if not client: + return jsonify({'error': 'Cannot connect to Docker'}), 500 + + try: + container = client.containers.get(container_id) + container.start() + logger.info(f"Started container {container_id}") + return jsonify({'success': True, 'message': f'Container {container_id} started'}) + except Exception as e: + logger.error(f"Error starting container: {e}", exc_info=True) + return jsonify({'error': str(e)}), 500 + +@app.route('/api/containers//stop', methods=['POST']) +def stop_container(container_id): + """Stop a running container""" + auth_header = request.headers.get('Authorization') + if not auth_header or not auth_header.startswith('Bearer '): + return jsonify({'error': 'Unauthorized'}), 401 + + token = auth_header.split(' ')[1] + if token not in sessions: + return jsonify({'error': 'Invalid session'}), 401 + + client = get_docker_client() + if not client: + return jsonify({'error': 'Cannot connect to Docker'}), 500 + + try: + container = client.containers.get(container_id) + container.stop() + logger.info(f"Stopped container {container_id}") + return jsonify({'success': True, 'message': f'Container {container_id} stopped'}) + except Exception as e: + logger.error(f"Error stopping container: {e}", exc_info=True) + return jsonify({'error': str(e)}), 500 + +@app.route('/api/containers//restart', methods=['POST']) +def restart_container(container_id): + """Restart a container""" + auth_header = request.headers.get('Authorization') + if not auth_header or not auth_header.startswith('Bearer '): + return jsonify({'error': 'Unauthorized'}), 401 + + token = auth_header.split(' ')[1] + if token not in sessions: + return jsonify({'error': 'Invalid session'}), 401 + + client = get_docker_client() + if not client: + return jsonify({'error': 'Cannot connect to Docker'}), 500 + + try: + container = client.containers.get(container_id) + container.restart() + logger.info(f"Restarted container {container_id}") + return jsonify({'success': True, 'message': f'Container {container_id} restarted'}) + except Exception as e: + logger.error(f"Error restarting container: {e}", exc_info=True) + return jsonify({'error': str(e)}), 500 + +@app.route('/api/containers/', methods=['DELETE']) +def remove_container(container_id): + """Remove a container""" + auth_header = request.headers.get('Authorization') + if not auth_header or not auth_header.startswith('Bearer '): + return jsonify({'error': 'Unauthorized'}), 401 + + token = auth_header.split(' ')[1] + if token not in sessions: + return jsonify({'error': 'Invalid session'}), 401 + + client = get_docker_client() + if not client: + return jsonify({'error': 'Cannot connect to Docker'}), 500 + + try: + container = client.containers.get(container_id) + # Force remove (including if running) + container.remove(force=True) + logger.info(f"Removed container {container_id}") + return jsonify({'success': True, 'message': f'Container {container_id} removed'}) + except Exception as e: + logger.error(f"Error removing container: {e}", exc_info=True) + return jsonify({'error': str(e)}), 500 + +@app.route('/api/health', methods=['GET']) +def health(): + """Health check endpoint""" + return jsonify({'status': 'healthy'}) + +# WebSocket handlers for interactive terminal +active_terminals = {} + +@socketio.on('connect', namespace='/terminal') +def handle_connect(): + """Handle WebSocket connection""" + logger.info(f"Client connected to terminal WebSocket: {request.sid}") + +@socketio.on('disconnect', namespace='/terminal') +def handle_disconnect(): + """Handle WebSocket disconnection""" + logger.info(f"Client disconnected from terminal WebSocket: {request.sid}") + # Clean up any active terminal sessions + if request.sid in active_terminals: + try: + exec_instance = active_terminals[request.sid]['exec'] + # Try to stop the exec instance + if hasattr(exec_instance, 'kill'): + exec_instance.kill() + except: + pass + del active_terminals[request.sid] + +@socketio.on('start_terminal', namespace='/terminal') +def handle_start_terminal(data): + """Start an interactive terminal session""" + try: + container_id = data.get('container_id') + token = data.get('token') + cols = data.get('cols', 80) + rows = data.get('rows', 24) + + # Validate token + if not token or token not in sessions: + emit('error', {'error': 'Unauthorized'}) + disconnect() + return + + # Get Docker client and container + client = get_docker_client() + if not client: + emit('error', {'error': 'Cannot connect to Docker'}) + return + + container = client.containers.get(container_id) + + # Create an interactive bash session with PTY + exec_instance = container.exec_run( + ['/bin/bash'], + stdin=True, + stdout=True, + stderr=True, + tty=True, + socket=True, + environment={ + 'TERM': 'xterm-256color', + 'COLUMNS': str(cols), + 'LINES': str(rows), + 'LANG': 'C.UTF-8' + } + ) + + # Store the exec instance + active_terminals[request.sid] = { + 'exec': exec_instance, + 'container_id': container_id + } + + # Capture request.sid before starting thread to avoid context issues + sid = request.sid + + # Start a thread to read from the container and send to client + def read_output(): + sock = exec_instance.output + try: + while True: + # Check if socket is still connected + if sid not in active_terminals: + break + + try: + # Read data from container + data = sock.recv(4096) + if not data: + break + + # Send to client + try: + decoded_data = data.decode('utf-8') + except UnicodeDecodeError: + decoded_data = data.decode('latin-1', errors='replace') + + socketio.emit('output', {'data': decoded_data}, + namespace='/terminal', room=sid) + except Exception as e: + logger.error(f"Error reading from container: {e}") + break + finally: + # Clean up + if sid in active_terminals: + del active_terminals[sid] + try: + sock.close() + except: + pass + socketio.emit('exit', {'code': 0}, + namespace='/terminal', room=sid) + + # Start the output reader thread + output_thread = threading.Thread(target=read_output, daemon=True) + output_thread.start() + + emit('started', {'message': 'Terminal started'}) + + except Exception as e: + logger.error(f"Error starting terminal: {e}", exc_info=True) + emit('error', {'error': str(e)}) + +@socketio.on('input', namespace='/terminal') +def handle_input(data): + """Handle input from the client""" + try: + if request.sid not in active_terminals: + emit('error', {'error': 'No active terminal session'}) + return + + terminal_data = active_terminals[request.sid] + exec_instance = terminal_data['exec'] + input_data = data.get('data', '') + + # Send input to the container + sock = exec_instance.output + # Access the underlying socket for sendall method + if hasattr(sock, '_sock'): + sock._sock.sendall(input_data.encode('utf-8')) + else: + # Fallback for direct socket objects + sock.sendall(input_data.encode('utf-8')) + + except Exception as e: + logger.error(f"Error sending input: {e}", exc_info=True) + emit('error', {'error': str(e)}) + +@socketio.on('resize', namespace='/terminal') +def handle_resize(data): + """Handle terminal resize""" + try: + cols = data.get('cols', 80) + rows = data.get('rows', 24) + + if request.sid in active_terminals: + terminal_data = active_terminals[request.sid] + exec_instance = terminal_data['exec'] + + # Note: Docker exec_run doesn't support resizing after creation + # This is a limitation of the Docker API + # We acknowledge the resize but can't actually resize the PTY + logger.info(f"Terminal resize requested: {cols}x{rows}") + + except Exception as e: + logger.error(f"Error resizing terminal: {e}", exc_info=True) + +if __name__ == '__main__': + # Run diagnostics on startup + logger.info("Backend server starting...") + diagnose_docker_environment() + + # Try to get Docker client and log result + test_client = get_docker_client() + if test_client: + logger.info("✓ Docker connection verified on startup") + else: + logger.error("✗ Docker connection FAILED on startup - check logs above for details") + + socketio.run(app, host='0.0.0.0', port=5000, debug=True, allow_unsafe_werkzeug=True) diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..086e854 --- /dev/null +++ b/backend/config.py @@ -0,0 +1,28 @@ +"""Application configuration and constants.""" +import os +import sys +import logging + +# Configure logging +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.StreamHandler(sys.stdout) + ] +) + +logger = logging.getLogger(__name__) + +# Default credentials (should be environment variables in production) +ADMIN_USERNAME = os.getenv('ADMIN_USERNAME', 'admin') +ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD', 'admin123') + +# Simple in-memory session storage (in production, use proper session management) +sessions = {} + +# Track working directory per session +session_workdirs = {} + +# Active terminal sessions +active_terminals = {} diff --git a/backend/handlers/__init__.py b/backend/handlers/__init__.py new file mode 100644 index 0000000..ac843f4 --- /dev/null +++ b/backend/handlers/__init__.py @@ -0,0 +1 @@ +"""Socket.io handlers - one file per event.""" diff --git a/backend/handlers/terminal/__init__.py b/backend/handlers/terminal/__init__.py new file mode 100644 index 0000000..949e7e5 --- /dev/null +++ b/backend/handlers/terminal/__init__.py @@ -0,0 +1 @@ +"""Terminal WebSocket handlers.""" diff --git a/backend/handlers/terminal/connect.py b/backend/handlers/terminal/connect.py new file mode 100644 index 0000000..e89e8fb --- /dev/null +++ b/backend/handlers/terminal/connect.py @@ -0,0 +1,8 @@ +"""Terminal WebSocket connect handler.""" +from flask import request +from config import logger + + +def handle_connect(): + """Handle WebSocket connection.""" + logger.info("Client connected to terminal WebSocket: %s", request.sid) diff --git a/backend/handlers/terminal/disconnect.py b/backend/handlers/terminal/disconnect.py new file mode 100644 index 0000000..66caba5 --- /dev/null +++ b/backend/handlers/terminal/disconnect.py @@ -0,0 +1,17 @@ +"""Terminal WebSocket disconnect handler.""" +from flask import request +from config import logger, active_terminals + + +def handle_disconnect(): + """Handle WebSocket disconnection.""" + logger.info("Client disconnected from terminal WebSocket: %s", request.sid) + # Clean up any active terminal sessions + if request.sid in active_terminals: + try: + exec_instance = active_terminals[request.sid]['exec'] + if hasattr(exec_instance, 'kill'): + exec_instance.kill() + except Exception: # pylint: disable=broad-exception-caught + pass + del active_terminals[request.sid] diff --git a/backend/handlers/terminal/input.py b/backend/handlers/terminal/input.py new file mode 100644 index 0000000..8007772 --- /dev/null +++ b/backend/handlers/terminal/input.py @@ -0,0 +1,32 @@ +"""Terminal WebSocket input handler.""" +from flask import request +from flask_socketio import emit +from config import logger, active_terminals + + +def handle_input(data): + """Handle input from the client. + + Args: + data: Input data containing the user's input string + """ + try: + if request.sid not in active_terminals: + emit('error', {'error': 'No active terminal session'}) + return + + terminal_data = active_terminals[request.sid] + exec_instance = terminal_data['exec'] + input_data = data.get('data', '') + + # Send input to the container + sock = exec_instance.output + # Access the underlying socket for sendall method + if hasattr(sock, '_sock'): + sock._sock.sendall(input_data.encode('utf-8')) # pylint: disable=protected-access + else: + sock.sendall(input_data.encode('utf-8')) + + except Exception as e: # pylint: disable=broad-exception-caught + logger.error("Error sending input: %s", e, exc_info=True) + emit('error', {'error': str(e)}) diff --git a/backend/handlers/terminal/register.py b/backend/handlers/terminal/register.py new file mode 100644 index 0000000..7305834 --- /dev/null +++ b/backend/handlers/terminal/register.py @@ -0,0 +1,33 @@ +"""Register all terminal WebSocket handlers.""" +from handlers.terminal.connect import handle_connect +from handlers.terminal.disconnect import handle_disconnect +from handlers.terminal.start import handle_start_terminal +from handlers.terminal.input import handle_input +from handlers.terminal.resize import handle_resize + + +def register_terminal_handlers(socketio): + """Register all terminal WebSocket event handlers. + + Args: + socketio: SocketIO instance to register handlers with + """ + @socketio.on('connect', namespace='/terminal') + def on_connect(): + return handle_connect() + + @socketio.on('disconnect', namespace='/terminal') + def on_disconnect(): + return handle_disconnect() + + @socketio.on('start_terminal', namespace='/terminal') + def on_start_terminal(data): + return handle_start_terminal(socketio, data) + + @socketio.on('input', namespace='/terminal') + def on_input(data): + return handle_input(data) + + @socketio.on('resize', namespace='/terminal') + def on_resize(data): + return handle_resize(data) diff --git a/backend/handlers/terminal/resize.py b/backend/handlers/terminal/resize.py new file mode 100644 index 0000000..0a844b1 --- /dev/null +++ b/backend/handlers/terminal/resize.py @@ -0,0 +1,24 @@ +"""Terminal WebSocket resize handler.""" +from flask import request +from config import logger, active_terminals + + +def handle_resize(data): + """Handle terminal resize. + + Args: + data: Resize data containing cols and rows + + Note: + Docker exec_run doesn't support resizing after creation. + This is a limitation of the Docker API. + """ + try: + cols = data.get('cols', 80) + rows = data.get('rows', 24) + + if request.sid in active_terminals: + logger.info("Terminal resize requested: %sx%s", cols, rows) + + except Exception as e: # pylint: disable=broad-exception-caught + logger.error("Error resizing terminal: %s", e, exc_info=True) diff --git a/backend/handlers/terminal/start.py b/backend/handlers/terminal/start.py new file mode 100644 index 0000000..86bb322 --- /dev/null +++ b/backend/handlers/terminal/start.py @@ -0,0 +1,65 @@ +"""Terminal WebSocket start handler.""" +from flask import request +from flask_socketio import emit, disconnect +from config import logger, sessions, active_terminals +from utils.docker_client import get_docker_client +from utils.terminal_helpers import create_output_reader + + +def handle_start_terminal(socketio, data): + """Start an interactive terminal session. + + Args: + socketio: SocketIO instance + data: Request data containing container_id, token, cols, rows + """ + try: + container_id = data.get('container_id') + token = data.get('token') + cols = data.get('cols', 80) + rows = data.get('rows', 24) + + # Validate token + if not token or token not in sessions: + emit('error', {'error': 'Unauthorized'}) + disconnect() + return + + # Get Docker client and container + client = get_docker_client() + if not client: + emit('error', {'error': 'Cannot connect to Docker'}) + return + + container = client.containers.get(container_id) + + # Create an interactive bash session with PTY + exec_instance = container.exec_run( + ['/bin/bash'], + stdin=True, + stdout=True, + stderr=True, + tty=True, + socket=True, + environment={ + 'TERM': 'xterm-256color', + 'COLUMNS': str(cols), + 'LINES': str(rows), + 'LANG': 'C.UTF-8' + } + ) + + # Store the exec instance + active_terminals[request.sid] = { + 'exec': exec_instance, + 'container_id': container_id + } + + # Start output reader thread + create_output_reader(socketio, request.sid, exec_instance) + + emit('started', {'message': 'Terminal started'}) + + except Exception as e: # pylint: disable=broad-exception-caught + logger.error("Error starting terminal: %s", e, exc_info=True) + emit('error', {'error': str(e)}) diff --git a/backend/routes/__init__.py b/backend/routes/__init__.py new file mode 100644 index 0000000..db8799d --- /dev/null +++ b/backend/routes/__init__.py @@ -0,0 +1 @@ +"""API routes - one file per endpoint for clarity.""" diff --git a/backend/routes/containers/__init__.py b/backend/routes/containers/__init__.py new file mode 100644 index 0000000..fada2f4 --- /dev/null +++ b/backend/routes/containers/__init__.py @@ -0,0 +1 @@ +"""Container management routes - one file per endpoint.""" diff --git a/backend/routes/containers/exec.py b/backend/routes/containers/exec.py new file mode 100644 index 0000000..84982bc --- /dev/null +++ b/backend/routes/containers/exec.py @@ -0,0 +1,65 @@ +"""Execute command in container route.""" +from flask import Blueprint, request, jsonify +from config import logger, session_workdirs +from utils.auth import check_auth +from utils.docker_client import get_docker_client +from utils.exec_helpers import ( + build_bash_command, + build_sh_command, + execute_in_container, + decode_output, + extract_workdir +) + +exec_bp = Blueprint('exec_container', __name__) + + +@exec_bp.route('/api/containers//exec', methods=['POST']) +def exec_container(container_id): + """Execute command in container.""" + is_valid, token, error_response = check_auth() + if not is_valid: + return error_response + + data = request.get_json() + user_command = data.get('command', 'echo "No command provided"') + + client = get_docker_client() + if not client: + return jsonify({'error': 'Cannot connect to Docker'}), 500 + + try: + container = client.containers.get(container_id) + + # Get or initialize session working directory + session_key = f"{token}_{container_id}" + if session_key not in session_workdirs: + session_workdirs[session_key] = '/' + + current_workdir = session_workdirs[session_key] + is_cd_command = user_command.strip().startswith('cd ') + + # Try bash first, fallback to sh if bash doesn't exist + try: + bash_command = build_bash_command(current_workdir, user_command, is_cd_command) + exec_instance = execute_in_container(container, bash_command) + except Exception as bash_error: # pylint: disable=broad-exception-caught + logger.warning("Bash execution failed, trying sh: %s", bash_error) + sh_command = build_sh_command(current_workdir, user_command, is_cd_command) + exec_instance = execute_in_container(container, sh_command) + + # Decode and extract workdir from output + output = decode_output(exec_instance) + output, new_workdir = extract_workdir(output, current_workdir, is_cd_command) + + # Update session workdir + session_workdirs[session_key] = new_workdir + + return jsonify({ + 'output': output, + 'exit_code': exec_instance.exit_code, + 'workdir': new_workdir + }) + except Exception as e: # pylint: disable=broad-exception-caught + logger.error("Error executing command: %s", e, exc_info=True) + return jsonify({'error': str(e)}), 500 diff --git a/backend/routes/containers/list.py b/backend/routes/containers/list.py new file mode 100644 index 0000000..b46a935 --- /dev/null +++ b/backend/routes/containers/list.py @@ -0,0 +1,37 @@ +"""List containers route.""" +from flask import Blueprint, jsonify +from utils.auth import check_auth +from utils.docker_client import get_docker_client +from utils.formatters import format_uptime + +list_bp = Blueprint('list_containers', __name__) + + +@list_bp.route('/api/containers', methods=['GET']) +def get_containers(): + """Get list of all containers.""" + is_valid, _, error_response = check_auth() + if not is_valid: + return error_response + + client = get_docker_client() + if not client: + return jsonify({'error': 'Cannot connect to Docker'}), 500 + + try: + containers = client.containers.list(all=True) + container_list = [] + + for container in containers: + container_list.append({ + 'id': container.short_id, + 'name': container.name, + 'image': container.image.tags[0] if container.image.tags else 'unknown', + 'status': container.status, + 'uptime': format_uptime(container.attrs['Created']) + if container.status == 'running' else 'N/A' + }) + + return jsonify({'containers': container_list}) + except Exception as e: # pylint: disable=broad-exception-caught + return jsonify({'error': str(e)}), 500 diff --git a/backend/routes/containers/remove.py b/backend/routes/containers/remove.py new file mode 100644 index 0000000..376a952 --- /dev/null +++ b/backend/routes/containers/remove.py @@ -0,0 +1,28 @@ +"""Remove container route.""" +from flask import Blueprint, jsonify +from config import logger +from utils.auth import check_auth +from utils.docker_client import get_docker_client + +remove_bp = Blueprint('remove_container', __name__) + + +@remove_bp.route('/api/containers/', methods=['DELETE']) +def remove_container(container_id): + """Remove a container.""" + is_valid, _, error_response = check_auth() + if not is_valid: + return error_response + + client = get_docker_client() + if not client: + return jsonify({'error': 'Cannot connect to Docker'}), 500 + + try: + container = client.containers.get(container_id) + container.remove(force=True) + logger.info("Removed container %s", container_id) + return jsonify({'success': True, 'message': f'Container {container_id} removed'}) + except Exception as e: # pylint: disable=broad-exception-caught + logger.error("Error removing container: %s", e, exc_info=True) + return jsonify({'error': str(e)}), 500 diff --git a/backend/routes/containers/restart.py b/backend/routes/containers/restart.py new file mode 100644 index 0000000..1b6820d --- /dev/null +++ b/backend/routes/containers/restart.py @@ -0,0 +1,28 @@ +"""Restart container route.""" +from flask import Blueprint, jsonify +from config import logger +from utils.auth import check_auth +from utils.docker_client import get_docker_client + +restart_bp = Blueprint('restart_container', __name__) + + +@restart_bp.route('/api/containers//restart', methods=['POST']) +def restart_container(container_id): + """Restart a container.""" + is_valid, _, error_response = check_auth() + if not is_valid: + return error_response + + client = get_docker_client() + if not client: + return jsonify({'error': 'Cannot connect to Docker'}), 500 + + try: + container = client.containers.get(container_id) + container.restart() + logger.info("Restarted container %s", container_id) + return jsonify({'success': True, 'message': f'Container {container_id} restarted'}) + except Exception as e: # pylint: disable=broad-exception-caught + logger.error("Error restarting container: %s", e, exc_info=True) + return jsonify({'error': str(e)}), 500 diff --git a/backend/routes/containers/start.py b/backend/routes/containers/start.py new file mode 100644 index 0000000..424c39c --- /dev/null +++ b/backend/routes/containers/start.py @@ -0,0 +1,28 @@ +"""Start container route.""" +from flask import Blueprint, jsonify +from config import logger +from utils.auth import check_auth +from utils.docker_client import get_docker_client + +start_bp = Blueprint('start_container', __name__) + + +@start_bp.route('/api/containers//start', methods=['POST']) +def start_container(container_id): + """Start a stopped container.""" + is_valid, _, error_response = check_auth() + if not is_valid: + return error_response + + client = get_docker_client() + if not client: + return jsonify({'error': 'Cannot connect to Docker'}), 500 + + try: + container = client.containers.get(container_id) + container.start() + logger.info("Started container %s", container_id) + return jsonify({'success': True, 'message': f'Container {container_id} started'}) + except Exception as e: # pylint: disable=broad-exception-caught + logger.error("Error starting container: %s", e, exc_info=True) + return jsonify({'error': str(e)}), 500 diff --git a/backend/routes/containers/stop.py b/backend/routes/containers/stop.py new file mode 100644 index 0000000..e57b0d1 --- /dev/null +++ b/backend/routes/containers/stop.py @@ -0,0 +1,28 @@ +"""Stop container route.""" +from flask import Blueprint, jsonify +from config import logger +from utils.auth import check_auth +from utils.docker_client import get_docker_client + +stop_bp = Blueprint('stop_container', __name__) + + +@stop_bp.route('/api/containers//stop', methods=['POST']) +def stop_container(container_id): + """Stop a running container.""" + is_valid, _, error_response = check_auth() + if not is_valid: + return error_response + + client = get_docker_client() + if not client: + return jsonify({'error': 'Cannot connect to Docker'}), 500 + + try: + container = client.containers.get(container_id) + container.stop() + logger.info("Stopped container %s", container_id) + return jsonify({'success': True, 'message': f'Container {container_id} stopped'}) + except Exception as e: # pylint: disable=broad-exception-caught + logger.error("Error stopping container: %s", e, exc_info=True) + return jsonify({'error': str(e)}), 500 diff --git a/backend/routes/health.py b/backend/routes/health.py new file mode 100644 index 0000000..a7b0c74 --- /dev/null +++ b/backend/routes/health.py @@ -0,0 +1,10 @@ +"""Health check route.""" +from flask import Blueprint, jsonify + +health_bp = Blueprint('health', __name__) + + +@health_bp.route('/api/health', methods=['GET']) +def health(): + """Health check endpoint.""" + return jsonify({'status': 'healthy'}) diff --git a/backend/routes/login.py b/backend/routes/login.py new file mode 100644 index 0000000..bc552b0 --- /dev/null +++ b/backend/routes/login.py @@ -0,0 +1,31 @@ +"""Login route.""" +from datetime import datetime +from flask import Blueprint, request, jsonify +from config import ADMIN_USERNAME, ADMIN_PASSWORD, sessions + +login_bp = Blueprint('login', __name__) + + +@login_bp.route('/api/auth/login', methods=['POST']) +def login(): + """Authenticate user.""" + data = request.get_json() + username = data.get('username') + password = data.get('password') + + if username == ADMIN_USERNAME and password == ADMIN_PASSWORD: + session_token = f"session_{username}_{datetime.now().timestamp()}" + sessions[session_token] = { + 'username': username, + 'created_at': datetime.now() + } + return jsonify({ + 'success': True, + 'token': session_token, + 'username': username + }) + + return jsonify({ + 'success': False, + 'message': 'Invalid credentials' + }), 401 diff --git a/backend/routes/logout.py b/backend/routes/logout.py new file mode 100644 index 0000000..82f53ab --- /dev/null +++ b/backend/routes/logout.py @@ -0,0 +1,17 @@ +"""Logout route.""" +from flask import Blueprint, request, jsonify +from config import sessions + +logout_bp = Blueprint('logout', __name__) + + +@logout_bp.route('/api/auth/logout', methods=['POST']) +def logout(): + """Logout user.""" + auth_header = request.headers.get('Authorization') + if auth_header and auth_header.startswith('Bearer '): + token = auth_header.split(' ')[1] + if token in sessions: + del sessions[token] + + return jsonify({'success': True}) diff --git a/backend/utils/__init__.py b/backend/utils/__init__.py new file mode 100644 index 0000000..183c974 --- /dev/null +++ b/backend/utils/__init__.py @@ -0,0 +1 @@ +"""Utility modules.""" diff --git a/backend/utils/auth.py b/backend/utils/auth.py new file mode 100644 index 0000000..2fd80f8 --- /dev/null +++ b/backend/utils/auth.py @@ -0,0 +1,20 @@ +"""Authentication utilities.""" +from flask import request, jsonify +from config import sessions + + +def check_auth(): + """Check if request has valid authentication. + + Returns: + tuple: (is_valid, token, error_response) + """ + auth_header = request.headers.get('Authorization') + if not auth_header or not auth_header.startswith('Bearer '): + return False, None, (jsonify({'error': 'Unauthorized'}), 401) + + token = auth_header.split(' ')[1] + if token not in sessions: + return False, None, (jsonify({'error': 'Invalid session'}), 401) + + return True, token, None diff --git a/backend/utils/diagnostics/__init__.py b/backend/utils/diagnostics/__init__.py new file mode 100644 index 0000000..cc85028 --- /dev/null +++ b/backend/utils/diagnostics/__init__.py @@ -0,0 +1 @@ +"""Docker diagnostics utilities.""" diff --git a/backend/utils/diagnostics/docker_env.py b/backend/utils/diagnostics/docker_env.py new file mode 100644 index 0000000..b3df39a --- /dev/null +++ b/backend/utils/diagnostics/docker_env.py @@ -0,0 +1,85 @@ +"""Docker environment diagnostics.""" +import os +from config import logger + + +def diagnose_docker_environment(): + """Diagnose Docker environment and configuration.""" + logger.info("=== Docker Environment Diagnosis ===") + + # Check environment variables + docker_host = os.getenv('DOCKER_HOST', 'Not set') + docker_cert_path = os.getenv('DOCKER_CERT_PATH', 'Not set') + docker_tls_verify = os.getenv('DOCKER_TLS_VERIFY', 'Not set') + + logger.info("DOCKER_HOST: %s", docker_host) + logger.info("DOCKER_CERT_PATH: %s", docker_cert_path) + logger.info("DOCKER_TLS_VERIFY: %s", docker_tls_verify) + + # Check what's in /var/run + logger.info("Checking /var/run directory contents:") + try: + if os.path.exists('/var/run'): + var_run_contents = os.listdir('/var/run') + logger.info(" /var/run contains: %s", var_run_contents) + + # Check for any Docker-related files + docker_related = [f for f in var_run_contents if 'docker' in f.lower()] + if docker_related: + logger.info(" Docker-related files/dirs found: %s", docker_related) + else: + logger.warning(" /var/run directory doesn't exist") + except Exception as e: # pylint: disable=broad-exception-caught + logger.error(" Error reading /var/run: %s", e) + + # Check Docker socket + socket_path = '/var/run/docker.sock' + logger.info("Checking Docker socket at %s", socket_path) + + if os.path.exists(socket_path): + logger.info("✓ Docker socket exists at %s", socket_path) + + # Check permissions + import stat # pylint: disable=import-outside-toplevel + st = os.stat(socket_path) + logger.info(" Socket permissions: %s", oct(st.st_mode)) + logger.info(" Socket owner UID: %s", st.st_uid) + logger.info(" Socket owner GID: %s", st.st_gid) + + # Check if readable/writable + readable = os.access(socket_path, os.R_OK) + writable = os.access(socket_path, os.W_OK) + logger.info(" Readable: %s", readable) + logger.info(" Writable: %s", writable) + + if not (readable and writable): + logger.warning("⚠ Socket exists but lacks proper permissions!") + else: + logger.error("✗ Docker socket NOT found at %s", socket_path) + logger.error(" This means the Docker socket mount is NOT configured in CapRover") + logger.error(" The serviceUpdateOverride in captain-definition may not be applied") + + # Check current user + import pwd # pylint: disable=import-outside-toplevel + try: + current_uid = os.getuid() + current_gid = os.getgid() + user_info = pwd.getpwuid(current_uid) + logger.info("Current user: %s (UID: %s, GID: %s)", + user_info.pw_name, current_uid, current_gid) + + # Check groups + import grp # pylint: disable=import-outside-toplevel + groups = os.getgroups() + logger.info("User groups (GIDs): %s", groups) + + for gid in groups: + try: + group_info = grp.getgrgid(gid) + logger.info(" - %s (GID: %s)", group_info.gr_name, gid) + except KeyError: + logger.info(" - Unknown group (GID: %s)", gid) + except Exception as e: # pylint: disable=broad-exception-caught + logger.error("Error checking user info: %s", e) + + logger.info("=== End Diagnosis ===") diff --git a/backend/utils/docker_client.py b/backend/utils/docker_client.py new file mode 100644 index 0000000..03e6947 --- /dev/null +++ b/backend/utils/docker_client.py @@ -0,0 +1,38 @@ +"""Docker client getter.""" +import docker +from config import logger +from utils.diagnostics.docker_env import diagnose_docker_environment + + +def get_docker_client(): + """Get Docker client with enhanced error reporting.""" + try: + logger.info("Attempting to connect to Docker...") + + # Try default connection first + try: + client = docker.from_env() + client.ping() + logger.info("✓ Successfully connected to Docker using docker.from_env()") + return client + except Exception as e: # pylint: disable=broad-exception-caught + logger.warning("docker.from_env() failed: %s", e) + + # Try explicit Unix socket connection + try: + logger.info("Trying explicit Unix socket connection...") + client = docker.DockerClient(base_url='unix:///var/run/docker.sock') + client.ping() + logger.info("✓ Successfully connected to Docker using Unix socket") + return client + except Exception as e: # pylint: disable=broad-exception-caught + logger.warning("Unix socket connection failed: %s", e) + + # If all fails, run diagnostics and return None + logger.error("All Docker connection attempts failed!") + diagnose_docker_environment() + return None + + except Exception as e: # pylint: disable=broad-exception-caught + logger.error("Unexpected error in get_docker_client: %s", e, exc_info=True) + return None diff --git a/backend/utils/exec_helpers.py b/backend/utils/exec_helpers.py new file mode 100644 index 0000000..29eaae4 --- /dev/null +++ b/backend/utils/exec_helpers.py @@ -0,0 +1,108 @@ +"""Helper functions for container exec operations.""" + + +def build_bash_command(current_workdir, user_command, is_cd_command): + """Build bash command for execution. + + Args: + current_workdir: Current working directory + user_command: User's command + is_cd_command: Whether this is a cd command + + Returns: + list: Command array for Docker exec + """ + path_export = 'export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' + + if is_cd_command: + target_dir = user_command.strip()[3:].strip() or '~' + resolve_command = f'cd "{current_workdir}" && cd {target_dir} && pwd' + return ['/bin/bash', '-c', f'{path_export}; {resolve_command}'] + + return [ + '/bin/bash', '-c', + f'{path_export}; cd "{current_workdir}" && {user_command}; echo "::WORKDIR::$(pwd)"' + ] + + +def build_sh_command(current_workdir, user_command, is_cd_command): + """Build sh command for execution (fallback). + + Args: + current_workdir: Current working directory + user_command: User's command + is_cd_command: Whether this is a cd command + + Returns: + list: Command array for Docker exec + """ + path_export = 'export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' + + if is_cd_command: + target_dir = user_command.strip()[3:].strip() or '~' + resolve_command = f'cd "{current_workdir}" && cd {target_dir} && pwd' + return ['/bin/sh', '-c', f'{path_export}; {resolve_command}'] + + return [ + '/bin/sh', '-c', + f'{path_export}; cd "{current_workdir}" && {user_command}; echo "::WORKDIR::$(pwd)"' + ] + + +def execute_in_container(container, command): + """Execute command in container. + + Args: + container: Docker container object + command: Command to execute + + Returns: + Docker exec instance + """ + return container.exec_run( + command, + stdout=True, + stderr=True, + stdin=False, + tty=True, + environment={'TERM': 'xterm-256color', 'LANG': 'C.UTF-8'} + ) + + +def decode_output(exec_instance): + """Decode exec output with fallback encoding. + + Args: + exec_instance: Docker exec instance + + Returns: + str: Decoded output + """ + if not exec_instance.output: + return '' + + try: + return exec_instance.output.decode('utf-8') + except UnicodeDecodeError: + return exec_instance.output.decode('latin-1', errors='replace') + + +def extract_workdir(output, current_workdir, is_cd_command): + """Extract working directory from command output. + + Args: + output: Command output + current_workdir: Current working directory + is_cd_command: Whether this was a cd command + + Returns: + tuple: (cleaned_output, new_workdir) + """ + if is_cd_command: + return '', output.strip() + + if '::WORKDIR::' in output: + parts = output.rsplit('::WORKDIR::', 1) + return parts[0], parts[1].strip() + + return output, current_workdir diff --git a/backend/utils/formatters.py b/backend/utils/formatters.py new file mode 100644 index 0000000..0db204a --- /dev/null +++ b/backend/utils/formatters.py @@ -0,0 +1,26 @@ +"""Formatting utility functions.""" +from datetime import datetime + + +def format_uptime(created_at): + """Format container uptime. + + Args: + created_at: ISO format datetime string + + Returns: + Formatted uptime string (e.g., "2d 3h", "5h 30m", "15m") + """ + created = datetime.fromisoformat(created_at.replace('Z', '+00:00')) + now = datetime.now(created.tzinfo) + delta = now - created + + days = delta.days + hours = delta.seconds // 3600 + minutes = (delta.seconds % 3600) // 60 + + if days > 0: + return f"{days}d {hours}h" + if hours > 0: + return f"{hours}h {minutes}m" + return f"{minutes}m" diff --git a/backend/utils/terminal_helpers.py b/backend/utils/terminal_helpers.py new file mode 100644 index 0000000..91263b8 --- /dev/null +++ b/backend/utils/terminal_helpers.py @@ -0,0 +1,50 @@ +"""Helper functions for terminal operations.""" +import threading +from config import logger, active_terminals + + +def create_output_reader(socketio, sid, exec_instance): + """Create and start output reader thread. + + Args: + socketio: SocketIO instance + sid: Session ID + exec_instance: Docker exec instance + + Returns: + Thread: Started output reader thread + """ + def read_output(): + sock = exec_instance.output + try: + while True: + if sid not in active_terminals: + break + + try: + data = sock.recv(4096) + if not data: + break + + try: + decoded_data = data.decode('utf-8') + except UnicodeDecodeError: + decoded_data = data.decode('latin-1', errors='replace') + + socketio.emit('output', {'data': decoded_data}, + namespace='/terminal', room=sid) + except Exception as e: # pylint: disable=broad-exception-caught + logger.error("Error reading from container: %s", e) + break + finally: + if sid in active_terminals: + del active_terminals[sid] + try: + sock.close() + except Exception: # pylint: disable=broad-exception-caught + pass + socketio.emit('exit', {'code': 0}, namespace='/terminal', room=sid) + + thread = threading.Thread(target=read_output, daemon=True) + thread.start() + return thread