590 lines
22 KiB
Python
590 lines
22 KiB
Python
# 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'
|
|
|
|
restore_jobs = {}
|
|
backup_jobs = {} # for manual backup runs
|
|
|
|
|
|
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):
|
|
"""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(
|
|
['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
|
|
# ─────────────────────────────────────────────
|
|
@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()
|
|
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():
|
|
return render_template(
|
|
'pages/users.html',
|
|
users=get_all_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():
|
|
return render_template(
|
|
'pages/settings.html',
|
|
main_server=MAIN_SERVER_IP,
|
|
system=get_system_info(),
|
|
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 on every page (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 — container actions
|
|
# ─────────────────────────────────────────────
|
|
@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()
|
|
|
|
if not name or not 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'
|
|
'new_status_raw': status_info['raw'],
|
|
})
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# API — single container status (for polling)
|
|
# ─────────────────────────────────────────────
|
|
@app.route('/api/container/status/<name>')
|
|
@login_required
|
|
def api_container_status(name):
|
|
"""Quick single-container status check."""
|
|
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():
|
|
"""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()
|
|
|
|
if not bfile:
|
|
return jsonify({'error': 'backup_file required'}), 400
|
|
|
|
result = audit_backup(bfile, source)
|
|
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()
|
|
|
|
if not bfile:
|
|
return jsonify({'success': False, 'message': 'backup_file required'}), 400
|
|
|
|
success, message = delete_backup(bfile, source)
|
|
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/<job_id>.
|
|
Only works when running on the main server (where the backup script lives).
|
|
"""
|
|
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/<job_id>')
|
|
@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
|
|
return jsonify({
|
|
'status': job['status'],
|
|
'log': job['log'],
|
|
'elapsed': round(time.time() - job.get('started', time.time()))
|
|
})
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# API — users (LOCAL — users on this host)
|
|
# ─────────────────────────────────────────────
|
|
@app.route('/api/users')
|
|
@login_required
|
|
def api_users():
|
|
return jsonify(get_all_users())
|
|
|
|
|
|
@app.route('/api/users/<username>/containers')
|
|
@login_required
|
|
def api_user_containers(username):
|
|
return jsonify(get_user_containers(username))
|
|
|
|
|
|
@app.route('/api/users/<username>/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
|
|
|
|
# ── Resolve backup archive path ──────────────────────────────────────────
|
|
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/<job_id>')
|
|
@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)
|