lockbot 2.5.4__tar.gz → 2.6.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {lockbot-2.5.4/python/lockbot.egg-info → lockbot-2.6.0}/PKG-INFO +3 -1
- {lockbot-2.5.4 → lockbot-2.6.0}/pyproject.toml +3 -1
- lockbot-2.6.0/python/lockbot/backend/app/backup/router.py +96 -0
- lockbot-2.6.0/python/lockbot/backend/app/backup/scheduler.py +139 -0
- lockbot-2.6.0/python/lockbot/backend/app/backup/service.py +191 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/bots/models.py +1 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/bots/router.py +205 -30
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/bots/schemas.py +25 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/main.py +67 -2
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/base_bot.py +5 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/config.py +5 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/device_bot.py +48 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/handler.py +1 -1
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/i18n/en.py +11 -2
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/i18n/zh.py +13 -2
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/node_bot.py +46 -0
- lockbot-2.6.0/python/lockbot/core/platforms/__init__.py +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/queue_bot.py +42 -0
- {lockbot-2.5.4 → lockbot-2.6.0/python/lockbot.egg-info}/PKG-INFO +3 -1
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot.egg-info/SOURCES.txt +4 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot.egg-info/requires.txt +2 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/LICENSE +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/MANIFEST.in +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/README.md +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/__init__.py +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/__init__.py +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/__init__.py +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/admin/__init__.py +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/admin/router.py +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/audit/__init__.py +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/audit/models.py +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/audit/router.py +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/audit/service.py +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/auth/__init__.py +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/auth/dependencies.py +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/auth/models.py +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/auth/router.py +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/auth/schemas.py +0 -0
- {lockbot-2.5.4/python/lockbot/backend/app/bots → lockbot-2.6.0/python/lockbot/backend/app/backup}/__init__.py +0 -0
- {lockbot-2.5.4/python/lockbot/backend/app/logs → lockbot-2.6.0/python/lockbot/backend/app/bots}/__init__.py +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/bots/encryption.py +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/bots/manager.py +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/bots/webhook_handler.py +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/config.py +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/database.py +0 -0
- {lockbot-2.5.4/python/lockbot/backend/app/settings → lockbot-2.6.0/python/lockbot/backend/app/logs}/__init__.py +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/rate_limit.py +0 -0
- {lockbot-2.5.4/python/lockbot/core → lockbot-2.6.0/python/lockbot/backend/app/settings}/__init__.py +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/settings/models.py +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/settings/router.py +0 -0
- {lockbot-2.5.4/python/lockbot/core/platforms → lockbot-2.6.0/python/lockbot/core}/__init__.py +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/bot_instance.py +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/device_usage_alert.py +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/device_usage_utils.py +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/entry.py +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/env.py +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/i18n/__init__.py +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/io.py +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/message_adapter.py +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/msg_utils.py +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/platforms/infoflow.py +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/request.py +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/scheduler.py +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/utils.py +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot.egg-info/dependency_links.txt +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot.egg-info/top_level.txt +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/setup.cfg +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/tools/create_super_admin.py +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/tools/gen_keys.py +0 -0
- {lockbot-2.5.4 → lockbot-2.6.0}/tools/reset_super_admin_password.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lockbot
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.6.0
|
|
4
4
|
Summary: Cluster resource management bot for IM platforms
|
|
5
5
|
Author-email: Jianbang Yang <yangjianbang112@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -34,6 +34,8 @@ Requires-Dist: bcrypt
|
|
|
34
34
|
Requires-Dist: python-multipart
|
|
35
35
|
Requires-Dist: httpx
|
|
36
36
|
Requires-Dist: slowapi
|
|
37
|
+
Requires-Dist: pyzipper
|
|
38
|
+
Requires-Dist: bce-python-sdk
|
|
37
39
|
Provides-Extra: dev
|
|
38
40
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
39
41
|
Requires-Dist: ruff==0.15.10; extra == "dev"
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "lockbot"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.6.0"
|
|
8
8
|
description = "Cluster resource management bot for IM platforms"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -42,6 +42,8 @@ dependencies = [
|
|
|
42
42
|
"python-multipart",
|
|
43
43
|
"httpx",
|
|
44
44
|
"slowapi",
|
|
45
|
+
"pyzipper",
|
|
46
|
+
"bce-python-sdk",
|
|
45
47
|
]
|
|
46
48
|
|
|
47
49
|
[project.optional-dependencies]
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Backup API — configuration + trigger endpoints.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends, Request
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
from sqlalchemy.orm import Session
|
|
8
|
+
|
|
9
|
+
from lockbot.backend.app.audit.service import write_audit_log
|
|
10
|
+
from lockbot.backend.app.auth.dependencies import require_super_admin
|
|
11
|
+
from lockbot.backend.app.auth.models import User
|
|
12
|
+
from lockbot.backend.app.bots.encryption import mask
|
|
13
|
+
from lockbot.backend.app.database import get_db
|
|
14
|
+
|
|
15
|
+
from .scheduler import backup_scheduler
|
|
16
|
+
from .service import (
|
|
17
|
+
ENCRYPTED_KEYS,
|
|
18
|
+
get_backup_config,
|
|
19
|
+
run_backup,
|
|
20
|
+
save_backup_config,
|
|
21
|
+
test_bos_connection,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
router = APIRouter(prefix="/api/admin/backup", tags=["backup"])
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BackupSettingsIn(BaseModel):
|
|
28
|
+
settings: dict[str, str]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@router.get("/settings")
|
|
32
|
+
def get_settings(
|
|
33
|
+
_admin: User = Depends(require_super_admin),
|
|
34
|
+
db: Session = Depends(get_db),
|
|
35
|
+
):
|
|
36
|
+
"""Return backup config with sensitive fields masked."""
|
|
37
|
+
config = get_backup_config(db)
|
|
38
|
+
# Mask secrets for display
|
|
39
|
+
for key in ENCRYPTED_KEYS:
|
|
40
|
+
if config.get(key):
|
|
41
|
+
config[key] = mask(config[key])
|
|
42
|
+
return config
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@router.put("/settings")
|
|
46
|
+
def update_settings(
|
|
47
|
+
body: BackupSettingsIn,
|
|
48
|
+
_admin: User = Depends(require_super_admin),
|
|
49
|
+
db: Session = Depends(get_db),
|
|
50
|
+
):
|
|
51
|
+
"""Save backup configuration."""
|
|
52
|
+
save_backup_config(db, body.settings)
|
|
53
|
+
backup_scheduler.reload()
|
|
54
|
+
return {"ok": True}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@router.get("/scheduler/status")
|
|
58
|
+
def scheduler_status(
|
|
59
|
+
_admin: User = Depends(require_super_admin),
|
|
60
|
+
):
|
|
61
|
+
"""Return backup scheduler liveness status."""
|
|
62
|
+
return backup_scheduler.status()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@router.post("/run")
|
|
66
|
+
def trigger_backup(
|
|
67
|
+
request: Request,
|
|
68
|
+
_admin: User = Depends(require_super_admin),
|
|
69
|
+
db: Session = Depends(get_db),
|
|
70
|
+
):
|
|
71
|
+
"""Trigger an immediate backup to BOS."""
|
|
72
|
+
result = run_backup(db)
|
|
73
|
+
write_audit_log(
|
|
74
|
+
db,
|
|
75
|
+
_admin,
|
|
76
|
+
"admin.bos_backup",
|
|
77
|
+
detail={"object_key": result.get("object_key"), "size": result.get("size")},
|
|
78
|
+
ip=request.client.host if request.client else None,
|
|
79
|
+
result="success" if result["success"] else "failure",
|
|
80
|
+
)
|
|
81
|
+
db.commit()
|
|
82
|
+
return result
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@router.post("/test-connection")
|
|
86
|
+
def test_connection(
|
|
87
|
+
_admin: User = Depends(require_super_admin),
|
|
88
|
+
db: Session = Depends(get_db),
|
|
89
|
+
):
|
|
90
|
+
"""Test BOS connectivity."""
|
|
91
|
+
config = get_backup_config(db)
|
|
92
|
+
try:
|
|
93
|
+
test_bos_connection(config)
|
|
94
|
+
return {"ok": True}
|
|
95
|
+
except Exception as e:
|
|
96
|
+
return {"ok": False, "error": str(e)}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Backup scheduler — daemon thread for periodic BOS backups.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
from datetime import datetime, timedelta, timezone
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BackupScheduler:
|
|
13
|
+
"""Simple daemon-thread scheduler for periodic backups."""
|
|
14
|
+
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self._thread: threading.Thread | None = None
|
|
17
|
+
self._stop_event = threading.Event()
|
|
18
|
+
self._reload_event = threading.Event()
|
|
19
|
+
self._started_at: datetime | None = None
|
|
20
|
+
self._last_heartbeat: datetime | None = None
|
|
21
|
+
self._last_error: str = ""
|
|
22
|
+
self._next_run_at: datetime | None = None
|
|
23
|
+
|
|
24
|
+
def start(self):
|
|
25
|
+
if self._thread and self._thread.is_alive():
|
|
26
|
+
return
|
|
27
|
+
self._stop_event.clear()
|
|
28
|
+
self._started_at = datetime.now(timezone.utc)
|
|
29
|
+
self._last_error = ""
|
|
30
|
+
self._thread = threading.Thread(target=self._run, daemon=True, name="backup-scheduler")
|
|
31
|
+
self._thread.start()
|
|
32
|
+
logger.info("Backup scheduler started")
|
|
33
|
+
|
|
34
|
+
def stop(self):
|
|
35
|
+
self._stop_event.set()
|
|
36
|
+
self._reload_event.set() # Wake up sleeping thread
|
|
37
|
+
if self._thread:
|
|
38
|
+
self._thread.join(timeout=5)
|
|
39
|
+
logger.info("Backup scheduler stopped")
|
|
40
|
+
|
|
41
|
+
def reload(self):
|
|
42
|
+
"""Signal the scheduler to re-read config and recalculate next run."""
|
|
43
|
+
self._reload_event.set()
|
|
44
|
+
|
|
45
|
+
def status(self) -> dict:
|
|
46
|
+
"""Return scheduler liveness information for admin monitoring."""
|
|
47
|
+
thread_alive = bool(self._thread and self._thread.is_alive())
|
|
48
|
+
return {
|
|
49
|
+
"running": thread_alive and not self._stop_event.is_set(),
|
|
50
|
+
"thread_alive": thread_alive,
|
|
51
|
+
"started_at": self._started_at.isoformat() if self._started_at else "",
|
|
52
|
+
"last_heartbeat": self._last_heartbeat.isoformat() if self._last_heartbeat else "",
|
|
53
|
+
"next_run_at": self._next_run_at.isoformat() if self._next_run_at else "",
|
|
54
|
+
"last_error": self._last_error,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
def _run(self):
|
|
58
|
+
while not self._stop_event.is_set():
|
|
59
|
+
self._last_heartbeat = datetime.now(timezone.utc)
|
|
60
|
+
try:
|
|
61
|
+
seconds = self._seconds_until_next()
|
|
62
|
+
if seconds is not None:
|
|
63
|
+
self._next_run_at = datetime.now(timezone.utc) + timedelta(seconds=seconds)
|
|
64
|
+
else:
|
|
65
|
+
self._next_run_at = None
|
|
66
|
+
if seconds is None:
|
|
67
|
+
# Auto-backup disabled, sleep and wait for reload
|
|
68
|
+
self._reload_event.wait(timeout=300)
|
|
69
|
+
self._reload_event.clear()
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
# Sleep until next fire time (interruptible by reload/stop)
|
|
73
|
+
logger.info("Next backup in %d seconds", seconds)
|
|
74
|
+
triggered = self._reload_event.wait(timeout=seconds)
|
|
75
|
+
self._reload_event.clear()
|
|
76
|
+
if triggered or self._stop_event.is_set():
|
|
77
|
+
continue # Config changed or shutting down, recalculate
|
|
78
|
+
|
|
79
|
+
# Time to backup
|
|
80
|
+
self._do_backup()
|
|
81
|
+
except Exception as exc:
|
|
82
|
+
self._last_error = str(exc)
|
|
83
|
+
logger.exception("Backup scheduler error")
|
|
84
|
+
# Sleep a bit before retry
|
|
85
|
+
self._stop_event.wait(timeout=60)
|
|
86
|
+
|
|
87
|
+
def _seconds_until_next(self) -> int | None:
|
|
88
|
+
"""Calculate seconds until next backup. Returns None if disabled."""
|
|
89
|
+
from lockbot.backend.app.backup.service import get_backup_config
|
|
90
|
+
from lockbot.backend.app.database import SessionLocal
|
|
91
|
+
|
|
92
|
+
db = SessionLocal()
|
|
93
|
+
try:
|
|
94
|
+
config = get_backup_config(db)
|
|
95
|
+
finally:
|
|
96
|
+
db.close()
|
|
97
|
+
|
|
98
|
+
if config["backup_auto_enabled"] != "true":
|
|
99
|
+
return None
|
|
100
|
+
if config["backup_method"] != "bos":
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
freq = config["backup_frequency"] # e.g. "daily:03:00"
|
|
104
|
+
if not freq.startswith("daily:"):
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
time_str = freq[6:] # "HH:MM"
|
|
108
|
+
try:
|
|
109
|
+
hour, minute = int(time_str[:2]), int(time_str[3:5])
|
|
110
|
+
except (ValueError, IndexError):
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
now = datetime.now(timezone.utc)
|
|
114
|
+
target = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
|
115
|
+
if target <= now:
|
|
116
|
+
# Next day
|
|
117
|
+
from datetime import timedelta
|
|
118
|
+
|
|
119
|
+
target += timedelta(days=1)
|
|
120
|
+
|
|
121
|
+
return int((target - now).total_seconds())
|
|
122
|
+
|
|
123
|
+
def _do_backup(self):
|
|
124
|
+
"""Execute backup using a fresh DB session."""
|
|
125
|
+
from lockbot.backend.app.backup.service import run_backup
|
|
126
|
+
from lockbot.backend.app.database import SessionLocal
|
|
127
|
+
|
|
128
|
+
db = SessionLocal()
|
|
129
|
+
try:
|
|
130
|
+
result = run_backup(db)
|
|
131
|
+
if result["success"]:
|
|
132
|
+
logger.info("Scheduled backup completed: %s", result["object_key"])
|
|
133
|
+
else:
|
|
134
|
+
logger.error("Scheduled backup failed: %s", result.get("error"))
|
|
135
|
+
finally:
|
|
136
|
+
db.close()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
backup_scheduler = BackupScheduler()
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BOS backup service — create archive and upload to Baidu Object Storage.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import sqlite3
|
|
7
|
+
import tempfile
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pyzipper
|
|
12
|
+
from baidubce.auth.bce_credentials import BceCredentials
|
|
13
|
+
from baidubce.bce_client_configuration import BceClientConfiguration
|
|
14
|
+
from baidubce.services.bos.bos_client import BosClient
|
|
15
|
+
from sqlalchemy.orm import Session
|
|
16
|
+
|
|
17
|
+
from lockbot.backend.app.bots.encryption import decrypt, encrypt
|
|
18
|
+
from lockbot.backend.app.config import DATA_DIR, DATABASE_URL
|
|
19
|
+
from lockbot.backend.app.settings.models import SiteSetting
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
# Setting keys
|
|
24
|
+
_KEYS = [
|
|
25
|
+
"backup_method",
|
|
26
|
+
"backup_bos_ak",
|
|
27
|
+
"backup_bos_sk",
|
|
28
|
+
"backup_bos_endpoint",
|
|
29
|
+
"backup_bos_bucket",
|
|
30
|
+
"backup_bos_prefix",
|
|
31
|
+
"backup_zip_password",
|
|
32
|
+
"backup_frequency",
|
|
33
|
+
"backup_auto_enabled",
|
|
34
|
+
"backup_last_time",
|
|
35
|
+
"backup_last_status",
|
|
36
|
+
"backup_total_count",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
ENCRYPTED_KEYS = {"backup_bos_sk"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_backup_config(db: Session) -> dict:
|
|
43
|
+
"""Read all backup_* settings, decrypting sensitive fields."""
|
|
44
|
+
rows = db.query(SiteSetting).filter(SiteSetting.key.in_(_KEYS)).all()
|
|
45
|
+
config = {k: "" for k in _KEYS}
|
|
46
|
+
for row in rows:
|
|
47
|
+
val = row.value or ""
|
|
48
|
+
if row.key in ENCRYPTED_KEYS and val:
|
|
49
|
+
try:
|
|
50
|
+
val = decrypt(val)
|
|
51
|
+
except Exception:
|
|
52
|
+
val = ""
|
|
53
|
+
config[row.key] = val
|
|
54
|
+
return config
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def save_backup_config(db: Session, data: dict) -> None:
|
|
58
|
+
"""Save backup settings, encrypting sensitive fields. Skips masked values."""
|
|
59
|
+
for key, value in data.items():
|
|
60
|
+
if key not in _KEYS:
|
|
61
|
+
continue
|
|
62
|
+
# Skip masked placeholder (frontend sends *** for unchanged secrets)
|
|
63
|
+
if key in ENCRYPTED_KEYS and value and value.startswith("***"):
|
|
64
|
+
continue
|
|
65
|
+
store_val = value
|
|
66
|
+
if key in ENCRYPTED_KEYS and value:
|
|
67
|
+
store_val = encrypt(value)
|
|
68
|
+
row = db.get(SiteSetting, key)
|
|
69
|
+
if row is None:
|
|
70
|
+
row = SiteSetting(key=key, value=store_val, updated_at=datetime.now(timezone.utc))
|
|
71
|
+
db.add(row)
|
|
72
|
+
else:
|
|
73
|
+
row.value = store_val
|
|
74
|
+
row.updated_at = datetime.now(timezone.utc)
|
|
75
|
+
db.commit()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _update_backup_stats(db: Session, success: bool) -> None:
|
|
79
|
+
"""Update last backup time, status, and increment count."""
|
|
80
|
+
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
|
81
|
+
updates = {
|
|
82
|
+
"backup_last_time": now,
|
|
83
|
+
"backup_last_status": "success" if success else "failed",
|
|
84
|
+
}
|
|
85
|
+
if success:
|
|
86
|
+
row = db.get(SiteSetting, "backup_total_count")
|
|
87
|
+
count = int(row.value) if row and row.value else 0
|
|
88
|
+
updates["backup_total_count"] = str(count + 1)
|
|
89
|
+
|
|
90
|
+
for key, val in updates.items():
|
|
91
|
+
row = db.get(SiteSetting, key)
|
|
92
|
+
if row is None:
|
|
93
|
+
row = SiteSetting(key=key, value=val, updated_at=datetime.now(timezone.utc))
|
|
94
|
+
db.add(row)
|
|
95
|
+
else:
|
|
96
|
+
row.value = val
|
|
97
|
+
row.updated_at = datetime.now(timezone.utc)
|
|
98
|
+
db.commit()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def create_backup_archive(password: str | None = None) -> Path:
|
|
102
|
+
"""Create a zip archive of DB + bot state files. Returns path to temp zip."""
|
|
103
|
+
if not DATABASE_URL.startswith("sqlite:///"):
|
|
104
|
+
raise RuntimeError("Backup only supported for SQLite")
|
|
105
|
+
|
|
106
|
+
db_path = DATABASE_URL[len("sqlite:///") :]
|
|
107
|
+
tmp_dir = tempfile.mkdtemp(prefix="lockbot_backup_")
|
|
108
|
+
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
|
109
|
+
zip_path = Path(tmp_dir) / f"lockbot_backup_{ts}.zip"
|
|
110
|
+
|
|
111
|
+
# Safe SQLite copy
|
|
112
|
+
backup_db = Path(tmp_dir) / "lockbot.db"
|
|
113
|
+
src = sqlite3.connect(db_path)
|
|
114
|
+
dst = sqlite3.connect(str(backup_db))
|
|
115
|
+
src.backup(dst)
|
|
116
|
+
src.close()
|
|
117
|
+
dst.close()
|
|
118
|
+
|
|
119
|
+
# Collect bot state files
|
|
120
|
+
bots_dir = Path(DATA_DIR) / "bots"
|
|
121
|
+
state_files: list[tuple[Path, str]] = []
|
|
122
|
+
if bots_dir.exists():
|
|
123
|
+
for state_file in bots_dir.rglob("bot_state.json"):
|
|
124
|
+
arcname = str(state_file.relative_to(Path(DATA_DIR)))
|
|
125
|
+
state_files.append((state_file, arcname))
|
|
126
|
+
|
|
127
|
+
# Create zip (AES encrypted if password set)
|
|
128
|
+
if password:
|
|
129
|
+
with pyzipper.AESZipFile(
|
|
130
|
+
str(zip_path), "w", compression=pyzipper.ZIP_DEFLATED, encryption=pyzipper.WZ_AES
|
|
131
|
+
) as zf:
|
|
132
|
+
zf.setpassword(password.encode())
|
|
133
|
+
zf.write(str(backup_db), "lockbot.db")
|
|
134
|
+
for fpath, arcname in state_files:
|
|
135
|
+
zf.write(str(fpath), arcname)
|
|
136
|
+
else:
|
|
137
|
+
with pyzipper.ZipFile(str(zip_path), "w", compression=pyzipper.ZIP_DEFLATED) as zf:
|
|
138
|
+
zf.write(str(backup_db), "lockbot.db")
|
|
139
|
+
for fpath, arcname in state_files:
|
|
140
|
+
zf.write(str(fpath), arcname)
|
|
141
|
+
|
|
142
|
+
# Cleanup temp db
|
|
143
|
+
backup_db.unlink(missing_ok=True)
|
|
144
|
+
return zip_path
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _build_bos_client(config: dict) -> BosClient:
|
|
148
|
+
"""Create BOS client from config dict."""
|
|
149
|
+
return BosClient(
|
|
150
|
+
BceClientConfiguration(
|
|
151
|
+
credentials=BceCredentials(config["backup_bos_ak"], config["backup_bos_sk"]),
|
|
152
|
+
endpoint=config["backup_bos_endpoint"],
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def upload_to_bos(filepath: Path, config: dict) -> str:
|
|
158
|
+
"""Upload file to BOS. Returns the object key."""
|
|
159
|
+
client = _build_bos_client(config)
|
|
160
|
+
prefix = config["backup_bos_prefix"].strip("/")
|
|
161
|
+
object_key = f"{prefix}/{filepath.name}" if prefix else filepath.name
|
|
162
|
+
client.put_object_from_file(config["backup_bos_bucket"], object_key, str(filepath))
|
|
163
|
+
return object_key
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_bos_connection(config: dict) -> bool:
|
|
167
|
+
"""Test BOS connectivity by checking bucket existence."""
|
|
168
|
+
client = _build_bos_client(config)
|
|
169
|
+
client.list_objects(config["backup_bos_bucket"], max_keys=1)
|
|
170
|
+
return True
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def run_backup(db: Session) -> dict:
|
|
174
|
+
"""Execute full backup flow: archive → upload → update stats."""
|
|
175
|
+
config = get_backup_config(db)
|
|
176
|
+
zip_path = None
|
|
177
|
+
try:
|
|
178
|
+
password = config["backup_zip_password"] or None
|
|
179
|
+
zip_path = create_backup_archive(password)
|
|
180
|
+
object_key = upload_to_bos(zip_path, config)
|
|
181
|
+
size = zip_path.stat().st_size
|
|
182
|
+
_update_backup_stats(db, success=True)
|
|
183
|
+
return {"success": True, "object_key": object_key, "size": size}
|
|
184
|
+
except Exception as e:
|
|
185
|
+
logger.exception("Backup failed")
|
|
186
|
+
_update_backup_stats(db, success=False)
|
|
187
|
+
return {"success": False, "error": str(e)}
|
|
188
|
+
finally:
|
|
189
|
+
if zip_path and zip_path.exists():
|
|
190
|
+
zip_path.unlink(missing_ok=True)
|
|
191
|
+
zip_path.parent.rmdir()
|
|
@@ -34,6 +34,7 @@ class Bot(Base):
|
|
|
34
34
|
consecutive_failures: Mapped[int] = mapped_column(Integer, default=0)
|
|
35
35
|
last_request_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
36
36
|
config_overrides: Mapped[str] = mapped_column(Text, default="{}") # JSON
|
|
37
|
+
api_key: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
|
37
38
|
|
|
38
39
|
# Soft delete
|
|
39
40
|
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|