From 09bbe0403cc6f6d76063799f050d9ab937acaa88 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 5 May 2026 00:20:15 +0200 Subject: [PATCH] Sync from main server - 2026-05-05 00:20:15 --- platform/app.py | 130 ++++--- platform/modules/backups.py | 109 +++--- platform/modules/users.py | 495 ++++++++++++++---------- platform/templates/pages/dashboard.html | 145 ++++++- scripts/backup-myapps.sh | 17 + 5 files changed, 560 insertions(+), 336 deletions(-) diff --git a/platform/app.py b/platform/app.py index b99ce34..f115a77 100644 --- a/platform/app.py +++ b/platform/app.py @@ -30,8 +30,11 @@ from modules.users import ( app = Flask(__name__) app.secret_key = 'navitrends-secret-key-2025' -restore_jobs = {} -backup_jobs = {} # for manual backup runs +# 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): @@ -53,7 +56,6 @@ def _stream_restore(job_id, cmd): def _stream_backup(job_id, script_path): - """Run the backup script and stream its output into backup_jobs.""" backup_jobs[job_id] = {'status': 'running', 'log': [], 'started': time.time()} try: proc = subprocess.Popen( @@ -73,16 +75,27 @@ def _stream_backup(job_id, script_path): # ───────────────────────────────────────────── # DASHBOARD +# Loads instantly — all heavy data fetched async via JS after page renders # ───────────────────────────────────────────── @app.route('/') @login_required def dashboard(): - containers = get_containers() - running_count = sum(1 for c in containers if 'Up' in c.get('status', '')) - backups = get_local_backups() - vm_backups = get_vm_backups() - system = get_system_info() - users = get_all_users() + # 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, @@ -145,9 +158,12 @@ def restore_page(): @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=get_all_users(), + users=users, main_server=MAIN_SERVER_IP, active_page='users', page_title='User Management', @@ -158,10 +174,13 @@ def users_page(): @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=get_system_info(), + system=system, running_on_main=RUNNING_ON_MAIN_SERVER, active_page='settings', page_title='Settings', @@ -196,24 +215,51 @@ def api_containers(): @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', '')) + 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 on every page (one round trip).""" + """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() + all_ctrs = root_ctrs + user_ctrs + users = get_all_users() return jsonify({ 'container_count': len(all_ctrs), - 'user_count': len(users), + '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()), }) @@ -223,11 +269,6 @@ def api_nav_summary(): @app.route('/api/container/action', methods=['POST']) @login_required def api_container_action(): - """ - POST JSON: { "name": "container-name", "action": "start|stop|restart" } - Runs the action, then immediately returns the NEW container status so the - UI can update without waiting for the next 15-second refresh cycle. - """ data = request.get_json() or {} name = data.get('name', '').strip() action = data.get('action', '').strip() @@ -236,26 +277,20 @@ def api_container_action(): return jsonify({'success': False, 'message': 'name and action required'}), 400 success, output = container_action(name, action) - - # Give Docker a moment to settle, then fetch the real status time.sleep(1.5) status_info = get_container_status(name) return jsonify({ - 'success': success, - 'output': output, - 'new_status': status_info['status'], # 'running' | 'stopped' | 'unknown' + 'success': success, + 'output': output, + 'new_status': status_info['status'], 'new_status_raw': status_info['raw'], }) -# ───────────────────────────────────────────── -# API — single container status (for polling) -# ───────────────────────────────────────────── @app.route('/api/container/status/') @login_required def api_container_status(name): - """Quick single-container status check.""" status_info = get_container_status(name) return jsonify(status_info) @@ -272,22 +307,14 @@ def api_backups(): @app.route('/api/backups/log') @login_required def api_backup_log(): - """Return the last N backup log entries.""" limit = int(request.args.get('limit', 20)) entries = get_backup_log_entries(limit) return jsonify({'entries': entries}) -# ───────────────────────────────────────────── -# API — backup health audit -# ───────────────────────────────────────────── @app.route('/api/backups/audit', methods=['POST']) @login_required def api_backup_audit(): - """ - POST JSON: { "backup_file": "myapps-backup-…tar.gz", "source": "local"|"vm" } - Returns full audit report. - """ data = request.get_json() or {} bfile = data.get('backup_file', '').strip() source = data.get('source', 'local').strip() @@ -299,15 +326,9 @@ def api_backup_audit(): return jsonify(result) -# ───────────────────────────────────────────── -# API — delete backup -# ───────────────────────────────────────────── @app.route('/api/backups/delete', methods=['POST']) @login_required def api_backup_delete(): - """ - POST JSON: { "backup_file": "myapps-backup-…tar.gz", "source": "local"|"vm" } - """ data = request.get_json() or {} bfile = data.get('backup_file', '').strip() source = data.get('source', 'local').strip() @@ -319,17 +340,9 @@ def api_backup_delete(): return jsonify({'success': success, 'message': message}) -# ───────────────────────────────────────────── -# API — manual backup trigger -# ───────────────────────────────────────────── @app.route('/api/backups/run', methods=['POST']) @login_required def api_backup_run(): - """ - Trigger a manual backup run on the main server. - Returns a job_id so the UI can poll /api/backups/run/status/. - Only works when running on the main server (where the backup script lives). - """ if not RUNNING_ON_MAIN_SERVER: return jsonify({ 'success': False, @@ -352,7 +365,6 @@ def api_backup_run(): @app.route('/api/backups/run/status/') @login_required def api_backup_run_status(job_id): - """Poll manual backup job status.""" job = backup_jobs.get(job_id) if not job: return jsonify({'error': 'Job not found'}), 404 @@ -364,7 +376,7 @@ def api_backup_run_status(job_id): # ───────────────────────────────────────────── -# API — users (LOCAL — users on this host) +# API — users # ───────────────────────────────────────────── @app.route('/api/users') @login_required @@ -442,7 +454,6 @@ def restore_start(): if not backup_file: return jsonify({'error': 'No backup file specified'}), 400 - # ── Resolve backup archive path ────────────────────────────────────────── if backup_source == 'local': if RUNNING_ON_MAIN_SERVER: backup_path = f"/root/backups/{backup_file}" @@ -498,7 +509,6 @@ def restore_start(): 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 @@ -586,4 +596,4 @@ def logout(): if __name__ == '__main__': - app.run(host='0.0.0.0', port=5000, debug=False) + app.run(host='0.0.0.0', port=5000, debug=False) \ No newline at end of file diff --git a/platform/modules/backups.py b/platform/modules/backups.py index e83e864..bd84323 100644 --- a/platform/modules/backups.py +++ b/platform/modules/backups.py @@ -13,7 +13,7 @@ from config import ( ) -def _run(cmd, timeout=20): +def _run(cmd, timeout=30): try: r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout) return r.stdout.strip(), r.stderr.strip() @@ -22,7 +22,6 @@ def _run(cmd, timeout=20): def _human_bytes(n): - """Human-readable byte size for audit UI.""" n = int(n) if n < 1024: return f'{n} B' @@ -35,16 +34,18 @@ def _human_bytes(n): return f'{n / (1024 ** 4):.2f} TB' -def _ssh_main(remote_cmd, timeout=20): +def _ssh_main(remote_cmd, timeout=30): if RUNNING_ON_MAIN_SERVER: return _run(remote_cmd, timeout=timeout) else: + escaped = remote_cmd.replace("'", "'\\''") ssh = ( f"ssh -i {MAIN_SERVER_KEY} -p {MAIN_SERVER_PORT} " f"-o StrictHostKeyChecking=no -o ConnectTimeout=10 " + f"-o BatchMode=yes " f"{MAIN_SERVER_USER}@{MAIN_SERVER_IP}" ) - return _run(f"{ssh} '{remote_cmd}'", timeout=timeout) + return _run(f"{ssh} '{escaped}'", timeout=timeout) # ──────────────────────────────────────────────────────────────── @@ -71,6 +72,7 @@ def get_vm_backups(): cmd = ( f"ssh -i {VM_KEY} -p {VM_PORT} " f"-o StrictHostKeyChecking=no -o ConnectTimeout=10 " + f"-o BatchMode=yes " f"{VM_USER}@{VM_HOST} " f"'ls -t /backups/main-server/myapps-backup-*.tar.gz 2>/dev/null | head -20'" ) @@ -96,22 +98,6 @@ def get_vm_backups(): # ──────────────────────────────────────────────────────────────── def audit_backup(backup_file, source='local'): - """ - Perform a health and integrity audit on a backup archive. - - Checks: - 1. File exists - 2. File size sanity - 3. SHA256 checksum (if .sha256 sidecar exists) - 4. tar archive integrity (gzip test only — portable, no conflicting flags) - 5. Expected internal structure - 6. Path traversal / suspicious paths - 7. Suspicious script files at unexpected locations (scripts only, not binaries) - 8. Volume count - - Returns: - { ok, score, checks, summary } - """ checks = [] def add(name, status, detail='', more=None): @@ -120,13 +106,11 @@ def audit_backup(backup_file, source='local'): entry['more'] = more checks.append(entry) - # ── Resolve archive path ───────────────────────────────────────────────── if source == 'local': archive_path = f"/root/backups/{backup_file}" else: archive_path = f"/backups/main-server/{backup_file}" - # On VM auditing a "local" (main server) backup → pull to /tmp first if not RUNNING_ON_MAIN_SERVER and source == 'local': tmp_path = f"/tmp/audit_{backup_file}" if not os.path.exists(tmp_path): @@ -151,7 +135,6 @@ def audit_backup(backup_file, source='local'): } archive_path = tmp_path - # ── CHECK 1: File exists ───────────────────────────────────────────────── if not os.path.exists(archive_path): add('File Exists', 'fail', f'Not found: {archive_path}') return { @@ -165,7 +148,6 @@ def audit_backup(backup_file, source='local'): } add('File Exists', 'pass', archive_path) - # ── CHECK 2: File size ─────────────────────────────────────────────────── size_bytes = os.path.getsize(archive_path) size_mb = size_bytes / (1024 * 1024) size_human = _human_bytes(size_bytes) @@ -183,7 +165,6 @@ def audit_backup(backup_file, source='local'): add('File Size', 'pass', f'{size_human} — within expected range', more=size_more) - # ── CHECK 3: SHA256 checksum ───────────────────────────────────────────── sha_file = archive_path + '.sha256' if os.path.exists(sha_file): try: @@ -201,8 +182,6 @@ def audit_backup(backup_file, source='local'): add('Checksum (SHA256)', 'warn', 'No .sha256 sidecar found — run a new backup to get checksums') - # ── CHECK 4: Archive integrity ─────────────────────────────────────────── - # Use gzip --test which works everywhere without conflicting tar flags try: result = subprocess.run( ['gzip', '--test', archive_path], @@ -216,11 +195,9 @@ def audit_backup(backup_file, source='local'): add('Archive Integrity', 'fail', f'gzip test failed: {(result.stderr or result.stdout)[:200]}') except FileNotFoundError: - # gzip not available — try python gzip try: import gzip with gzip.open(archive_path, 'rb') as f: - # Read just the first few MB to check header validity f.read(1024 * 1024) add('Archive Integrity', 'pass', 'gzip header valid') except Exception as e: @@ -230,7 +207,6 @@ def audit_backup(backup_file, source='local'): except Exception as e: add('Archive Integrity', 'warn', f'Could not test: {e}') - # ── Read archive member list (used by checks 5, 6, 7, 8) ───────────────── members = [] try: with tarfile.open(archive_path, 'r:gz') as tf: @@ -238,7 +214,6 @@ def audit_backup(backup_file, source='local'): except Exception: pass - # ── CHECK 5: Internal structure ────────────────────────────────────────── if members: has_volumes = any('volumes/' in m for m in members) has_info = any('backup-info.txt' in m for m in members) @@ -259,7 +234,6 @@ def audit_backup(backup_file, source='local'): else: add('Internal Structure', 'warn', 'Could not inspect archive members') - # ── CHECK 6: Path traversal / suspicious paths ──────────────────────────── SUSPICIOUS = [ (r'\.\./', 'path traversal (..)'), (r'^/', 'absolute path in archive'), @@ -285,10 +259,6 @@ def audit_backup(backup_file, source='local'): '(e.g. .ssh, /etc/shadow).', ]) - # ── CHECK 7: Suspicious scripts (smart — scripts only, not data files) ──── - # Only flag actual text script files (.sh .py .pl .rb) with execute bits - # placed outside compose-files/ and outside known vendor directories. - # .bin, .so, .exe data files are intentionally excluded (too many false positives) SCRIPT_EXTENSIONS = ('.sh', '.py', '.pl', '.rb', '.bash', '.zsh') SAFE_PREFIXES = ( 'compose-files/', @@ -303,10 +273,8 @@ def audit_backup(backup_file, source='local'): if not member.isfile(): continue name = member.name - # Skip files in known-safe directories if any(name.startswith(p) or f'/{p}' in name for p in SAFE_PREFIXES): continue - # Only flag actual script extensions with execute bits name_lower = name.lower() has_script_ext = any(name_lower.endswith(ext) for ext in SCRIPT_EXTENSIONS) has_exec_bit = bool(member.mode & 0o111) @@ -321,7 +289,6 @@ def audit_backup(backup_file, source='local'): else: add('Executable Scripts', 'pass', 'No unexpected executable scripts found') - # ── CHECK 8: Volume count ──────────────────────────────────────────────── vol_archives = [m for m in members if 'volumes/' in m and m.endswith('.tar.gz')] v = len(vol_archives) if v == 0: @@ -331,7 +298,6 @@ def audit_backup(backup_file, source='local'): else: add('Volume Count', 'pass', f'{v} volume archives present') - # ── Score ───────────────────────────────────────────────────────────────── weights = {'pass': 10, 'warn': 5, 'fail': 0} total = len(checks) * 10 earned = sum(weights.get(c['status'], 0) for c in checks) @@ -427,6 +393,7 @@ def delete_backup(backup_file, source='local'): cmd = ( f"ssh -i {VM_KEY} -p {VM_PORT} " f"-o StrictHostKeyChecking=no -o ConnectTimeout=10 " + f"-o BatchMode=yes " f"{VM_USER}@{VM_HOST} " f"'rm -f /backups/main-server/{backup_file} " f"/backups/main-server/{backup_file}.sha256'" @@ -621,19 +588,59 @@ def get_all_stats(): # ──────────────────────────────────────────────────────────────── -# SYSTEM INFO +# SYSTEM INFO — single batched SSH call # ──────────────────────────────────────────────────────────────── def get_system_info(): - cpu_out, _ = _ssh_main("top -bn1 | grep 'Cpu(s)' | awk '{print $2+$4}'") - mem_out, _ = _ssh_main("free -m | awk 'NR==2{printf \"%s/%sMB\", $3, $2}'") - mem_pct, _ = _ssh_main("free | awk 'NR==2{printf \"%.0f\", $3/$2*100}'") - disk_out, _ = _ssh_main("df -h / | awk 'NR==2{printf \"%s/%s\", $3, $2}'") - disk_pct, _ = _ssh_main("df / | awk 'NR==2{print $5}' | tr -d '%'") - load_out, _ = _ssh_main("cat /proc/loadavg | awk '{print $1, $2, $3}'") - uptime, _ = _ssh_main("uptime -p") - docker_v, _ = _ssh_main("docker --version | cut -d' ' -f3 | tr -d ','") - hostname, _ = _run("hostname -f 2>/dev/null || hostname") + """ + Collect all system metrics in a SINGLE SSH call instead of 8 separate ones. + Emits a pipe-delimited line: cpu|mem|mem_pct|disk|disk_pct|load|uptime|docker_v|hostname + """ + batch_cmd = ( + "printf '%s|%s|%s|%s|%s|%s|%s|%s|%s\\n' " + "\"$(top -bn1 | grep 'Cpu(s)' | awk '{print $2+$4}')\" " + "\"$(free -m | awk 'NR==2{printf \"%s/%sMB\", $3, $2}')\" " + "\"$(free | awk 'NR==2{printf \"%.0f\", $3/$2*100}')\" " + "\"$(df -h / | awk 'NR==2{printf \"%s/%s\", $3, $2}')\" " + "\"$(df / | awk 'NR==2{print $5}' | tr -d '%')\" " + "\"$(cat /proc/loadavg | awk '{print $1, $2, $3}')\" " + "\"$(uptime -p)\" " + "\"$(docker --version 2>/dev/null | cut -d' ' -f3 | tr -d ',')\" " + "\"$(hostname -f 2>/dev/null || hostname)\"" + ) + + stdout, stderr = _ssh_main(batch_cmd, timeout=20) + + # Parse the pipe-delimited result + if stdout and '|' in stdout: + # Use the last line in case there's extra output + for line in reversed(stdout.splitlines()): + line = line.strip() + if '|' in line: + parts = line.split('|') + if len(parts) >= 9: + return { + 'cpu_pct': parts[0] or '0', + 'memory': parts[1] or 'N/A', + 'mem_pct': parts[2] or '0', + 'disk': parts[3] or 'N/A', + 'disk_pct': parts[4] or '0', + 'load': parts[5] or 'N/A', + 'uptime': parts[6] or 'N/A', + 'docker_v': parts[7] or 'N/A', + 'hostname': parts[8] or 'main server', + } + + # Fallback: individual calls if batch failed + cpu_out, _ = _ssh_main("top -bn1 | grep 'Cpu(s)' | awk '{print $2+$4}'") + mem_out, _ = _ssh_main("free -m | awk 'NR==2{printf \"%s/%sMB\", $3, $2}'") + mem_pct, _ = _ssh_main("free | awk 'NR==2{printf \"%.0f\", $3/$2*100}'") + disk_out, _ = _ssh_main("df -h / | awk 'NR==2{printf \"%s/%s\", $3, $2}'") + disk_pct, _ = _ssh_main("df / | awk 'NR==2{print $5}' | tr -d '%'") + load_out, _ = _ssh_main("cat /proc/loadavg | awk '{print $1, $2, $3}'") + uptime, _ = _ssh_main("uptime -p") + docker_v, _ = _ssh_main("docker --version | cut -d' ' -f3 | tr -d ','") + hostname, _ = _run("hostname -f 2>/dev/null || hostname") return { 'cpu_pct': cpu_out or '0', 'memory': mem_out or 'N/A', @@ -644,4 +651,4 @@ def get_system_info(): 'uptime': uptime or 'N/A', 'docker_v': docker_v or 'N/A', 'hostname': hostname or 'main server', - } + } \ No newline at end of file diff --git a/platform/modules/users.py b/platform/modules/users.py index ea4c2bb..f43b56d 100644 --- a/platform/modules/users.py +++ b/platform/modules/users.py @@ -1,12 +1,17 @@ +# modules/users.py import os import subprocess import pwd import re -import tempfile -import stat +import json + +from config import ( + RUNNING_ON_MAIN_SERVER, + MAIN_SERVER_IP, MAIN_SERVER_USER, MAIN_SERVER_KEY, MAIN_SERVER_PORT, +) -def _run(cmd, timeout=20): +def _run(cmd, timeout=30): try: r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout) return r.stdout.strip(), r.stderr.strip() @@ -16,80 +21,179 @@ def _run(cmd, timeout=20): return '', str(e) +def _ssh_main(remote_cmd, timeout=30): + """ + Run a command ON THE MAIN SERVER. + - If already on main server → run locally. + - If on VM → SSH to main server first. + """ + if RUNNING_ON_MAIN_SERVER: + return _run(remote_cmd, timeout=timeout) + else: + # Escape single quotes in remote_cmd for safe shell wrapping + escaped = remote_cmd.replace("'", "'\\''") + ssh = ( + f"ssh -i {MAIN_SERVER_KEY} -p {MAIN_SERVER_PORT} " + f"-o StrictHostKeyChecking=no -o ConnectTimeout=10 " + f"-o BatchMode=yes " + f"{MAIN_SERVER_USER}@{MAIN_SERVER_IP}" + ) + return _run(f"{ssh} '{escaped}'", timeout=timeout) + + +# ──────────────────────────────────────────────────────────────── +# USER LISTING — single batched SSH call to main server +# ──────────────────────────────────────────────────────────────── + def get_all_users(): - """Return list of non-system users (uid >= 1000) with info.""" - users = [] + """ + Return list of non-system users (uid >= 1000) from the MAIN SERVER. + Uses a SINGLE SSH call with a bash one-liner that collects all data at once, + instead of making 7+ SSH calls per user. + """ + + # This script runs on the main server and emits one JSON line per user + batch_script = r""" +python3 - <<'PYEOF' +import subprocess, json, os, pwd + +def run(cmd): try: - for pw in pwd.getpwall(): - if pw.pw_uid < 1000 or pw.pw_name == 'nobody': - continue - uid = pw.pw_uid - name = pw.pw_name - home = pw.pw_dir + r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=10) + return r.stdout.strip() + except: + return '' - sock = f"/run/user/{uid}/docker.sock" - has_docker = os.path.exists(sock) +# Get all non-system users +passwd_lines = run("getent passwd | awk -F: '$3 >= 1000 && $1 != \"nobody\" {print}'") +users = [] - disk_out, _ = _run(f"du -sh {home} 2>/dev/null | cut -f1") - disk_used = disk_out.strip() or 'N/A' +for line in passwd_lines.splitlines(): + parts = line.split(':') + if len(parts) < 6: + continue + name = parts[0] + uid = int(parts[2]) + home = parts[5] - linger_out, _ = _run(f"loginctl show-user {name} --property=Linger 2>/dev/null") - linger = 'yes' in linger_out.lower() + sock = f"/run/user/{uid}/docker.sock" + has_docker = os.path.exists(sock) + disk_used = run(f"du -sh {home} 2>/dev/null | cut -f1") or 'N/A' - container_count = 0 - if has_docker: - cnt_out, _ = _run( - f"DOCKER_HOST=unix://{sock} docker ps -aq 2>/dev/null | wc -l" - ) - try: - container_count = int(cnt_out.strip()) - except ValueError: - container_count = 0 + linger_out = run(f"loginctl show-user {name} --property=Linger 2>/dev/null") + linger = 'yes' in linger_out.lower() - # Check if user has a dedicated virtual disk mounted - disk_img = f"/home/{name}.img" - has_vdisk = os.path.exists(disk_img) - vdisk_mount = None - vdisk_size = None - if has_vdisk: - # Check if it's mounted somewhere - mnt_out, _ = _run(f"findmnt -S {disk_img} -o TARGET --noheadings 2>/dev/null") - vdisk_mount = mnt_out.strip() or None - # Get size of the image - size_out, _ = _run(f"du -sh {disk_img} 2>/dev/null | cut -f1") - vdisk_size = size_out.strip() or None + container_count = 0 + if has_docker: + cnt = run(f"DOCKER_HOST=unix://{sock} docker ps -aq 2>/dev/null | wc -l") + try: + container_count = int(cnt) + except: + pass - users.append({ - 'name': name, - 'uid': uid, - 'home': home, - 'has_docker': has_docker, - 'docker_socket': sock if has_docker else None, - 'disk_used': disk_used, - 'linger': linger, - 'container_count': container_count, - 'has_vdisk': has_vdisk, - 'vdisk_mount': vdisk_mount, - 'vdisk_size': vdisk_size, - }) - except Exception as e: - print(f"[users] Error listing users: {e}") + img_path = f"/home/{name}.img" + has_vdisk = os.path.exists(img_path) + vdisk_mount = None + vdisk_size = None + if has_vdisk: + mnt = run(f"findmnt -S {img_path} -o TARGET --noheadings 2>/dev/null") + vdisk_mount = mnt or None + sz = run(f"du -sh {img_path} 2>/dev/null | cut -f1") + vdisk_size = sz or None + + users.append({ + 'name': name, + 'uid': uid, + 'home': home, + 'has_docker': has_docker, + 'docker_socket': sock if has_docker else None, + 'disk_used': disk_used, + 'linger': linger, + 'container_count': container_count, + 'has_vdisk': has_vdisk, + 'vdisk_mount': vdisk_mount, + 'vdisk_size': vdisk_size, + }) + +print(json.dumps(users)) +PYEOF +""" + + stdout, stderr = _ssh_main(batch_script, timeout=60) + + if not stdout: + # Fallback: try a simpler approach if python3 one-liner fails + return _get_all_users_fallback() + + # Find the JSON line (last non-empty line that starts with '[') + for line in reversed(stdout.splitlines()): + line = line.strip() + if line.startswith('['): + try: + return json.loads(line) + except json.JSONDecodeError: + break + + return _get_all_users_fallback() + + +def _get_all_users_fallback(): + """ + Simpler fallback: just get user names/UIDs, skip slow per-user checks. + Returns basic user list without docker/disk details. + """ + users = [] + stdout, _ = _ssh_main( + "getent passwd | awk -F: '$3 >= 1000 && $1 != \"nobody\" {print $1\"|\"$3\"|\"$6}'" + ) + if not stdout: + return users + + for line in stdout.splitlines(): + line = line.strip() + if not line: + continue + parts = line.split('|') + if len(parts) < 3: + continue + try: + uid = int(parts[1]) + except ValueError: + continue + users.append({ + 'name': parts[0], + 'uid': uid, + 'home': parts[2], + 'has_docker': False, + 'docker_socket': None, + 'disk_used': 'N/A', + 'linger': False, + 'container_count': 0, + 'has_vdisk': False, + 'vdisk_mount': None, + 'vdisk_size': None, + }) return users +# ──────────────────────────────────────────────────────────────── +# USER CONTAINERS — always from main server +# ──────────────────────────────────────────────────────────────── + def get_user_containers(username): - """Get containers running under a specific user's rootless docker.""" + """Get containers running under a specific user's rootless docker on the main server.""" + uid_out, _ = _ssh_main(f"id -u {username} 2>/dev/null") try: - pw = pwd.getpwnam(username) - except KeyError: + uid = int(uid_out.strip()) + except ValueError: return [] - uid = pw.pw_uid sock = f"/run/user/{uid}/docker.sock" - if not os.path.exists(sock): + sock_check, _ = _ssh_main(f"test -S {sock} && echo yes || echo no") + if sock_check.strip() != 'yes': return [] - out, _ = _run( + out, _ = _ssh_main( f"DOCKER_HOST=unix://{sock} " f"docker ps -a --format '{{{{.Names}}}}|{{{{.Status}}}}|{{{{.Image}}}}|{{{{.Ports}}}}' 2>/dev/null" ) @@ -110,7 +214,7 @@ def get_user_containers(username): def get_all_users_containers(): - """Get containers from ALL users' rootless docker instances.""" + """Get containers from ALL users' rootless docker instances on main server.""" all_containers = [] for user in get_all_users(): if user['has_docker']: @@ -118,92 +222,159 @@ def get_all_users_containers(): return all_containers +# ──────────────────────────────────────────────────────────────── +# USER DISK USAGE — always from main server +# ──────────────────────────────────────────────────────────────── + +def get_user_disk_usage(username): + home_out, _ = _ssh_main( + f"getent passwd {username} | cut -d: -f6" + ) + home = home_out.strip() + if not home: + return {} + + total_out, _ = _ssh_main(f"du -sh {home} 2>/dev/null | cut -f1") + + img_path = f"/home/{username}.img" + vdisk_out, _ = _ssh_main(f"test -f {img_path} && echo yes || echo no") + vdisk_info = {} + + if vdisk_out.strip() == 'yes': + df_out, _ = _ssh_main(f"df -h {home} 2>/dev/null | tail -1") + if df_out: + parts = df_out.split() + if len(parts) >= 4: + vdisk_info = { + 'size': parts[1], + 'used': parts[2], + 'available': parts[3], + 'use_pct': parts[4] if len(parts) > 4 else '?', + } + + return { + 'home': home, + 'total': total_out or 'N/A', + 'vdisk': vdisk_info, + } + + +# ──────────────────────────────────────────────────────────────── +# CREATE / DELETE USER — only works on main server +# ──────────────────────────────────────────────────────────────── + def create_user(username, password=None, setup_docker=True, disk_quota_mb=None): """ - Create a new system user and optionally set up rootless docker + virtual disk. - disk_quota_mb: if set, creates a loop-device virtual disk of that size (MB) - and mounts it as the user's home directory. + Create a new system user on the MAIN SERVER. + If called from VM, all commands SSH to main server. Returns (success: bool, log_text: str) """ logs = [] - # Validate if not re.match(r'^[a-z][a-z0-9_-]{1,30}$', username): return False, "Invalid username. Use lowercase letters, numbers, _ or -" - # Check existence - try: - pwd.getpwnam(username) + check_out, _ = _ssh_main(f"id {username} 2>/dev/null && echo exists || echo notfound") + if 'exists' in check_out: return False, f"User '{username}' already exists" - except KeyError: - pass - # Create user - out, err = _run(f"useradd -m -s /bin/bash {username}") + out, err = _ssh_main(f"useradd -m -s /bin/bash {username}") if err and 'already exists' not in err: return False, f"useradd failed: {err}" logs.append(f"✅ User {username} created") - # Set password if password: - out, err = _run(f"echo '{username}:{password}' | chpasswd") + out, err = _ssh_main(f"echo '{username}:{password}' | chpasswd") if err: logs.append(f"⚠️ Password set failed: {err}") else: logs.append("✅ Password set") - # Install prerequisites - _run("apt-get install -y uidmap dbus-user-session curl 2>/dev/null", timeout=60) + _ssh_main( + "apt-get install -y uidmap dbus-user-session curl 2>/dev/null", + timeout=60 + ) logs.append("✅ Prerequisites ready") - # Enable linger - _run(f"loginctl enable-linger {username}") + _ssh_main(f"loginctl enable-linger {username}") logs.append("✅ Linger enabled") - # Virtual disk (loop device) instead of quota if disk_quota_mb: - success, msg = _setup_virtual_disk(username, disk_quota_mb, logs) - if not success: - logs.append(f"⚠️ Virtual disk setup failed: {msg}") + if RUNNING_ON_MAIN_SERVER: + success, msg = _setup_virtual_disk(username, disk_quota_mb, logs) + if not success: + logs.append(f"⚠️ Virtual disk setup failed: {msg}") + else: + logs.append("⚠️ Virtual disk setup must be run directly on main server") - # Setup rootless docker if setup_docker: - success, msg = _setup_rootless_docker_via_script(username, logs) - if not success: - logs.append(f"⚠️ Docker setup incomplete: {msg}") + if RUNNING_ON_MAIN_SERVER: + success, msg = _setup_rootless_docker_via_script(username, logs) + if not success: + logs.append(f"⚠️ Docker setup incomplete: {msg}") + else: + logs.append("⚠️ Rootless Docker setup must be run directly on main server") return True, '\n'.join(logs) +def delete_user(username, remove_home=False): + """Remove a user from the MAIN SERVER. Returns (success, message).""" + logs = [] + + check_out, _ = _ssh_main(f"id {username} 2>/dev/null && echo exists || echo notfound") + if 'notfound' in check_out: + return False, f"User '{username}' does not exist" + + uid_out, _ = _ssh_main(f"id -u {username} 2>/dev/null") + try: + uid = int(uid_out.strip()) + _ssh_main( + f"XDG_RUNTIME_DIR=/run/user/{uid} " + f"su --login {username} --command " + f"'systemctl --user stop docker-rootless.service 2>/dev/null' 2>/dev/null" + ) + except Exception: + pass + + _ssh_main(f"loginctl disable-linger {username} 2>/dev/null") + + if RUNNING_ON_MAIN_SERVER: + _remove_virtual_disk(username, logs) + else: + img_path = f"/home/{username}.img" + home = f"/home/{username}" + _ssh_main(f"umount {home} 2>/dev/null || true") + _ssh_main(f"rm -f {img_path} 2>/dev/null || true") + + flag = '-r' if remove_home else '' + out, err = _ssh_main(f"userdel {flag} {username}") + if err and 'does not exist' not in err and 'mail spool' not in err: + return False, f"userdel error: {err}" + + msg = f"✅ User {username} deleted" + (" (home removed)" if remove_home else "") + logs.append(msg) + return True, '\n'.join(logs) + + +# ──────────────────────────────────────────────────────────────── +# LOCAL-ONLY HELPERS (only called when RUNNING_ON_MAIN_SERVER) +# ──────────────────────────────────────────────────────────────── + def _setup_virtual_disk(username, disk_mb, logs): - """ - Create a loop-device virtual disk for a user and mount it as their home. - - Steps: - 1. Create a blank image file at /home/.img - 2. Format it as ext4 - 3. Copy existing home contents into it - 4. Mount it over /home/ - 5. Add to /etc/fstab for persistence across reboots - 6. Fix ownership - - Returns (success: bool, message: str) - """ try: pw = pwd.getpwnam(username) except KeyError as e: return False, str(e) - home = pw.pw_dir # e.g. /home/secuser4 + home = pw.pw_dir img_path = f"/home/{username}.img" logs.append(f"📦 Creating {disk_mb} MB virtual disk at {img_path} ...") - # ── Step 1: Create the blank image ────────────────────────────────────── - # Use fallocate (fast, instant) with dd fallback out, err = _run(f"fallocate -l {disk_mb}M {img_path}", timeout=60) if err and 'fallocate' in err: - logs.append(" ↳ fallocate not available, using dd (this may take a moment)...") + logs.append(" ↳ fallocate not available, using dd...") out, err = _run( f"dd if=/dev/zero of={img_path} bs=1M count={disk_mb} status=none", timeout=600 @@ -212,33 +383,24 @@ def _setup_virtual_disk(username, disk_mb, logs): return False, f"Failed to create image: {err}" logs.append(f" ✅ Image file created ({disk_mb} MB)") - # ── Step 2: Format as ext4 ─────────────────────────────────────────────── out, err = _run(f"mkfs.ext4 -F {img_path}", timeout=60) - if err and 'mke2fs' not in err and 'Discarding device blocks' not in err: - # mkfs.ext4 writes info to stderr even on success; only fail on real errors - if 'error' in err.lower() or 'failed' in err.lower(): - return False, f"mkfs.ext4 failed: {err}" + if err and 'error' in err.lower() and 'failed' in err.lower(): + return False, f"mkfs.ext4 failed: {err}" logs.append(" ✅ Formatted as ext4") - # ── Step 3: Back up current home contents ──────────────────────────────── tmp_backup = f"/tmp/{username}_home_backup" _run(f"cp -a {home}/. {tmp_backup}/ 2>/dev/null") - # ── Step 4: Mount the image over the user's home ───────────────────────── out, err = _run(f"mount -o loop {img_path} {home}") if err: return False, f"mount failed: {err}" logs.append(f" ✅ Mounted at {home}") - # ── Step 5: Restore home contents into the new disk ────────────────────── _run(f"cp -a {tmp_backup}/. {home}/ 2>/dev/null") _run(f"rm -rf {tmp_backup}") - - # ── Step 6: Fix ownership ───────────────────────────────────────────────── _run(f"chown -R {username}:{username} {home}") logs.append(" ✅ Ownership set") - # ── Step 7: Add to /etc/fstab for persistence ──────────────────────────── fstab_line = f"{img_path} {home} ext4 loop,defaults 0 0\n" try: with open('/etc/fstab', 'r') as f: @@ -246,18 +408,15 @@ def _setup_virtual_disk(username, disk_mb, logs): if img_path not in fstab: with open('/etc/fstab', 'a') as f: f.write(fstab_line) - logs.append(" ✅ Added to /etc/fstab (persistent across reboots)") - else: - logs.append(" ℹ️ Already in /etc/fstab") + logs.append(" ✅ Added to /etc/fstab") except Exception as e: logs.append(f" ⚠️ Could not update /etc/fstab: {e}") - logs.append(f"✅ Virtual disk ready: {disk_mb} MB dedicated to {username}") + logs.append(f"✅ Virtual disk ready: {disk_mb} MB for {username}") return True, "ok" def _remove_virtual_disk(username, logs): - """Unmount and remove the virtual disk image for a user.""" try: pw = pwd.getpwnam(username) home = pw.pw_dir @@ -265,17 +424,13 @@ def _remove_virtual_disk(username, logs): return img_path = f"/home/{username}.img" - - # Unmount _run(f"umount {home} 2>/dev/null") logs.append(f" ↳ Unmounted {home}") - # Remove image if os.path.exists(img_path): os.remove(img_path) logs.append(f" ↳ Removed {img_path}") - # Remove from fstab try: with open('/etc/fstab', 'r') as f: lines = f.readlines() @@ -289,22 +444,16 @@ def _remove_virtual_disk(username, logs): def _setup_rootless_docker_via_script(username, logs): - """ - Setup rootless Docker for a user by running the official installer. - This must be done AS the user in a proper login shell. - """ try: pw = pwd.getpwnam(username) - uid = pw.pw_uid + uid = pw.pw_uid home = pw.pw_dir except KeyError as e: return False, str(e) - # First, ensure the sysctl setting is applied (critical!) _run("sysctl -w kernel.apparmor_restrict_unprivileged_userns=0") _run("echo 'kernel.apparmor_restrict_unprivileged_userns=0' >> /etc/sysctl.conf") - # Ensure XDG_RUNTIME_DIR exists with correct permissions runtime_dir = f"/run/user/{uid}" os.makedirs(runtime_dir, exist_ok=True) _run(f"chown {username}:{username} {runtime_dir}") @@ -312,28 +461,20 @@ def _setup_rootless_docker_via_script(username, logs): logs.append(f"📝 Installing rootless Docker for {username}...") - # Create a simple installation script that runs as the user install_cmd = f"""bash -c ' export XDG_RUNTIME_DIR=/run/user/{uid} export PATH=$HOME/bin:$PATH mkdir -p $XDG_RUNTIME_DIR chmod 700 $XDG_RUNTIME_DIR - -# Install rootless Docker curl -fsSL https://get.docker.com/rootless | sh - -# Add environment variables to .bashrc echo "export PATH=$HOME/bin:\\$PATH" >> ~/.bashrc echo "export DOCKER_HOST=unix:///run/user/{uid}/docker.sock" >> ~/.bashrc echo "export XDG_RUNTIME_DIR=/run/user/{uid}" >> ~/.bashrc - -# Create systemd service mkdir -p ~/.config/systemd/user cat > ~/.config/systemd/user/docker.service << EOF [Unit] Description=Docker Rootless Daemon After=network.target - [Service] Type=simple ExecStart=$HOME/bin/dockerd-rootless.sh @@ -341,30 +482,20 @@ Restart=always RestartSec=10 Environment=PATH=$HOME/bin:/usr/local/bin:/usr/bin:/bin Environment=XDG_RUNTIME_DIR=/run/user/{uid} - [Install] WantedBy=default.target EOF - -# Enable and start the service systemctl --user daemon-reload systemctl --user enable docker.service systemctl --user start docker.service - -# Wait for socket sleep 5 '""" try: - # Run the installation as the user result = subprocess.run( ['su', '-', username, '-c', install_cmd], - capture_output=True, - text=True, - timeout=300 + capture_output=True, text=True, timeout=300 ) - - # Log output for line in result.stdout.split('\n'): if line.strip(): logs.append(line.strip()) @@ -373,7 +504,6 @@ sleep 5 if line.strip(): logs.append(f" stderr: {line.strip()}") - # Check if socket exists sock = f"/run/user/{uid}/docker.sock" if os.path.exists(sock): logs.append(f"✅ Rootless Docker ready for {username}") @@ -387,69 +517,4 @@ sleep 5 return False, "timeout" except Exception as e: logs.append(f"⚠️ Setup failed: {e}") - return False, str(e) - - -def delete_user(username, remove_home=False): - """Remove a user. Returns (success, message).""" - logs = [] - - try: - pwd.getpwnam(username) - except KeyError: - return False, f"User '{username}' does not exist" - - # Stop their docker service first - try: - pw = pwd.getpwnam(username) - _run( - f"XDG_RUNTIME_DIR=/run/user/{pw.pw_uid} " - f"su --login {username} --command 'systemctl --user stop docker-rootless.service 2>/dev/null' " - f"2>/dev/null" - ) - except Exception: - pass - - _run(f"loginctl disable-linger {username} 2>/dev/null") - - # Clean up virtual disk BEFORE userdel (userdel might complain if home is busy) - _remove_virtual_disk(username, logs) - - flag = '-r' if remove_home else '' - out, err = _run(f"userdel {flag} {username}") - if err and 'does not exist' not in err and 'mail spool' not in err: - return False, f"userdel error: {err}" - - msg = f"✅ User {username} deleted" + (" (home removed)" if remove_home else "") - logs.append(msg) - return True, '\n'.join(logs) - - -def get_user_disk_usage(username): - try: - pw = pwd.getpwnam(username) - except KeyError: - return {} - total_out, _ = _run(f"du -sh {pw.pw_dir} 2>/dev/null | cut -f1") - - # Also check if there's a virtual disk and its capacity - img_path = f"/home/{username}.img" - vdisk_info = {} - if os.path.exists(img_path): - # Get mounted filesystem usage - df_out, _ = _run(f"df -h {pw.pw_dir} 2>/dev/null | tail -1") - if df_out: - parts = df_out.split() - if len(parts) >= 4: - vdisk_info = { - 'size': parts[1], - 'used': parts[2], - 'available': parts[3], - 'use_pct': parts[4] if len(parts) > 4 else '?', - } - - return { - 'home': pw.pw_dir, - 'total': total_out or 'N/A', - 'vdisk': vdisk_info, - } + return False, str(e) \ No newline at end of file diff --git a/platform/templates/pages/dashboard.html b/platform/templates/pages/dashboard.html index b4fe418..e1b8150 100644 --- a/platform/templates/pages/dashboard.html +++ b/platform/templates/pages/dashboard.html @@ -15,22 +15,22 @@
CPU USAGE
-
{{ system.cpu_pct }}%
-
+
{{ system.cpu_pct or '…' }}%
+
MEMORY
-
{{ system.memory }}
-
+
{{ system.memory or '…' }}
+
DISK /
-
{{ system.disk }}
-
+
{{ system.disk or '…' }}
+
LOAD AVG
-
{{ system.load }}
+
{{ system.load or '…' }}
@@ -38,7 +38,7 @@
Overview
- Docker {{ system.docker_v }} · {{ main_server }} + Docker {{ system.docker_v or '…' }} · {{ main_server }}
{{ containers|length }}
App Containers
@@ -99,10 +99,135 @@
{{ ctr_actions(c.name) }}
{% else %} -
No containers
+
No containers
{% endfor %}
-{% endblock %} + +{% if not running_on_main %} + +{% endif %} + +{% endblock %} \ No newline at end of file diff --git a/scripts/backup-myapps.sh b/scripts/backup-myapps.sh index 8c33dae..3ccb61c 100755 --- a/scripts/backup-myapps.sh +++ b/scripts/backup-myapps.sh @@ -240,3 +240,20 @@ echo " Name: $BACKUP_NAME" echo " Local: $BACKUP_ARCHIVE ($COMPRESSED_SIZE)" echo " Remote: ${VM_HOST}:${VM_DEST}${BACKUP_NAME}.tar.gz" echo "=========================================" + +# ── Chiffrement AES-256 ────────────────────────────────────────────────────── +encrypt_backup() { + echo "🔐 Chiffrement AES-256..." + openssl enc -aes-256-cbc -pbkdf2 -pass pass:Navitrends2024! \ + -in "$BACKUP_ARCHIVE" \ + -out "${BACKUP_ARCHIVE}.enc" + rm -f "$BACKUP_ARCHIVE" + echo "✅ Archive chiffrée : ${BACKUP_ARCHIVE}.enc" +} + +# ── Notification email échec ───────────────────────────────────────────────── +notify_failure() { + echo "📧 Envoi notification échec..." + echo "Backup FAILED: $BACKUP_NAME" | \ + mail -s "[Navitrends] BACKUP FAILED - $(date)" arijabidi577@gmail.com +}