# app.py from flask import Flask, render_template, request, redirect, url_for, session, jsonify import os import subprocess import threading import uuid import time from config import ( MAIN_SERVER_IP, RUNNING_ON_MAIN_SERVER, VM_HOST, VM_PORT, VM_KEY, VM_USER, MAIN_SERVER_KEY, MAIN_SERVER_PORT, MAIN_SERVER_USER, ) from modules.auth import login_required from modules.backups import ( get_containers, get_all_root_containers, get_local_backups, get_vm_backups, get_all_stats, get_system_info, get_rootless_user_containers_remote, container_action, get_container_status, audit_backup, delete_backup, get_backup_log_entries, get_backup_script_path, ) from modules.commands import run_command from modules.users import ( get_all_users, get_user_containers, get_all_users_containers, create_user, delete_user, get_user_disk_usage, ) app = Flask(__name__) app.secret_key = 'navitrends-secret-key-2025' # Increase default timeout for slow VM→main-server SSH calls app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0 restore_jobs = {} backup_jobs = {} def _stream_restore(job_id, cmd): restore_jobs[job_id] = {'status': 'running', 'log': [], 'started': time.time()} try: proc = subprocess.Popen( cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1 ) for line in proc.stdout: restore_jobs[job_id]['log'].append(line.rstrip()) proc.wait() restore_jobs[job_id]['status'] = 'done' if proc.returncode == 0 else 'error' restore_jobs[job_id]['returncode'] = proc.returncode except Exception as e: restore_jobs[job_id]['log'].append(f"ERROR: {e}") restore_jobs[job_id]['status'] = 'error' def _stream_backup(job_id, script_path): backup_jobs[job_id] = {'status': 'running', 'log': [], 'started': time.time()} try: proc = subprocess.Popen( ['bash', script_path], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1 ) for line in proc.stdout: backup_jobs[job_id]['log'].append(line.rstrip()) proc.wait() backup_jobs[job_id]['status'] = 'done' if proc.returncode == 0 else 'error' backup_jobs[job_id]['returncode'] = proc.returncode except Exception as e: backup_jobs[job_id]['log'].append(f"ERROR: {e}") backup_jobs[job_id]['status'] = 'error' # ───────────────────────────────────────────── # DASHBOARD # Loads instantly — all heavy data fetched async via JS after page renders # ───────────────────────────────────────────── @app.route('/') @login_required def dashboard(): # On the VM: skip slow SSH calls at page load — JS fetches them async via /api/dashboard # On the main server: fetch everything normally (local calls, no SSH delay) backups = get_local_backups() vm_backups = get_vm_backups() if RUNNING_ON_MAIN_SERVER: containers = get_containers() running_count = sum(1 for c in containers if 'Up' in c.get('status', '')) system = get_system_info() users = get_all_users() else: containers = [] # loaded async by JS via /api/dashboard running_count = 0 system = {} users = [] return render_template('pages/dashboard.html', containers=containers, running_count=running_count, backups=backups, vm_backups=vm_backups, main_server=MAIN_SERVER_IP, system=system, users=users, running_on_main=RUNNING_ON_MAIN_SERVER, active_page='dashboard', page_title='Dashboard', page_subtitle=MAIN_SERVER_IP) @app.route('/containers') @login_required def containers_page(): return render_template( 'pages/containers.html', main_server=MAIN_SERVER_IP, active_page='containers', page_title='All Containers', page_subtitle='main server · all users' ) @app.route('/backups') @login_required def backups_page(): return render_template( 'pages/backups.html', backups=get_local_backups(), vm_backups=get_vm_backups(), main_server=MAIN_SERVER_IP, active_page='backups', page_title='Backup Management', page_subtitle='local & VM' ) @app.route('/restore') @login_required def restore_page(): prefill = { 'source': request.args.get('source', '').strip(), 'file': request.args.get('file', '').strip(), } return render_template( 'pages/restore.html', backups=get_local_backups(), vm_backups=get_vm_backups(), restore_prefill=prefill, main_server=MAIN_SERVER_IP, active_page='restore', page_title='Restore', page_subtitle='backup → target' ) @app.route('/users') @login_required def users_page(): # On VM: skip slow SSH call — JS loads users async via /api/users # On main server: fetch normally (local, fast) users = get_all_users() if RUNNING_ON_MAIN_SERVER else [] return render_template( 'pages/users.html', users=users, main_server=MAIN_SERVER_IP, active_page='users', page_title='User Management', page_subtitle='linux + docker' ) @app.route('/settings') @login_required def settings_page(): # On VM: skip slow SSH call — JS loads system info async via /api/system # On main server: fetch normally (local, fast) system = get_system_info() if RUNNING_ON_MAIN_SERVER else {} return render_template( 'pages/settings.html', main_server=MAIN_SERVER_IP, system=system, running_on_main=RUNNING_ON_MAIN_SERVER, active_page='settings', page_title='Settings', page_subtitle='platform config' ) # ───────────────────────────────────────────── # API — system + stats (always from main server) # ───────────────────────────────────────────── @app.route('/api/system') @login_required def api_system(): return jsonify(get_system_info()) @app.route('/api/stats') @login_required def api_stats(): return jsonify(get_all_stats()) @app.route('/api/containers') @login_required def api_containers(): containers = get_all_root_containers() running_count = sum(1 for c in containers if 'Up' in c.get('status', '')) return jsonify({'containers': containers, 'running': running_count}) @app.route('/api/containers/all') @login_required def api_containers_all(): """Root containers + rootless-user containers, all from main server.""" root_ctrs = get_all_root_containers() user_ctrs = get_rootless_user_containers_remote() all_ctrs = root_ctrs + user_ctrs running = sum(1 for c in all_ctrs if 'Up' in c.get('status', '')) return jsonify({'containers': all_ctrs, 'running': running}) @app.route('/api/nav-summary') @login_required def api_nav_summary(): """Lightweight counts for sidebar badges (one round trip).""" root_ctrs = get_all_root_containers() user_ctrs = get_rootless_user_containers_remote() all_ctrs = root_ctrs + user_ctrs users = get_all_users() return jsonify({ 'container_count': len(all_ctrs), 'user_count': len(users), }) # ───────────────────────────────────────────── # API — dashboard summary (fast async load) # ───────────────────────────────────────────── @app.route('/api/dashboard') @login_required def api_dashboard(): """ Single endpoint the dashboard JS calls after page render. Returns system info + container summary + user count in one shot. """ system = get_system_info() root_ctrs = get_all_root_containers() user_ctrs = get_rootless_user_containers_remote() all_ctrs = root_ctrs + user_ctrs users = get_all_users() running = sum(1 for c in all_ctrs if 'Up' in c.get('status', '')) return jsonify({ 'system': system, 'containers': all_ctrs, 'running_count': running, 'user_count': len(users), 'local_backups': len(get_local_backups()), 'vm_backups': len(get_vm_backups()), }) # ───────────────────────────────────────────── # API — container actions # ───────────────────────────────────────────── @app.route('/api/container/action', methods=['POST']) @login_required def api_container_action(): data = request.get_json() or {} name = data.get('name', '').strip() action = data.get('action', '').strip() if not name or not action: return jsonify({'success': False, 'message': 'name and action required'}), 400 success, output = container_action(name, action) time.sleep(1.5) status_info = get_container_status(name) return jsonify({ 'success': success, 'output': output, 'new_status': status_info['status'], 'new_status_raw': status_info['raw'], }) @app.route('/api/container/status/') @login_required def api_container_status(name): status_info = get_container_status(name) return jsonify(status_info) # ───────────────────────────────────────────── # API — backups # ───────────────────────────────────────────── @app.route('/api/backups') @login_required def api_backups(): return jsonify({'local': get_local_backups(), 'vm': get_vm_backups()}) @app.route('/api/backups/log') @login_required def api_backup_log(): limit = int(request.args.get('limit', 20)) entries = get_backup_log_entries(limit) return jsonify({'entries': entries}) @app.route('/api/backups/audit', methods=['POST']) @login_required def api_backup_audit(): data = request.get_json() or {} bfile = data.get('backup_file', '').strip() source = data.get('source', 'local').strip() if not bfile: return jsonify({'error': 'backup_file required'}), 400 result = audit_backup(bfile, source) return jsonify(result) @app.route('/api/backups/delete', methods=['POST']) @login_required def api_backup_delete(): data = request.get_json() or {} bfile = data.get('backup_file', '').strip() source = data.get('source', 'local').strip() if not bfile: return jsonify({'success': False, 'message': 'backup_file required'}), 400 success, message = delete_backup(bfile, source) return jsonify({'success': success, 'message': message}) @app.route('/api/backups/run', methods=['POST']) @login_required def api_backup_run(): if not RUNNING_ON_MAIN_SERVER: return jsonify({ 'success': False, 'message': 'Manual backup can only be triggered from the main server platform.' }), 400 script = '/root/backup-myapps.sh' if not os.path.exists(script): return jsonify({ 'success': False, 'message': f'Backup script not found at {script}' }), 500 job_id = str(uuid.uuid4()) t = threading.Thread(target=_stream_backup, args=(job_id, script), daemon=True) t.start() return jsonify({'success': True, 'job_id': job_id, 'status': 'started'}) @app.route('/api/backups/run/status/') @login_required def api_backup_run_status(job_id): job = backup_jobs.get(job_id) if not job: return jsonify({'error': 'Job not found'}), 404 return jsonify({ 'status': job['status'], 'log': job['log'], 'elapsed': round(time.time() - job.get('started', time.time())) }) # ───────────────────────────────────────────── # API — users # ───────────────────────────────────────────── @app.route('/api/users') @login_required def api_users(): return jsonify(get_all_users()) @app.route('/api/users//containers') @login_required def api_user_containers(username): return jsonify(get_user_containers(username)) @app.route('/api/users//disk') @login_required def api_user_disk(username): return jsonify(get_user_disk_usage(username)) @app.route('/api/users/create', methods=['POST']) @login_required def api_create_user(): data = request.get_json() or {} username = data.get('username', '').strip() password = data.get('password', '').strip() setup_docker = data.get('setup_docker', True) disk_quota_mb = data.get('disk_quota_mb') if not username: return jsonify({'success': False, 'message': 'Username required'}), 400 success, message = create_user( username=username, password=password or None, setup_docker=setup_docker, disk_quota_mb=disk_quota_mb, ) return jsonify({'success': success, 'message': message}) @app.route('/api/users/delete', methods=['POST']) @login_required def api_delete_user(): data = request.get_json() or {} username = data.get('username', '').strip() remove_home = data.get('remove_home', False) if not username: return jsonify({'success': False, 'message': 'Username required'}), 400 success, message = delete_user(username, remove_home=remove_home) return jsonify({'success': success, 'message': message}) # ───────────────────────────────────────────── # RESTORE # ───────────────────────────────────────────── @app.route('/restore/start', methods=['POST']) @login_required def restore_start(): data = request.get_json() if not data: return jsonify({'error': 'No JSON body'}), 400 backup_source = data.get('backup_source', 'local') backup_file = data.get('backup_file', '').strip() target = data.get('target', 'local') remote_ip = data.get('remote_ip', '').strip() remote_port = str(data.get('remote_port', '22')).strip() or '22' remote_user = data.get('remote_user', 'root').strip() or 'root' auth_method = data.get('auth_method', 'key') ssh_key_path = data.get('ssh_key_path', VM_KEY).strip() ssh_password = data.get('ssh_password', '').strip() if not backup_file: return jsonify({'error': 'No backup file specified'}), 400 if backup_source == 'local': if RUNNING_ON_MAIN_SERVER: backup_path = f"/root/backups/{backup_file}" if not os.path.exists(backup_path): return jsonify({'error': f'Not found: {backup_path}'}), 400 else: backup_path = f"/tmp/{backup_file}" if not os.path.exists(backup_path): pull_cmd = ( f"scp -i {MAIN_SERVER_KEY} -P {MAIN_SERVER_PORT} " f"-o StrictHostKeyChecking=no -o ConnectTimeout=15 " f"{MAIN_SERVER_USER}@{MAIN_SERVER_IP}:/root/backups/{backup_file} " f"{backup_path}" ) res = subprocess.run(pull_cmd, shell=True, capture_output=True, text=True) if res.returncode != 0: return jsonify({'error': f'Failed to pull from main server: {res.stderr}'}), 500 else: if RUNNING_ON_MAIN_SERVER: backup_path = f"/tmp/{backup_file}" if not os.path.exists(backup_path): pull_cmd = ( f"scp -i {VM_KEY} -P {VM_PORT} " f"-o StrictHostKeyChecking=no -o ConnectTimeout=15 " f"{VM_USER}@{VM_HOST}:/backups/main-server/{backup_file} " f"{backup_path}" ) res = subprocess.run(pull_cmd, shell=True, capture_output=True, text=True) if res.returncode != 0: return jsonify({'error': f'Failed to pull from VM: {res.stderr}'}), 500 else: backup_path = f"/backups/main-server/{backup_file}" if not os.path.exists(backup_path): return jsonify({'error': f'Not found: {backup_path}'}), 400 restore_script_local = os.path.join( os.path.dirname(os.path.abspath(__file__)), 'restore-myapps.sh' ) if not os.path.exists(restore_script_local): return jsonify({'error': f'restore-myapps.sh not found at {restore_script_local}'}), 500 if target == 'local': hostname = os.uname().nodename session_dir = f"/tmp/restore-session-{uuid.uuid4().hex[:8]}" cmd = ( f"set -e && " f"echo '🖥️ Restoring on this server ({hostname})...' && " f"mkdir -p {session_dir} && " f"echo '📂 Extracting backup...' && " f"tar -xzf {backup_path} -C {session_dir} --strip-components=1 && " f"cp {restore_script_local} {session_dir}/restore-myapps.sh && " f"chmod +x {session_dir}/restore-myapps.sh && " f"cd {session_dir} && bash restore-myapps.sh ; " f"EXIT=$? ; rm -rf {session_dir} ; exit $EXIT" ) else: if not remote_ip: return jsonify({'error': 'remote_ip required'}), 400 base_opts = "-o StrictHostKeyChecking=no -o ConnectTimeout=15" if auth_method == 'key': if not ssh_key_path: return jsonify({'error': 'ssh_key_path required'}), 400 ssh_prefix = f"ssh -p {remote_port} -i {ssh_key_path} {base_opts}" scp_prefix = f"scp -P {remote_port} -i {ssh_key_path} {base_opts}" else: if not ssh_password: return jsonify({'error': 'ssh_password required'}), 400 ssh_prefix = f"sshpass -p '{ssh_password}' ssh -p {remote_port} {base_opts}" scp_prefix = f"sshpass -p '{ssh_password}' scp -P {remote_port} {base_opts}" remote_dest = f"/backups/restore-session-{uuid.uuid4().hex[:8]}" cmd = ( f"echo '🔗 Connecting to {remote_user}@{remote_ip}:{remote_port}...' && " f"{ssh_prefix} {remote_user}@{remote_ip} 'mkdir -p {remote_dest}' && " f"echo '✅ Connected.' && " f"echo '📤 Copying backup archive...' && " f"{scp_prefix} {backup_path} {remote_user}@{remote_ip}:{remote_dest}/{backup_file} && " f"echo '📤 Copying restore script...' && " f"{scp_prefix} {restore_script_local} {remote_user}@{remote_ip}:{remote_dest}/restore-myapps.sh && " f"echo '🚀 Running restore on {remote_ip}:{remote_port}...' && " f"{ssh_prefix} {remote_user}@{remote_ip} " f"'set -e && cd {remote_dest} && " f"tar -xzf {backup_file} --strip-components=1 && " f"chmod +x restore-myapps.sh && bash restore-myapps.sh' ; " f"EXIT=$? ; " f"{ssh_prefix} {remote_user}@{remote_ip} 'rm -rf {remote_dest}' 2>/dev/null ; " f"exit $EXIT" ) job_id = str(uuid.uuid4()) t = threading.Thread(target=_stream_restore, args=(job_id, cmd), daemon=True) t.start() return jsonify({'job_id': job_id, 'status': 'started'}) @app.route('/restore/status/') @login_required def restore_status_poll(job_id): job = restore_jobs.get(job_id) if not job: return jsonify({'error': 'Job not found'}), 404 return jsonify({ 'status': job['status'], 'log': job['log'], 'elapsed': round(time.time() - job.get('started', time.time())) }) # ───────────────────────────────────────────── # SERVER STATUS # ───────────────────────────────────────────── @app.route('/server/status') @login_required def server_status(): stdout, stderr = run_command("uptime") if stderr or not stdout: return jsonify({'status': 'offline', 'error': stderr or 'Failed'}) return jsonify({'status': 'online', 'info': stdout.strip()}) # ───────────────────────────────────────────── # AUTH # ───────────────────────────────────────────── @app.route('/login', methods=['GET', 'POST']) def login(): error = '' if request.method == 'POST': if request.form.get('password') == 'admin123': session['logged_in'] = True return redirect(url_for('dashboard')) error = 'Wrong password' return render_template('login.html', error=error) @app.route('/logout') def logout(): session.pop('logged_in', None) return redirect(url_for('login')) if __name__ == '__main__': app.run(host='0.0.0.0', port=5000, debug=False)