mirror of
https://github.com/TZERO78/kopi-docka.git
synced 2026-06-19 07:37:12 +00:00
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:
@@ -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
|
||||
@@ -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",
|
||||
),
|
||||
):
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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,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',
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+8
@@ -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())
|
||||
Executable
+8
@@ -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 @@
|
||||
pip
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user