Initial commit: VPN Telegram bot (sanitized defaults)
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
"""Бэкап / восстановление PostgreSQL через pg_dump и psql."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import gzip
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BACKUP_DIR = Path(os.getenv("BACKUP_DIR") or "/app/backups")
|
||||
_SAFE_NAME = re.compile(r"^vpnbot-[\w.\-]+\.sql\.gz$", re.I)
|
||||
_UPLOAD_NAME = re.compile(r"^[\w.\-]+\.sql(\.gz)?$", re.I)
|
||||
|
||||
|
||||
def _pg_env() -> tuple[str, str, str, str, str]:
|
||||
user = os.getenv("POSTGRES_USER") or "vpnbot"
|
||||
dbname = os.getenv("POSTGRES_DB") or "vpnbot"
|
||||
password = os.getenv("POSTGRES_PASSWORD") or "vpnbot"
|
||||
host = os.getenv("POSTGRES_HOST") or "db"
|
||||
port = os.getenv("POSTGRES_PORT") or "5432"
|
||||
return user, dbname, password, host, port
|
||||
|
||||
|
||||
def resolve_backup_path(name: str) -> Path:
|
||||
"""Безопасный путь к файлу бэкапа (без path traversal)."""
|
||||
base = Path(name).name
|
||||
if not _SAFE_NAME.match(base):
|
||||
raise ValueError("Некорректное имя файла бэкапа")
|
||||
path = (BACKUP_DIR / base).resolve()
|
||||
root = BACKUP_DIR.resolve()
|
||||
if path.parent != root or not path.is_file():
|
||||
raise FileNotFoundError("Бэкап не найден")
|
||||
return path
|
||||
|
||||
|
||||
def delete_backup(name: str) -> None:
|
||||
path = resolve_backup_path(name)
|
||||
path.unlink()
|
||||
|
||||
|
||||
async def _run_pg_dump_to_host() -> bytes:
|
||||
"""pg_dump по сети Docker к сервису db."""
|
||||
user, dbname, password, host, port = _pg_env()
|
||||
pg_dump = shutil.which("pg_dump")
|
||||
if not pg_dump:
|
||||
raise FileNotFoundError(
|
||||
"pg_dump не найден. Пересоберите образ: docker compose up -d --build bot"
|
||||
)
|
||||
|
||||
env = os.environ.copy()
|
||||
env["PGPASSWORD"] = password
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
pg_dump,
|
||||
"-h",
|
||||
host,
|
||||
"-p",
|
||||
port,
|
||||
"-U",
|
||||
user,
|
||||
"-d",
|
||||
dbname,
|
||||
"--no-owner",
|
||||
"--no-acl",
|
||||
"--clean",
|
||||
"--if-exists",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=env,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError((stderr or b"").decode("utf-8", "replace")[:500] or "pg_dump failed")
|
||||
if not stdout:
|
||||
raise RuntimeError("pg_dump вернул пустой дамп")
|
||||
return stdout
|
||||
|
||||
|
||||
async def _run_pg_dump_via_docker(compose_dir: Path | None) -> bytes:
|
||||
user, dbname, password, _host, _port = _pg_env()
|
||||
if not shutil.which("docker"):
|
||||
raise FileNotFoundError("docker не найден")
|
||||
|
||||
cmd = [
|
||||
"docker",
|
||||
"compose",
|
||||
"exec",
|
||||
"-T",
|
||||
"-e",
|
||||
f"PGPASSWORD={password}",
|
||||
"db",
|
||||
"pg_dump",
|
||||
"-U",
|
||||
user,
|
||||
"-d",
|
||||
dbname,
|
||||
"--no-owner",
|
||||
"--no-acl",
|
||||
"--clean",
|
||||
"--if-exists",
|
||||
]
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=str(compose_dir) if compose_dir else None,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError((stderr or b"").decode("utf-8", "replace")[:500] or "docker pg_dump failed")
|
||||
if not stdout:
|
||||
raise RuntimeError("docker pg_dump вернул пустой дамп")
|
||||
return stdout
|
||||
|
||||
|
||||
async def run_pg_dump_backup(
|
||||
*,
|
||||
compose_dir: Path | None = None,
|
||||
keep: int = 14,
|
||||
) -> Path:
|
||||
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
out = BACKUP_DIR / f"vpnbot-{stamp}.sql.gz"
|
||||
|
||||
errors: list[str] = []
|
||||
stdout: bytes | None = None
|
||||
|
||||
try:
|
||||
stdout = await _run_pg_dump_to_host()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
errors.append(f"network pg_dump: {exc}")
|
||||
logger.warning("backup via host pg_dump failed: %s", exc)
|
||||
|
||||
if stdout is None:
|
||||
try:
|
||||
stdout = await _run_pg_dump_via_docker(compose_dir)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
errors.append(f"docker pg_dump: {exc}")
|
||||
logger.warning("backup via docker failed: %s", exc)
|
||||
|
||||
if stdout is None:
|
||||
raise RuntimeError(
|
||||
"Не удалось сделать бэкап. "
|
||||
+ " | ".join(errors)
|
||||
+ ". Убедитесь, что bot пересобран с postgresql-client."
|
||||
)
|
||||
|
||||
with gzip.open(out, "wb") as fh:
|
||||
fh.write(stdout)
|
||||
|
||||
_prune(BACKUP_DIR, keep=keep)
|
||||
logger.info("postgres backup written %s (%s bytes)", out, len(stdout))
|
||||
return out
|
||||
|
||||
|
||||
def _prune(directory: Path, *, keep: int) -> None:
|
||||
files = sorted(
|
||||
directory.glob("vpnbot-*.sql.gz"),
|
||||
key=lambda p: p.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
for old in files[max(0, keep) :]:
|
||||
try:
|
||||
old.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def list_backups(limit: int = 30) -> list[dict]:
|
||||
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
files = sorted(
|
||||
BACKUP_DIR.glob("vpnbot-*.sql.gz"),
|
||||
key=lambda p: p.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
out = []
|
||||
for p in files[:limit]:
|
||||
st = p.stat()
|
||||
out.append(
|
||||
{
|
||||
"name": p.name,
|
||||
"size": st.st_size,
|
||||
"mtime": datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat(),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _gunzip_if_needed(raw: bytes, *, filename: str = "") -> bytes:
|
||||
name = (filename or "").lower()
|
||||
if name.endswith(".gz") or raw[:2] == b"\x1f\x8b":
|
||||
return gzip.decompress(raw)
|
||||
return raw
|
||||
|
||||
|
||||
async def _run_psql_sql(sql: bytes) -> None:
|
||||
user, dbname, password, host, port = _pg_env()
|
||||
psql = shutil.which("psql")
|
||||
if not psql:
|
||||
raise FileNotFoundError(
|
||||
"psql не найден. Пересоберите образ: docker compose up -d --build bot"
|
||||
)
|
||||
|
||||
# Чистый restore: рвём сессии и пересоздаём schema public,
|
||||
# иначе старые дампы без DROP падают на «relation already exists».
|
||||
safe_user = user.replace('"', "")
|
||||
pre = (
|
||||
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
|
||||
f"WHERE datname = '{dbname}' AND pid <> pg_backend_pid();\n"
|
||||
"DROP SCHEMA IF EXISTS public CASCADE;\n"
|
||||
"CREATE SCHEMA public;\n"
|
||||
f'GRANT ALL ON SCHEMA public TO "{safe_user}";\n'
|
||||
"GRANT ALL ON SCHEMA public TO public;\n"
|
||||
f'ALTER SCHEMA public OWNER TO "{safe_user}";\n'
|
||||
).encode("utf-8")
|
||||
|
||||
env = os.environ.copy()
|
||||
env["PGPASSWORD"] = password
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
psql,
|
||||
"-h",
|
||||
host,
|
||||
"-p",
|
||||
port,
|
||||
"-U",
|
||||
user,
|
||||
"-d",
|
||||
dbname,
|
||||
"-v",
|
||||
"ON_ERROR_STOP=1",
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=env,
|
||||
)
|
||||
_stdout, stderr = await proc.communicate(pre + sql)
|
||||
if proc.returncode != 0:
|
||||
err = (stderr or b"").decode("utf-8", "replace")[:800]
|
||||
raise RuntimeError(err or "psql restore failed")
|
||||
logger.info("postgres restore ok (%s bytes sql)", len(sql))
|
||||
|
||||
|
||||
async def restore_from_bytes(raw: bytes, *, filename: str = "") -> None:
|
||||
if not raw:
|
||||
raise ValueError("Пустой файл")
|
||||
if len(raw) > 200 * 1024 * 1024:
|
||||
raise ValueError("Файл слишком большой (лимит 200 МБ)")
|
||||
sql = _gunzip_if_needed(raw, filename=filename)
|
||||
if not sql.strip():
|
||||
raise ValueError("Дамп пустой после распаковки")
|
||||
head = sql[:4000].decode("utf-8", "replace").lower()
|
||||
if (
|
||||
"create " not in head
|
||||
and "drop " not in head
|
||||
and "copy " not in head
|
||||
and "insert " not in head
|
||||
):
|
||||
raise ValueError("Файл не похож на SQL-дамп PostgreSQL")
|
||||
await _run_psql_sql(sql)
|
||||
|
||||
|
||||
async def restore_from_backup_name(name: str) -> None:
|
||||
path = resolve_backup_path(name)
|
||||
raw = path.read_bytes()
|
||||
await restore_from_bytes(raw, filename=path.name)
|
||||
|
||||
|
||||
async def save_uploaded_backup(raw: bytes, *, original_name: str = "") -> Path:
|
||||
"""Сохранить загруженный дамп как vpnbot-*.sql.gz на диск."""
|
||||
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
base = Path(original_name or "upload.sql.gz").name
|
||||
if not _UPLOAD_NAME.match(base):
|
||||
base = "upload.sql.gz"
|
||||
sql = _gunzip_if_needed(raw, filename=base)
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
out = BACKUP_DIR / f"vpnbot-{stamp}.sql.gz"
|
||||
with gzip.open(out, "wb") as fh:
|
||||
fh.write(sql)
|
||||
return out
|
||||
Reference in New Issue
Block a user