feat: Major refactor of config management and password handling

- Updated config.py to improve configuration management with secure password handling.
- Introduced a new Config class to encapsulate configuration loading, validation, and password management.
- Enhanced password generation and storage mechanisms, including support for systemd-creds and password files.
- Created a default configuration template with improved security notes and instructions.
- Added validation checks for repository paths and password configurations.
- Updated dependencies.py and system_utils.py to correct import paths.
- Added new entry points for console scripts in entry_points.txt.
- Updated package metadata for version 2.0.0, including license and dependencies.
- Added MANIFEST.in to include necessary files in the package distribution.
- Created executable scripts for kopi-docka and kopi-docka-service.
This commit is contained in:
TZERO78
2025-10-01 16:52:44 +02:00
parent fc3fa727e7
commit 45d4d89f0c
27 changed files with 1117 additions and 527 deletions
+7
View File
@@ -0,0 +1,7 @@
cat > MANIFEST.in << 'EOF'
include README.md
include LICENSE
include requirements.txt
recursive-include kopi_docka/templates *.conf
recursive-include kopi_docka/templates *.ini
EOF
+8 -2
View File
@@ -55,10 +55,16 @@ logger = get_logger(__name__)
def initialize_context(
ctx: typer.Context,
config_path: Optional[Path] = typer.Option(
None, "--config", "-c", help="Path to configuration file."
None,
"--config",
help="Path to configuration file.",
envvar="KOPI_DOCKA_CONFIG",
),
log_level: str = typer.Option(
"INFO", "--log-level", help="Log level (DEBUG, INFO, WARNING, ERROR)."
"INFO",
"--log-level",
help="Log level (DEBUG, INFO, WARNING, ERROR).",
envvar="KOPI_DOCKA_LOG_LEVEL",
),
):
"""
+23 -14
View File
@@ -194,19 +194,28 @@ def cmd_restore(ctx: typer.Context):
def register(app: typer.Typer):
"""Register all backup commands."""
app.command("list")(
lambda ctx,
units=typer.Option(True, "--units", help="List discovered backup units"),
snapshots=typer.Option(False, "--snapshots", help="List repository snapshots"):
cmd_list(ctx, units, snapshots)
)
@app.command("list")
def _list_cmd(
ctx: typer.Context,
units: bool = typer.Option(True, "--units", help="List discovered backup units"),
snapshots: bool = typer.Option(False, "--snapshots", help="List repository snapshots"),
):
"""List backup units or repository snapshots."""
cmd_list(ctx, units, snapshots)
app.command("backup")(
lambda ctx,
unit=typer.Option(None, "--unit", "-u", help="Backup only these units"),
dry_run=typer.Option(False, "--dry-run", help="Simulate backup"),
update_recovery_bundle=typer.Option(None, "--update-recovery/--no-update-recovery"):
cmd_backup(ctx, unit, dry_run, update_recovery_bundle)
)
@app.command("backup")
def _backup_cmd(
ctx: typer.Context,
unit: Optional[List[str]] = typer.Option(None, "--unit", help="Backup only these units"),
dry_run: bool = typer.Option(False, "--dry-run", help="Simulate backup"),
update_recovery_bundle: Optional[bool] = typer.Option(
None, "--update-recovery/--no-update-recovery"
),
):
"""Run a cold backup for selected units (or all)."""
cmd_backup(ctx, unit, dry_run, update_recovery_bundle)
app.command("restore")(cmd_restore)
@app.command("restore")
def _restore_cmd(ctx: typer.Context):
"""Launch the interactive restore wizard."""
cmd_restore(ctx)
+345 -28
View File
@@ -8,7 +8,7 @@ from typing import Optional
import typer
from ..helpers import Config, create_default_config, get_logger
from ..helpers import Config, create_default_config, get_logger, generate_secure_password
logger = get_logger(__name__)
@@ -39,7 +39,7 @@ def cmd_config(ctx: typer.Context, show: bool = True):
typer.echo(f"Configuration file: {cfg.config_file}")
typer.echo("=" * 60)
config = configparser.ConfigParser()
config = configparser.ConfigParser(interpolation=None)
config.read(cfg.config_file)
for section in config.sections():
@@ -66,22 +66,55 @@ def cmd_new_config(
except Exception:
pass # Config doesn't exist, that's fine
if existing_cfg and existing_cfg.config_file.exists() and not force:
typer.echo(f"⚠️ Config already exists at: {existing_cfg.config_file}")
typer.echo("Use --force to overwrite or 'edit-config' to modify")
raise typer.Exit(code=1)
if existing_cfg and existing_cfg.config_file.exists():
typer.echo(f"⚠️ Config already exists at: {existing_cfg.config_file}")
typer.echo("")
if not force:
typer.echo("Use one of these options:")
typer.echo(" kopi-docka edit-config - Modify existing config")
typer.echo(" kopi-docka new-config --force - Overwrite with warnings")
typer.echo(" kopi-docka reset-config - Complete reset (DANGEROUS)")
raise typer.Exit(code=1)
# With --force: Show warnings
typer.echo("⚠️ WARNING: This will overwrite the existing configuration!")
typer.echo("")
typer.echo("This means:")
typer.echo(" • A NEW password will be generated")
typer.echo(" • The OLD password will NOT work anymore")
typer.echo(" • You will LOSE ACCESS to existing backups!")
typer.echo("")
if not typer.confirm("Continue anyway?", default=False):
typer.echo("Aborted.")
typer.echo("")
typer.echo("💡 Safer alternatives:")
typer.echo(" kopi-docka edit-config - Edit existing config")
typer.echo(" kopi-docka change-password - Change repository password safely")
raise typer.Exit(code=0)
# Backup old config
from datetime import datetime
import shutil
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
timestamp_backup = existing_cfg.config_file.parent / f"{existing_cfg.config_file.stem}.{timestamp}.backup"
shutil.copy2(existing_cfg.config_file, timestamp_backup)
typer.echo(f"✓ Old config backed up to: {timestamp_backup}")
typer.echo("")
typer.echo("Creating new configuration...")
created_path = create_default_config(path, force)
created_path = create_default_config(path, force=True)
typer.echo(f"✓ Config created at: {created_path}")
if edit:
editor = os.environ.get('EDITOR', 'nano')
typer.echo(f"\nOpening in {editor} for initial setup...")
typer.echo("Important settings to configure:")
typer.echo(" • repository_path - Where to store backups")
typer.echo(" • password - Strong password for encryption")
typer.echo(" • backup paths - Adjust for your system")
typer.echo("Important settings to review:")
typer.echo(" • repository_path: Where to store backups")
typer.echo(" • password: Default is 'kopia-docka' (change after init!)")
typer.echo(" • backup paths: Adjust for your system")
typer.echo("")
subprocess.call([editor, str(created_path)])
@@ -100,7 +133,273 @@ def cmd_edit_config(ctx: typer.Context, editor: Optional[str] = None):
Config(cfg.config_file)
typer.echo("✓ Configuration valid")
except Exception as e:
typer.echo(f"⚠️ Configuration might have issues: {e}")
typer.echo(f"⚠️ Configuration might have issues: {e}")
def cmd_reset_config(path: Optional[Path] = None):
"""
Reset configuration completely (DANGEROUS).
This will delete the existing config and create a new one with a new password.
Use this only if you want to start fresh or have no existing backups.
"""
typer.echo("=" * 70)
typer.echo("⚠️ DANGER ZONE: CONFIGURATION RESET")
typer.echo("=" * 70)
typer.echo("")
typer.echo("This operation will:")
typer.echo(" 1. DELETE the existing configuration")
typer.echo(" 2. Generate a COMPLETELY NEW password")
typer.echo(" 3. Make existing backups INACCESSIBLE")
typer.echo("")
typer.echo("✓ Only proceed if:")
typer.echo(" • You want to start completely fresh")
typer.echo(" • You have no existing backups")
typer.echo(" • You have backed up your old password elsewhere")
typer.echo("")
typer.echo("✗ DO NOT proceed if:")
typer.echo(" • You have existing backups you want to keep")
typer.echo(" • You just want to change a setting (use 'edit-config' instead)")
typer.echo("=" * 70)
typer.echo("")
# First confirmation
if not typer.confirm("Do you understand that this will make existing backups inaccessible?", default=False):
typer.echo("Aborted - Good choice!")
raise typer.Exit(code=0)
# Show what will be reset
existing_path = path or (Path('/etc/kopi-docka.conf') if os.geteuid() == 0
else Path.home() / '.config' / 'kopi-docka' / 'config.conf')
if existing_path.exists():
typer.echo(f"\nConfig to reset: {existing_path}")
# Try to show current repository path
try:
cfg = Config(existing_path)
repo_path = cfg.get('kopia', 'repository_path')
typer.echo(f"Current repository: {repo_path}")
typer.echo("")
typer.echo("⚠️ If you want to KEEP this repository, you must:")
typer.echo(" 1. Backup your current password from the config")
typer.echo(" 2. Copy it to the new config after creation")
except Exception:
pass
typer.echo("")
# Second confirmation with explicit typing
confirmation = typer.prompt("Type 'DELETE' to confirm reset (or anything else to abort)")
if confirmation != "DELETE":
typer.echo("Aborted.")
raise typer.Exit(code=0)
# Backup before deletion
if existing_path.exists():
from datetime import datetime
import shutil
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_path = existing_path.parent / f"{existing_path.stem}.{timestamp}.backup"
shutil.copy2(existing_path, backup_path)
typer.echo(f"\n✓ Backup created: {backup_path}")
# Also backup password file if exists
password_file = existing_path.parent / f".{existing_path.stem}.password"
if password_file.exists():
password_backup = existing_path.parent / f".{existing_path.stem}.{timestamp}.password.backup"
shutil.copy2(password_file, password_backup)
typer.echo(f"✓ Password backed up: {password_backup}")
# Delete old config
if existing_path.exists():
existing_path.unlink()
typer.echo(f"✓ Deleted old config: {existing_path}")
typer.echo("")
# Create new config
typer.echo("Creating fresh configuration...")
cmd_new_config(force=True, edit=True, path=path)
def cmd_change_password(
ctx: typer.Context,
new_password: Optional[str] = None,
):
"""Change Kopia repository password and store securely."""
cfg = ensure_config(ctx)
from ..cores import KopiaRepository
repo = KopiaRepository(cfg)
# Connect check
try:
if not repo.is_connected():
typer.echo("↻ Connecting to repository...")
repo.connect()
except Exception as e:
typer.echo(f"✗ Failed to connect: {e}")
typer.echo("\nMake sure:")
typer.echo(" • Repository exists and is initialized")
typer.echo(" • Current password in config is correct")
raise typer.Exit(code=1)
typer.echo("=" * 70)
typer.echo("CHANGE KOPIA REPOSITORY PASSWORD")
typer.echo("=" * 70)
typer.echo(f"Repository: {repo.repo_path}")
typer.echo(f"Profile: {repo.profile_name}")
typer.echo("")
# Get new password
if not new_password:
import getpass
typer.echo("Enter new password (empty = auto-generate):")
new_password = getpass.getpass("New password: ")
if not new_password:
new_password = generate_secure_password()
typer.echo("\n" + "=" * 70)
typer.echo("GENERATED PASSWORD:")
typer.echo(new_password)
typer.echo("=" * 70 + "\n")
if not typer.confirm("Use this password?"):
typer.echo("Aborted.")
raise typer.Exit(code=0)
else:
new_password_confirm = getpass.getpass("Confirm: ")
if new_password != new_password_confirm:
typer.echo("✗ Passwords don't match!")
raise typer.Exit(code=1)
if len(new_password) < 12:
typer.echo(f"\n⚠️ WARNING: Password is short ({len(new_password)} chars)")
if not typer.confirm("Continue?"):
raise typer.Exit(code=0)
# Change in Kopia
typer.echo("\n↻ Changing repository password...")
try:
import subprocess
env = repo._get_env().copy()
env["KOPIA_NEW_PASSWORD"] = new_password
cmd = ["kopia", "repository", "change-password", "--config-file", repo._get_config_file()]
proc = subprocess.run(cmd, env=env, text=True, capture_output=True)
if proc.returncode != 0:
typer.echo(f"✗ Failed: {proc.stderr or proc.stdout}")
raise typer.Exit(code=1)
typer.echo("✓ Repository password changed")
except Exception as e:
typer.echo(f"✗ Error: {e}")
raise typer.Exit(code=1)
# Store password securely
_store_password_secure(cfg, new_password)
typer.echo("\n" + "=" * 70)
typer.echo("✓ PASSWORD CHANGED SUCCESSFULLY")
typer.echo("=" * 70)
def _store_password_secure(cfg: Config, password: str):
"""Store password with systemd-creds or fallback to encrypted file."""
import shutil
cred_name = f"kopia_password_{cfg.get('kopia', 'profile', fallback='kopi-docka')}"
if shutil.which('systemd-creds'):
typer.echo("\n↻ Storing with systemd-creds...")
import tempfile
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.txt') as tmp:
tmp.write(password)
tmp_path = tmp.name
try:
cred_dir = Path("/etc/credstore.encrypted")
cred_dir.mkdir(parents=True, exist_ok=True)
cred_path = cred_dir / cred_name
backup_path = cred_dir / f"{cred_name}.backup"
# Backup previous password (idempotent)
if cred_path.exists():
shutil.copy2(cred_path, backup_path)
typer.echo(f"✓ Previous password backed up: {backup_path}")
# Encrypt new credential
cmd = [
"sudo", "systemd-creds", "encrypt",
"--name", cred_name,
tmp_path,
str(cred_path)
]
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode == 0:
typer.echo(f"✓ New password encrypted: {cred_path}")
# Update config
config = configparser.ConfigParser(interpolation=None)
config.read(cfg.config_file)
if not config.has_section('kopia'):
config.add_section('kopia')
config.set('kopia', 'password', f'${{CREDENTIALS_DIRECTORY}}/{cred_name}')
with open(cfg.config_file, 'w') as f:
config.write(f)
typer.echo("✓ Config updated")
else:
typer.echo(f"✗ systemd-creds failed: {proc.stderr}")
_store_plaintext_fallback(cfg, password)
finally:
import os
os.unlink(tmp_path)
else:
typer.echo("\n⚠️ systemd-creds not available (need systemd 250+)")
_store_plaintext_fallback(cfg, password)
def _store_plaintext_fallback(cfg: Config, password: str):
"""Fallback: Store in plain file with chmod 600."""
import shutil
typer.echo("↻ Storing in plain text file (chmod 600)...")
password_file = cfg.config_file.parent / f".{cfg.config_file.stem}.password"
backup_file = cfg.config_file.parent / f".{cfg.config_file.stem}.password.backup"
# Backup previous password (idempotent)
if password_file.exists():
shutil.copy2(password_file, backup_file)
typer.echo(f"✓ Previous password backed up: {backup_file}")
# Write new password
password_file.write_text(password + "\n", encoding='utf-8')
password_file.chmod(0o600)
typer.echo(f"✓ New password file: {password_file}")
# Update config
config = configparser.ConfigParser(interpolation=None)
config.read(cfg.config_file)
if not config.has_section('kopia'):
config.add_section('kopia')
config.set('kopia', 'password_file', str(password_file))
with open(cfg.config_file, 'w') as f:
config.write(f)
typer.echo("✓ Config updated")
# -------------------------
@@ -110,21 +409,39 @@ def cmd_edit_config(ctx: typer.Context, editor: Optional[str] = None):
def register(app: typer.Typer):
"""Register all configuration commands."""
app.command("config")(
lambda ctx: cmd_config(
ctx,
show=typer.Option(True, "--show", help="Show current configuration")
)
)
@app.command("show-config")
def _config_cmd(ctx: typer.Context):
"""Show current configuration."""
cmd_config(ctx, show=True)
app.command("new-config")(
lambda force=typer.Option(False, "--force", "-f", help="Overwrite existing config"),
edit=typer.Option(True, "--edit/--no-edit", help="Open in editor after creation"),
path=typer.Option(None, "--path", "-p", help="Custom config path"):
cmd_new_config(force, edit, path)
)
@app.command("new-config")
def _new_config_cmd(
force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing config (with warnings)"),
edit: bool = typer.Option(True, "--edit/--no-edit", help="Open in editor after creation"),
path: Optional[Path] = typer.Option(None, "--path", help="Custom config path"),
):
"""Create new configuration file."""
cmd_new_config(force, edit, path)
app.command("edit-config")(
lambda ctx, editor=typer.Option(None, "--editor", "-e", help="Specify editor to use"):
cmd_edit_config(ctx, editor)
)
@app.command("edit-config")
def _edit_config_cmd(
ctx: typer.Context,
editor: Optional[str] = typer.Option(None, "--editor", help="Specify editor to use"),
):
"""Edit existing configuration file."""
cmd_edit_config(ctx, editor)
@app.command("reset-config")
def _reset_config_cmd(
path: Optional[Path] = typer.Option(None, "--path", help="Custom config path"),
):
"""Reset configuration completely (DANGEROUS - creates new password!)."""
cmd_reset_config(path)
@app.command("change-password")
def _change_password_cmd(
ctx: typer.Context,
new_password: Optional[str] = typer.Option(None, "--new-password", help="New password (will prompt if not provided)"),
):
"""Change Kopia repository password."""
cmd_change_password(ctx, new_password)
+18 -10
View File
@@ -89,15 +89,23 @@ def cmd_deps():
def register(app: typer.Typer):
"""Register all dependency commands."""
app.command("check")(
lambda ctx, verbose=typer.Option(False, "--verbose", "-v", help="Show detailed information"):
cmd_check(ctx, verbose)
)
@app.command("check")
def _check_cmd(
ctx: typer.Context,
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed information"),
):
"""Check system requirements and dependencies."""
cmd_check(ctx, verbose)
app.command("install-deps")(
lambda force=typer.Option(False, "--force", "-f", help="Skip confirmation prompt"),
dry_run=typer.Option(False, "--dry-run", help="Show what would be installed"):
cmd_install_deps(force, dry_run)
)
@app.command("install-deps")
def _install_deps_cmd(
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt"),
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be installed"),
):
"""Install missing system dependencies."""
cmd_install_deps(force, dry_run)
app.command("deps")(cmd_deps)
@app.command("show-deps")
def _deps_cmd():
"""Show dependency installation guide."""
cmd_deps()
+57 -144
View File
@@ -134,12 +134,41 @@ def cmd_init(ctx: typer.Context):
typer.echo(f"Using profile: {repo.profile_name}")
typer.echo(f"Repository: {repo.repo_path}")
# Warnung bei Standard-Passwort
try:
current_password = cfg.get_password()
if current_password == 'kopia-docka':
typer.echo("")
typer.echo("⚠️ WARNING: Using default password 'kopia-docka'!")
typer.echo(" This is INSECURE for production use.")
typer.echo("")
if not typer.confirm("Continue with default password?", default=False):
typer.echo("\nChange password first:")
typer.echo(" kopi-docka change-password")
raise typer.Exit(code=0)
except ValueError as e:
typer.echo(f"⚠️ Password issue: {e}")
typer.echo("Continuing anyway (will fail if repository needs password)...")
try:
repo.connect()
typer.echo("✓ Repository connected")
repo.initialize()
typer.echo("✓ Repository initialized")
# Erinnerung bei Standard-Passwort
try:
if cfg.get_password() == 'kopia-docka':
typer.echo("")
typer.echo("=" * 60)
typer.echo("⚠️ NEXT STEP: Change the default password NOW!")
typer.echo("=" * 60)
typer.echo(" kopi-docka change-password")
typer.echo("=" * 60)
except ValueError:
pass
except Exception as e:
typer.echo(f"✗ Init/connect failed: {e}")
typer.echo(f"✗ Init failed: {e}")
raise typer.Exit(code=1)
@@ -308,8 +337,7 @@ def cmd_repo_selftest(
conf_path = conf_dir / f"selftest-{stamp}.conf"
conf_path.write_text(
f"""
[kopia]
f"""[kopia]
repository_path = {repo_dir}
password = {password}
profile = {test_profile}
@@ -319,7 +347,7 @@ daily = 7
weekly = 4
monthly = 12
yearly = 3
""".strip(),
""",
encoding="utf-8",
)
@@ -332,13 +360,13 @@ yearly = 3
typer.echo("↻ Connecting/creating test repository…")
try:
test_repo.connect()
test_repo.initialize()
except Exception as e:
typer.echo(f"✗ Could not connect/create selftest repo: {e}")
typer.echo(f"✗ Could not initialize selftest repo: {e}")
raise typer.Exit(code=1)
if not test_repo.is_connected():
typer.echo("✗ Not connected after connect().")
typer.echo("✗ Not connected after initialize().")
raise typer.Exit(code=1)
_print_kopia_native_status(test_repo)
@@ -392,118 +420,6 @@ def cmd_repo_maintenance(ctx: typer.Context):
raise typer.Exit(code=1)
def cmd_change_password(
ctx: typer.Context,
new_password: Optional[str] = None,
update_config: bool = True,
):
"""Change Kopia repository password."""
cfg = ensure_config(ctx)
repo = ensure_repository(ctx)
typer.echo("=" * 60)
typer.echo("CHANGE KOPIA REPOSITORY PASSWORD")
typer.echo("=" * 60)
typer.echo(f"Repository: {repo.repo_path}")
typer.echo(f"Profile: {repo.profile_name}")
typer.echo("")
# Get new password
if not new_password:
import getpass
typer.echo("Enter new password (or leave empty to auto-generate):")
new_password = getpass.getpass("New password: ")
if not new_password:
new_password = generate_secure_password()
typer.echo("")
typer.echo("=" * 60)
typer.echo("AUTO-GENERATED PASSWORD:")
typer.echo("=" * 60)
typer.echo(new_password)
typer.echo("=" * 60)
typer.echo("")
if not typer.confirm("Use this password?"):
typer.echo("Aborted.")
raise typer.Exit(code=0)
else:
new_password_confirm = getpass.getpass("Confirm new password: ")
if new_password != new_password_confirm:
typer.echo("Passwords don't match!")
raise typer.Exit(code=1)
if len(new_password) < 12:
typer.echo("")
typer.echo(f"WARNING: Password is very short ({len(new_password)} chars).")
if not typer.confirm("Continue with weak password?"):
raise typer.Exit(code=1)
typer.echo("")
typer.echo("Changing repository password...")
try:
import os
env = repo._get_env().copy()
env["KOPIA_NEW_PASSWORD"] = new_password
cmd = [
"kopia", "repository", "change-password",
"--config-file", repo._get_config_file()
]
proc = subprocess.run(cmd, env=env, text=True, capture_output=True)
if proc.returncode != 0:
typer.echo(f"Failed to change password: {proc.stderr or proc.stdout}")
raise typer.Exit(code=1)
typer.echo("✓ Repository password changed successfully")
except Exception as e:
typer.echo(f"Error changing password: {e}")
raise typer.Exit(code=1)
# Update config
if update_config:
typer.echo("")
if not typer.confirm("Update password in config file?"):
typer.echo("")
typer.echo("Password changed in repository but NOT in config file.")
return
try:
import configparser
config = configparser.ConfigParser(interpolation=None)
config.read(cfg.config_file)
if not config.has_section('kopia'):
config.add_section('kopia')
config.set('kopia', 'password', new_password)
with open(cfg.config_file, 'w') as f:
config.write(f)
cfg.config_file.chmod(0o600)
typer.echo(f"✓ Config file updated: {cfg.config_file}")
password_file = cfg.config_file.parent / f".{cfg.config_file.stem}.password"
with open(password_file, 'w') as f:
f.write(f"{new_password}\n")
password_file.chmod(0o600)
typer.echo(f"✓ Password file updated: {password_file}")
except Exception as e:
typer.echo(f"Warning: Could not update config file: {e}")
typer.echo("")
typer.echo("=" * 60)
typer.echo("PASSWORD CHANGE COMPLETE")
typer.echo("=" * 60)
# -------------------------
# Registration
# -------------------------
@@ -511,32 +427,29 @@ def cmd_change_password(
def register(app: typer.Typer):
"""Register all repository commands."""
# Simple commands without parameters
app.command("init")(cmd_init)
app.command("repo-status")(cmd_repo_status)
app.command("repo-which-config")(cmd_repo_which_config)
app.command("repo-set-default")(cmd_repo_set_default)
app.command("repo-init-path")(
lambda ctx,
path=typer.Argument(..., help="Repository path"),
profile=typer.Option(None, "--profile", "-p"),
set_default=typer.Option(False, "--set-default/--no-set-default"),
password=typer.Option(None, "--password"):
cmd_repo_init_path(ctx, path, profile, set_default, password)
)
app.command("repo-selftest")(
lambda tmpdir=typer.Option(Path("/tmp"), "--tmpdir"),
keep=typer.Option(False, "--keep/--no-keep"),
password=typer.Option(None, "--password"):
cmd_repo_selftest(tmpdir, keep, password)
)
app.command("repo-maintenance")(cmd_repo_maintenance)
app.command("change-password")(
lambda ctx,
new_password=typer.Option(None, "--new-password"),
update_config=typer.Option(True, "--update-config/--no-update-config"):
cmd_change_password(ctx, new_password, update_config)
)
@app.command("repo-init-path")
def _repo_init_path_cmd(
ctx: typer.Context,
path: Path = typer.Argument(..., help="Repository path"),
profile: Optional[str] = typer.Option(None, "--profile", help="Profile name"),
set_default: bool = typer.Option(False, "--set-default/--no-set-default"),
password: Optional[str] = typer.Option(None, "--password"),
):
"""Create a Kopia filesystem repository at PATH."""
cmd_repo_init_path(ctx, path, profile, set_default, password)
@app.command("repo-selftest")
def _repo_selftest_cmd(
tmpdir: Path = typer.Option(Path("/tmp"), "--tmpdir"),
keep: bool = typer.Option(False, "--keep/--no-keep"),
password: Optional[str] = typer.Option(None, "--password"),
):
"""Create ephemeral test repository."""
cmd_repo_selftest(tmpdir, keep, password)
+18 -10
View File
@@ -49,14 +49,22 @@ def cmd_write_units(output_dir: Path = Path("/etc/systemd/system")):
def register(app: typer.Typer):
"""Register all service commands."""
app.command("daemon")(
lambda interval_minutes=typer.Option(None, "--interval-minutes", help="Run backup every N minutes"),
backup_cmd=typer.Option("/usr/bin/env kopi-docka backup", "--backup-cmd"),
log_level=typer.Option("INFO", "--log-level"):
cmd_daemon(interval_minutes, backup_cmd, log_level)
)
@app.command("daemon")
def _daemon_cmd(
interval_minutes: Optional[int] = typer.Option(
None, "--interval-minutes", help="Run backup every N minutes"
),
backup_cmd: str = typer.Option(
"/usr/bin/env kopi-docka backup", "--backup-cmd"
),
log_level: str = typer.Option("INFO", "--log-level"),
):
"""Run the systemd-friendly daemon (service)."""
cmd_daemon(interval_minutes, backup_cmd, log_level)
app.command("write-units")(
lambda output_dir=typer.Option(Path("/etc/systemd/system"), "--output-dir"):
cmd_write_units(output_dir)
)
@app.command("write-units")
def _write_units_cmd(
output_dir: Path = typer.Option(Path("/etc/systemd/system"), "--output-dir"),
):
"""Write example systemd service and timer units."""
cmd_write_units(output_dir)
+6 -1
View File
@@ -6,7 +6,11 @@ from .docker_discovery import DockerDiscovery
from .repository_manager import KopiaRepository
from .dry_run_manager import DryRunReport
from .disaster_recovery_manager import DisasterRecoveryManager
from .service_manager import KopiDockaService, ServiceConfig
from .service_manager import (
KopiDockaService,
ServiceConfig,
write_systemd_units, # ← Diese Zeile hinzufügen
)
from .kopia_policy_manager import KopiaPolicyManager
__all__ = [
@@ -18,5 +22,6 @@ __all__ = [
'DisasterRecoveryManager',
'KopiDockaService',
'ServiceConfig',
'write_systemd_units', # ← Diese Zeile hinzufügen
'KopiaPolicyManager',
]
+3 -3
View File
@@ -32,9 +32,9 @@ import subprocess
from typing import List, Dict, Optional, Any
from pathlib import Path
from .logging import get_logger
from .types import BackupUnit, ContainerInfo, VolumeInfo
from .constants import (
from ..helpers.logging import get_logger
from ..types import BackupUnit, ContainerInfo, VolumeInfo
from ..helpers.constants import (
DOCKER_COMPOSE_PROJECT_LABEL,
DOCKER_COMPOSE_CONFIG_LABEL,
DOCKER_COMPOSE_SERVICE_LABEL,
+21 -14
View File
@@ -2,22 +2,20 @@
################################################################################
# KOPI-DOCKA
#
# @file: repository.py
# @module: kopi_docka.repository
# @file: repository_manager.py
# @module: kopi_docka.cores.repository_manager
# @description: Kopia CLI wrapper with profile-specific config handling.
# @author: Markus F. (TZERO78) & KI-Assistenten
# @repository: https://github.com/TZERO78/kopi-docka
# @version: 1.1.0
# @version: 1.2.0
#
# ------------------------------------------------------------------------------
# MIT-Lizenz: siehe LICENSE oder https://opensource.org/licenses/MIT
# ==============================================================================
# Changelog v1.1.0:
# - initialize() ist jetzt idempotent (prüft ob bereits connected)
# - connect() vereinfacht, ruft nicht mehr initialize() auf
# - disconnect() Methode hinzugefügt
# - create_snapshot_from_stdin() verwendet --config-file explizit
# - Bessere Fehlerbehandlung und Logging
# Changelog v1.2.0:
# - Password handling via Config.get_password() method
# - Support for systemd-creds and password_file references
# - Fallback to default password for initial setup
################################################################################
"""
@@ -56,7 +54,7 @@ class KopiaRepository:
def __init__(self, config: Config):
self.config = config
self.repo_path = config.kopia_repository_path
self.password = config.kopia_password
# Password wird dynamisch über get_password() geholt
self.profile_name = config.kopia_profile
# --------------- Low-level helpers ---------------
@@ -70,8 +68,18 @@ class KopiaRepository:
def _get_env(self) -> Dict[str, str]:
"""Build environment for Kopia CLI (password, cache dir)."""
env = os.environ.copy()
if self.password:
env["KOPIA_PASSWORD"] = self.password
# Hole Passwort dynamisch über Config.get_password()
try:
password = self.config.get_password()
if password:
env["KOPIA_PASSWORD"] = password
except ValueError as e:
logger.warning(f"Could not get password: {e}")
# Fallback zu direktem Config-Wert für Kompatibilität
password = self.config.get('kopia', 'password', fallback='')
if password:
env["KOPIA_PASSWORD"] = password
cache_dir = self.config.kopia_cache_directory
if cache_dir:
@@ -309,8 +317,7 @@ class KopiaRepository:
except Exception as e:
logger.debug("Skipping policy defaults (optional): %s", e)
if self.password:
logger.info("Repository initialized with configured password. Keep it safe!")
logger.info("Repository initialized successfully")
def make_default_profile(self) -> None:
"""
+392 -285
View File
@@ -1,68 +1,424 @@
#!/usr/bin/env python3
################################################################################
# KOPI-DOCKA
#
# @file: config.py
# @module: kopi_docka.config
# @description: Configuration management with template support
# @module: kopi_docka.helpers.config
# @description: Configuration management with secure password handling
# @author: Markus F. (TZERO78) & KI-Assistenten
# @repository: https://github.com/TZERO78/kopi-docka
# @version: 1.0.0
# @version: 2.0.0
#
# ------------------------------------------------------------------------------
# Copyright (c) 2025 Markus F. (TZERO78)
# ------------------------------------------------------------------------------
# MIT-Lizenz: siehe LICENSE oder https://opensource.org/licenses/MIT
################################################################################
"""
Configuration management for Kopi-Docka.
This module handles reading, writing, and validating configuration files,
as well as creating default configurations when needed.
Handles loading, validation, and access to configuration settings.
Supports secure password storage via systemd-creds or password files.
"""
from __future__ import annotations
import configparser
import logging
import secrets
import shutil
import string
from pathlib import Path
from typing import Optional, Dict, Any, List
import os
import sys
import re
import shutil
import tempfile
from pathlib import Path
from typing import Optional, List, Dict, Any
from .constants import DEFAULT_CONFIG_PATHS
logger = logging.getLogger(__name__)
from .constants import DEFAULT_CONFIG_PATHS, VERSION
from .logging import get_logger
logger = get_logger(__name__)
def generate_secure_password(length: int = 32) -> str:
"""
Generate a cryptographically secure password.
Generate a cryptographically secure random password.
Args:
length: Password length
length: Password length (default: 32)
Returns:
Secure random password
Random password string
"""
alphabet = string.ascii_letters + string.digits + string.punctuation
import secrets
import string
# Alle sicheren Zeichen (keine Verwechslungsgefahr wie 0/O, 1/l)
alphabet = string.ascii_letters + string.digits + "!@#$^&*()-_=+[]{}|;:,.<>?/"
return ''.join(secrets.choice(alphabet) for _ in range(length))
class Config:
"""
Configuration manager for Kopi-Docka.
Loads and validates configuration from INI files.
Provides secure password handling with multiple sources.
"""
def __init__(self, config_path: Optional[Path] = None):
"""
Initialize configuration.
Args:
config_path: Optional path to config file
"""
# WICHTIG: Interpolation deaktivieren wegen % in Passwörtern
self._config = configparser.ConfigParser(interpolation=None)
self.config_file = self._find_config_file(config_path)
if not self.config_file.exists():
logger.info(f"No configuration found. Creating default at {self.config_file}")
from . import create_default_config
create_default_config(self.config_file)
self._load_config()
self._ensure_required_values()
# --------------- Properties ---------------
@property
def kopia_repository_path(self) -> str:
"""Get kopia repository path."""
return self.get('kopia', 'repository_path')
@property
def kopia_profile(self) -> str:
"""Get kopia profile name."""
return self.get('kopia', 'profile', fallback='kopi-docka')
@property
def kopia_cache_directory(self) -> Optional[str]:
"""Get kopia cache directory."""
return self.get('kopia', 'cache_directory', fallback=None)
@property
def kopia_password(self) -> str:
"""Get kopia password (deprecated, use get_password() instead)."""
try:
return self.get_password()
except ValueError:
return ''
# --------------- Password Management ---------------
def get_password(self) -> str:
"""
Get repository password from config.
Supports multiple password sources:
1. Direct password in config (e.g., "kopia-docka")
2. Reference to systemd-creds: ${CREDENTIALS_DIRECTORY}/name
3. Reference to password file: password_file setting
Returns:
Repository password
Raises:
ValueError: If password not accessible
"""
password = self.get('kopia', 'password', fallback='')
# Check for systemd-creds reference
if password.startswith('${CREDENTIALS_DIRECTORY}/'):
cred_name = password.replace('${CREDENTIALS_DIRECTORY}/', '')
# In systemd service: /run/credentials/kopi-docka.service/
cred_path = Path(f"/run/credentials/kopi-docka.service/{cred_name}")
if cred_path.exists():
return cred_path.read_text().strip()
else:
# Fallback: Try credstore.encrypted (needs manual decrypt)
encrypted_path = Path(f"/etc/credstore.encrypted/{cred_name}")
if encrypted_path.exists():
raise ValueError(
f"Credential exists but not loaded: {encrypted_path}\n"
"Run as systemd service or manually decrypt with:\n"
f" systemd-creds decrypt {encrypted_path}"
)
raise ValueError(f"Credential not found: {cred_name}")
# Check for password_file reference
password_file_str = self.get('kopia', 'password_file', fallback='')
if password_file_str:
password_file = Path(password_file_str).expanduser()
if password_file.exists():
return password_file.read_text().strip()
else:
raise ValueError(f"Password file not found: {password_file}")
# Direct password in config (including "kopia-docka")
if password:
return password
raise ValueError(
"No password configured.\n"
"Options:\n"
" 1. Use default: password = kopia-docka\n"
" 2. Change with: kopi-docka change-password"
)
# --------------- Core Methods ---------------
def get(self, section: str, option: str, fallback: Any = None) -> Any:
"""Get configuration value with fallback."""
try:
return self._config.get(section, option)
except (configparser.NoSectionError, configparser.NoOptionError):
return fallback
def set(self, section: str, option: str, value: Any) -> None:
"""Set configuration value."""
if not self._config.has_section(section):
self._config.add_section(section)
self._config.set(section, option, str(value))
def save(self) -> None:
"""Save configuration to file atomically with proper permissions."""
# Atomic save mit temp file
temp_fd, temp_path = tempfile.mkstemp(
dir=self.config_file.parent,
prefix='.kopi-docka-config-',
suffix='.tmp'
)
try:
# Schreibe mit UTF-8 encoding
with os.fdopen(temp_fd, 'w', encoding='utf-8') as f:
self._config.write(f)
# Atomic replace
os.replace(temp_path, self.config_file)
# WICHTIG: Setze Permissions NACH replace hart auf 0600
os.chmod(self.config_file, 0o600)
logger.info(f"Configuration saved to {self.config_file}")
except Exception as e:
# Cleanup bei Fehler
try:
if os.path.exists(temp_path):
os.unlink(temp_path)
except:
pass
logger.error(f"Failed to save configuration: {e}")
raise e
def display(self) -> None:
"""Display current configuration (with sensitive values masked)."""
print(f"Configuration file: {self.config_file}")
print("=" * 60)
# Erweiterte Masking-Patterns mit Regex
sensitive_patterns = re.compile(
r'(password|secret|key|token|credential|auth|api_key|client_secret|'
r'access_key|private_key|webhook|smtp_pass)',
re.IGNORECASE
)
for section in self._config.sections():
print(f"\n[{section}]")
for option, value in self._config.items(section):
# Check ob Option sensitiv ist
if sensitive_patterns.search(option):
# Zeige erste 3 Zeichen für Debugging
if value and len(value) > 3:
value = f"{value[:3]}***MASKED***"
else:
value = '***MASKED***'
print(f" {option} = {value}")
def validate(self) -> List[str]:
"""
Validiere die Konfiguration mit sinnvollen Wertebereichen.
Returns:
Liste von Fehlermeldungen (leer wenn alles OK)
"""
errors = []
# Check repository path
repo_path = self.get('kopia', 'repository_path')
# Nur lokale Pfade validieren, keine Remote-Repos (s3://, b2://, etc.)
if repo_path and '://' not in repo_path:
path = Path(repo_path).expanduser()
if not path.exists():
errors.append(f"Repository path does not exist: {path}")
elif not os.access(path, os.W_OK):
errors.append(f"Repository path not writable: {path}")
# Check password
try:
pwd = self.get_password()
if not pwd:
errors.append("No password configured")
elif pwd == 'kopia-docka':
logger.warning("Using default password - change after init!")
except ValueError as e:
errors.append(f"Password error: {e}")
# Check numeric values
parallel_workers = self.get('backup', 'parallel_workers', fallback='auto')
if parallel_workers != 'auto':
try:
workers = int(parallel_workers)
if workers < 1 or workers > 32:
errors.append(f"parallel_workers out of range (1-32): {workers}")
except ValueError:
errors.append(f"parallel_workers must be 'auto' or integer: {parallel_workers}")
return errors
# --------------- Private Methods ---------------
@staticmethod
def _get_default_config() -> Dict[str, Dict[str, Any]]:
"""
Get default configuration structure.
Returns:
Dictionary of default configuration sections and values
"""
return {
'kopia': {
'repository_path': '/backup/kopia-repository',
'password': 'kopia-docka', # Standard-Passwort
'password_file': '', # Leer = nicht verwendet
'profile': 'kopi-docka',
'compression': 'zstd',
'encryption': 'AES256-GCM-HMAC-SHA256',
'cache_directory': '/var/cache/kopi-docka',
},
'backup': {
'base_path': '/backup/kopi-docka',
'parallel_workers': 'auto',
'stop_timeout': 30,
'start_timeout': 60,
'database_backup': 'true',
'update_recovery_bundle': 'false',
'recovery_bundle_path': '/backup/recovery',
'recovery_bundle_retention': 3,
'exclude_patterns': '',
'pre_backup_hook': '',
'post_backup_hook': ''
},
'docker': {
'socket': '/var/run/docker.sock',
'compose_timeout': 300,
'prune_stopped_containers': 'false'
},
'retention': {
'daily': 7,
'weekly': 4,
'monthly': 12,
'yearly': 5
},
'logging': {
'level': 'INFO',
'file': '',
'max_size_mb': 100,
'backup_count': 5
}
}
def _find_config_file(self, config_path: Optional[Path] = None) -> Path:
"""
Find or determine configuration file path.
Args:
config_path: Explicitly provided configuration path
Returns:
Path to configuration file
"""
# WICHTIG: Respektiere expliziten Pfad, auch wenn Datei noch nicht existiert
if config_path:
# Konvertiere zu Path falls string
if isinstance(config_path, str):
config_path = Path(config_path)
# Expandiere ~ und mache absolut
config_path = config_path.expanduser().resolve()
# Stelle sicher dass Parent-Directory existiert
try:
config_path.parent.mkdir(parents=True, exist_ok=True, mode=0o755)
except PermissionError as e:
logger.error(f"Cannot create config directory {config_path.parent}: {e}")
raise
return config_path
# Check standard locations - USER FIRST (vermeidet Rechteprobleme)
search_order = [
DEFAULT_CONFIG_PATHS['user'], # ~/.config/... zuerst
DEFAULT_CONFIG_PATHS['root'] # /etc/... als Fallback
]
for location in search_order:
expanded_location = Path(location).expanduser()
if expanded_location.exists():
if os.access(expanded_location, os.R_OK):
logger.debug(f"Using config file: {expanded_location}")
return expanded_location
else:
logger.warning(f"Config file exists but not readable: {expanded_location}")
# Nichts gefunden - nutze Standard basierend auf Benutzer
if os.geteuid() == 0: # Running as root
path = Path(DEFAULT_CONFIG_PATHS['root'])
else:
path = Path(DEFAULT_CONFIG_PATHS['user'])
path = path.expanduser()
path.parent.mkdir(parents=True, exist_ok=True, mode=0o755)
logger.debug(f"Using default config path: {path}")
return path
def _load_config(self) -> None:
"""Load configuration from file with UTF-8 encoding."""
try:
with open(self.config_file, 'r', encoding='utf-8') as f:
self._config.read_file(f)
logger.info(f"Configuration loaded from {self.config_file}")
except UnicodeDecodeError as e:
logger.error(f"Config file encoding error (expected UTF-8): {e}")
raise
except Exception as e:
logger.error(f"Failed to load configuration: {e}")
raise
def _ensure_required_values(self) -> None:
"""
Stelle sicher dass kritische Werte existieren.
Generiert Standard-Werte falls nötig.
"""
# Für neue Configs mit Template werden keine Werte generiert
# Das Template enthält bereits alle Defaults
pass
def create_default_config(path: Optional[Path] = None, force: bool = False) -> Path:
"""
Create a default configuration file from the template.
Create default configuration from template.
Args:
path: Optional path where to create the config file. If None, uses default.
force: Overwrite existing file if True.
path: Optional path where to create the config file
force: Overwrite existing file if True
Returns:
Path to the created config file
"""
from datetime import datetime
# Handle None path - determine default location
if path is None:
if os.geteuid() == 0:
path = Path('/etc/kopi-docka.conf')
@@ -71,277 +427,28 @@ def create_default_config(path: Optional[Path] = None, force: bool = False) -> P
else:
path = Path(path).expanduser()
# Now safe to check exists
if path.exists() and not force:
logger.warning(f"Configuration file already exists at {path}, not overwriting.")
logger.warning(f"Configuration file already exists at {path}")
return path
# Ensure the parent directory exists
path.parent.mkdir(parents=True, exist_ok=True)
# Find the template file - direct path
template_name = 'config-template.conf'
template_path = Path(__file__).parent.parent / "templates" / template_name
# Copy template
template_path = Path(__file__).parent.parent / "templates" / "config_template.conf"
if not template_path.exists():
raise FileNotFoundError(
f"Configuration template not found at {template_path}. "
f"This indicates a broken installation. "
f"Please reinstall kopi-docka or check package data."
f"Critical error: Template missing from package installation."
)
# Read template and replace password
with open(template_path, 'r', encoding='utf-8') as f:
template_content = f.read()
generated_password = None
# Replace password placeholder if exists
if 'CHANGE_ME_TO_A_SECURE_PASSWORD' in template_content:
generated_password = generate_secure_password()
config_content = template_content.replace('CHANGE_ME_TO_A_SECURE_PASSWORD', generated_password)
logger.info("Generated secure password for repository")
else:
config_content = template_content
# Write config file
with open(path, 'w', encoding='utf-8') as f:
f.write(config_content)
# Set secure permissions (readable only by owner)
shutil.copy2(template_path, path)
path.chmod(0o600)
logger.info(f"Configuration created at {path}")
print(f"\n✓ Configuration created: {path}")
print(" Default password: kopia-docka")
print("\n⚠️ IMPORTANT: Change the password after 'kopi-docka init':")
print(" kopi-docka change-password\n")
# If password was generated, save it separately and display it
if generated_password:
# Save password file in systemd-creds compatible format
password_file = path.parent / f".{path.stem}.password"
# Create the password file content in a format ready for secrets
with open(password_file, 'w') as f:
# Just the password on first line (for easy piping to secret stores)
f.write(f"{generated_password}\n")
password_file.chmod(0o600) # Only owner can read
# Also create a info file for the user
info_file = path.parent / f".{path.stem}.password.info"
with open(info_file, 'w') as f:
f.write(f"Kopi-Docka Repository Password Information\n")
f.write(f"==========================================\n\n")
f.write(f"Created: {datetime.now().isoformat()}\n")
f.write(f"Config: {path}\n")
f.write(f"Password file: {password_file}\n\n")
f.write(f"IMPORTANT: This password is required for ALL restore operations!\n\n")
f.write(f"Migration to secure storage:\n")
f.write(f"----------------------------\n")
f.write(f"# For systemd-creds (systemd 250+):\n")
f.write(f"sudo systemd-creds encrypt --name=kopia_password {password_file} /etc/credstore/kopia_password\n\n")
f.write(f"# For Docker secrets:\n")
f.write(f"docker secret create kopia_password {password_file}\n\n")
f.write(f"# For environment variable:\n")
f.write(f"export KOPIA_PASSWORD=$(cat {password_file})\n\n")
f.write(f"After migration, delete the password file:\n")
f.write(f"shred -vzu {password_file}\n")
info_file.chmod(0o600)
# Display password prominently
print("\n" + "="*70)
print("🔐 REPOSITORY PASSWORD GENERATED")
print("="*70)
print(f"Password: {generated_password}")
print("="*70)
print(f"✓ Password saved to: {password_file}")
print(f"✓ Migration guide: {info_file}")
print("")
# Check if systemd-creds is available
if shutil.which('systemd-creds'):
print("⚠️ MIGRATE TO SECURE STORAGE:")
print(f" sudo systemd-creds encrypt --name=kopia_password {password_file}")
print("")
print("⚠️ Then delete the plaintext file:")
print(f" shred -vzu {password_file}")
else:
print("💡 TIP: Install systemd 250+ for encrypted credential storage")
print(" or use environment variable: export KOPIA_PASSWORD=$(cat {password_file})")
print("="*70 + "\n")
return path
class Config:
"""
Manages application configuration.
"""
def __init__(self, config_path: Optional[Path] = None):
"""
Initialize configuration.
Args:
config_path: Optional path to config file
"""
self._config = configparser.ConfigParser(interpolation=None)
self.config_file = self._find_config_file(config_path)
if not self.config_file.exists():
logger.info(f"No configuration found. Creating default at {self.config_file}")
create_default_config(self.config_file)
self._load_config()
self._ensure_required_values()
def _find_config_file(self, config_path: Optional[Path] = None) -> Path:
"""Find the configuration file."""
if config_path:
return Path(config_path).expanduser()
# Check default locations
if os.geteuid() == 0:
# Running as root
return Path(DEFAULT_CONFIG_PATHS["root"])
else:
# Running as user (development mode)
logger.warning("Running as non-root. Using user-specific config path.")
user_path = Path(DEFAULT_CONFIG_PATHS["user"]).expanduser()
user_path.parent.mkdir(parents=True, exist_ok=True)
return user_path
def _load_config(self):
"""Load configuration from the file."""
try:
self._config.read(self.config_file)
logger.debug(f"Configuration loaded from {self.config_file}")
except Exception as e:
logger.error(f"Failed to load configuration: {e}")
raise
def _ensure_required_values(self):
"""Ensure critical configuration values are present."""
password = self.get('kopia', 'password')
if not password or password == 'CHANGE_ME_TO_A_SECURE_PASSWORD':
raise ValueError("Kopia password is not set or still has placeholder value.")
if not self.get('kopia', 'repository_path'):
raise ValueError("Kopia repository path is not set.")
def get(self, section: str, option: str, fallback: Any = None) -> Any:
"""Get a configuration value."""
try:
if self._config.has_option(section, option):
return self._config.get(section, option)
except:
pass
return fallback
def getint(self, section: str, option: str, fallback: int = 0) -> int:
"""Get an integer configuration value."""
value = self.get(section, option, fallback)
if isinstance(value, int):
return value
if isinstance(value, str):
if value.lower() == 'auto':
return -1
try:
return int(value)
except (ValueError, TypeError):
pass
return fallback
def getboolean(self, section: str, option: str, fallback: bool = False) -> bool:
"""Get a boolean configuration value."""
value = self.get(section, option, fallback)
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.lower() in ('true', 'yes', '1', 'on')
return fallback
def getlist(self, section: str, option: str, fallback: Optional[List[str]] = None) -> List[str]:
"""Get a list from comma-separated configuration value."""
value = self.get(section, option)
if not value:
return fallback or []
if isinstance(value, str):
return [item.strip() for item in value.split(',') if item.strip()]
return fallback or []
# --- Properties for easy access ---
@property
def kopia_repository_path(self) -> Path:
"""Get Kopia repository path."""
path = self.get('kopia', 'repository_path')
# Don't expand path for remote repositories
if '://' in str(path):
return path
return Path(path).expanduser()
@property
def kopia_password(self) -> str:
"""Get Kopia password."""
# Check environment variable first
env_password = os.environ.get('KOPIA_PASSWORD')
if env_password:
return env_password
return self.get('kopia', 'password')
@property
def kopia_profile(self) -> str:
"""Get Kopia profile name."""
return self.get('kopia', 'profile', 'kopi-docka')
@property
def kopia_cache_directory(self) -> Path:
"""Get Kopia cache directory."""
return Path(self.get('kopia', 'cache_directory')).expanduser()
@property
def backup_base_path(self) -> Path:
"""Get backup base path."""
return Path(self.get('backup', 'base_path')).expanduser()
@property
def log_file(self) -> Optional[Path]:
"""Get log file path."""
file_path = self.get('logging', 'file')
if file_path:
return Path(file_path).expanduser()
return None
@property
def parallel_workers(self) -> int:
"""Get number of parallel workers."""
workers = self.getint('backup', 'parallel_workers', -1)
if workers == -1: # auto
from .system_utils import SystemUtils
return SystemUtils.get_optimal_workers()
return workers
@property
def docker_socket(self) -> str:
"""Get Docker socket path."""
return self.get('docker', 'socket', '/var/run/docker.sock')
@property
def database_backup_enabled(self) -> bool:
"""Check if database backup is enabled."""
return self.getboolean('backup', 'database_backup', True)
@property
def exclude_patterns(self) -> List[str]:
"""Get exclude patterns for tar."""
return self.getlist('backup', 'exclude_patterns', [])
return path
+9 -2
View File
@@ -31,7 +31,7 @@ import subprocess
from typing import List, Dict, Tuple, Optional
from pathlib import Path
from helpers.logging import get_logger
from ..helpers.logging import get_logger
logger = get_logger(__name__)
@@ -437,7 +437,14 @@ class DependencyManager:
commands.extend(
[
"rpm --import https://kopia.io/signing-key",
"cat > /etc/yum.repos.d/kopia.repo <<EOF\n[kopia]\nname=Kopia\nbaseurl=http://packages.kopia.io/rpm/stable/\$basearch/\nenabled=1\ngpgcheck=1\ngpgkey=https://kopia.io/signing-key\nEOF",
r"""cat > /etc/yum.repos.d/kopia.repo <<EOF
[kopia]
name=Kopia
baseurl=http://packages.kopia.io/rpm/stable/\$basearch/
enabled=1
gpgcheck=1
gpgkey=https://kopia.io/signing-key
EOF""",
"yum install -y kopia",
]
)
+1 -1
View File
@@ -33,7 +33,7 @@ from typing import Dict, Optional, Tuple
import psutil
from .constants import RAM_WORKER_THRESHOLDS
from ..helpers.constants import RAM_WORKER_THRESHOLDS
logger = logging.getLogger(__name__)
+11 -13
View File
@@ -64,21 +64,19 @@
# - First-run authentication prompts
repository_path = /backup/kopia-repository
# Repository password - CRITICAL: SAVE THIS PASSWORD!
# - Required for ALL restore operations
# - Auto-generated if CHANGE_ME_TO_A_SECURE_PASSWORD on first run
# - Can be overridden by environment: KOPIA_PASSWORD
# - Store securely (password manager, vault, etc.)
# Repository password
# Default: kopia-docka (INSECURE - change after 'kopi-docka init'!)
#
# SECURITY NOTE: Password is stored in plaintext here!
# For production, migrate to secure storage:
# 1. systemd-creds: systemd-creds encrypt --name=kopia_password
# 2. Environment: export KOPIA_PASSWORD=your-password
# 3. Docker secret: docker secret create kopia_password
# After migration, replace password value with: USE_EXTERNAL_SECRET
# After initialization, change with:
# kopi-docka change-password
#
# WARNING: This will be replaced with auto-generated password on first run
password = CHANGE_ME_TO_A_SECURE_PASSWORD
# This will store the password securely in:
# - systemd-creds (encrypted, recommended)
# - Or plaintext file with chmod 600 (fallback)
#
# The config will then reference the secure storage location instead
# of containing the password directly.
password = kopia-docka
# Kopia profile name - allows multiple Kopia installations
# Default 'kopi-docka' isolates from other Kopia usage
+8
View File
@@ -0,0 +1,8 @@
#!/home/tzeroadmin/projects/kopi-docka/venv/bin/python3.12
# -*- coding: utf-8 -*-
import re
import sys
from kopi_docka.__main__ import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())
+8
View File
@@ -0,0 +1,8 @@
#!/home/tzeroadmin/projects/kopi-docka/venv/bin/python3.12
# -*- coding: utf-8 -*-
import re
import sys
from kopi_docka.cores.service_manager import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())
@@ -0,0 +1 @@
import __editable___kopi_docka_2_0_0_finder; __editable___kopi_docka_2_0_0_finder.install()
@@ -0,0 +1,85 @@
from __future__ import annotations
import sys
from importlib.machinery import ModuleSpec, PathFinder
from importlib.machinery import all_suffixes as module_suffixes
from importlib.util import spec_from_file_location
from itertools import chain
from pathlib import Path
MAPPING: dict[str, str] = {'kopi_docka': '/home/tzeroadmin/projects/kopi-docka/kopi_docka'}
NAMESPACES: dict[str, list[str]] = {}
PATH_PLACEHOLDER = '__editable__.kopi_docka-2.0.0.finder' + ".__path_hook__"
class _EditableFinder: # MetaPathFinder
@classmethod
def find_spec(cls, fullname: str, path=None, target=None) -> ModuleSpec | None: # type: ignore
# Top-level packages and modules (we know these exist in the FS)
if fullname in MAPPING:
pkg_path = MAPPING[fullname]
return cls._find_spec(fullname, Path(pkg_path))
# Handle immediate children modules (required for namespaces to work)
# To avoid problems with case sensitivity in the file system we delegate
# to the importlib.machinery implementation.
parent, _, child = fullname.rpartition(".")
if parent and parent in MAPPING:
return PathFinder.find_spec(fullname, path=[MAPPING[parent]])
# Other levels of nesting should be handled automatically by importlib
# using the parent path.
return None
@classmethod
def _find_spec(cls, fullname: str, candidate_path: Path) -> ModuleSpec | None:
init = candidate_path / "__init__.py"
candidates = (candidate_path.with_suffix(x) for x in module_suffixes())
for candidate in chain([init], candidates):
if candidate.exists():
return spec_from_file_location(fullname, candidate)
return None
class _EditableNamespaceFinder: # PathEntryFinder
@classmethod
def _path_hook(cls, path) -> type[_EditableNamespaceFinder]:
if path == PATH_PLACEHOLDER:
return cls
raise ImportError
@classmethod
def _paths(cls, fullname: str) -> list[str]:
paths = NAMESPACES[fullname]
if not paths and fullname in MAPPING:
paths = [MAPPING[fullname]]
# Always add placeholder, for 2 reasons:
# 1. __path__ cannot be empty for the spec to be considered namespace.
# 2. In the case of nested namespaces, we need to force
# import machinery to query _EditableNamespaceFinder again.
return [*paths, PATH_PLACEHOLDER]
@classmethod
def find_spec(cls, fullname: str, target=None) -> ModuleSpec | None: # type: ignore
if fullname in NAMESPACES:
spec = ModuleSpec(fullname, None, is_package=True)
spec.submodule_search_locations = cls._paths(fullname)
return spec
return None
@classmethod
def find_module(cls, _fullname) -> None:
return None
def install():
if not any(finder == _EditableFinder for finder in sys.meta_path):
sys.meta_path.append(_EditableFinder)
if not NAMESPACES:
return
if not any(hook == _EditableNamespaceFinder._path_hook for hook in sys.path_hooks):
# PathEntryFinder is needed to create NamespaceSpec without private APIS
sys.path_hooks.append(_EditableNamespaceFinder._path_hook)
if PATH_PLACEHOLDER not in sys.path:
sys.path.append(PATH_PLACEHOLDER) # Used just to trigger the path hook
@@ -0,0 +1,50 @@
Metadata-Version: 2.4
Name: kopi-docka
Version: 2.0.0
Summary: Robust cold backups for Docker environments using Kopia
Home-page: https://github.com/TZERO78/kopi-docka
Author: Markus F. (TZERO78) & Contributors
Author-email:
License: MIT
Project-URL: Source, https://github.com/TZERO78/kopi-docka
Project-URL: Issues, https://github.com/TZERO78/kopi-docka/issues
Project-URL: Documentation, https://github.com/TZERO78/kopi-docka#readme
Keywords: docker backup kopia volumes cold-backup systemd restore disaster-recovery
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: System Administrators
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: System :: Archiving :: Backup
Classifier: Topic :: System :: Systems Administration
Classifier: Topic :: Utilities
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: psutil>=5.9.0
Requires-Dist: typer>=0.9.0
Provides-Extra: systemd
Requires-Dist: systemd-python>=234; extra == "systemd"
Provides-Extra: dev
Requires-Dist: pytest>=7.0.0; extra == "dev"
Requires-Dist: pytest-cov>=3.0.0; extra == "dev"
Requires-Dist: black>=22.0.0; extra == "dev"
Requires-Dist: flake8>=4.0.0; extra == "dev"
Requires-Dist: mypy>=0.950; extra == "dev"
Dynamic: author
Dynamic: classifier
Dynamic: description-content-type
Dynamic: home-page
Dynamic: keywords
Dynamic: license
Dynamic: license-file
Dynamic: project-url
Dynamic: provides-extra
Dynamic: requires-dist
Dynamic: requires-python
Dynamic: summary
@@ -0,0 +1,14 @@
../../../bin/kopi-docka,sha256=ST2EHsYcSz7c3APsxRNzEKhRUE63MjIDlthZ8Hwsa-M,258
../../../bin/kopi-docka-service,sha256=KgAcpFuXGHXlu137Oi8euIvhHW_cSkXbFdV8itSKth4,271
__editable__.kopi_docka-2.0.0.pth,sha256=66NBGk6OO0K4oBZXjCYXyswXVJByBVyhnYcVbS_r1-s,91
__editable___kopi_docka_2_0_0_finder.py,sha256=MTgd3nrsiz-IkzNA8HpE19NsUssejIHwlwIPIMhUV8s,3404
__pycache__/__editable___kopi_docka_2_0_0_finder.cpython-312.pyc,,
kopi_docka-2.0.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
kopi_docka-2.0.0.dist-info/METADATA,sha256=l5Yki02FUjpbaVMSFRjMWomusdk_l2L6PUOwcyfkcmQ,1870
kopi_docka-2.0.0.dist-info/RECORD,,
kopi_docka-2.0.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
kopi_docka-2.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
kopi_docka-2.0.0.dist-info/direct_url.json,sha256=bZYuq3dFUspokKZKT8ctpEmW58KA1D3LByOAyoX9uqc,86
kopi_docka-2.0.0.dist-info/entry_points.txt,sha256=-1Y4w9IoOEDJj2RmZkupKEWqL59ViFTVAtxy36NBFSg,115
kopi_docka-2.0.0.dist-info/licenses/LICENSE,sha256=jS33TuTJH-WdJuadPRVoWs-VGWWUJO2LscvxE1EFU7M,1076
kopi_docka-2.0.0.dist-info/top_level.txt,sha256=HJBLjhNsI953XJQI9ISyd5y1D6OaWVQguQ185bpDlTQ,11
@@ -0,0 +1,5 @@
Wheel-Version: 1.0
Generator: setuptools (80.9.0)
Root-Is-Purelib: true
Tag: py3-none-any
@@ -0,0 +1 @@
{"dir_info": {"editable": true}, "url": "file:///home/tzeroadmin/projects/kopi-docka"}
@@ -0,0 +1,3 @@
[console_scripts]
kopi-docka = kopi_docka.__main__:main
kopi-docka-service = kopi_docka.cores.service_manager:main
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Markus F. (TZERO78)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1 @@
kopi_docka