libervia-backend/libervia/backend/tools/backup/gpg.py
Goffi 86a91b5850 tools (backup): add backup/restore:
Add an offline backup/restore engine in `tools.backup`:

- format based on XEP-0227 (pie), extended with Libervia sections
- handle several data types (params, private, roster, pep , gpg, files and db)
- per profile export/import and global handlers
- a manifest is used to have metadata and checksums
- full database snapshot/restore with the `db` type
- sqla: add `get_db_url`
- tools.common.utils: add `is_safe_relpath` and `is_process_running`

rel 480
2026-09-15 11:48:11 +02:00

210 lines
7.4 KiB
Python

#!/usr/bin/env python3
# Libervia: an XMPP client
# Copyright (C) 2009-2026 Jérôme Poisson (goffi@goffi.org)
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import os
from pathlib import Path
import shutil
from libervia.backend.tools.common.gpg_provider import get_provider
from libervia.backend.tools.common.gpg_provider.exceptions import (
GPGProviderError,
InvalidPacket,
UnknownKey,
)
from .core import BackupError, GPG_DIR, GPG_FILE_TPL, GlobalHandler, Report, Staging
# errors the GPG provider can raise (they have no common base class)
GPG_ERRORS = (GPGProviderError, InvalidPacket, UnknownKey)
def export_gpg(home: Path, staging: Staging) -> None:
"""Copy a profile GPG home to the staging
@param home: GPG home to copy (the name is the profile name)
@param staging: staging where the GPG files are copied
"""
if not home.is_dir():
return
for path in sorted(home.rglob("*")):
if path.is_file():
staging.add_file(
GPG_FILE_TPL.format(
profile=home.name, path=path.relative_to(home).as_posix()
),
path,
0o600,
)
def gpg_list_keys(home: Path) -> set[str]:
"""List the keys of a GPG home
@param home: GPG home path
@return: key fingerprints
@raise BackupError: if the keys can't be listed
"""
if not home.is_dir():
return set()
try:
provider = get_provider(str(home))
return {key.fingerprint for key in provider.list_public_keys("")}
except GPG_ERRORS as e:
raise BackupError(f"can't list GPG keys: {e}") from e
def _chmod_gpg_home(home: Path) -> None:
"""Set the restrictive permissions required by GPG on a home"""
for dirpath, dirnames, filenames in os.walk(home):
for dirname in dirnames:
os.chmod(Path(dirpath) / dirname, 0o700)
for filename in filenames:
os.chmod(Path(dirpath) / filename, 0o600)
os.chmod(home, 0o700)
def check_gpg_restore(
target_home: Path, backup_home: Path
) -> tuple[list[str], list[str]]:
"""Check a GPG home restore is safe (union merge, nothing is deleted)
The only risk is restoring an old backup onto a diverged keyring.
@param target_home: existing GPG home (restore target)
@param backup_home: GPG home from the backup
@return: (added, diverged) fingerprints
"""
backup_keys = gpg_list_keys(backup_home)
target_keys = gpg_list_keys(target_home)
return sorted(backup_keys - target_keys), sorted(target_keys - backup_keys)
def import_gpg(target_home: Path, backup_home: Path) -> int:
"""Merge the keys of a backup GPG home into a target home
Union merge: nothing is ever deleted or overwritten; if the target doesn't exist, the
backup home is simply copied.
@param target_home: GPG home to restore into
@param backup_home: GPG home from the backup
@return: number of secret key files added to the target
@raise BackupError: if the import fails
"""
if not target_home.exists():
target_home.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(backup_home, target_home)
_chmod_gpg_home(target_home)
return 0
# union copy of the secret keys (never overwrites an existing one)
src_keys = backup_home / "private-keys-v1.d"
dst_keys = target_home / "private-keys-v1.d"
dst_keys.mkdir(parents=True, exist_ok=True)
added = 0
if src_keys.is_dir():
for key in sorted(src_keys.glob("*.key")):
dst = dst_keys / key.name
if not dst.exists():
shutil.copy2(key, dst)
added += 1
# the keybox is a binary database which can't be merged: the backup keys are
# exported and re-imported (the secret keys are already in the copied .key files)
try:
src_provider = get_provider(str(backup_home))
dst_provider = get_provider(str(target_home))
packets = [
src_provider.export_public_key(key)
for key in src_provider.list_public_keys("")
]
for packet in packets:
try:
dst_provider.import_public_key(packet)
except InvalidPacket:
# key already in the target keybox: nothing to import
continue
except GPG_ERRORS as e:
raise BackupError(f"GPG import failed: {e}") from e
_chmod_gpg_home(target_home)
return added
class GpgHandler(GlobalHandler):
name = "gpg"
def _staged_profiles(self, staging: Staging) -> list[str]:
return sorted(
{name.split("/")[1] for name in staging.names() if name.startswith("gpg/")}
)
def export(
self,
local_dir: Path,
staging: Staging,
report: Report,
profiles: list[str] | None = None,
) -> None:
"""Copy the GPG homes to the staging
@param profiles: GPG homes to copy
None to copy all of them
@param report: export report
"""
gpg_dir = local_dir / GPG_DIR
if not gpg_dir.is_dir():
return
wanted = set(profiles) if profiles is not None else None
for home in sorted(gpg_dir.iterdir()):
if home.is_dir() and (wanted is None or home.name in wanted):
export_gpg(home, staging)
def import_(
self,
local_dir: Path,
staging: Staging,
report: Report,
profiles: list[str] | None = None,
force: bool = False,
) -> None:
"""Merge the backup GPG homes into the local ones (union merge)
A keyring which diverged from the backup is skipped unless ``force`` is set (the
backup may be older than the current keys).
@param profiles: GPG homes to import (None means all of them)
"""
wanted = set(profiles) if profiles is not None else None
for profile_name in self._staged_profiles(staging):
if wanted is not None and profile_name not in wanted:
continue
backup_home = staging.path(f"gpg/{profile_name}")
if not backup_home.is_dir() or not any(backup_home.iterdir()):
continue
target_home = local_dir / GPG_DIR / profile_name
added, diverged = check_gpg_restore(target_home, backup_home)
if diverged and not force:
report.skipped.append(
f"gpg: {profile_name}: the current keyring has keys not in "
f"the backup ({', '.join(diverged)})"
)
continue
count = import_gpg(target_home, backup_home)
report.done.append(
f"gpg: keyring merged into {target_home} ({count} new secret key file(s))"
)
for fp in added:
report.done.append(f"gpg: added key {fp}")