restic backup

Dưới đây là hướng dẫn cấu hình và sử dụng Restic để backup web và gửi backup web qua Google Drive bằng Rclone để lưu trữ, phù hợp cho hosting DirectAdmin hoặc cPanel nếu bạn không muốn trả phí. Restic tương tự như Jetbackup, mặc dù có nhiều tính năng không thể bằng nhưng về cơ bản nó đáp ứng được các tính năng backup, restore và thuận tiện khi đẩy qua Google Drive.

I. Chuẩn bị môi trường

Cài python 3.8 và pip3

Cài python 3.8

sudo dnf module enable python38
sudo dnf install -y python38 python38-devel python38-libs python38-pip
python3.8 --version

Chọn python 3.8 sử dụng chính

sudo alternatives --install /usr/bin/python3 python3 /usr/bin/python3.8 1
sudo alternatives --config python3
Choose 3.8

Cài pip3 với python 3.8

python3.8 -m ensurepip --default-pip

Cài mudule requests

pip3 install requests

Cài mariadb client

 yum install -y mariadb-client gzip

Cài Restic

Tải Restic

wget https://github.com/restic/restic/releases/download/v0.17.0/restic_0.17.0_linux_amd64.bz2
bzip2 -d restic_0.17.0_linux_amd64.bz2
sudo mv restic_0.17.0_linux_amd64 /usr/local/bin/restic
sudo chmod +x /usr/local/bin/restic

Kiểm tra version

[root@directadmin-manhhc ~]# restic version
restic 0.17.0 compiled with go1.22.5 on linux/amd64

Cài đặt Rclone và kết nối tới Google Drive

Tạo thư mục trên Google Drive, chuẩn bị key như khoanh đỏ

google drive

Cài rclone

yum -y install rclone

Tạo kết nối giữa rclone và Google Drive

[root@directadmin-manhhc ~]# rclone config
2025/02/23 00:43:33 NOTICE: Config file "/root/.config/rclone/rclone.conf" not found - using defaults
No remotes found - make a new one
n) New remote
s) Set configuration password
q) Quit config
n/s/q> n
name> gd
Option Storage.
Type of storage to configure.
Enter a string value. Press Enter for the default ("").
Choose a number from below, or type in your own value.
16 / Google Drive
\ "drive"
Storage> 16
Option client_id.
Google Application Client Id
Setting your own is recommended.
See https://rclone.org/drive/#making-your-own-client-id for how to create your own.
If you leave this blank, it will use an internal key which is low performance.
Enter a string value. Press Enter for the default ("").
client_id> -> Enter
Option client_secret.
OAuth Client Secret.
Leave blank normally.
Enter a string value. Press Enter for the default ("").
client_secret> -> Enter
Option scope.
Scope that rclone should use when requesting access from drive.
Enter a string value. Press Enter for the default ("").
Choose a number from below, or type in your own value.
1 / Full access all files, excluding Application Data Folder.
\ "drive"
2 / Read-only access to file metadata and file contents.
\ "drive.readonly"
/ Access to files created by rclone only.
3 | These are visible in the drive website.
| File authorization is revoked when the user deauthorizes the app.
\ "drive.file"
/ Allows read and write access to the Application Data folder.
4 | This is not visible in the drive website.
\ "drive.appfolder"
/ Allows read-only access to file metadata but
5 | does not allow any access to read or download file content.
\ "drive.metadata.readonly"
scope> 1
Option root_folder_id.
ID of the root folder.
Leave blank normally.
Fill in to access "Computers" folders (see docs), or for rclone to use
a non root folder as its starting point.
Enter a string value. Press Enter for the default ("").
root_folder_id> 15ygZ7eCD-oe_UGSDle0rrBW-ixNoo-IW
Option service_account_file.
Service Account Credentials JSON file path.
Leave blank normally.
Needed only if you want use SA instead of interactive login.
Leading `~` will be expanded in the file name as will environment variables such as `${RCLONE_CONFIG_DIR}`.
Enter a string value. Press Enter for the default ("").
service_account_file> 
Edit advanced config?
y) Yes
n) No (default)
y/n> n
Use auto config?
* Say Y if not sure
* Say N if you are working on a remote or headless machine

y) Yes (default)
n) No
y/n> y
2025/02/23 01:01:03 NOTICE: If your browser doesn't open automatically go to the following link: http://127.0.0.1:53682/auth?state=7PVkRig2emA2rTSZIilZ_g
2025/02/23 01:01:03 NOTICE: Log in and authorize rclone for access
2025/02/23 01:01:03 NOTICE: Waiting for code...

Ở bước này, chúng ta cần Create an SSH Tunnel trên máy tính của mình

ssh -L 53682:localhost:53682 -C -N -l <your_user> <your_remote_server_ip>

Sau khi tạo xong, chúng ta sử dụng trình duyệt và truy cập link bên dưới để xác thực

http://127.0.0.1:53682/auth?state=7PVkRig2emA2rTSZIilZ_g

rclone

Sau khi xác thực xong, chúng ta quay lại mục config rclone

2025/02/23 01:08:01 NOTICE: Got code
Configure this as a Shared Drive (Team Drive)?

y) Yes
n) No (default)
y/n> y
No Shared Drives found in your account
--------------------
[Google Drive]
type = drive
scope = drive
root_folder_id = 15ygZ7eCD-oe_UGSDle0rrBW-ixNoo-IW
token = {"access_token":"ya29.a0AXeO80RNvLKeJ6V8nct3xj2ba7DSULHF20yzU-iK2kuUToKpokvzOfIu70GcDvQ0_W5UzxuekZJjSPVaHENmXqH5FCp8QNNWUW8KuwtdjwDRsARJbvVjVc07WVIKjW2Sv0nRC4rNfO_F-R6vcMf4_sATQFXEvOFyyZ1meUHlaCgYKAQsSAQ8SFQHGX2MijIzXy4evckuO5Wlf58w90g0175","token_type":"Bearer","refresh_token":"1//0e0nzpbFD7TYICgYIARAAGA4SNwF-L9IrFN0iV18mT7jFMbX-oIQcKN27uPM85aBe9tgNgweWvuHYTUYS6cDJJHO4zQBtOUU3AjY","expiry":"2025-02-23T02:08:00.403968704+07:00"}
--------------------
y) Yes this is OK (default)
e) Edit this remote
d) Delete this remote
y/e/d> y
Current remotes:

Name Type
==== ====
gd drive

e) Edit existing remote
n) New remote
d) Delete remote
r) Rename remote
c) Copy remote
s) Set configuration password
q) Quit config
e/n/d/r/c/s/q> q

Khởi tạo Restic

Khởi tạo Restic repository

export RESTIC_REPOSITORY="rclone:gd:/restic-backups"
export RESTIC_PASSWORD="str0ngPassword"
[root@directadmin-manhhc ~]# restic init
enter password for new repository: 
enter password again: 
created restic repository c20a319272 at rclone:gd:/restic-backups

Please note that knowledge of your password is required to access
the repository. Losing your password means that your data is
irrecoverably lost.

Như vậy là mình đã hướng dẫn các bạn cách để kết nối Rclone với Google Drive, sau đó tạo thư mục để Restic backup và lưu trữ dữ liệu trên đó

II. Script backup

#!/usr/bin/env python3
import logging
import os
import subprocess
import json
import concurrent.futures
import smtplib
import requests
from pathlib import Path
from datetime import datetime, timedelta
from email.mime.text import MIMEText
from email.utils import formatdate
import time

CONFIG = {
'restic_bin': '/usr/local/bin/restic',
'rclone_bin': '/usr/local/bin/rclone',
'repository': 'rclone:gd:/restic-backups',
'password': 'i2bdi2oa(23J',
'exclude_patterns': [
'*.tmp',
'*.cache',
'*.log',
'*.temp',
'/home/*/tmp',
'/home/*/cache'
],
'file_types': {
'config': ['.conf', '.ini', '.cfg'],
'website': ['.html', '.php', '.css', '.js'],
'database': ['.sql', '.dmp'],
'media': ['.jpg', '.png', '.mp4']
},
'rclone_config': '/root/.config/rclone/rclone.conf',
'mysql': {
'enabled': True,
'user': 'da_admin',
'password': 'qcqw832hjasUancis',
'host': 'localhost',
'port': 3306,
'dump_dir': '/tmp/mysql_dumps',
'retention_days': 3,
'excluded_databases': [
'information_schema', 
'performance_schema', 
'mysql', 
'sys'
],
'options': [
'--single-transaction',
'--quick',
'--skip-lock-tables'
]
},
'telegram': {
'enabled': True,
'bot_token': 'CHATBOT_TOKEN',
'chat_id': 'CHAT_ID',
'api_timeout': 10
},
'parallel_workers': 4,
'compression': 'auto',
'upload_limit': 204800,
'cache_dir': '/tmp/restic-cache',
'retention_policy': {
'keep_last': 7,
'keep_daily': 14,
'keep_weekly': 8,
'keep_monthly': 12,
'enable_prune': True
},
'date_tag_format': '%Y%m%d'
}

logging.basicConfig(
filename='/var/log/file_level_backup.log',
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
filemode='a'
)

class EnhancedBackup:
def __init__(self):
self.start_time = datetime.now()
self.env = os.environ.copy()
self.current_date_tag = datetime.now().strftime(CONFIG['date_tag_format'])
self.env.update({
'RESTIC_PASSWORD': CONFIG['password'],
'RCLONE_CONFIG': CONFIG['rclone_config']
})
self.db_success_count = 0
self.db_failure_count = 0
self.setup_path()
self.clean_old_dumps()

def setup_path(self):
essential_paths = ['/usr/bin', '/usr/local/bin', '/bin']
self.env['PATH'] = ":".join(essential_paths + self.env.get('PATH', '').split(':'))

def clean_old_dumps(self):
if CONFIG['mysql']['enabled']:
dump_dir = Path(CONFIG['mysql']['dump_dir'])
cutoff = time.time() - (CONFIG['mysql']['retention_days'] * 86400)
for f in dump_dir.glob("*.sql.gz"):
if f.stat().st_mtime < cutoff:
try:
f.unlink()
logging.info(f"Deleted old dump: {f}")
except Exception as e:
logging.error(f"Failed to delete {f}: {str(e)}")

def mysql_backup(self):
if not CONFIG['mysql']['enabled']:
return True

try:
databases = self.get_mysql_databases()
if not databases:
logging.warning("No databases found for backup")
return True

logging.info(f"Starting MySQL backup for {len(databases)} databases")

with concurrent.futures.ThreadPoolExecutor(max_workers=CONFIG['parallel_workers']) as executor:
futures = {executor.submit(self.backup_single_database, db): db for db in databases}

for future in concurrent.futures.as_completed(futures):
db_name = futures[future]
try:
future.result()
except Exception as e:
logging.error(f"Database backup error: {db_name} - {str(e)}")

return self.db_failure_count == 0

except Exception as e:
logging.error(f"MySQL backup failed: {str(e)}")
return False

def get_mysql_databases(self):
cmd = [
'mysql',
f"--user={CONFIG['mysql']['user']}",
f"--password={CONFIG['mysql']['password']}",
f"--host={CONFIG['mysql']['host']}",
f"--port={CONFIG['mysql']['port']}",
"-NBe", 
"SHOW DATABASES;"
]

try:
result = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
check=True,
env=self.env
)
databases = result.stdout.splitlines()
return [db for db in databases if db not in CONFIG['mysql']['excluded_databases']]
except subprocess.CalledProcessError as e:
logging.error(f"Failed to get database list: {e.stderr}")
raise Exception("MySQL connection failed")

def backup_single_database(self, db_name):
dump_dir = Path(CONFIG['mysql']['dump_dir'])
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
dump_file = dump_dir / f"{db_name}_{timestamp}.sql.gz"

try:
cmd = [
'mysqldump',
f"--user={CONFIG['mysql']['user']}",
f"--password={CONFIG['mysql']['password']}",
f"--host={CONFIG['mysql']['host']}",
f"--port={CONFIG['mysql']['port']}",
*CONFIG['mysql']['options'],
db_name,
'| gzip'
]

with open(dump_file, 'wb') as f:
process = subprocess.run(
' '.join(cmd),
shell=True,
stdout=f,
stderr=subprocess.PIPE,
env=self.env
)

if process.returncode != 0:
raise Exception(f"mysqldump error: {process.stderr.decode()}")

backup_cmd = [
CONFIG['restic_bin'],
'backup',
str(dump_file),
'--repo', CONFIG['repository'],
'--tag', f"mysql,database:{db_name},date:{self.current_date_tag}",
'--option', f"rclone.program={CONFIG['rclone_bin']}",
'--option', f"rclone.args=serve restic --stdio --config {CONFIG['rclone_config']}",
'--compression', CONFIG['compression']
]

if self.run_cmd(backup_cmd):
logging.info(f"Backup successful for database: {db_name}")
dump_file.unlink()
self.db_success_count += 1
return True

except Exception as e:
logging.error(f"Failed to backup database {db_name}: {str(e)}")
if dump_file.exists():
dump_file.unlink()
self.db_failure_count += 1
return False

def backup_user(self, user):
home_dir = f"/home/{user}"
cmd = [
CONFIG['restic_bin'],
'backup',
home_dir,
'--repo', CONFIG['repository'],
'--exclude-file', self.generate_exclude_file(user),
'--tag', f"user:{user},type:full,date:{self.current_date_tag}",
'--option', f"rclone.program={CONFIG['rclone_bin']}",
'--option', f"rclone.args=serve restic --stdio --config {CONFIG['rclone_config']}",
'--compression', CONFIG['compression'],
'--limit-upload', str(CONFIG['upload_limit'])
]
return self.run_cmd(cmd)

def generate_exclude_file(self, user):
exclude_path = f"/tmp/exclude_{user}.txt"
with open(exclude_path, 'w') as f:
for pattern in CONFIG['exclude_patterns']:
f.write(pattern.replace('*', user) + '\n')
return exclude_path

def run_cmd(self, cmd, context="Command"):
logging.info(f"Executing: {' '.join(cmd)}")
try:
result = subprocess.run(
cmd,
env=self.env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
check=True
)
logging.info(f"{context} succeeded: {result.stdout[:200]}...")
return True
except subprocess.CalledProcessError as e:
error_msg = f"""
{context} failed: {' '.join(e.cmd)}
Exit code: {e.returncode}
Error output: {e.stderr[:1000]}
"""
logging.error(error_msg)
return False

def process_users(self):
users_dir = '/usr/local/directadmin/data/users'
users = [u for u in os.listdir(users_dir) if os.path.isdir(os.path.join(users_dir, u))]

with concurrent.futures.ThreadPoolExecutor(max_workers=CONFIG['parallel_workers']) as executor:
futures = {executor.submit(self.backup_user, user): user for user in users}

for future in concurrent.futures.as_completed(futures):
user = futures[future]
try:
if future.result():
logging.info(f"User {user} backup completed")
else:
logging.warning(f"User {user} backup had errors")
except Exception as e:
logging.error(f"User {user} backup failed: {str(e)}")

def apply_retention_policy(self):
try:
cmd = [
CONFIG['restic_bin'],
'forget',
'--repo', CONFIG['repository'],
'--keep-last', str(CONFIG['retention_policy']['keep_last']),
'--keep-daily', str(CONFIG['retention_policy']['keep_daily']),
'--keep-weekly', str(CONFIG['retention_policy']['keep_weekly']),
'--keep-monthly', str(CONFIG['retention_policy']['keep_monthly']),
'--option', f"rclone.program={CONFIG['rclone_bin']}",
'--option', f"rclone.args=serve restic --stdio --config {CONFIG['rclone_config']}"
]

if CONFIG['retention_policy']['enable_prune']:
cmd.append('--prune')

return self.run_cmd(cmd, "Retention Policy")
except Exception as e:
logging.error(f"Retention policy failed: {str(e)}")
return False

def send_telegram_report(self, success=True, error_msg=None):
if not CONFIG['telegram']['enabled']:
return

try:
report = self.generate_report(success, error_msg)
bot_token = CONFIG['telegram']['bot_token']
chat_id = CONFIG['telegram']['chat_id']

url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
payload = {
'chat_id': chat_id,
'text': report,
'parse_mode': 'HTML'
}

response = requests.post(
url,
data=payload,
timeout=CONFIG['telegram']['api_timeout']
)

if response.status_code != 200:
logging.error(f"Telegram API error: {response.text}")

except Exception as e:
logging.error(f"Telegram notification failed: {str(e)}")

def generate_report(self, success, error_msg=None):
status_emoji = "✅" if success else "❌"
status_text = "THÀNH CÔNG" if success else "THẤT BẠI"
duration = datetime.now() - self.start_time

total_seconds = duration.total_seconds()
hours = int(total_seconds // 3600)
minutes = int((total_seconds % 3600) // 60)
seconds = int(total_seconds % 60)
duration_str = f"{hours}h {minutes}m {seconds}s"

report = f"""
<b>{status_emoji} BACKUP REPORT {status_emoji}</b>
━━━━━━━━━━━━━━━━━━━
<b>• Trạng thái:</b> {status_text}
<b>• Thời gian bắt đầu:</b> {self.start_time.strftime('%Y-%m-%d %H:%M:%S')}
<b>• Thời lượng:</b> {duration_str}
<b>• Repository:</b> {CONFIG['repository']}
<b>• Tag ngày:</b> {self.current_date_tag}
<b>• Databases:</b> {self.db_success_count} thành công, {self.db_failure_count} thất bại
"""

if error_msg:
report += f"\n<b>• Lỗi chính:</b> <code>{error_msg[:500]}</code>"

return report

def main():
start_time = datetime.now()
error_message = None
success = False

try:
backup = EnhancedBackup()

# Backup MySQL
if not backup.mysql_backup():
error_message = "MySQL backup failed"
raise Exception(error_message)

# Backup users
backup.process_users()

# Áp dụng retention policy
if not backup.apply_retention_policy():
error_message = "Retention policy application failed"
raise Exception(error_message)

# Kiểm tra repository
check_cmd = [
CONFIG['restic_bin'],
'check',
'--repo', CONFIG['repository'],
'--option', f"rclone.program={CONFIG['rclone_bin']}",
'--option', f"rclone.args=serve restic --stdio --config {CONFIG['rclone_config']}"
]
backup.run_cmd(check_cmd, "Repository Check")

success = True

except Exception as e:
error_message = str(e)
logging.critical(f"Critical failure: {error_message}")

finally:
# Gửi thông báo
report = backup.generate_report(success, error_message)

# Gửi qua Telegram
backup.send_telegram_report(success, error_message)

# Dọn dẹp file tạm
for f in Path('/tmp').glob('exclude_*.txt'):
try:
f.unlink()
except Exception as e:
logging.error(f"Cleanup failed for {f}: {str(e)}")

return 0 if success else 1

if __name__ == "__main__":
exit(main())

Tổng quan

  • Mục đích: Tự động hóa việc sao lưu dữ liệu (MySQL và thư mục người dùng), quản lý bản sao lưu, và thông báo kết quả.
  • Telegram: Gửi thông báo.
  • MySQL: Sao lưu cơ sở dữ liệu.
  • Rclone: Dùng để kết nối với Google Drive làm nơi lưu trữ.
  • Restic: Công cụ sao lưu chính.Công cụ sử dụng:
  • Cấu hình: Được lưu trong biến CONFIG với các thông tin như đường dẫn, mật khẩu, chính sách giữ lại, v.v.
  • Cơ chế: Sử dụng đa luồng (concurrent.futures) để xử lý song song, tăng hiệu suất.

Phân tích chi tiết

1. Cấu hình (CONFIG)

  • Chính sách giữ lại: Số lượng bản sao lưu giữ lại theo ngày, tuần, tháng.
  • Telegram: Token bot và chat ID để gửi thông báo.
  • Cơ sở dữ liệu MySQL: Thông tin kết nối, thư mục lưu tạm, chính sách giữ lại dump.
  • Loại trừexclude_patterns liệt kê các mẫu tệp/thư mục không cần sao lưu.
  • Mật khẩupassword cho Restic.
  • Kho lưu trữrepository (Google Drive qua Rclone).
  • Đường dẫn công cụrestic_binrclone_bin.Đây là một từ điển chứa toàn bộ thông tin cấu hình:

2. Cấu hình logging

  • Ghi log vào tệp /var/log/file_level_backup.log với định dạng bao gồm thời gian, mức độ (INFO, ERROR, v.v.), và thông điệp.

3. Lớp EnhancedBackup

Đây là lớp chính điều phối toàn bộ quy trình sao lưu:

  • __init__: Khởi tạo môi trường, thiết lập biến môi trường (RESTIC_PASSWORDRCLONE_CONFIG), làm sạch các dump cũ.
  • setup_path: Đảm bảo các đường dẫn cần thiết có trong biến $PATH.
  • clean_old_dumps: Xóa các tệp dump MySQL cũ hơn retention_days.

4. Sao lưu MySQL (mysql_backup)

  • Kiểm tra: Nếu MySQL không được bật (enabled: False), bỏ qua.
  • Lấy danh sách cơ sở dữ liệu: Dùng lệnh mysql SHOW DATABASES để lấy danh sách, loại bỏ các cơ sở dữ liệu bị loại trừ (excluded_databases).

Sao lưu từng cơ sở dữ liệu:

  • Dùng mysqldump để tạo tệp .sql.gz.
  • Sao lưu tệp này vào Restic với các thẻ (tags) như mysqldatabase:<tên>date:<ngày>.
  • Xóa tệp tạm sau khi sao lưu thành công.
  • Song song: Sử dụng ThreadPoolExecutor để sao lưu nhiều cơ sở dữ liệu cùng lúc.

5. Sao lưu thư mục người dùng (backup_user)

  • Đường dẫn: Sao lưu thư mục /home/<user>.
  • Loại trừ: Tạo tệp loại trừ tạm thời dựa trên exclude_patterns.
  • Lệnh Restic: Thêm các thẻ như user:<tên>type:full, giới hạn băng thông upload.

6. Chạy lệnh (run_cmd)

  • Hàm chung để thực thi lệnh shell, ghi log kết quả hoặc lỗi nếu có.

7. Xử lý người dùng (process_users)

  • Lấy danh sách người dùng từ /usr/local/directadmin/data/users.
  • Dùng ThreadPoolExecutor để sao lưu song song nhiều người dùng.

8. Áp dụng chính sách giữ lại (apply_retention_policy)

  • Dùng lệnh restic forget để xóa các bản sao lưu cũ theo chính sách trong retention_policy.
  • Nếu enable_prune được bật, thêm –prune để dọn dẹp không gian.

9. Gửi thông báo Telegram (send_telegram_report)

  • Tạo báo cáo với trạng thái (thành công/thất bại), thời gian, số cơ sở dữ liệu sao lưu, lỗi (nếu có).
  • Gửi qua API Telegram với định dạng HTML.

10. Hàm chính (main)

  • Dọn dẹp tệp tạm (/tmp/exclude_*.txt).
  • Gửi thông báo qua Telegram.
  • Kiểm tra tính toàn vẹn của kho lưu trữ (restic check).
  • Áp dụng chính sách giữ lại.
  • Sao lưu người dùng.
  • Sao lưu MySQL.Điều phối toàn bộ quy trình:
  • Trả về mã thoát: 0 (thành công) hoặc 1 (thất bại).

Điểm mạnh

Tự động hóa toàn diện: Từ sao lưu, quản lý bản sao, đến thông báo.

  • Hiệu suất cao: Sử dụng xử lý song song để tăng tốc độ.
  • Linh hoạt: Cấu hình dễ dàng thay đổi qua CONFIG.
  • Ghi log chi tiết: Dễ dàng theo dõi và debug nếu có lỗi.
  • Thông báo tức thì: Telegram giúp quản trị viên nắm bắt tình hình nhanh chóng.

Kết luận

Script này là một giải pháp mạnh mẽ và hiệu quả để sao lưu dữ liệu lên Google Drive, phù hợp cho môi trường quản trị hệ thống như DirectAdmin. Tuy nhiên, cần cải thiện bảo mật và xử lý lỗi để tăng độ tin cậy. Nếu bạn muốn tối ưu hơn, hãy cân nhắc các gợi ý trên hoặc yêu cầu tôi chỉnh sửa cụ thể một phần nào đó!

III. Script Restore

#!/usr/bin/env python3
import subprocess
import os
import logging
import json
from pathlib import Path
from datetime import datetime
import requests
import time
import shutil

# Cấu hình giống script backup
CONFIG = {
'restic_bin': '/usr/local/bin/restic',
'rclone_bin': '/usr/local/bin/rclone',
'repository': 'rclone:gd:/restic-backups',
'password': 'i2bdi2oa(23J',
'rclone_config': '/root/.config/rclone/rclone.conf',
'mysql': {
'user': 'da_admin',
'password': 'qcqw832hjasUancis',
'host': 'localhost',
'port': 3306,
'dump_dir': '/tmp/mysql_restores'
},
'telegram': {
'enabled': True,
'bot_token': 'CHATBOT_TOKEN',
'chat_id': 'CHAT_ID',
'api_timeout': 10
}
}

logging.basicConfig(
filename='/var/log/file_level_restore.log',
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
filemode='a'
)

class EnhancedRestore:
def __init__(self):
self.start_time = datetime.now()
self.env = os.environ.copy()
self.env.update({
'RESTIC_PASSWORD': CONFIG['password'],
'RCLONE_CONFIG': CONFIG['rclone_config']
})
self.restored_items = []
self.setup_path()
self.ensure_restore_dir()

def setup_path(self):
essential_paths = ['/usr/bin', '/usr/local/bin', '/bin']
self.env['PATH'] = ":".join(essential_paths + self.env.get('PATH', '').split(':'))

def ensure_restore_dir(self):
restore_dir = Path(CONFIG['mysql']['dump_dir'])
restore_dir.mkdir(parents=True, exist_ok=True)

def run_cmd(self, cmd, context="Command"):
logging.info(f"Executing: {' '.join(cmd)}")
try:
result = subprocess.run(
cmd,
env=self.env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
check=True
)
logging.info(f"{context} succeeded: {result.stdout[:200]}...")
return result.stdout
except subprocess.CalledProcessError as e:
error_msg = f"{context} failed: {e.stderr}"
logging.error(error_msg)
raise Exception(error_msg)

def list_snapshots(self, tag_filter=None):
cmd = [
CONFIG['restic_bin'],
'snapshots',
'--repo', CONFIG['repository'],
'--json',
'--option', f"rclone.program={CONFIG['rclone_bin']}",
'--option', f"rclone.args=serve restic --stdio --config {CONFIG['rclone_config']}"
]
if tag_filter:
cmd.extend(['--tag', tag_filter])
output = self.run_cmd(cmd, "List Snapshots")
return json.loads(output)

def select_snapshot(self, snapshots):
print("\nAvailable Snapshots:")
for i, snapshot in enumerate(snapshots):
tags = ",".join(snapshot.get('tags', []))
time = snapshot.get('time', 'N/A')
print(f"{i}: ID={snapshot['short_id']}, Time={time}, Tags={tags}")
while True:
try:
choice = int(input("\nSelect snapshot number to restore (0-{}): ".format(len(snapshots)-1)))
if 0 <= choice < len(snapshots):
return snapshots[choice]['short_id']
print("Invalid choice. Try again.")
except ValueError:
print("Please enter a valid number.")

def restore_domain(self, user, domain):
domain_path = Path(f"/home/{user}/domains/{domain}")
domain_path.mkdir(parents=True, exist_ok=True)
temp_restore_path = Path(f"/tmp/restore_{user}_{domain}_{int(time.time())}")
temp_restore_path.mkdir(parents=True, exist_ok=True)

snapshots = self.list_snapshots(f"user:{user},domain:{domain}")
if not snapshots:
logging.error(f"No snapshots found for user:{user}, domain:{domain}")
print(f"No backups found for {domain}")
return False

snapshot_id = self.select_snapshot(snapshots)

cmd = [
CONFIG['restic_bin'],
'restore',
snapshot_id,
'--repo', CONFIG['repository'],
'--target', str(temp_restore_path),
'--option', f"rclone.program={CONFIG['rclone_bin']}",
'--option', f"rclone.args=serve restic --stdio --config {CONFIG['rclone_config']}"
]

try:
self.run_cmd(cmd, f"Restore domain {domain} for user {user} to temp dir")
restored_domain_path = temp_restore_path / "home" / user / "domains" / domain
if not restored_domain_path.exists():
raise Exception(f"Expected restored path {restored_domain_path} not found")

for item in restored_domain_path.iterdir():
dest_path = domain_path / item.name
if dest_path.exists():
if dest_path.is_symlink():
dest_path.unlink()
elif dest_path.is_dir():
shutil.rmtree(dest_path)
else:
dest_path.unlink()
shutil.move(str(item), str(domain_path))

self.set_permissions(domain_path, user)
self.restored_items.append(f"Domain: {domain} (User: {user})")
logging.info(f"Successfully restored domain {domain} for user {user}")
print(f"Domain {domain} restored successfully.")
return True

except Exception as e:
logging.error(f"Restore failed for domain {domain}: {str(e)}")
print(f"Failed to restore domain {domain}: {str(e)}")
return False

finally:
if temp_restore_path.exists():
shutil.rmtree(temp_restore_path, ignore_errors=True)

def restore_database(self, db_name):
snapshots = self.list_snapshots(f"mysql,database:{db_name}")
if not snapshots:
logging.error(f"No snapshots found for database:{db_name}")
print(f"No backups found for database {db_name}")
return False

snapshot_id = self.select_snapshot(snapshots)
temp_restore_path = Path(f"/tmp/restore_db_{db_name}_{int(time.time())}")
temp_restore_path.mkdir(parents=True, exist_ok=True)
dump_file = Path(CONFIG['mysql']['dump_dir']) / f"{db_name}_restore_{snapshot_id}.sql.gz"

cmd = [
CONFIG['restic_bin'],
'restore',
snapshot_id,
'--repo', CONFIG['repository'],
'--target', str(temp_restore_path),
'--option', f"rclone.program={CONFIG['rclone_bin']}",
'--option', f"rclone.args=serve restic --stdio --config {CONFIG['rclone_config']}"
]

try:
self.run_cmd(cmd, f"Restore dump for database {db_name}")

gz_files = list(temp_restore_path.rglob(f"{db_name}_*.sql.gz"))
if not gz_files:
raise Exception(f"No .sql.gz file found in restored snapshot for {db_name}")
if len(gz_files) > 1:
logging.warning(f"Multiple .sql.gz files found, using the first one: {gz_files[0]}")

restored_gz_file = gz_files[0]
shutil.move(str(restored_gz_file), str(dump_file)) # Di chuyển file đến vị trí mong muốn

with subprocess.Popen(['gunzip', str(dump_file)], stdout=subprocess.PIPE, stderr=subprocess.PIPE) as gunzip:
gunzip.wait()
if gunzip.returncode != 0:
raise Exception(f"Gunzip failed: {gunzip.stderr.read().decode()}")

sql_file = dump_file.with_suffix('')
self.import_database(db_name, sql_file)
sql_file.unlink() # Dọn dẹp file SQL
self.restored_items.append(f"Database: {db_name}") # Ghi nhận restore thành công
logging.info(f"Successfully restored database {db_name}")
print(f"Database {db_name} restored successfully.")
return True

except Exception as e:
logging.error(f"Restore failed for database {db_name}: {str(e)}")
print(f"Failed to restore database {db_name}: {str(e)}")
return False

finally:
if temp_restore_path.exists():
shutil.rmtree(temp_restore_path, ignore_errors=True)

def import_database(self, db_name, sql_file):
drop_cmd = [
'mysql',
f"--user={CONFIG['mysql']['user']}",
f"--password={CONFIG['mysql']['password']}",
f"--host={CONFIG['mysql']['host']}",
f"--port={CONFIG['mysql']['port']}",
'-e', f"DROP DATABASE IF EXISTS {db_name}; CREATE DATABASE {db_name};"
]
self.run_cmd(drop_cmd, f"Recreate database {db_name}")

import_cmd = [
'mysql',
f"--user={CONFIG['mysql']['user']}",
f"--password={CONFIG['mysql']['password']}",
f"--host={CONFIG['mysql']['host']}",
f"--port={CONFIG['mysql']['port']}",
db_name
]
with open(sql_file, 'r') as f:
result = subprocess.run(
import_cmd,
stdin=f,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=self.env,
universal_newlines=True
)
if result.returncode != 0:
raise Exception(f"Database import failed: {result.stderr}")

def set_permissions(self, domain_path, user):
import pwd
import grp
try:
uid = pwd.getpwnam(user).pw_uid
gid = grp.getgrnam(user).gr_gid
os.chown(str(domain_path), uid, gid)
os.chmod(str(domain_path), 0o751)
for root, dirs, files in os.walk(str(domain_path)):
for d in dirs:
d_path = os.path.join(root, d)
os.chown(d_path, uid, gid)
os.chmod(d_path, 0o755)
for f in files:
f_path = os.path.join(root, f)
os.chown(f_path, uid, gid)
os.chmod(f_path, 0o644)
logging.info(f"Permissions set successfully for {domain_path}")
except Exception as e:
logging.error(f"Failed to set permissions for {domain_path}: {str(e)}")
raise

def send_telegram_report(self, success=True, error_msg=None):
if not CONFIG['telegram']['enabled']:
return
report = self.generate_report(success, error_msg)
url = f"https://api.telegram.org/bot{CONFIG['telegram']['bot_token']}/sendMessage"
payload = {'chat_id': CONFIG['telegram']['chat_id'], 'text': report, 'parse_mode': 'HTML'}
requests.post(url, data=payload, timeout=CONFIG['telegram']['api_timeout'])

def generate_report(self, success, error_msg=None):
status_emoji = "✅" if success else "❌"
status_text = "THÀNH CÔNG" if success else "THẤT BẠI"
duration = datetime.now() - self.start_time
duration_str = str(duration).split('.')[0]

report = f"""
<b>{status_emoji} RESTORE REPORT {status_emoji}</b>
━━━━━━━━━━━━━━━━━━━
<b>• Trạng thái:</b> {status_text}
<b>• Thời gian bắt đầu:</b> {self.start_time.strftime('%Y-%m-%d %H:%M:%S')}
<b>• Thời lượng:</b> {duration_str}
<b>• Repository:</b> {CONFIG['repository']}
"""
if self.restored_items: # Thêm danh sách các đối tượng restore thành công
report += "<b>• Restored successfully:</b>\n" + "\n".join([f" - {item}" for item in self.restored_items])
if error_msg:
report += f"\n<b>• Lỗi chính:</b> <code>{error_msg[:500]}</code>"
return report

def main():
restore = EnhancedRestore()
success = False
error_message = None

try:
print("Restore Options:")
print("1. Restore a domain")
print("2. Restore a database")
choice = input("Select an option (1-2): ")

if choice == '1':
user = input("Enter username: ")
domain = input("Enter domain name (e.g., example.com): ")
success = restore.restore_domain(user, domain)
elif choice == '2':
db_name = input("Enter database name: ")
success = restore.restore_database(db_name)
else:
raise Exception("Invalid option selected")

if not success:
raise Exception("Restore process failed")

except Exception as e:
error_message = str(e)
logging.critical(f"Restore failed: {error_message}")
success = False

finally:
restore.send_telegram_report(success, error_message)
restore_dir = Path(CONFIG['mysql']['dump_dir'])
for f in restore_dir.glob("*.sql.gz"):
f.unlink(missing_ok=True)
return 0 if success else 1

if __name__ == "__main__":
exit(main())

Tổng quan

  • Mục đích: Phục hồi dữ liệu từ bản sao lưu trên Google Drive, bao gồm domain của người dùng và cơ sở dữ liệu MySQL.

Công cụ sử dụng:

  • Restic: Công cụ phục hồi chính.
  • Rclone: Kết nối với Google Drive.
  • MySQL: Nhập lại dữ liệu cơ sở dữ liệu.
  • Telegram: Gửi báo cáo kết quả.
  • Cấu hình: Được định nghĩa trong biến CONFIG, tương tự script backup nhưng rút gọn cho mục đích phục hồi.
  • Cơ chế: Tương tác với người dùng qua dòng lệnh để chọn snapshot, phục hồi dữ liệu vào thư mục tạm, sau đó di chuyển đến vị trí đích.

Phân tích chi tiết

1. Cấu hình (CONFIG)

Là một từ điển chứa thông tin cần thiết:

  • Đường dẫn công cụrestic_binrclone_bin.
  • Kho lưu trữrepository (Google Drive qua Rclone).
  • Mật khẩupassword cho Restic.
  • Rclone: Đường dẫn cấu hình rclone_config.
  • MySQL: Thông tin kết nối và thư mục tạm để lưu file phục hồi.
  • Telegram: Token bot và chat ID để gửi thông báo.

2. Cấu hình logging

  • Ghi log vào /var/log/file_level_restore.log với định dạng gồm thời gian, mức độ (INFO, ERROR), và thông điệp.

3. Lớp EnhancedRestore

Đây là lớp chính điều phối quá trình phục hồi:

  • __init__: Khởi tạo môi trường, thiết lập biến môi trường (RESTIC_PASSWORDRCLONE_CONFIG), tạo thư mục tạm nếu cần.
  • setup_path: Đảm bảo các đường dẫn cần thiết trong $PATH.
  • ensure_restore_dir: Tạo thư mục tạm cho MySQL nếu chưa tồn tại (/tmp/mysql_restores).

4. Chạy lệnh (run_cmd)

  • Hàm chung để thực thi lệnh shell với Restic hoặc MySQL, ghi log kết quả hoặc lỗi, trả về đầu ra stdout nếu thành công.

5. Liệt kê và chọn snapshot

  • list_snapshots: Dùng lệnh restic snapshots để lấy danh sách snapshot từ kho lưu trữ, có thể lọc theo tag (ví dụ: user:<tên>mysql).
  • select_snapshot: Hiển thị danh sách snapshot (ID, thời gian, tags) và yêu cầu người dùng chọn một snapshot bằng số thứ tự.

6. Phục hồi domain (restore_domain)

Quy trình:

  • Tạo thư mục đích (/home/<user>/domains/<domain>) và thư mục tạm (/tmp/restore_<user>_<domain>_<timestamp>).
  • Lấy danh sách snapshot liên quan đến user:<user>,domain:<domain>.
  • Phục hồi snapshot được chọn vào thư mục tạm bằng restic restore.
  • Di chuyển nội dung từ thư mục tạm về thư mục đích, xóa nội dung cũ nếu tồn tại (hỗ trợ symlink, thư mục, file).
  • Thiết lập quyền sở hữu và phân quyền (set_permissions).
  • Ghi nhận thành công vào restored_items và dọn dẹp thư mục tạm.
  • Xử lý lỗi: Nếu không tìm thấy snapshot hoặc quá trình thất bại, ghi log lỗi và trả về False.

7. Phục hồi cơ sở dữ liệu (restore_database)

Quy trình:

  • Lấy danh sách snapshot liên quan đến mysql,database:<db_name>.
  • Phục hồi snapshot vào thư mục tạm (/tmp/restore_db_<db_name>_<timestamp>).
  • Tìm file .sql.gz trong thư mục tạm, di chuyển đến /tmp/mysql_restores.
  • Giải nén file bằng gunzip để lấy .sql.
  • Nhập dữ liệu vào MySQL bằng hàm import_database.
  • Dọn dẹp file tạm và ghi nhận thành công.
  • Xử lý lỗi: Nếu không tìm thấy snapshot hoặc file .sql.gz, hoặc giải nén/nhập dữ liệu thất bại, ghi log lỗi và trả về False.

8. Nhập cơ sở dữ liệu (import_database)

  • Xóa và tạo lại cơ sở dữ liệu bằng lệnh mysql -e.
  • Nhập dữ liệu từ file .sql vào cơ sở dữ liệu đích.

9. Thiết lập quyền (set_permissions)

Đặt quyền sở hữu (chown) và phân quyền (chmod) cho domain:

  • Thư mục gốc: 751 (rwxr-x–x).
  • Thư mục con: 755 (rwxr-xr-x).
  • File: 644 (rw-r–r–).
  • Dùng UID/GID của người dùng để đảm bảo tính chính xác.

10. Gửi thông báo Telegram (send_telegram_report)

  • Tạo báo cáo với trạng thái, thời gian, danh sách item phục hồi thành công, và lỗi (nếu có).
  • Gửi qua API Telegram với định dạng HTML.

11. Hàm chính (main)

Quy trình:

  • Hiển thị menu chọn phục hồi domain (1) hoặc cơ sở dữ liệu (2).
  • Thu thập thông tin từ người dùng (username, domain, hoặc tên database).
  • Gọi hàm tương ứng (restore_domain hoặc restore_database).
  • Gửi báo cáo qua Telegram và dọn dẹp file .sql.gz trong thư mục tạm.
  • Trả về: Mã thoát 0 (thành công) hoặc 1 (thất bại).

Điểm mạnh

  • Tương tác tốt: Người dùng có thể chọn snapshot cụ thể để phục hồi.
  • Quản lý quyền: Đảm bảo quyền sở hữu và phân quyền phù hợp sau khi phục hồi domain.
  • Thông báo chi tiết: Báo cáo Telegram cung cấp thông tin đầy đủ về quá trình phục hồi.
  • Dọn dẹp cẩn thận: Xóa thư mục tạm và file .sql.gz sau khi hoàn tất.

Kết luận

Script này là một công cụ phục hồi hiệu quả, phù hợp với hệ thống sử dụng DirectAdmin và Google Drive để lưu trữ sao lưu. Nó cung cấp khả năng tương tác tốt và xử lý đầy đủ các bước từ khôi phục dữ liệu đến thiết lập quyền. Tuy nhiên, để tăng độ tin cậy và bảo mật, bạn nên xem xét các cải tiến tôi đã đề xuất.

Để lại một bình luận

Email của bạn sẽ không được hiển thị công khai. Các trường bắt buộc được đánh dấu *