diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fbd0003 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env.* \ No newline at end of file diff --git a/README.md b/README.md index 7881249..66cb487 100644 --- a/README.md +++ b/README.md @@ -1 +1,61 @@ -# Travel Backup Script +# Wasabi Restic Backup Script + +This script automates backups to a [Wasabi](https://wasabi.com/) (S3-compatible) bucket using [restic](https://restic.net/). +It supports environment-based secrets (via `.env` + [python-dotenv](https://pypi.org/project/python-dotenv/)), CLI overrides, and dry-run/verbose modes. + +--- + +## Features + +- Backup any file or directory to Wasabi S3 storage with restic. +- Secrets loaded from a `.env` file (no need to type passwords on the CLI). +- CLI arguments override `.env` and system environment variables. +- Verbose and dry-run modes for debugging. +- Environment variable redaction in output (so logs won’t leak secrets). + +--- + +## Requirements + +- Python 3.8+ +- [restic](https://restic.net/) installed and in your `PATH` +- `python-dotenv` installed: + ```bash + pip install python-dotenv + + +## Initialize the Repository (first run only) + +restic -r s3:s3.[REGION].wasabisys.com/[BUCKET_NAME]/[PREFIX] init + + +## CLI Options + +Option Description +--source, -s Source path to back up (required) +--bucket, -b Wasabi bucket name (required unless --repository used) +--endpoint, -e S3 endpoint (default: s3.wasabisys.com or from env) +--prefix, -p Path inside bucket (default: travel-backup) +--access-key Wasabi access key (overrides env/.env) +--secret-key Wasabi secret key (overrides env/.env) +--password, -P Restic password (overrides env/.env) +--repository, -r Full restic repository string (overrides bucket/endpoint/prefix) +--env-file Path to .env file (default: .env) +--dry-run Show command and env but do not execute +--verbose Show extra debug info + +## Example Dry Run +python travel-backup-backup.py --source /etc --bucket my-bucket --dry-run --verbose + + +## Run the Backup + +python backup.py --source /path/to/data --bucket my-bucket + +**Example with overrides:** +python backup.py \ + --source ~/Documents \ + --bucket my-bucket \ + --prefix laptop-backups \ + --verbose + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3e338bf --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +python-dotenv \ No newline at end of file diff --git a/travel-backup-script.py b/travel-backup-script.py new file mode 100644 index 0000000..6b8b5a3 --- /dev/null +++ b/travel-backup-script.py @@ -0,0 +1,136 @@ +import argparse +import subprocess +import os +import sys +from shutil import which +from dotenv import load_dotenv + + +def build_repo(endpoint: str, bucket: str, prefix: str) -> str: + """ + Build a restic S3 repository URL in the correct format: + s3:ENDPOINT/BUCKET/PREFIX + """ + parts = [f"s3:{endpoint.rstrip('/')}"] + if bucket: + parts.append(bucket.strip("/")) + if prefix: + parts.append(prefix.strip("/")) + return "/".join(parts) + + +def redact_env(env: dict) -> dict: + """Return a copy of env with secrets redacted.""" + redacted = env.copy() + for k in ("AWS_SECRET_ACCESS_KEY", "RESTIC_PASSWORD", "AWS_SECRET_KEY"): + if k in redacted and redacted[k]: + redacted[k] = "***REDACTED***" + return redacted + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Backup files to a Wasabi (S3) bucket using restic." + ) + parser.add_argument("--source", "-s", required=True, + help="Source path to back up (file or directory)") + parser.add_argument("--bucket", "-b", + help="Wasabi bucket name (can be omitted if --repository is used)") + parser.add_argument("--endpoint", "-e", + default=os.environ.get("WASABI_ENDPOINT", "s3.wasabisys.com"), + help="Wasabi S3 endpoint host (default: s3.wasabisys.com or WASABI_ENDPOINT env)") + parser.add_argument("--prefix", "-p", + default=os.environ.get("RESTIC_PREFIX", "travel-backup"), + help="Prefix/path inside the bucket (default: travel-backup)") + parser.add_argument("--access-key", + help="Wasabi access key (falls back to .env or AWS_ACCESS_KEY_ID env)") + parser.add_argument("--secret-key", + help="Wasabi secret key (falls back to .env or AWS_SECRET_ACCESS_KEY env)") + parser.add_argument("--password", "-P", + help="Restic repository password (falls back to .env or RESTIC_PASSWORD env)") + parser.add_argument("--repository", "-r", + help="Full restic repository string (overrides bucket+endpoint+prefix)") + parser.add_argument("--dry-run", action="store_true", + help="Print the restic command and environment without running it") + parser.add_argument("--verbose", action="store_true", help="Show more output") + parser.add_argument("--env-file", default=".env", + help="Path to .env file (default: .env)") + + args = parser.parse_args(argv) + + # Load env file (if it exists) + if os.path.exists(args.env_file): + load_dotenv(args.env_file) + + # Validate source path + src = args.source + if not os.path.exists(src): + print(f"error: source path does not exist: {src}") + return 2 + + # Build repository string + if args.repository: + repo = args.repository + else: + if not args.bucket: + print("error: --bucket is required when --repository is not provided") + return 2 + repo = build_repo(args.endpoint, args.bucket, args.prefix) + + # Prepare environment + env = os.environ.copy() + + # Apply CLI > env > .env precedence + if args.password: + env["RESTIC_PASSWORD"] = args.password + if args.access_key: + env["AWS_ACCESS_KEY_ID"] = args.access_key + if args.secret_key: + env["AWS_SECRET_ACCESS_KEY"] = args.secret_key + + # Validation: ensure critical vars exist + missing = [] + for var in ("RESTIC_PASSWORD", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"): + if not env.get(var): + missing.append(var) + if missing: + print(f"error: missing required environment variables: {', '.join(missing)}") + print(f"tip: define them in {args.env_file} or pass as CLI args") + return 2 + + # Ensure restic is installed + if which("restic") is None: + print("error: restic binary not found in PATH. Install restic and try again.") + return 3 + + cmd = ["restic", "-r", repo, "backup", src] + + if args.verbose or args.dry_run: + print("Repository:", repo) + print("Command:", " ".join(cmd)) + print("Environment (redacted):") + for k, v in redact_env(env).items(): + if k.startswith("AWS_") or k.startswith("RESTIC_") or k in ("WASABI_ENDPOINT",): + print(f" {k}={v}") + + if args.dry_run: + print("Dry-run mode: not running restic.") + return 0 + + # Run restic + try: + result = subprocess.run(cmd, env=env, capture_output=True, text=True) + except Exception as exc: + print("error: failed to run restic:", exc) + return 4 + + if result.stdout: + print(result.stdout) + if result.stderr: + print(result.stderr, file=sys.stderr) + + return result.returncode + + +if __name__ == "__main__": + sys.exit(main())