From 6158b346134e5e6ef4bf2b546f70b6e77f9758b6 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 13 May 2026 01:06:32 +0200 Subject: [PATCH] Sync from main server - 2026-05-13 01:06:32 --- platform/app.py | 341 ++++++++-- platform/modules/cloud_backup.py | 336 ++++++++++ platform/templates/base.html | 3 + platform/templates/pages/backups.html | 820 +++++++++++++++++++++++- platform/templates/pages/cloud.html | 414 ++++++++++++ platform/templates/pages/dashboard.html | 64 +- platform/templates/pages/restore.html | 203 +++++- scripts/backup-myapps.sh | 107 +++- 8 files changed, 2159 insertions(+), 129 deletions(-) create mode 100644 platform/modules/cloud_backup.py create mode 100644 platform/templates/pages/cloud.html diff --git a/platform/app.py b/platform/app.py index f115a77..e9050ad 100644 --- a/platform/app.py +++ b/platform/app.py @@ -1,10 +1,12 @@ # app.py from flask import Flask, render_template, request, redirect, url_for, session, jsonify import os +import re import subprocess import threading import uuid import time +from datetime import datetime, timezone from config import ( MAIN_SERVER_IP, RUNNING_ON_MAIN_SERVER, @@ -20,17 +22,21 @@ from modules.backups import ( container_action, get_container_status, audit_backup, delete_backup, get_backup_log_entries, get_backup_script_path, + _ssh_main, _human_bytes, _run, ) 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, ) +from modules.cloud_backup import ( + r2_test_connection, r2_list_backups, r2_get_bucket_stats, + r2_delete_backup, r2_upload_async, get_upload_job, + r2_is_configured, R2_BUCKET_NAME, +) 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 = {} @@ -74,14 +80,12 @@ def _stream_backup(job_id, script_path): # ───────────────────────────────────────────── -# DASHBOARD -# Loads instantly — all heavy data fetched async via JS after page renders +# PAGES # ───────────────────────────────────────────── + @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() @@ -91,7 +95,7 @@ def dashboard(): system = get_system_info() users = get_all_users() else: - containers = [] # loaded async by JS via /api/dashboard + containers = [] running_count = 0 system = {} users = [] @@ -141,7 +145,7 @@ def backups_page(): def restore_page(): prefill = { 'source': request.args.get('source', '').strip(), - 'file': request.args.get('file', '').strip(), + 'file': request.args.get('file', '').strip(), } return render_template( 'pages/restore.html', @@ -158,8 +162,6 @@ 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', @@ -174,8 +176,6 @@ 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', @@ -188,9 +188,26 @@ def settings_page(): ) +@app.route('/cloud') +@login_required +def cloud_page(): + return render_template( + 'pages/cloud.html', + local_backups=get_local_backups(), + vm_backups=get_vm_backups(), + main_server=MAIN_SERVER_IP, + r2_bucket=R2_BUCKET_NAME, + r2_configured=r2_is_configured(), + active_page='cloud', + page_title='Cloud Storage', + page_subtitle='Cloudflare R2' + ) + + # ───────────────────────────────────────────── -# API — system + stats (always from main server) +# API — system + stats # ───────────────────────────────────────────── + @app.route('/api/system') @login_required def api_system(): @@ -214,7 +231,6 @@ def api_containers(): @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 @@ -225,7 +241,6 @@ def api_containers_all(): @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 @@ -236,16 +251,9 @@ def api_nav_summary(): }) -# ───────────────────────────────────────────── -# 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() @@ -254,18 +262,19 @@ def api_dashboard(): 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()), + '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(): @@ -291,13 +300,13 @@ def api_container_action(): @app.route('/api/container/status/') @login_required def api_container_status(name): - status_info = get_container_status(name) - return jsonify(status_info) + return jsonify(get_container_status(name)) # ───────────────────────────────────────────── # API — backups # ───────────────────────────────────────────── + @app.route('/api/backups') @login_required def api_backups(): @@ -307,7 +316,7 @@ def api_backups(): @app.route('/api/backups/log') @login_required def api_backup_log(): - limit = int(request.args.get('limit', 20)) + limit = int(request.args.get('limit', 20)) entries = get_backup_log_entries(limit) return jsonify({'entries': entries}) @@ -375,9 +384,130 @@ def api_backup_run_status(job_id): }) +@app.route('/api/backups/details', methods=['POST']) +@login_required +def api_backup_details(): + """ + Fast metadata for the Details popup — no gzip scan, just stat + sha sidecar. + Body: { backup_file: "myapps-backup-YYYYMMDD_HHMMSS.tar.gz", source: "local"|"vm" } + """ + data = request.get_json() or {} + backup_file = data.get('backup_file', '').strip() + source = data.get('source', 'local').strip() + + if not re.match(r'^myapps-backup-\d{8}_\d{6}\.tar\.gz$', backup_file): + return jsonify({'error': 'Invalid filename'}), 400 + + if source == 'local': + archive_path = f'/root/backups/{backup_file}' + sha_path = archive_path + '.sha256' + else: + archive_path = f'/backups/main-server/{backup_file}' + sha_path = archive_path + '.sha256' + + result = { + 'backup_file': backup_file, + 'source': source, + 'path': archive_path, + 'size_bytes': None, + 'size_human': None, + 'created_at': None, + 'age_days': None, + 'sha256': None, + 'on_cloud': False, + } + + # ── size + mtime ────────────────────────────────────────────── + if source == 'local': + if RUNNING_ON_MAIN_SERVER: + # direct stat on this machine + if os.path.exists(archive_path): + st = os.stat(archive_path) + result['size_bytes'] = st.st_size + result['size_human'] = _human_bytes(st.st_size) + mtime = datetime.fromtimestamp(st.st_mtime, tz=timezone.utc) + result['created_at'] = mtime.strftime('%Y-%m-%d %H:%M:%S UTC') + result['age_days'] = (datetime.now(tz=timezone.utc) - mtime).days + if os.path.exists(sha_path): + try: + with open(sha_path) as f: + result['sha256'] = f.read().split()[0].strip() + except Exception: + pass + else: + # SSH to main server + stat_out, _ = _ssh_main(f"stat -c '%s %Y' {archive_path} 2>/dev/null") + if stat_out: + parts = stat_out.split() + if len(parts) >= 2: + size_bytes = int(parts[0]) + mtime = datetime.fromtimestamp(int(parts[1]), tz=timezone.utc) + result['size_bytes'] = size_bytes + result['size_human'] = _human_bytes(size_bytes) + result['created_at'] = mtime.strftime('%Y-%m-%d %H:%M:%S UTC') + result['age_days'] = (datetime.now(tz=timezone.utc) - mtime).days + sha_out, _ = _ssh_main(f"cat {sha_path} 2>/dev/null | awk '{{print $1}}'") + if sha_out.strip(): + result['sha256'] = sha_out.strip() + + elif source == 'vm': + if not RUNNING_ON_MAIN_SERVER: + # we ARE the VM — direct stat + if os.path.exists(archive_path): + st = os.stat(archive_path) + result['size_bytes'] = st.st_size + result['size_human'] = _human_bytes(st.st_size) + mtime = datetime.fromtimestamp(st.st_mtime, tz=timezone.utc) + result['created_at'] = mtime.strftime('%Y-%m-%d %H:%M:%S UTC') + result['age_days'] = (datetime.now(tz=timezone.utc) - mtime).days + if os.path.exists(sha_path): + try: + with open(sha_path) as f: + result['sha256'] = f.read().split()[0].strip() + except Exception: + pass + else: + # SSH to VM + ssh_prefix = ( + f"ssh -i {VM_KEY} -p {VM_PORT} " + f"-o StrictHostKeyChecking=no -o ConnectTimeout=10 -o BatchMode=yes " + f"{VM_USER}@{VM_HOST}" + ) + stat_out, _ = _run( + f"{ssh_prefix} \"stat -c '%s %Y' {archive_path} 2>/dev/null\"", + timeout=20 + ) + if stat_out: + parts = stat_out.split() + if len(parts) >= 2: + size_bytes = int(parts[0]) + mtime = datetime.fromtimestamp(int(parts[1]), tz=timezone.utc) + result['size_bytes'] = size_bytes + result['size_human'] = _human_bytes(size_bytes) + result['created_at'] = mtime.strftime('%Y-%m-%d %H:%M:%S UTC') + result['age_days'] = (datetime.now(tz=timezone.utc) - mtime).days + sha_out, _ = _run( + f"{ssh_prefix} \"cat {sha_path} 2>/dev/null | awk '{{print $1}}'\"", + timeout=15 + ) + if sha_out.strip(): + result['sha256'] = sha_out.strip() + + # ── R2 presence check (best-effort) ────────────────────────── + try: + r2_list = r2_list_backups() + r2_names = {b.get('name') for b in r2_list} + result['on_cloud'] = backup_file in r2_names + except Exception: + pass + + return jsonify(result) + + # ───────────────────────────────────────────── # API — users # ───────────────────────────────────────────── + @app.route('/api/users') @login_required def api_users(): @@ -399,10 +529,10 @@ def api_user_disk(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) + 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: @@ -431,9 +561,113 @@ def api_delete_user(): return jsonify({'success': success, 'message': message}) +# ───────────────────────────────────────────── +# API — Cloudflare R2 cloud storage +# ───────────────────────────────────────────── + +@app.route('/api/cloud/r2/test') +@login_required +def api_r2_test(): + return jsonify(r2_test_connection()) + + +@app.route('/api/cloud/r2/stats') +@login_required +def api_r2_stats(): + return jsonify(r2_get_bucket_stats()) + + +@app.route('/api/cloud/r2/backups') +@login_required +def api_r2_list(): + return jsonify({'backups': r2_list_backups()}) + + +@app.route('/api/cloud/r2/delete', methods=['POST']) +@login_required +def api_r2_delete(): + data = request.get_json() or {} + key = data.get('key', '').strip() + if not key: + return jsonify({'success': False, 'message': 'key required'}), 400 + success, message = r2_delete_backup(key) + return jsonify({'success': success, 'message': message}) + + +@app.route('/api/cloud/r2/audit', methods=['POST']) +@login_required +def api_r2_audit(): + from modules.cloud_backup import r2_audit_backup + data = request.get_json() or {} + key = data.get('key', '').strip() + if not key: + return jsonify({'error': 'key required'}), 400 + return jsonify(r2_audit_backup(key)) + + +@app.route('/api/cloud/r2/upload', methods=['POST']) +@login_required +def api_r2_upload(): + data = request.get_json() or {} + backup_file = data.get('backup_file', '').strip() + source = data.get('source', 'local').strip() + + if not backup_file: + return jsonify({'success': False, 'message': 'backup_file required'}), 400 + + if source == 'local': + if RUNNING_ON_MAIN_SERVER: + local_path = f"/root/backups/{backup_file}" + else: + local_path = f"/tmp/{backup_file}" + if not os.path.exists(local_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"{local_path}" + ) + res = subprocess.run(pull_cmd, shell=True, capture_output=True, text=True) + if res.returncode != 0: + return jsonify({'success': False, 'message': f'Failed to pull from main server: {res.stderr}'}), 500 + else: + if RUNNING_ON_MAIN_SERVER: + local_path = f"/tmp/{backup_file}" + if not os.path.exists(local_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"{local_path}" + ) + res = subprocess.run(pull_cmd, shell=True, capture_output=True, text=True) + if res.returncode != 0: + return jsonify({'success': False, 'message': f'Failed to pull from VM: {res.stderr}'}), 500 + else: + local_path = f"/backups/main-server/{backup_file}" + + if not os.path.exists(local_path): + return jsonify({'success': False, 'message': f'File not found: {local_path}'}), 400 + + job_id = str(uuid.uuid4()) + t = threading.Thread(target=r2_upload_async, args=(local_path, job_id), daemon=True) + t.start() + return jsonify({'success': True, 'job_id': job_id}) + + +@app.route('/api/cloud/r2/upload/status/') +@login_required +def api_r2_upload_status(job_id): + job = get_upload_job(job_id) + if not job: + return jsonify({'error': 'Job not found'}), 404 + return jsonify(job) + + # ───────────────────────────────────────────── # RESTORE # ───────────────────────────────────────────── + @app.route('/restore/start', methods=['POST']) @login_required def restore_start(): @@ -443,6 +677,7 @@ def restore_start(): backup_source = data.get('backup_source', 'local') backup_file = data.get('backup_file', '').strip() + cloud_key = data.get('cloud_key', '').strip() target = data.get('target', 'local') remote_ip = data.get('remote_ip', '').strip() remote_port = str(data.get('remote_port', '22')).strip() or '22' @@ -454,7 +689,21 @@ def restore_start(): if not backup_file: return jsonify({'error': 'No backup file specified'}), 400 - if backup_source == 'local': + # ── Resolve backup path by source ──────────────────────────── + if backup_source == 'cloud': + backup_path = f"/tmp/{backup_file}" + if not os.path.exists(backup_path): + from modules.cloud_backup import _get_r2_client, _get_r2_config + try: + cfg = _get_r2_config() + bucket = cfg["bucket_name"] + key = cloud_key or f"backups/{backup_file}" + client = _get_r2_client() + client.download_file(bucket, key, backup_path) + except Exception as e: + return jsonify({'error': f'Failed to download from R2: {e}'}), 500 + + elif backup_source == 'local': if RUNNING_ON_MAIN_SERVER: backup_path = f"/root/backups/{backup_file}" if not os.path.exists(backup_path): @@ -471,7 +720,7 @@ def restore_start(): 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: + else: # vm if RUNNING_ON_MAIN_SERVER: backup_path = f"/tmp/{backup_file}" if not os.path.exists(backup_path): @@ -496,13 +745,13 @@ def restore_start(): return jsonify({'error': f'restore-myapps.sh not found at {restore_script_local}'}), 500 if target == 'local': - hostname = os.uname().nodename + 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"echo 'Restoring on this server ({hostname})...' && " f"mkdir -p {session_dir} && " - f"echo '📂 Extracting backup...' && " + 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 && " @@ -527,14 +776,14 @@ def restore_start(): remote_dest = f"/backups/restore-session-{uuid.uuid4().hex[:8]}" cmd = ( - f"echo '🔗 Connecting to {remote_user}@{remote_ip}:{remote_port}...' && " + 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"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"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"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 && " @@ -566,6 +815,7 @@ def restore_status_poll(job_id): # ───────────────────────────────────────────── # SERVER STATUS # ───────────────────────────────────────────── + @app.route('/server/status') @login_required def server_status(): @@ -578,6 +828,7 @@ def server_status(): # ───────────────────────────────────────────── # AUTH # ───────────────────────────────────────────── + @app.route('/login', methods=['GET', 'POST']) def login(): error = '' diff --git a/platform/modules/cloud_backup.py b/platform/modules/cloud_backup.py new file mode 100644 index 0000000..5a5a2ac --- /dev/null +++ b/platform/modules/cloud_backup.py @@ -0,0 +1,336 @@ +""" +modules/cloud_backup.py +Cloudflare R2 cloud backup integration via boto3 (S3-compatible API) +""" + +import os +import time +import threading +from datetime import datetime, timezone + +try: + import boto3 + from botocore.exceptions import ClientError, NoCredentialsError + BOTO3_AVAILABLE = True +except ImportError: + BOTO3_AVAILABLE = False + +# Upload progress tracking (in-memory) +_upload_jobs: dict = {} + + +def _get_r2_config() -> dict: + """Read R2 config fresh from env vars every time (not cached at import).""" + return { + "account_id": os.environ.get("R2_ACCOUNT_ID", "35e00c230cc8066252a2d9890b69aea2"), + "access_key_id": os.environ.get("R2_ACCESS_KEY_ID", ""), + "secret_access_key": os.environ.get("R2_SECRET_ACCESS_KEY", ""), + "bucket_name": os.environ.get("R2_BUCKET_NAME", "navitrends-backups"), + } + + +# Module-level name used in app.py template rendering +R2_BUCKET_NAME = os.environ.get("R2_BUCKET_NAME", "navitrends-backups") + + +def _get_r2_client(): + """Return a boto3 S3 client pointed at Cloudflare R2.""" + if not BOTO3_AVAILABLE: + raise RuntimeError("boto3 is not installed. Run: pip install boto3") + cfg = _get_r2_config() + if not cfg["access_key_id"] or not cfg["secret_access_key"]: + raise RuntimeError( + "R2 credentials not configured. " + "Set R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY environment variables." + ) + return boto3.client( + "s3", + endpoint_url=f"https://{cfg['account_id']}.r2.cloudflarestorage.com", + aws_access_key_id=cfg["access_key_id"], + aws_secret_access_key=cfg["secret_access_key"], + region_name="auto", + ) + + +def r2_is_configured() -> bool: + """Return True only if credentials are set and boto3 is available.""" + cfg = _get_r2_config() + return BOTO3_AVAILABLE and bool(cfg["access_key_id"]) and bool(cfg["secret_access_key"]) + + +def r2_test_connection() -> dict: + if not BOTO3_AVAILABLE: + return {"success": False, "message": "boto3 not installed (pip install boto3)", "bucket_exists": False} + cfg = _get_r2_config() + if not cfg["access_key_id"] or not cfg["secret_access_key"]: + return {"success": False, "message": "R2 credentials not set", "bucket_exists": False} + try: + client = _get_r2_client() + resp = client.list_buckets() + buckets = [b["Name"] for b in resp.get("Buckets", [])] + bucket = cfg["bucket_name"] + exists = bucket in buckets + return { + "success": True, + "message": f"Connected to R2. Bucket '{bucket}': {'exists' if exists else 'not created yet'}", + "bucket_exists": exists, + "buckets": buckets, + } + except NoCredentialsError: + return {"success": False, "message": "Invalid R2 credentials", "bucket_exists": False} + except Exception as e: + return {"success": False, "message": str(e), "bucket_exists": False} + + +def r2_ensure_bucket() -> tuple[bool, str]: + cfg = _get_r2_config() + bucket = cfg["bucket_name"] + try: + client = _get_r2_client() + try: + client.head_bucket(Bucket=bucket) + return True, f"Bucket '{bucket}' already exists" + except ClientError as e: + if e.response["Error"]["Code"] in ("404", "NoSuchBucket"): + client.create_bucket(Bucket=bucket) + return True, f"Bucket '{bucket}' created successfully" + raise + except Exception as e: + return False, f"Failed to ensure bucket: {e}" + + +def r2_list_backups() -> list[dict]: + if not r2_is_configured(): + return [] + cfg = _get_r2_config() + bucket = cfg["bucket_name"] + try: + client = _get_r2_client() + resp = client.list_objects_v2(Bucket=bucket, Prefix="backups/") + objects = [] + for obj in resp.get("Contents", []): + # Skip .sha256 checksum files — those are audit files, not backups + if obj["Key"].endswith(".sha256"): + continue + size = obj["Size"] + objects.append({ + "key": obj["Key"], + "name": obj["Key"].replace("backups/", ""), + "size": size, + "size_human": _human_size(size), + "last_modified": obj["LastModified"].isoformat(), + "last_modified_str": obj["LastModified"].strftime("%Y-%m-%d %H:%M UTC"), + }) + return sorted(objects, key=lambda x: x["last_modified"], reverse=True) + except Exception: + return [] + + +def r2_delete_backup(key: str) -> tuple[bool, str]: + cfg = _get_r2_config() + bucket = cfg["bucket_name"] + try: + client = _get_r2_client() + client.delete_object(Bucket=bucket, Key=key) + return True, f"Deleted {key} from R2" + except Exception as e: + return False, str(e) + + +def r2_get_bucket_stats() -> dict: + if not r2_is_configured(): + return {"count": 0, "total_size": 0, "total_size_human": "0 B", "configured": False} + cfg = _get_r2_config() + bucket = cfg["bucket_name"] + try: + client = _get_r2_client() + resp = client.list_objects_v2(Bucket=bucket, Prefix="backups/") + # Count and size only real archives, not .sha256 checksum files + objects = [o for o in resp.get("Contents", []) if not o["Key"].endswith(".sha256")] + total = sum(o["Size"] for o in objects) + return { + "count": len(objects), + "total_size": total, + "total_size_human": _human_size(total), + "configured": True, + } + except Exception: + return {"count": 0, "total_size": 0, "total_size_human": "0 B", "configured": True} + + +class _ProgressCallback: + def __init__(self, job_id: str, file_size: int): + self._job_id = job_id + self._size = file_size + self._uploaded = 0 + self._lock = threading.Lock() + + def __call__(self, bytes_amount: int): + with self._lock: + self._uploaded += bytes_amount + pct = int(self._uploaded * 100 / self._size) if self._size else 0 + if self._job_id in _upload_jobs: + _upload_jobs[self._job_id]["uploaded"] = self._uploaded + _upload_jobs[self._job_id]["progress"] = pct + _upload_jobs[self._job_id]["log"].append( + f" {_human_size(self._uploaded)} / {_human_size(self._size)} ({pct}%)" + ) + + +def r2_upload_async(local_path: str, job_id: str) -> None: + _upload_jobs[job_id] = { + "status": "running", + "log": [f"Starting upload: {os.path.basename(local_path)}"], + "progress": 0, + "uploaded": 0, + "started": time.time(), + } + try: + if not r2_is_configured(): + raise RuntimeError("R2 credentials not configured") + + cfg = _get_r2_config() + bucket = cfg["bucket_name"] + + ok, msg = r2_ensure_bucket() + if not ok: + raise RuntimeError(msg) + _upload_jobs[job_id]["log"].append(f"OK: {msg}") + + file_size = os.path.getsize(local_path) + object_key = f"backups/{os.path.basename(local_path)}" + client = _get_r2_client() + callback = _ProgressCallback(job_id, file_size) + + _upload_jobs[job_id]["log"].append( + f"Uploading {_human_size(file_size)} to r2://{bucket}/{object_key}" + ) + + client.upload_file( + local_path, + bucket, + object_key, + Callback=callback, + ExtraArgs={"Metadata": { + "uploaded-by": "navitrends-platform", + "uploaded-at": datetime.now(timezone.utc).isoformat(), + "original-file": os.path.basename(local_path), + }}, + ) + + sha_path = local_path + ".sha256" + if os.path.exists(sha_path): + client.upload_file(sha_path, bucket, object_key + ".sha256") + _upload_jobs[job_id]["log"].append("SHA256 checksum uploaded") + + elapsed = round(time.time() - _upload_jobs[job_id]["started"], 1) + _upload_jobs[job_id]["log"].append( + f"Upload complete in {elapsed}s — r2://{bucket}/{object_key}" + ) + _upload_jobs[job_id]["status"] = "done" + _upload_jobs[job_id]["progress"] = 100 + _upload_jobs[job_id]["object_key"] = object_key + + except Exception as e: + _upload_jobs[job_id]["log"].append(f"Upload failed: {e}") + _upload_jobs[job_id]["status"] = "error" + + +def get_upload_job(job_id: str) -> dict | None: + job = _upload_jobs.get(job_id) + if not job: + return None + return { + "status": job["status"], + "log": job["log"], + "progress": job["progress"], + "elapsed": round(time.time() - job.get("started", time.time())), + } + + +def _human_size(n: int) -> str: + for unit in ("B", "KB", "MB", "GB"): + if n < 1024: + return f"{n:.1f} {unit}" + n /= 1024 + return f"{n:.1f} TB" + +def r2_audit_backup(key: str) -> dict: + """Audit a backup in R2 by verifying its SHA256 checksum and metadata.""" + cfg = _get_r2_config() + bucket = cfg["bucket_name"] + report = { + "backup": key, + "status": "unknown", + "checks": [], + "warnings": [], + "errors": [], + } + + try: + client = _get_r2_client() + + # Check 1: Object exists + try: + head = client.head_object(Bucket=bucket, Key=key) + size = head["ContentLength"] + last_mod = head["LastModified"].strftime("%Y-%m-%d %H:%M UTC") + report["checks"].append(f"✅ Object exists in R2 ({_human_size(size)}, modified {last_mod})") + report["size"] = size + report["size_human"] = _human_size(size) + report["last_modified"] = last_mod + except ClientError as e: + report["errors"].append(f"❌ Object not found in R2: {e}") + report["status"] = "error" + return report + + # Check 2: SHA256 companion file + sha_key = key + ".sha256" + try: + sha_obj = client.get_object(Bucket=bucket, Key=sha_key) + sha_content = sha_obj["Body"].read().decode("utf-8").strip() + stored_hash = sha_content.split()[0] if sha_content else "" + report["checks"].append("✅ SHA256 checksum file found") + report["stored_hash"] = (stored_hash[:16] + "…") if stored_hash else "N/A" + except ClientError: + report["warnings"].append("⚠️ No SHA256 checksum file found — integrity cannot be verified") + report["stored_hash"] = None + + # Check 3: Upload metadata + metadata = head.get("Metadata", {}) + uploaded_by = metadata.get("uploaded-by", "") + uploaded_at = metadata.get("uploaded-at", "") + if uploaded_by: + report["checks"].append(f"✅ Uploaded by: {uploaded_by}") + if uploaded_at: + report["checks"].append(f"✅ Upload timestamp: {uploaded_at[:19]}") + if not uploaded_by and not uploaded_at: + report["warnings"].append("⚠️ No upload metadata found (may have been uploaded outside the platform)") + + # Check 4: File size sanity + if size < 10 * 1024 * 1024: + report["warnings"].append(f"⚠️ File is very small ({_human_size(size)}) — may be incomplete") + else: + report["checks"].append(f"✅ File size looks healthy ({_human_size(size)})") + + # Check 5: Filename format + import re + filename = key.replace("backups/", "") + if re.match(r"myapps-backup-\d{8}_\d{6}\.tar\.gz", filename): + report["checks"].append("✅ Filename format is valid") + else: + report["warnings"].append(f"⚠️ Unexpected filename format: {filename}") + + # Final status + if report["errors"]: + report["status"] = "error" + elif report["warnings"]: + report["status"] = "warning" + else: + report["status"] = "healthy" + + except Exception as e: + report["errors"].append(f"❌ Audit failed: {e}") + report["status"] = "error" + + return report diff --git a/platform/templates/base.html b/platform/templates/base.html index ca287cc..d80a05a 100644 --- a/platform/templates/base.html +++ b/platform/templates/base.html @@ -39,6 +39,9 @@ Backups + + Cloud Storage + diff --git a/platform/templates/pages/backups.html b/platform/templates/pages/backups.html index 911170b..1e775f4 100644 --- a/platform/templates/pages/backups.html +++ b/platform/templates/pages/backups.html @@ -1,5 +1,276 @@ {% extends "base.html" %} {% block content %} + +{# ── Extra styles scoped to this page ── #} + + +{# ── Manual Backup ── #}
Manual Backup
@@ -20,41 +291,73 @@
+{# ── Available Backups ── #}
Available Backups
-
+ +
+ + {# LOCAL #}
-
🖥️ MAIN SERVER
/root/backups/
+
+
🖥️ MAIN SERVER
+ /root/backups/ +
{% for b in backups %} -
{{ b }}
+
+ {{ b }} +
+ + + + + +
+
{% else %}
No backups
{% endfor %}
+ + {# VM #}
-
💾 VM SERVER
/backups/main-server/
+
+
💾 VM SERVER
+ /backups/main-server/ +
{% for b in vm_backups %} -
{{ b }}
+
+ {{ b }} +
+ + + + +
+
{% else %}
No VM backups
{% endfor %}
-
-
-
@@ -106,16 +110,26 @@ -{% if not running_on_main %} -{% endif %} - {% endblock %} \ No newline at end of file diff --git a/platform/templates/pages/restore.html b/platform/templates/pages/restore.html index c1eab92..de31cc7 100644 --- a/platform/templates/pages/restore.html +++ b/platform/templates/pages/restore.html @@ -10,24 +10,54 @@
STEP 1 — SELECT BACKUP SOURCE
+
+ + {# Separate selects — only one shown at a time #}
- + {% for b in backups %} + + {% else %} + + {% endfor %} + + {# VM select #} + + + {# Cloud select — populated via JS #} + +
+ + {# Cloud info notice #} + @@ -84,7 +114,7 @@
-

⚠ Healthy running containers are skipped.

@@ -100,7 +130,158 @@
+ -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/scripts/backup-myapps.sh b/scripts/backup-myapps.sh index 3ccb61c..66d769c 100755 --- a/scripts/backup-myapps.sh +++ b/scripts/backup-myapps.sh @@ -2,6 +2,10 @@ # ============================================= # backup-myapps.sh — Run on MAIN SERVER # Backs up: Frappe, Nextcloud, Mautic, n8n, Odoo +# Storage tiers: +# 1. Local → /root/backups/ +# 2. VM → SSH tunnel → /backups/main-server/ +# 3. Cloud → Cloudflare R2 (S3-compatible) # Usage: ./backup-myapps.sh # ============================================= @@ -19,6 +23,20 @@ VM_PORT="2223" VM_KEY="/root/.ssh/contabo-key" VM_DEST="/backups/main-server/" +# ── Cloudflare R2 config ───────────────────────────────────────────────────── +# Set these via environment or export before running the script +R2_ACCOUNT_ID="${R2_ACCOUNT_ID:-35e00c230cc8066252a2d9890b69aea2}" +R2_BUCKET="${R2_BUCKET_NAME:-navitrends-backups}" +R2_ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" +# AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY must be exported in the env +# or set in /root/.r2-credentials (sourced below) + +CREDENTIALS_FILE="/root/.r2-credentials" +if [ -f "$CREDENTIALS_FILE" ]; then + # shellcheck source=/dev/null + source "$CREDENTIALS_FILE" +fi + # Log file for backup status (used by boot-check script) BACKUP_LOG_FILE="/root/backups/backup-status.log" MAX_BACKUPS=10 @@ -34,6 +52,7 @@ log_status() { echo "=========================================" echo "📦 Starting Backup: $BACKUP_NAME" echo " Apps: Frappe, Nextcloud, Mautic, n8n, Odoo" +echo " Tiers: Local → VM → ☁ Cloudflare R2" echo "=========================================" mkdir -p "$BACKUP_DIR" @@ -153,12 +172,15 @@ Hostname: $(hostname) Apps: Frappe, Nextcloud, Mautic, n8n, Odoo Volumes: $VOLUME_COUNT volume(s) backed up Docker info: $(docker --version) +Storage Tiers: + - Local: /root/backups/ + - VM: ${VM_HOST}:${VM_PORT} → ${VM_DEST} + - Cloud: Cloudflare R2 → s3://${R2_BUCKET}/backups/ Volumes included: $(ls volumes/*.tar.gz 2>/dev/null | xargs -I{} basename {} || echo "none") EOF -# Write individual volume checksums for integrity verification later echo "" >> backup-info.txt echo "Volume SHA256 checksums:" >> backup-info.txt for f in volumes/*.tar.gz; do @@ -177,11 +199,9 @@ tar -czf "${BACKUP_NAME}.tar.gz" "${BACKUP_NAME}/" COMPRESSED_SIZE=$(du -h "${BACKUP_NAME}.tar.gz" | cut -f1) echo " ✅ Compressed size: $COMPRESSED_SIZE → $BACKUP_ARCHIVE" -# Write a top-level SHA256 for the final archive (used by health-check) sha256sum "${BACKUP_NAME}.tar.gz" > "${BACKUP_NAME}.tar.gz.sha256" echo " ✅ Checksum written: ${BACKUP_NAME}.tar.gz.sha256" -# Remove staging directory now that archive is created rm -rf "$BACKUP_DIR" # -------------------------------------------------- @@ -205,10 +225,10 @@ else fi # -------------------------------------------------- -# 9. Send to VM over SSH +# 9. Send to VM over SSH [TIER 2] # -------------------------------------------------- echo "" -echo "📤 Sending backup to VM (${VM_HOST}:${VM_PORT})..." +echo "📤 [TIER 2] Sending backup to VM (${VM_HOST}:${VM_PORT})..." scp -i "$VM_KEY" \ -P "$VM_PORT" \ -o StrictHostKeyChecking=no \ @@ -218,30 +238,80 @@ scp -i "$VM_KEY" \ if [ $? -eq 0 ]; then echo " ✅ Backup sent to VM successfully!" - # Also send the checksum file scp -i "$VM_KEY" -P "$VM_PORT" -o StrictHostKeyChecking=no \ "${BACKUP_NAME}.tar.gz.sha256" "${VM_USER}@${VM_HOST}:${VM_DEST}" 2>/dev/null || true else - echo " ❌ Transfer failed. The compressed backup is still at:" - echo " $BACKUP_ARCHIVE" - echo " 💡 Retry manually:" - echo " scp -i $VM_KEY -P $VM_PORT $BACKUP_ARCHIVE ${VM_USER}@${VM_HOST}:${VM_DEST}" + echo " ❌ VM transfer failed. Local backup still at: $BACKUP_ARCHIVE" fi # -------------------------------------------------- -# 10. Write final status to log +# 10. Upload to Cloudflare R2 [TIER 3 — CLOUD] # -------------------------------------------------- -log_status "SUCCESS" "$BACKUP_NAME" "size=${COMPRESSED_SIZE}" +echo "" +echo "☁️ [TIER 3] Uploading to Cloudflare R2..." +echo " Bucket: ${R2_BUCKET}" +echo " Endpoint: ${R2_ENDPOINT}" + +# Check if AWS CLI is available (used for S3-compatible uploads) +if command -v aws &>/dev/null && [ -n "${AWS_ACCESS_KEY_ID:-}" ] && [ -n "${AWS_SECRET_ACCESS_KEY:-}" ]; then + # Upload main archive + aws s3 cp "${BACKUP_NAME}.tar.gz" \ + "s3://${R2_BUCKET}/backups/${BACKUP_NAME}.tar.gz" \ + --endpoint-url "${R2_ENDPOINT}" \ + --no-progress \ + && echo " ✅ R2 upload complete: s3://${R2_BUCKET}/backups/${BACKUP_NAME}.tar.gz" \ + || echo " ❌ R2 upload failed" + + # Upload checksum + aws s3 cp "${BACKUP_NAME}.tar.gz.sha256" \ + "s3://${R2_BUCKET}/backups/${BACKUP_NAME}.tar.gz.sha256" \ + --endpoint-url "${R2_ENDPOINT}" \ + --no-prth +os.environ.setdefault('R2_ACCOUNT_ID', '${R2_ACCOUNT_ID}') +os.environ.setdefault('R2_BUCKET_NAME', '${R2_BUCKET}') +try: + import boto3 + client = boto3.client( + 's3', + endpoint_url='${R2_ENDPOINT}', + aws_access_key_id=os.environ.get('AWS_ACCESS_KEY_ID',''), + aws_secret_access_key=os.environ.get('AWS_SECRET_ACCESS_KEY',''), + region_name='auto' + ) + archive = '/root/backups/${BACKUP_NAME}.tar.gz' + key = 'backups/${BACKUP_NAME}.tar.gz' + size = os.path.getsize(archive) + print(f' Uploading {size/1024/1024:.1f} MB...') + client.upload_file(archive, '${R2_BUCKET}', key) + print(' ✅ R2 upload complete (boto3 fallback)') +except Exception as e: + print(f' ❌ R2 upload failed: {e}') + sys.exit(1) +PYEOF + R2_STATUS="uploaded via boto3" +else + echo " ⚠️ Skipping R2 upload: neither aws-cli nor python3/boto3 available" + echo " 💡 Install: pip install boto3 OR apt install awscli" + echo " 💡 Then set: export AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=..." + R2_STATUS="skipped (no cli available)" +fi + +# -------------------------------------------------- +# 11. Write final status to log +# -------------------------------------------------- +log_status "SUCCESS" "$BACKUP_NAME" "size=${COMPRESSED_SIZE} | r2=${R2_STATUS}" echo "" echo "=========================================" -echo "✅ BACKUP COMPLETE" -echo " Name: $BACKUP_NAME" -echo " Local: $BACKUP_ARCHIVE ($COMPRESSED_SIZE)" -echo " Remote: ${VM_HOST}:${VM_DEST}${BACKUP_NAME}.tar.gz" +echo "✅ BACKUP COMPLETE — 3-tier storage" +echo "" +echo " 📦 Name: $BACKUP_NAME" +echo " 💾 Local: $BACKUP_ARCHIVE ($COMPRESSED_SIZE)" +echo " 🖥️ VM: ${VM_HOST}:${VM_DEST}${BACKUP_NAME}.tar.gz" +echo " ☁️ R2: s3://${R2_BUCKET}/backups/${BACKUP_NAME}.tar.gz" echo "=========================================" -# ── Chiffrement AES-256 ────────────────────────────────────────────────────── +# ── AES-256 encryption (optional, call manually) ───────────────────────────── encrypt_backup() { echo "🔐 Chiffrement AES-256..." openssl enc -aes-256-cbc -pbkdf2 -pass pass:Navitrends2024! \ @@ -251,9 +321,8 @@ encrypt_backup() { 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 + mail -s "[Navitrends] BACKUP FAILED - $(date)" ameniboukottaya@gmail.com }