420 lines
24 KiB
Bash
420 lines
24 KiB
Bash
#!/bin/bash
|
|
|
|
#=================================================================================
|
|
# M365 Mailbox Backup Tool - Automatic Installation Script (v2.1 - Interactive IP)
|
|
#=================================================================================
|
|
|
|
# --- Color Codes ---
|
|
GREEN='\033[0;32m'
|
|
RED='\033[0;31m'
|
|
NC='\033[0m' # No Color
|
|
|
|
if [ "$EUID" -ne 0 ]; then
|
|
echo -e "${RED}Please run this script with 'sudo': sudo ./install.sh${NC}"
|
|
exit 1
|
|
fi
|
|
|
|
echo "=== M365 Backup Tool Installation Started ==="
|
|
|
|
# --- NEW: INTERACTIVE IP PROMPT ---
|
|
DETECTED_IP=$(hostname -I | awk '{print $1}')
|
|
read -p "Enter the server IP address or press Enter to use the detected IP [$DETECTED_IP]: " USER_IP
|
|
IP_ADDR=${USER_IP:-$DETECTED_IP}
|
|
# ------------------------------------
|
|
|
|
REDIRECT_URI="https://$IP_ADDR/get_token"
|
|
echo "---------------------------------------------------------------------"
|
|
echo "IMPORTANT: Please ensure you have set the following Redirect URI"
|
|
echo "in your Azure AD App Registration under the 'Web' platform section:"
|
|
echo ""
|
|
echo "$REDIRECT_URI"
|
|
echo ""
|
|
echo "---------------------------------------------------------------------"
|
|
read -p "Press Enter after you have added the Redirect URI in the Azure Portal..."
|
|
|
|
echo -n "Enter your Azure Tenant ID: "
|
|
read TENANT_ID
|
|
echo -n "Enter your Application (Client) ID: "
|
|
read CLIENT_ID
|
|
echo -n "Enter your Client Secret Value (will not be visible): "
|
|
read -s CLIENT_SECRET
|
|
echo ""
|
|
echo -n "Enter comma-separated emails of allowed admins: "
|
|
read ALLOWED_ADMINS
|
|
echo ""
|
|
echo "Credentials received."
|
|
|
|
# --- System Dependency Installation ---
|
|
echo "--> Updating system and installing required packages..."
|
|
apt-get update > /dev/null 2>&1
|
|
apt-get install -y nginx redis-server python3-pip python3-venv > /dev/null 2>&1
|
|
|
|
# --- Project Directory and Virtual Environment ---
|
|
PROJECT_DIR="/opt/m365-backup-tool"
|
|
USERNAME=${SUDO_USER:-$(whoami)}
|
|
|
|
echo "--> Creating project directory: $PROJECT_DIR"
|
|
mkdir -p "$PROJECT_DIR"
|
|
cd "$PROJECT_DIR"
|
|
|
|
echo "--> Creating Python virtual environment..."
|
|
python3 -m venv venv
|
|
|
|
echo "--> Installing Python libraries..."
|
|
source venv/bin/activate
|
|
pip install Flask gunicorn "celery[redis]" msal requests python-decouple > /dev/null 2>&1
|
|
deactivate
|
|
|
|
# --- Application File Creation ---
|
|
echo "--> Creating .env file for credentials..."
|
|
cat <<EOF > .env
|
|
TENANT_ID=${TENANT_ID}
|
|
CLIENT_ID=${CLIENT_ID}
|
|
CLIENT_SECRET=${CLIENT_SECRET}
|
|
FLASK_SECRET_KEY=$(python3 -c 'import secrets; print(secrets.token_hex(24))')
|
|
ALLOWED_ADMINS=${ALLOWED_ADMINS}
|
|
REDIRECT_URI=${REDIRECT_URI}
|
|
EOF
|
|
|
|
echo "--> Creating application files and templates..."
|
|
# app.py file
|
|
cat <<'EOF' > app.py
|
|
import os, re, msal, requests, json, secrets
|
|
from flask import Flask, request, redirect, url_for, render_template, send_from_directory, session
|
|
from celery import Celery
|
|
from email import policy
|
|
from email.parser import BytesParser
|
|
from datetime import datetime, timedelta
|
|
from decouple import config
|
|
from functools import wraps
|
|
|
|
TENANT_ID = config('TENANT_ID', default='')
|
|
CLIENT_ID = config('CLIENT_ID', default='')
|
|
CLIENT_SECRET = config('CLIENT_SECRET', default='')
|
|
FLASK_SECRET_KEY = config('FLASK_SECRET_KEY')
|
|
ALLOWED_ADMINS = [admin.strip().lower() for admin in config('ALLOWED_ADMINS', default='').split(',')]
|
|
REDIRECT_URI = config('REDIRECT_URI')
|
|
AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}"
|
|
SCOPE = ["User.Read"]
|
|
BACKUP_DIR = os.path.join(os.getcwd(), "backup")
|
|
|
|
app = Flask(__name__)
|
|
app.secret_key = FLASK_SECRET_KEY
|
|
app.config.update(broker_url='redis://localhost:6379/0', result_backend='redis://localhost:6379/0')
|
|
celery = Celery(app.name, broker=app.config['broker_url'])
|
|
celery.conf.update(app.config)
|
|
|
|
msal_app = msal.ConfidentialClientApplication(client_id=CLIENT_ID, authority=AUTHORITY, client_credential=CLIENT_SECRET)
|
|
|
|
def login_required(f):
|
|
@wraps(f)
|
|
def decorated_function(*args, **kwargs):
|
|
if 'user' not in session: return redirect(url_for('show_login_page'))
|
|
if session['user'].get('email', '').lower() not in ALLOWED_ADMINS:
|
|
session.clear(); return "Access Denied. You are not an authorized administrator.", 403
|
|
return f(*args, **kwargs)
|
|
return decorated_function
|
|
|
|
@app.route('/login_page')
|
|
def show_login_page(): return render_template('login.html')
|
|
|
|
@app.route('/login')
|
|
def login():
|
|
session["flow"] = msal_app.initiate_auth_code_flow(SCOPE, redirect_uri=REDIRECT_URI)
|
|
return redirect(session["flow"]["auth_uri"])
|
|
|
|
@app.route('/get_token')
|
|
def get_token():
|
|
try:
|
|
result = msal_app.acquire_token_by_auth_code_flow(session.get("flow", {}), request.args)
|
|
if "error" in result: return f"Login Error: {result.get('error_description')}", 400
|
|
claims = result.get("id_token_claims", {})
|
|
user_email = claims.get("preferred_username")
|
|
if not user_email or user_email.lower() not in ALLOWED_ADMINS:
|
|
session.clear(); return "Access Denied. Your account is not in the list of authorized administrators.", 403
|
|
session["user"] = {"name": claims.get("name"), "email": user_email}
|
|
except ValueError as e: return f"Error processing token: {str(e)}", 400
|
|
return redirect(url_for('index'))
|
|
|
|
@app.route('/logout')
|
|
def logout():
|
|
session.clear()
|
|
return redirect(f"https://login.microsoftonline.com/common/oauth2/v2.0/logout?post_logout_redirect_uri={url_for('show_login_page', _external=True, _scheme='https')}")
|
|
|
|
def sanitize_filename(filename): return re.sub(r'[\\/*?:"<>|]', "", filename)
|
|
|
|
@celery.task
|
|
def trigger_backup(user_email, date_option='all_time', start_date_str=None, end_date_str=None):
|
|
app_for_backup = msal.ConfidentialClientApplication(client_id=CLIENT_ID, authority=AUTHORITY, client_credential=CLIENT_SECRET)
|
|
result = app_for_backup.acquire_token_for_client(scopes=["https://graph.microsoft.com/.default"])
|
|
if "access_token" not in result: return f"Auth Error for background task: {result.get('error_description')}"
|
|
access_token = result['access_token']; headers = {'Authorization': 'Bearer ' + access_token}
|
|
user_backup_path = os.path.join(BACKUP_DIR, user_email)
|
|
if not os.path.exists(user_backup_path): os.makedirs(user_backup_path)
|
|
all_folders, folders_url = [], f"https://graph.microsoft.com/v1.0/users/{user_email}/mailFolders?$top=100"
|
|
while folders_url:
|
|
response = requests.get(folders_url, headers=headers)
|
|
if response.status_code != 200: return f"Error fetching mail folders: {response.json()}"
|
|
data = response.json(); all_folders.extend(data.get('value', [])); folders_url = data.get('@odata.nextLink')
|
|
total_message_count = 0
|
|
for folder in all_folders:
|
|
folder_name, folder_id = folder.get('displayName'), folder.get('id')
|
|
if not folder_name or not folder_id: continue
|
|
local_folder_path = os.path.join(user_backup_path, sanitize_filename(folder_name))
|
|
if not os.path.exists(local_folder_path): os.makedirs(local_folder_path)
|
|
filter_query = ""
|
|
now = datetime.utcnow()
|
|
if date_option == 'last_3_months': filter_query = f"$filter=receivedDateTime ge {(now - timedelta(days=90)).isoformat()}Z"
|
|
elif date_option == 'last_6_months': filter_query = f"$filter=receivedDateTime ge {(now - timedelta(days=180)).isoformat()}Z"
|
|
elif date_option == 'last_1_year': filter_query = f"$filter=receivedDateTime ge {(now - timedelta(days=365)).isoformat()}Z"
|
|
elif date_option == 'last_2_years': filter_query = f"$filter=receivedDateTime ge {(now - timedelta(days=730)).isoformat()}Z"
|
|
elif date_option == 'custom_range' and start_date_str and end_date_str:
|
|
start_iso = datetime.fromisoformat(start_date_str).isoformat(); end_iso = (datetime.fromisoformat(end_date_str) + timedelta(days=1)).isoformat()
|
|
filter_query = f"$filter=receivedDateTime ge {start_iso}Z and receivedDateTime lt {end_iso}Z"
|
|
base_url = f"https://graph.microsoft.com/v1.0/users/{user_email}/mailFolders/{folder_id}/messages"
|
|
messages_url = f"{base_url}?$top=1000"
|
|
if filter_query: messages_url = f"{base_url}?{filter_query}&$top=1000"
|
|
message_count_in_folder = 0
|
|
while messages_url:
|
|
msg_response = requests.get(messages_url, headers=headers)
|
|
if msg_response.status_code != 200: break
|
|
messages_data = msg_response.json(); messages = messages_data.get('value', [])
|
|
for message in messages:
|
|
message_id = message.get('id')
|
|
if not message_id: continue
|
|
mime_url = f"https://graph.microsoft.com/v1.0/users/{user_email}/messages/{message_id}/$value"
|
|
mime_response = requests.get(mime_url, headers=headers)
|
|
if mime_response.status_code == 200:
|
|
with open(os.path.join(local_folder_path, f"{sanitize_filename(message_id)}.eml"), 'wb') as f: f.write(mime_response.content)
|
|
message_count_in_folder += 1
|
|
messages_url = messages_data.get('@odata.nextLink')
|
|
total_message_count += message_count_in_folder
|
|
return f"Total {total_message_count} emails downloaded."
|
|
@app.route('/')
|
|
@login_required
|
|
def index(): return render_template('index.html')
|
|
@app.route('/get_users')
|
|
@login_required
|
|
def get_users():
|
|
app_for_users = msal.ConfidentialClientApplication(client_id=CLIENT_ID, authority=AUTHORITY, client_credential=CLIENT_SECRET)
|
|
result = app_for_users.acquire_token_for_client(scopes=["https://graph.microsoft.com/.default"])
|
|
if "access_token" not in result: return render_template('partials/user_list_error.html', error_message="App Authentication Error.")
|
|
headers = {'Authorization': 'Bearer ' + result['access_token']}
|
|
users, users_url = [], "https://graph.microsoft.com/v1.0/users?$select=displayName,userPrincipalName"
|
|
while users_url:
|
|
response = requests.get(users_url, headers=headers)
|
|
if response.status_code != 200: return render_template('partials/user_list_error.html', error_message="Error fetching users.")
|
|
data = response.json(); users.extend(data.get('value', [])); users_url = data.get('@odata.nextLink')
|
|
return render_template('partials/user_list.html', users=sorted(users, key=lambda u: u.get('displayName') or ''))
|
|
@app.route('/backup', methods=['POST'])
|
|
@login_required
|
|
def backup():
|
|
emails = request.form.getlist('emails'); date_option = request.form.get('date_option'); start_date = request.form.get('start_date'); end_date = request.form.get('end_date')
|
|
if 'tasks' not in session: session['tasks'] = []
|
|
for email in emails:
|
|
task = trigger_backup.delay(email, date_option, start_date, end_date)
|
|
session['tasks'].append({'id': task.id, 'email': email})
|
|
session.modified = True
|
|
return redirect(url_for('list_tasks'))
|
|
@app.route('/tasks')
|
|
@login_required
|
|
def list_tasks():
|
|
task_info = []
|
|
for task_meta in session.get('tasks', []):
|
|
task = trigger_backup.AsyncResult(task_meta.get('id'))
|
|
task_info.append({'id': task_meta.get('id'), 'email': task_meta.get('email'), 'status': task.state, 'result': task.result if task.state in ['SUCCESS', 'FAILURE'] else str(task.info)})
|
|
return render_template('tasks.html', tasks=reversed(task_info))
|
|
@app.route('/backups')
|
|
@login_required
|
|
def list_backups():
|
|
if not os.path.exists(BACKUP_DIR): os.makedirs(BACKUP_DIR)
|
|
return render_template('backups.html', users=os.listdir(BACKUP_DIR))
|
|
@app.route('/backups/<path:user_email>')
|
|
@login_required
|
|
def restore_portal(user_email):
|
|
user_dir = os.path.join(BACKUP_DIR, user_email)
|
|
if not os.path.exists(user_dir): return "No backup found for this user.", 404
|
|
all_folders = [d for d in os.listdir(user_dir) if os.path.isdir(os.path.join(user_dir, d))]
|
|
active_folder_name = request.args.get('folder')
|
|
if not active_folder_name: active_folder_name = next((f for f in ['Inbox'] if f in all_folders), all_folders[0] if all_folders else None)
|
|
search_query = request.args.get('q', '').lower()
|
|
emails = []
|
|
if active_folder_name:
|
|
selected_folder_path = os.path.join(user_dir, active_folder_name)
|
|
if os.path.exists(selected_folder_path):
|
|
parser = BytesParser(policy=policy.default)
|
|
for filename in os.listdir(selected_folder_path):
|
|
if filename.endswith(".eml"):
|
|
with open(os.path.join(selected_folder_path, filename), 'rb') as f: msg = parser.parse(f)
|
|
if search_query:
|
|
content_to_search = str(msg.get_payload(decode=True))
|
|
if msg['subject']: content_to_search += msg['subject']
|
|
if search_query not in content_to_search.lower(): continue
|
|
emails.append({'filename': filename, 'subject': msg['subject'], 'from': msg['from'], 'date': msg['date']})
|
|
return render_template('portal.html', user_email=user_email, folders=sorted(all_folders), emails=emails, active_folder=active_folder_name, search_query=search_query)
|
|
@app.route('/download/<path:user_email>/<path:folder_name>/<path:filename>')
|
|
@login_required
|
|
def download_file(user_email, folder_name, filename): return send_from_directory(directory=os.path.join(BACKUP_DIR, user_email, folder_name), path=filename, as_attachment=True)
|
|
@app.route('/preview/<path:user_email>/<path:folder_name>/<path:filename>')
|
|
@login_required
|
|
def preview_file(user_email, folder_name, filename):
|
|
file_path = os.path.join(BACKUP_DIR, user_email, folder_name, filename)
|
|
if not os.path.exists(file_path): return {"error": "File not found"}, 404
|
|
with open(file_path, 'rb') as f: msg = BytesParser(policy=policy.default).parse(f)
|
|
html_body, plain_body = None, None
|
|
if msg.is_multipart():
|
|
for part in msg.walk():
|
|
ct = part.get_content_type()
|
|
if ct == 'text/html' and not html_body: html_body = part.get_payload(decode=True).decode(part.get_content_charset() or 'utf-8', errors='ignore')
|
|
elif ct == 'text/plain' and not plain_body: plain_body = part.get_payload(decode=True).decode(part.get_content_charset() or 'utf-8', errors='ignore')
|
|
else: plain_body = msg.get_payload(decode=True).decode(msg.get_content_charset() or 'utf-8', errors='ignore')
|
|
if html_body: return {"body": html_body}
|
|
elif plain_body: return {"body": f"<pre>{plain_body}</pre>"}
|
|
else: return {"body": "Could not display content."}
|
|
EOF
|
|
|
|
mkdir -p templates/partials
|
|
cat <<'EOF' > templates/login.html
|
|
<!doctype html><html lang="en" data-theme="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Login - M365 Backup Tool</title><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@1/css/pico.min.css"></head><body><main class="container"><article style="margin-top: 5rem;"><h1 style="text-align: center;">M365 Backup Tool</h1><p style="text-align: center;">Please log in with your Microsoft account to continue.</p><div style="text-align: center; margin-top: 2rem;"><a href="/login" role="button">Login with Microsoft</a></div></article></main></body></html>
|
|
EOF
|
|
cat <<'EOF' > templates/layout.html
|
|
<!doctype html><html lang="en" data-theme="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>{% block title %}{% endblock %} - M365 Backup Tool</title><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@1/css/pico.min.css"><style>:root { --pico-font-size: 100%; } .htmx-indicator{opacity:0;transition:opacity 200ms ease-in;text-align:center;margin-top:1rem;} .htmx-request .htmx-indicator,.htmx-request.htmx-indicator{opacity:1}</style></head><body><main class="container"><nav><ul><li><strong>M365 Backup Tool</strong></li></ul><ul><li><a href="/">New Backup</a></li><li><a href="/backups">Existing Backups</a></li><li><a href="/tasks">Tasks</a></li>{% if session.user %}<li><a href="/logout" role="button" class="secondary outline">Logout ({{ session.user.name }})</a></li>{% endif %}</ul></nav><hr>{% block content %}{% endblock %}</main><script src="https://unpkg.com/htmx.org@1.9.10"></script></body></html>
|
|
EOF
|
|
cat <<'EOF' > templates/index.html
|
|
{% extends 'layout.html' %}{% block title %}New Backup{% endblock %}{% block content %}<h2>Start New Backup</h2><form action="/backup" method="post"><div style="position: sticky; top: 0; background-color: var(--background-color); padding-top: 1rem; padding-bottom: 1rem; z-index: 10;"><div class="grid"><label for="date_option">Select Date Range<select id="date_option" name="date_option" onchange="toggleCustomDate()"><option value="all_time" selected>All Time</option><option value="last_3_months">Last 3 Months</option><option value="last_6_months">Last 6 Months</option><option value="last_1_year">Last 1 Year</option><option value="last_2_years">Last 2 Years</option><option value="custom_range">Custom Range</option></select></label><div></div></div><div id="custom_date_fields" style="display: none;" class="grid"><label for="start_date">Start Date<input type="date" id="start_date" name="start_date"></label><label for="end_date">End Date<input type="date" id="end_date" name="end_date"></label></div><button type="submit" style="margin-top: 1rem;">Backup Selected Users</button><hr></div><p>Select users to back up:</p><div hx-get="/get_users" hx-trigger="load" hx-indicator="#user-list-indicator"><p id="user-list-indicator" class="htmx-indicator"><progress></progress><br>Loading user list from Azure...</p></div></form><script>
|
|
function toggleCustomDate() {
|
|
const dateOption = document.getElementById('date_option'), customDateFields = document.getElementById('custom_date_fields');
|
|
if (dateOption.value === 'custom_range') { customDateFields.style.display = 'grid'; } else { customDateFields.style.display = 'none'; }
|
|
}
|
|
</script>{% endblock %}
|
|
EOF
|
|
cat <<'EOF' > templates/partials/user_list.html
|
|
<figure><table><thead><tr><th scope="col" style="width:50px;">Select</th><th scope="col">Display Name</th><th scope="col">Email</th></tr></thead><tbody>
|
|
{% for user in users %}<tr><td><input type="checkbox" name="emails" value="{{ user.userPrincipalName }}"></td><td>{{ user.displayName }}</td><td>{{ user.userPrincipalName }}</td></tr>
|
|
{% else %}<tr><td colspan="3">No users found or error loading users.</td></tr>{% endfor %}
|
|
</tbody></table></figure>
|
|
EOF
|
|
cat <<'EOF' > templates/partials/user_list_error.html
|
|
<article><h5 style="color:var(--pico-color-red);">Error Loading Users</h5><p>Could not load the user list.</p><p><small><strong>Details:</strong> {{ error_message }}</small></p></article>
|
|
EOF
|
|
cat <<'EOF' > templates/backups.html
|
|
{% extends 'layout.html' %}{% block title %}Existing Backups{% endblock %}{% block content %}<h2>Backed Up Users</h2><ul>{% for user in users %}<li><a href="/backups/{{ user }}">{{ user }}</a></li>{% else %}<li>No backups found yet.</li>{% endfor %}</ul>{% endblock %}
|
|
EOF
|
|
cat <<'EOF' > templates/portal.html
|
|
{% extends 'layout.html' %}{% block title %}{{ user_email }} - Restore Portal{% endblock %}{% block content %}
|
|
<style>.portal-grid{display:grid;grid-template-columns:250px 1fr 1fr;gap:1rem;height:80vh;}.folder-list,.email-list,.preview-pane{overflow-y:auto;height:100%;}.folder-list ul{list-style-type:none;padding:0;margin:0;}.folder-list a{display:block;padding:0.5rem 1rem;border-radius:0.25rem;text-decoration:none;color:var(--pico-contrast);}.folder-list a:hover{background-color:var(--pico-muted-background-color);}.folder-list a.active{background-color:var(--pico-primary);color:var(--pico-primary-inverse);}.email-list table tbody tr{cursor:pointer;}.preview-pane iframe{width:100%;height:100%;border:none;filter:blur(8px);transition:filter 0.3s ease-in-out;}.preview-pane:hover iframe{filter:blur(0);}</style>
|
|
<h2>Restore Portal for: {{ user_email }}</h2><hr><div class="portal-grid"><article class="folder-list"><ul>
|
|
{% for folder in folders %}<li><a href="{{ url_for('restore_portal', user_email=user_email, folder=folder) }}" class="{{ 'active' if folder == active_folder else '' }}">{{ folder }}</a></li>{% endfor %}
|
|
</ul></article><article class="email-list"><form method="get" action="{{ url_for('restore_portal', user_email=user_email) }}"><input type="search" name="q" placeholder="Search in {{ active_folder }}..." value="{{ search_query or '' }}" style="margin-bottom:0;"><input type="hidden" name="folder" value="{{ active_folder }}"></form><figure><table><tbody>
|
|
{% for email in emails %}<tr onclick="fetchEmail('{{ user_email }}', '{{ active_folder }}', '{{ email.filename }}')"><td><strong>{{ email.from }}</strong><br>{{ email.subject }}<br><small>{{ email.date }}</small></td></tr>
|
|
{% else %}<tr><td>No emails found in this folder.</td></tr>{% endfor %}
|
|
</tbody></table></figure></article><article class="preview-pane" id="preview-pane"><p>Select an email to preview...</p></article></div>
|
|
<script>
|
|
function fetchEmail(userEmail, folderName, fileName) {
|
|
const previewPane = document.getElementById('preview-pane');
|
|
previewPane.innerHTML = '<p>Loading...</p>';
|
|
fetch(`/preview/${userEmail}/${folderName}/${fileName}`).then(response=>response.json()).then(data=>{
|
|
if(data.error){previewPane.innerHTML=`<p>Error: ${data.error}</p>`;} else {
|
|
const iframe=document.createElement('iframe');previewPane.innerHTML='';previewPane.appendChild(iframe);
|
|
iframe.contentWindow.document.open();iframe.contentWindow.document.write(data.body);iframe.contentWindow.document.close();
|
|
}}).catch(error=>{previewPane.innerHTML=`<p>An error occurred: ${error}</p>`;});
|
|
}
|
|
</script>{% endblock %}
|
|
EOF
|
|
cat <<'EOF' > templates/tasks.html
|
|
{% extends 'layout.html' %}{% block title %}Task Status{% endblock %}{% block content %}<h2>Task Status</h2><figure><table><thead><tr><th>Task ID</th><th>User</th><th>Status</th><th>Result</th></tr></thead><tbody>
|
|
{% for task in tasks %}<tr><td><small>{{ task.id }}</small></td><td>{{ task.email }}</td><td>{{ task.status }}</td><td><small>{{ task.result }}</small></td></tr>
|
|
{% else %}<tr><td colspan="4">No tasks have been started yet.</td></tr>{% endfor %}
|
|
</tbody></table></figure><a href="{{ url_for('list_tasks') }}" role="button" class="secondary outline">Refresh Page</a>{% endblock %}
|
|
EOF
|
|
|
|
echo "--> Creating self-signed SSL certificate..."
|
|
mkdir -p /etc/nginx/ssl
|
|
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
|
-keyout /etc/nginx/ssl/m365-backup.key \
|
|
-out /etc/nginx/ssl/m365-backup.crt \
|
|
-subj "/C=XX/ST=Global/L=Global/O=Global/OU=IT/CN=$IP_ADDR"
|
|
|
|
echo "--> Creating systemd service files..."
|
|
cat <<EOF > /etc/systemd/system/m365-web.service
|
|
[Unit]
|
|
Description=Gunicorn for M365 Backup App
|
|
After=network.target
|
|
[Service]
|
|
User=$USERNAME
|
|
Group=www-data
|
|
WorkingDirectory=$PROJECT_DIR
|
|
ExecStart=$PROJECT_DIR/venv/bin/gunicorn --workers 3 --bind unix:$PROJECT_DIR/m365-backup.sock -m 007 app:app
|
|
Restart=always
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
EOF
|
|
|
|
cat <<EOF > /etc/systemd/system/m365-worker.service
|
|
[Unit]
|
|
Description=Celery Worker for M365 Backup App
|
|
After=network.target
|
|
[Service]
|
|
User=$USERNAME
|
|
Group=www-data
|
|
WorkingDirectory=$PROJECT_DIR
|
|
ExecStart=$PROJECT_DIR/venv/bin/celery -A app.celery worker --loglevel=info
|
|
Restart=always
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
EOF
|
|
|
|
echo "--> Creating Nginx proxy configuration..."
|
|
cat <<EOF > /etc/nginx/sites-available/m365-backup
|
|
server {
|
|
listen 80;
|
|
server_name $IP_ADDR;
|
|
return 301 https://\$server_name\$request_uri;
|
|
}
|
|
server {
|
|
listen 443 ssl;
|
|
server_name $IP_ADDR;
|
|
ssl_certificate /etc/nginx/ssl/m365-backup.crt;
|
|
ssl_certificate_key /etc/nginx/ssl/m365-backup.key;
|
|
ssl_protocols TLSv1.2 TLSv1.3;
|
|
location / {
|
|
include proxy_params;
|
|
proxy_pass http://unix:$PROJECT_DIR/m365-backup.sock;
|
|
}
|
|
}
|
|
EOF
|
|
rm -f /etc/nginx/sites-enabled/default
|
|
ln -sf /etc/nginx/sites-available/m365-backup /etc/nginx/sites-enabled/
|
|
|
|
chown -R $USERNAME:www-data "$PROJECT_DIR"
|
|
chmod -R 775 "$PROJECT_DIR"
|
|
|
|
echo "--> Starting and enabling all services..."
|
|
systemctl daemon-reload
|
|
systemctl restart redis-server nginx
|
|
systemctl start m365-web m365-worker
|
|
systemctl enable redis-server nginx m365-web m365-worker
|
|
|
|
echo "--> Verifying service status..."
|
|
sleep 5
|
|
FINAL_STATUS=0
|
|
if systemctl is-active --quiet m365-web.service; then
|
|
echo -e "${GREEN}✅ Web Server (Gunicorn) is running.${NC}"
|
|
else
|
|
echo -e "${RED}❌ Web Server (Gunicorn) failed to start!${NC}"; FINAL_STATUS=1; fi
|
|
if systemctl is-active --quiet m365-worker.service; then
|
|
echo -e "${GREEN}✅ Background Worker (Celery) is running.${NC}"
|
|
else
|
|
echo -e "${RED}❌ Background Worker (Celery) failed to start!${NC}"; FINAL_STATUS=1; fi
|
|
|
|
echo ""
|
|
echo "============================================================"
|
|
if [ $FINAL_STATUS -eq 0 ]; then
|
|
echo -e "${GREEN}✅ INSTALLATION COMPLETED SUCCESSFULLY!${NC}"
|
|
echo ""
|
|
echo "You can access the application at: https://$IP_ADDR"
|
|
else
|
|
echo -e "${RED}❌ INSTALLATION FAILED!${NC}"
|
|
echo -e "${RED}Please check the error messages and service logs.${NC}"
|
|
fi
|
|
echo "============================================================"
|