├── modules
├── __init__.py
├── helper.py
├── providers
│ ├── s3.py
│ ├── glacier.py
│ ├── copy.py
│ ├── __init__.py
│ ├── scp.py
│ └── sftp.py
├── glacier.py
├── fileutils.py
├── aws.py
└── configuration.py
├── requirements.txt
├── extras
├── testsuite
│ ├── .gitignore
│ ├── tests
│ │ ├── 001 Initial backup
│ │ ├── 003 Delete one file
│ │ ├── 015 Max size
│ │ ├── 007 Delete the new file again
│ │ ├── 009 Move file
│ │ ├── 002 Change on file
│ │ ├── 006 Create new file with same name as deleted
│ │ ├── 005 Delete one file and change another
│ │ ├── 008 Test prefix config
│ │ ├── 004 Run without changes
│ │ ├── 012 Copy file and use encrypted manifest
│ │ ├── 014 Handle legacy hashing
│ │ ├── 010 Move file and copy the moved file
│ │ ├── 011 Remove two files and generate backup with filelist and verify checksums
│ │ └── 013 Test new hash method
│ ├── test_key.private
│ ├── test_key.public
│ ├── README.md
│ ├── test_restore.sh
│ └── test_backup.sh
├── README.md
├── iceshelf.service
├── iceshelf-cronjob
└── analog-key.sh
├── exclusions
├── README.md
└── dovecot.excl
├── providers
├── s3.md
├── glacier.md
├── scp.md
├── sftp.md
└── cp.md
├── .gitignore
├── .github
└── workflows
│ └── python-app.yml
├── database.schema.json
├── TODO.md
├── README.iceshelf-retrieve.md
├── DATABASE.md
├── README.iceshelf-restore.md
├── iceshelf-inspect
├── iceshelf.sample.conf
├── iceshelf-retrieve
├── iceshelf-restore
├── LICENSE
├── README.md
└── iceshelf
/modules/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | python-gnupg
2 | awscli
3 | boto3
4 |
--------------------------------------------------------------------------------
/extras/testsuite/.gitignore:
--------------------------------------------------------------------------------
1 | done/
2 | content/
3 | data/
4 | tmp/
5 | /config_*
6 |
--------------------------------------------------------------------------------
/extras/testsuite/tests/001 Initial backup:
--------------------------------------------------------------------------------
1 | # Test 1
2 | #
3 | runTest "Initial backup" "" "" regular
4 |
--------------------------------------------------------------------------------
/extras/testsuite/test_key.private:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mrworf/iceshelf/HEAD/extras/testsuite/test_key.private
--------------------------------------------------------------------------------
/extras/testsuite/test_key.public:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mrworf/iceshelf/HEAD/extras/testsuite/test_key.public
--------------------------------------------------------------------------------
/extras/testsuite/tests/003 Delete one file:
--------------------------------------------------------------------------------
1 | rm content/b
2 | runTest "Delete one file" "" "" regular "Only in compare/content: b"
3 |
4 |
--------------------------------------------------------------------------------
/extras/testsuite/tests/015 Max size:
--------------------------------------------------------------------------------
1 | # Change a file
2 |
3 | OPT_SUCCESSRET=3
4 | runTest "Show files not fitting max size" "nofit" "" maxsize
5 |
--------------------------------------------------------------------------------
/extras/testsuite/tests/007 Delete the new file again:
--------------------------------------------------------------------------------
1 | rm content/b
2 | runTest "Delete the new file again" "" "" regular "Only in compare/content: b"
3 |
4 |
--------------------------------------------------------------------------------
/extras/testsuite/tests/009 Move file:
--------------------------------------------------------------------------------
1 | mv content/d content/dd
2 | runTest "Moved file" "" "" regular "Only in compare/content: d
3 | Only in content: dd"
4 |
5 |
--------------------------------------------------------------------------------
/extras/testsuite/tests/002 Change on file:
--------------------------------------------------------------------------------
1 | # Change a file
2 |
3 | dd if=/dev/urandom of=content/a bs=1024 count=123 2>/dev/null
4 | runTest "Change one file" "" "" regular
5 |
--------------------------------------------------------------------------------
/exclusions/README.md:
--------------------------------------------------------------------------------
1 | This folder holds ready-made exclusion files which can be easily
2 | added to your configuration file to avoid spending time doing it
3 | yourself.
4 |
5 | Feel free to expand upon this.
6 |
--------------------------------------------------------------------------------
/extras/testsuite/tests/006 Create new file with same name as deleted:
--------------------------------------------------------------------------------
1 |
2 | dd if=/dev/urandom of=content/b bs=1024 count=243 2>/dev/null
3 | runTest "Create new file with same name as deleted file" "" "" regular
4 |
--------------------------------------------------------------------------------
/extras/testsuite/tests/005 Delete one file and change another:
--------------------------------------------------------------------------------
1 | rm content/c
2 | dd if=/dev/urandom of=content/a bs=1024 count=123 2>/dev/null
3 | runTest "Delete one file and change another" "" "" regular "Only in compare/content: c"
4 |
5 |
--------------------------------------------------------------------------------
/exclusions/dovecot.excl:
--------------------------------------------------------------------------------
1 | # This exclusion file will ignore all unnecessary files in dovecot when
2 | # performing a backup of your mail.
3 | #
4 | ?dovecot.index
5 | ?dovecot.list.index
6 | *dovecot-uidlist
7 | *dovecot-keywords
8 | *.dovecot.lda-dupes
9 |
--------------------------------------------------------------------------------
/extras/testsuite/tests/008 Test prefix config:
--------------------------------------------------------------------------------
1 | runTest "Test prefix config" \
2 | "skip" \
3 | '
4 | function posttest() {
5 | ls -laR done/ | grep prefix > /dev/null
6 | if [ $? -ne 0 ]; then
7 | echo "Prefix not working"
8 | return 1
9 | fi
10 | }
11 | ' \
12 | prefix "" --full
13 |
14 |
--------------------------------------------------------------------------------
/extras/testsuite/tests/004 Run without changes:
--------------------------------------------------------------------------------
1 | runTest "Run without any changes" \
2 | "skip" \
3 | '
4 | function pretest() {
5 | if ! ${ICESHELF} --changes config_regular; then
6 | echo "ERROR: Changes detected when there should not be any"
7 | return 255
8 | fi
9 | }
10 | ' \
11 | regular ""
12 |
13 |
--------------------------------------------------------------------------------
/extras/testsuite/tests/012 Copy file and use encrypted manifest:
--------------------------------------------------------------------------------
1 | if [[ "$VARIANT" == *"encrypted"* ]]; then
2 | cp content/q content/qq
3 | runTest "Copy file and use encrypted manifest" "" '
4 | function posttest() {
5 | if ! ls -1 $(lastFolder) | grep json.gpg ; then
6 | echo "No encrypted json was found"
7 | return 1
8 | fi
9 | }
10 | ' encryptmani ""
11 | fi
12 |
--------------------------------------------------------------------------------
/extras/testsuite/tests/014 Handle legacy hashing:
--------------------------------------------------------------------------------
1 | # This strips all hash identifiers from the database and then runs
2 | # a changes test. It should not detect any changes.
3 |
4 | # Strip sha indicator from database
5 | cat data/checksum.json | sed -r 's/:sha[0-9]+//g' > data/checksum.json.tmp
6 | mv data/checksum.json.tmp data/checksum.json
7 |
8 | runTest "Handle legacy file" "nochange" '' regular
9 |
--------------------------------------------------------------------------------
/providers/s3.md:
--------------------------------------------------------------------------------
1 | # Amazon S3 Provider
2 |
3 | Uses `aws s3 cp` to upload files to an S3 bucket.
4 |
5 | ## Arguments
6 | - `bucket` – name of the target S3 bucket.
7 | - `prefix` – optional prefix inside the bucket.
8 |
9 | ## Pros
10 | - Objects can be stored in immutable storage classes (e.g. Glacier or Glacier Deep Archive) which protects against ransomware.
11 | - Highly durable and available.
12 |
13 | ## Cons
14 | - Requires the AWS CLI and credentials.
15 | - Transfer costs may apply.
16 |
--------------------------------------------------------------------------------
/providers/glacier.md:
--------------------------------------------------------------------------------
1 | # Glacier Provider
2 |
3 | Stores backups in Amazon Glacier using the `aws` CLI.
4 |
5 | ## Arguments
6 | - `vault` – name of the Glacier vault.
7 | - `threads` – optional number of upload threads.
8 |
9 | ## Pros
10 | - Data is stored immutably which offers strong protection against ransomware.
11 | - Very low storage cost for large archives.
12 |
13 | ## Cons
14 | - Retrieval can take many hours and incurs additional cost.
15 | - Requires AWS CLI and configured credentials.
16 |
--------------------------------------------------------------------------------
/providers/scp.md:
--------------------------------------------------------------------------------
1 | # SCP Provider
2 |
3 | Transfers files using the `scp` command.
4 |
5 | ## Arguments
6 | - `user` – user to connect as.
7 | - `host` – remote host.
8 | - `dest` – remote directory for the uploaded files.
9 | - `key` – optional SSH private key for authentication.
10 | - `password` – optional password or passphrase (requires `sshpass`).
11 |
12 | ## Pros
13 | - Easy to use and available on most systems.
14 |
15 | ## Cons
16 | - Requires SSH credentials.
17 | - Does not resume interrupted uploads.
18 |
--------------------------------------------------------------------------------
/providers/sftp.md:
--------------------------------------------------------------------------------
1 | # SFTP Provider
2 |
3 | Uploads backup files using the `sftp` command.
4 |
5 | ## Arguments
6 | - `user` – user to connect as.
7 | - `host` – remote host.
8 | - `dest` – remote directory where files are uploaded.
9 | - `key` – optional SSH private key.
10 | - `password` – optional password or passphrase (requires `sshpass`).
11 |
12 | ## Pros
13 | - Works over SSH and is widely supported.
14 |
15 | ## Cons
16 | - Requires SSH access and credentials.
17 | - Transfer speed may be limited by network latency.
18 |
--------------------------------------------------------------------------------
/providers/cp.md:
--------------------------------------------------------------------------------
1 | # Copy Provider
2 |
3 | Copies backup files to a local destination using the `cp` command. Useful when
4 | keeping archives on the same system or on a mounted network share.
5 |
6 | ## Arguments
7 | - `dest` – path to the target directory where files will be placed.
8 | - `create` – set to `yes` to create `dest` if it does not exist.
9 |
10 | ## Pros
11 | - Simple and uses basic tools available on any system.
12 | - No network transfer required.
13 |
14 | ## Cons
15 | - Provides no remote storage or redundancy.
16 |
--------------------------------------------------------------------------------
/extras/testsuite/tests/010 Move file and copy the moved file:
--------------------------------------------------------------------------------
1 | ### This has has a latent issue, iceshelf doesn't do deduplication which means
2 | ### that sometimes it catches the eee as a rename instead of ee.
3 | ### To solve this, we use regex to allow for both cases
4 |
5 | mv content/e content/ee || echo "ERROR: moving content/e to content/ee"
6 | cp content/ee content/eee || echo "ERROR: copying content/ee to content/eee"
7 | runTest "Move file and copy the same as well" "" "" regular '^Only in compare/content: e
8 | Only in content: eee?$'
9 |
10 |
--------------------------------------------------------------------------------
/extras/testsuite/tests/011 Remove two files and generate backup with filelist and verify checksums:
--------------------------------------------------------------------------------
1 | rm content/ee content/eee
2 | runTest "Remove two files and generate backup with filelist and verify checksums" "" '
3 | function posttest() {
4 | pushd $(lastFolder)
5 | # Make sure we do not get tripped by signed version of file list
6 | gpg -o filelist.lst -d *.lst.asc 2>/dev/null && rm *.lst.asc
7 | if ! shasum -c *.lst ; then
8 | echo "file list checksum failed"
9 | return 1
10 | fi
11 | popd
12 | }
13 | ' filelist "Only in compare/content: ee
14 | Only in compare/content: eee"
15 |
--------------------------------------------------------------------------------
/extras/README.md:
--------------------------------------------------------------------------------
1 | # Other
2 |
3 | This folder holds some goodies which might be useful for you.
4 |
5 | ## iceshelf.service
6 |
7 | A systemd service file for running iceshelf
8 |
9 | ## analog-key.sh
10 |
11 | A shell script which can transfer a GPG key into a printable form (as multiple QR codes) suitable for longterm backup. It can also take a scanned copy and restore the digital key. Finally it also has a validate mode where it simple exports, imports and confirms that the reconstituted key is identical to the one in GPGs keychain.
12 |
13 | It's HIGHLY recommended that you make copies of the key used for iceshelf backups, since without it, any and all backed up content is lost.
14 |
--------------------------------------------------------------------------------
/modules/helper.py:
--------------------------------------------------------------------------------
1 |
2 | def formatTime(seconds):
3 | if seconds < 60:
4 | # ss
5 | return "%ds" % seconds
6 | elif seconds < 3600:
7 | # mm:ss
8 | return "%dm %02ds" % (seconds / 60, seconds % 60)
9 | elif seconds < 86400:
10 | # hh:mm:ss
11 | return "%dh %02dm %02ds" % (seconds / 3600, (seconds % 3600) / 60, seconds % 60)
12 | else:
13 | # dd:hh:mm:ss
14 | return "%dd %02dh %02dm %02ds" % (seconds / 86400, (seconds % 86400) / 3600, (seconds % 3600) / 60, seconds % 60)
15 |
16 | def formatSize(size):
17 | return formatNumber(size, [" bytes", "K", "M", "G", "T"])
18 |
19 | def formatSpeed(bps):
20 | return formatNumber(bps, [" bytes/s", "K/s", "M/s", "G/s", "T/s"])
21 |
22 | def formatNumber(number, units):
23 | i = 0
24 | while number >= 1024 and i < len(units):
25 | number /= 1024
26 | i += 1
27 | return "%.1d%s" % (number, units[i])
28 |
--------------------------------------------------------------------------------
/extras/testsuite/tests/013 Test new hash method:
--------------------------------------------------------------------------------
1 | # Add a new file, change an old file
2 | # Run the backup using sha256 instead of sha1 and make sure these
3 | # files now have a sha256 entry.
4 |
5 | # Generate a 10k file that doesn't exist
6 | dd 2>/dev/null if=/dev/urandom of=content/qqq bs=1024 count=10
7 | # Get the hash of that
8 | NEW="$(sha256sum content/qqq | cut -d " " -f 1):sha256"
9 |
10 | # Generate a 10k file that does exist
11 | dd 2>/dev/null if=/dev/urandom of=content/q bs=1024 count=10
12 | OLD="$(sha256sum content/q | cut -d " " -f 1):sha256"
13 |
14 | runTest "Test change of hash config" "" \
15 | '
16 | function posttest() {
17 | grep "$NEW" data/checksum.json
18 | if [ $? -ne 0 ]; then
19 | echo "Hash did not change for content/qqq"
20 | return 1
21 | fi
22 | grep "$OLD" data/checksum.json
23 | if [ $? -ne 0 ]; then
24 | echo "Hash did not change for content/q"
25 | return 1
26 | fi
27 | }
28 | ' \
29 | changehash
30 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 |
5 | # C extensions
6 | *.so
7 |
8 | # Distribution / packaging
9 | .Python
10 | env/
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | *.egg-info/
23 | .installed.cfg
24 | *.egg
25 |
26 | # PyInstaller
27 | # Usually these files are written by a python script from a template
28 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
29 | *.manifest
30 | *.spec
31 |
32 | # Installer logs
33 | pip-log.txt
34 | pip-delete-this-directory.txt
35 |
36 | # Unit test / coverage reports
37 | htmlcov/
38 | .tox/
39 | .coverage
40 | .coverage.*
41 | .cache
42 | nosetests.xml
43 | coverage.xml
44 | *,cover
45 |
46 | # Translations
47 | *.mo
48 | *.pot
49 |
50 | # Django stuff:
51 | *.log
52 |
53 | # Sphinx documentation
54 | docs/_build/
55 |
56 | # PyBuilder
57 | target/
58 |
59 | backup/
60 |
61 |
--------------------------------------------------------------------------------
/extras/iceshelf.service:
--------------------------------------------------------------------------------
1 | [Unit]
2 | Description=incremental backups using Amazon Glacier
3 | Documentation=https://github.com/mrworf/iceshelf
4 | After=network.target
5 |
6 | [Service]
7 | # Configure your user here
8 | User=iceshelf
9 | Group=iceshelf
10 |
11 | # Configure paths to iceshelf and config here
12 | Environment="ICESHELF=/home/iceshelf/iceshelf/iceshelf"
13 | Environment="CONFIG=/home/iceshelf/backup.conf"
14 |
15 | Type=simple
16 | ExecStart=/usr/bin/python3 ${ICESHELF} ${CONFIG}
17 |
18 | # Restart if not finished
19 | RestartForceExitStatus=10
20 | SuccessExitStatus=10
21 |
22 | PrivateTmp=true
23 | NoNewPrivileges=true
24 | PrivateDevices=true
25 | # mounts read-only: /usr, /boot and /etc
26 | ProtectSystem=full
27 |
28 | # Everything is read-only by default
29 | ReadOnlyDirectories=/
30 | # Allow writing to these directories: (GnuPG needs to lock its keyrings, add tmp dir, done dir and data dir)
31 | ReadWriteDirectories=/home/iceshelf/.gnupg /home/iceshelf/backup/inprogress /home/iceshelf/backup/metadata /home/iceshelf/backup/done
32 | # Don't allow access to these directories: (GnuPG needs /dev)
33 | InaccessibleDirectories=-/root -/opt -/run -/sbin
34 |
35 | # -20 = highest, 19 lowest
36 | Nice=13
37 | # none, realtime, best-effort, idle
38 | IOSchedulingClass=idle
39 | # 0 = highest, 7 = lowest
40 | IOSchedulingPriority=6
41 |
42 |
43 | [Install]
44 | WantedBy=multi-user.target
45 |
--------------------------------------------------------------------------------
/modules/providers/s3.py:
--------------------------------------------------------------------------------
1 | import os
2 | import subprocess
3 | import logging
4 | from . import BackupProvider, _which
5 |
6 | class S3Provider(BackupProvider):
7 | name = 's3'
8 | def verify(self):
9 | self.bucket = self.options.get('bucket')
10 | self.prefix = self.options.get('prefix', '')
11 | if not self.bucket:
12 | logging.error('s3 provider requires "bucket"')
13 | return False
14 | if _which('aws') is None:
15 | logging.error('aws command not found')
16 | return False
17 | return True
18 |
19 | def storage_id(self):
20 | prefix = f'/{self.prefix}' if self.prefix else ''
21 | return f's3:{self.bucket}{prefix}'
22 |
23 | def upload_files(self, files):
24 | for f in files:
25 | key = os.path.join(self.prefix, os.path.basename(f))
26 | cmd = ['aws', 's3', 'cp', f, f's3://{self.bucket}/{key}']
27 | try:
28 | p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
29 | out, err = p.communicate()
30 | if p.returncode != 0:
31 | logging.error('aws s3 cp failed: %s', err)
32 | return False
33 | except Exception:
34 | logging.exception('aws s3 cp failed for %s', f)
35 | return False
36 | return True
37 |
--------------------------------------------------------------------------------
/.github/workflows/python-app.yml:
--------------------------------------------------------------------------------
1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python
2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
3 |
4 | name: Validate iceshelf
5 |
6 | on:
7 | push:
8 | branches: [ "master" ]
9 | pull_request:
10 | branches: [ "master" ]
11 |
12 | permissions:
13 | contents: read
14 |
15 | jobs:
16 | build:
17 |
18 | runs-on: ubuntu-latest
19 |
20 | steps:
21 | - uses: actions/checkout@v3
22 | - name: Set up Python 3
23 | uses: actions/setup-python@v3
24 | with:
25 | python-version: "3.10"
26 | - name: Install dependencies
27 | run: |
28 | sudo apt-get install par2
29 | sudo apt-get install gnupg
30 | python -m pip install --upgrade pip
31 | pip install pytest pylint
32 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
33 | - name: Lint with pylint
34 | run: |
35 | pylint modules iceshelf iceshelf-inspect iceshelf-restore iceshelf-retrieve --errors-only
36 | - name: Run backup tests
37 | run: |
38 | bash extras/testsuite/test_backup.sh insecure
39 | - name: Run restore tests
40 | run: |
41 | bash extras/testsuite/test_restore.sh
42 | #- name: Test with pytest
43 | # run: |
44 | # pytest
45 |
--------------------------------------------------------------------------------
/modules/providers/glacier.py:
--------------------------------------------------------------------------------
1 | import os
2 | import logging
3 | from . import BackupProvider, _which
4 | from modules import aws
5 |
6 | class GlacierProvider(BackupProvider):
7 | """Upload archives to AWS Glacier using the aws CLI."""
8 | name = 'glacier'
9 |
10 | def verify(self):
11 | self.vault = self.options.get('vault')
12 | self.threads = int(self.options.get('threads', 4))
13 | if not self.vault:
14 | logging.error('glacier provider requires "vault"')
15 | return False
16 | if _which('aws') is None:
17 | logging.error('aws command not found')
18 | return False
19 | if not aws.isConfigured():
20 | return False
21 | return True
22 |
23 | def storage_id(self):
24 | return f'glacier:{self.vault}'
25 |
26 | def get_vault(self):
27 | return self.vault
28 |
29 | def upload_files(self, files):
30 | cfg = {
31 | 'glacier-vault': self.vault,
32 | 'glacier-threads': self.threads,
33 | 'prepdir': os.path.dirname(files[0]) if files else ''
34 | }
35 | total = sum(os.path.getsize(f) for f in files)
36 | names = [os.path.basename(f) for f in files]
37 | # Ensure vault exists (createVault will no-op if it already exists)
38 | if not aws.createVault(cfg):
39 | return False
40 | return aws.uploadFiles(cfg, names, total)
41 |
--------------------------------------------------------------------------------
/database.schema.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "http://json-schema.org/draft-07/schema#",
3 | "title": "IceShelf Database",
4 | "type": "object",
5 | "required": ["dataset", "backups", "vault", "version", "timestamp"],
6 | "properties": {
7 | "dataset": {
8 | "type": "object",
9 | "additionalProperties": {
10 | "type": "object",
11 | "required": ["checksum", "memberof", "deleted"],
12 | "properties": {
13 | "checksum": {"type": "string"},
14 | "memberof": {"type": "array", "items": {"type": "string"}},
15 | "deleted": {"type": "array", "items": {"type": "string"}}
16 | }
17 | }
18 | },
19 | "backups": {
20 | "type": "object",
21 | "additionalProperties": {
22 | "type": "array",
23 | "items": {"type": "string"}
24 | }
25 | },
26 | "vault": {"type": "string"},
27 | "version": {
28 | "type": "array",
29 | "items": {"type": "integer"},
30 | "minItems": 3,
31 | "maxItems": 3
32 | },
33 | "moved": {
34 | "type": "object",
35 | "additionalProperties": {
36 | "type": "object",
37 | "required": ["reference", "original"],
38 | "properties": {
39 | "reference": {"type": "string"},
40 | "original": {"type": "string"}
41 | }
42 | }
43 | },
44 | "lastbackup": {"type": "string"},
45 | "timestamp": {"type": "number"}
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/modules/providers/copy.py:
--------------------------------------------------------------------------------
1 | import os
2 | import shutil
3 | import logging
4 | from . import BackupProvider, _which
5 |
6 | class CopyProvider(BackupProvider):
7 | name = 'cp'
8 | """Simple provider that copies files locally using cp."""
9 | def verify(self):
10 | dest = self.options.get('dest')
11 | if not dest:
12 | logging.error('copy provider requires "dest"')
13 | return False
14 | if not os.path.isdir(dest):
15 | if self.options.get('create', '').lower() in ['yes', 'true']:
16 | try:
17 | os.makedirs(dest, exist_ok=True)
18 | except Exception:
19 | logging.exception('Failed to create %s', dest)
20 | return False
21 | else:
22 | logging.error('Destination %s does not exist', dest)
23 | return False
24 | if _which('cp') is None:
25 | logging.error('cp command not found')
26 | return False
27 | self.dest = dest
28 | return True
29 |
30 | def storage_id(self):
31 | return f'cp:{self.dest}'
32 |
33 | def upload_files(self, files):
34 | for f in files:
35 | try:
36 | shutil.copy(f, os.path.join(self.dest, os.path.basename(f)))
37 | except Exception:
38 | logging.exception('Failed to copy %s', f)
39 | return False
40 | return True
41 |
--------------------------------------------------------------------------------
/TODO.md:
--------------------------------------------------------------------------------
1 | # TODO
2 |
3 | This file details what my goals are for this project, both in the short term and in the long term. It also details items which I intend to do but doesn't have a clear time plan.
4 |
5 | The list is intentionally kept vague to avoid over-promising and under-delivering :)
6 |
7 | ## Short term
8 | - Extend iceshelf to allow usage of alternate long-term storage solutions other than glacier
9 | - add hint if a backup is full instead of incremential
10 |
11 | ## Long term
12 | - Add testsuite coverage of iceshelf-restore
13 | - Detect duplication when using sha method (impossible with meta due to lack of details)
14 | - Move validation of exclusion rules to configuration parsing instead of during backup
15 |
16 | ## Anytime
17 | - Cleanup parameters
18 | - Add info about http://www.jabberwocky.com/software/paperkey/ to README.md
19 | - improve --modified output (min, max, etc)
20 | - add warning if one and the same file changes a lot
21 | - Add piece about "why encrypt" to README.md (ie, why I am so adamant about it). See second section in this file for current links about security until I get around to putting it in the README.md
22 | - Redo the "?bla" rule into a "*bla*" which makes more sense... But do we also need to support *bl*a* then? Probably
23 | - Detect missing key or wrong passphrase
24 |
25 | # Why use iceshelf with encryption?
26 |
27 | - http://arstechnica.com/tech-policy/2015/10/microsoft-wants-us-government-to-obey-eu-privacy-laws/
28 | - http://arstechnica.com/tech-policy/2015/10/apple-ceo-tim-cook-blasts-encryption-backdoors/
29 | - http://arstechnica.com/tech-policy/2015/10/judge-does-us-law-allow-feds-to-compel-apple-to-unlock-an-iphone/
30 |
31 |
--------------------------------------------------------------------------------
/modules/providers/__init__.py:
--------------------------------------------------------------------------------
1 | import shutil
2 | import logging
3 |
4 | class BackupProvider:
5 | """Base class for backup providers."""
6 |
7 | name = 'provider'
8 |
9 | def __init__(self, **options):
10 | self.options = options
11 |
12 | def verify(self):
13 | """Return True if the provider configuration is valid."""
14 | raise NotImplementedError
15 |
16 | def __str__(self):
17 | return self.name
18 |
19 | def storage_id(self):
20 | """Return a string describing where files are stored."""
21 | raise NotImplementedError
22 |
23 | def get_vault(self):
24 | """Return Glacier vault name if applicable, else None."""
25 | return None
26 |
27 | def upload_files(self, files):
28 | """Upload a list of files."""
29 | raise NotImplementedError
30 |
31 |
32 | def _which(program):
33 | return shutil.which(program)
34 |
35 | from . import sftp, s3, scp, copy, glacier
36 |
37 | PROVIDERS = {
38 | 'sftp': sftp.SFTPProvider,
39 | 's3': s3.S3Provider,
40 | 'scp': scp.SCPProvider,
41 | 'cp': copy.CopyProvider,
42 | 'glacier': glacier.GlacierProvider,
43 | }
44 |
45 | def get_provider(cfg):
46 | if not cfg or 'type' not in cfg:
47 | raise ValueError('Provider configuration missing type')
48 | t = cfg['type'].lower()
49 | cls = PROVIDERS.get(t)
50 | if not cls:
51 | raise ValueError('Unknown provider: %s' % t)
52 | opts = dict(cfg)
53 | opts.pop('type', None)
54 | provider = cls(**opts)
55 | if not provider.verify():
56 | logging.error('Provider verification failed for %s', t)
57 | return None
58 | return provider
59 |
--------------------------------------------------------------------------------
/extras/iceshelf-cronjob:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | #
3 | #
4 | SHOWSUCCESS=true
5 | USER=iceshelf
6 | CONFS=("/home/${USER}/backup.conf")
7 | EXEC=/home/${USER}/iceshelf/iceshelf
8 |
9 | # Allow you to override the defaults above without having to
10 | # edit this file.
11 | #
12 | if [ -f "/etc/default/iceshelf" ]; then
13 | source "/etc/default/iceshelf"
14 | fi
15 |
16 | ############# DO NOT CHANGE ANYTHING BELOW THIS POINT #####################
17 | #
18 | FINALRET=0
19 | for CONF in "${CONFS[@]}"; do
20 | TMPLOG=$(sudo -Hu ${USER} mktemp /tmp/iceshelf.log.XXXXX)
21 | RET=0
22 |
23 | if [ -z "$TMPLOG" ]; then
24 | echo "ERROR: User ${USER} does not exist" >&2
25 | exit 255
26 | fi
27 | if [ ! -f "$CONF" ]; then
28 | echo "ERROR: Configuration $CONF was not found" >&2
29 | exit 255
30 | fi
31 |
32 | # Avoid emails about stuff unless it did something
33 | sudo -Hu ${USER} ${EXEC} --changes --logfile $TMPLOG $CONF
34 | RET=$?
35 | if [ $RET -eq 1 ]; then
36 | # Changes detected, clear old log and do a real run
37 | echo -n >$TMPLOG ""
38 | sudo -Hu ${USER} ${EXEC} --logfile $TMPLOG $CONF
39 | RET=$?
40 | if $SHOWSUCCESS && [ $RET -eq 0 ]; then
41 | echo "SHOWSUCCESS is TRUE, showing result of successfull run" >&2
42 | echo "======================================================" >&2
43 | cat $TMPLOG >&2;
44 | fi
45 | fi
46 | if [ $RET -ne 0 ]; then
47 | echo "Backup failed with error code $RET, this is what happened:" >&2
48 | echo "==========================================================" >&2
49 | cat $TMPLOG >&2
50 | FINALRET=1
51 | fi
52 |
53 | # Always keep a log of all activities
54 | cat $TMPLOG >> /var/log/iceshelf.log
55 | rm $TMPLOG
56 | done
57 |
58 | exit $FINALRET
59 |
--------------------------------------------------------------------------------
/modules/providers/scp.py:
--------------------------------------------------------------------------------
1 | import os
2 | import subprocess
3 | import logging
4 | from . import BackupProvider, _which
5 |
6 | class SCPProvider(BackupProvider):
7 | name = 'scp'
8 | def verify(self):
9 | self.user = self.options.get('user')
10 | self.host = self.options.get('host')
11 | self.dest = self.options.get('dest', '.')
12 | self.key = self.options.get('key')
13 | self.password = self.options.get('password')
14 | if not self.user or not self.host:
15 | logging.error('scp provider requires "user" and "host"')
16 | return False
17 | if self.key and not os.path.exists(self.key):
18 | logging.error('SSH key %s not found', self.key)
19 | return False
20 | if self.password and _which('sshpass') is None:
21 | logging.error('sshpass command not found')
22 | return False
23 | if _which('scp') is None:
24 | logging.error('scp command not found')
25 | return False
26 | return True
27 |
28 | def storage_id(self):
29 | return f'scp:{self.user}@{self.host}:{self.dest}'
30 |
31 | def upload_files(self, files):
32 | base = []
33 | if self.password:
34 | base += ['sshpass', '-p', self.password]
35 | scp_cmd = ['scp']
36 | if self.key:
37 | scp_cmd += ['-i', self.key]
38 | for f in files:
39 | dest = f'{self.user}@{self.host}:{self.dest}/{os.path.basename(f)}'
40 | cmd = base + scp_cmd + [f, dest]
41 | try:
42 | p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
43 | out, err = p.communicate()
44 | if p.returncode != 0:
45 | logging.error('scp failed: %s', err)
46 | return False
47 | except Exception:
48 | logging.exception('scp failed for %s', f)
49 | return False
50 | return True
51 |
--------------------------------------------------------------------------------
/modules/providers/sftp.py:
--------------------------------------------------------------------------------
1 | import os
2 | import subprocess
3 | import logging
4 | from . import BackupProvider, _which
5 |
6 | class SFTPProvider(BackupProvider):
7 | name = 'sftp'
8 | def verify(self):
9 | self.user = self.options.get('user')
10 | self.host = self.options.get('host')
11 | self.dest = self.options.get('dest', '.')
12 | self.key = self.options.get('key')
13 | self.password = self.options.get('password')
14 | if not self.user or not self.host:
15 | logging.error('sftp provider requires "user" and "host"')
16 | return False
17 | if self.key and not os.path.exists(self.key):
18 | logging.error('SSH key %s not found', self.key)
19 | return False
20 | if self.password and _which('sshpass') is None:
21 | logging.error('sshpass command not found')
22 | return False
23 | if _which('sftp') is None:
24 | logging.error('sftp command not found')
25 | return False
26 | return True
27 |
28 | def storage_id(self):
29 | return f'sftp:{self.user}@{self.host}:{self.dest}'
30 |
31 | def upload_files(self, files):
32 | base = []
33 | if self.password:
34 | base += ['sshpass', '-p', self.password]
35 | sftp_cmd = ['sftp']
36 | if self.key:
37 | sftp_cmd += ['-i', self.key]
38 | for f in files:
39 | cmd = base + sftp_cmd + [f'{self.user}@{self.host}']
40 | batch = f'put {f} {self.dest}/{os.path.basename(f)}\n'
41 | try:
42 | p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
43 | out, err = p.communicate(batch.encode())
44 | if p.returncode != 0:
45 | logging.error('sftp failed: %s', err)
46 | return False
47 | except Exception:
48 | logging.exception('sftp failed for %s', f)
49 | return False
50 | return True
51 |
--------------------------------------------------------------------------------
/extras/testsuite/README.md:
--------------------------------------------------------------------------------
1 | # Backup and Restore tests
2 |
3 | A beginning to a suite of tests to confirm that the tool is doing the right thing.
4 |
5 | All backup test cases are stored inside the `tests/` directory. They manipulate
6 | the `content` folder and then execute `runTest` from `test_backup.sh`.
7 |
8 | `test_restore.sh` provides functional tests for `iceshelf-restore`. It exercises
9 | all permutations of encryption, signatures and parity while also verifying
10 | behavior when manifests are missing or archives are corrupt.
11 |
12 | runTest take the following arguments:
13 |
14 | - param1 = title of test
15 | - param2 = leave empty to run `iceshelf` with `--changes`. If no changes are detected, the test fails
16 | - param3 = provide `pretest()` and `posttest()` functions which are called before or after test, return non-zero to fail test
17 | - param4 = Which config file to use (unrelated to variant)
18 | -- regular = Simple backup
19 | -- prefix = Sets the prefix option
20 | -- filelist = produces a *.lst file
21 | -- encryptmani = Also encrypts manifest
22 | -- *NOTE* These configurations are also adapted based on variant, so variant encrypted will make all configs produce encrypted output
23 | - param5 = Text to compare output from `diff` of the source material and the resulting unpacked backup. If prefixed with `^` it will assume the text is a regular expression, otherwise it's just plain text comparison.
24 | - param6+ passed verbaitum to iceshelf
25 |
26 | _Of all these options, only 5 & 6 can be left empty._
27 |
28 | By setting the `ERROR` environment variable to `true`, you will trigger an error. This
29 | is done automatically inside `runTest` so unless you have specific needs, you shouldn't
30 | have to do anything.
31 |
32 | There are a number of other variables acccessible:
33 |
34 | - VARIANT indicates the current variant the test is used in, some cases (like #012) depend on this
35 |
36 | All tests are run in numerical order and therefore can depend on the output from the
37 | previous testcase.
38 |
39 | All tests are run multiple times in various configurations (encryption, signature, etc).
40 |
--------------------------------------------------------------------------------
/README.iceshelf-retrieve.md:
--------------------------------------------------------------------------------
1 | # iceshelf-retrieve
2 |
3 | `iceshelf-retrieve` downloads archives stored in AWS Glacier by
4 | [iceshelf](README.md). Retrieval from Glacier is asynchronous which means files
5 | cannot be fetched immediately. This helper keeps track of pending retrieval jobs
6 | and can be re-run until everything is downloaded and verified.
7 |
8 | ## Features
9 |
10 | - Handles Glacier inventory requests automatically.
11 | - Initiates archive retrieval jobs and resumes interrupted downloads.
12 | - Multi-threaded downloads with configurable thread count.
13 | - Verifies files using the Glacier SHA256 tree hash.
14 | - Provides progress information and clear error reporting.
15 |
16 | ## Usage
17 |
18 | ```
19 | iceshelf-retrieve VAULT BACKUP [BACKUP ...] [--database FILE] [--dest DIR] [--threads N]
20 | iceshelf-retrieve VAULT --all [--database FILE] [--dest DIR] [--threads N]
21 | ```
22 |
23 | - `VAULT` – name of the Glacier vault where archives are stored.
24 | - `--database` – path to the `checksum.json` database. This file is optional when using `--all`.
25 | - `BACKUP` – name of a backup set to retrieve (for example
26 | `20230101-123456-00000`). Multiple backups can be listed.
27 | - `--dest` – directory where files are stored (defaults to `retrieved/`). All
28 | downloads are placed directly in this directory without creating backup
29 | subfolders.
30 | - `--threads` – number of concurrent downloads.
31 | - `--all` – download every backup in the vault using only the Glacier inventory.
32 |
33 | Running the tool the first time will start an inventory retrieval job if no
34 | recent inventory exists. Once the inventory is available it will request
35 | retrieval for each file in the selected backup. With `--all`, the inventory
36 | is scanned to locate every backup in the vault and each one is downloaded in
37 | turn. Re-run the tool periodically until all files report `Finished`.
38 |
39 | ## Example
40 |
41 | ```
42 | ./iceshelf-retrieve myvault 20230101-123456-00000 --dest restore --threads 4
43 | ```
44 |
45 | Errors are printed with hints whenever possible. Ensure that your AWS
46 | credentials are configured for the account that owns the Glacier vault.
47 |
--------------------------------------------------------------------------------
/DATABASE.md:
--------------------------------------------------------------------------------
1 | Structure of the JSON database:
2 |
3 | {
4 | "dataset" : {...},
5 | "backups" : {...},
6 | "vault" : "
150 | Please print out this and keep it for your records. If you used a passphrase with this key,
151 | do NOT write it down on the same paper as it would make it useless.
152 |
154 | If you ever loose the script which generated these pages, here's how you restore it manually:
155 | Private/Secret GPG Key for "$ITEM"
148 | Generated $DATE
149 |
156 | zbarimg filename.pdf --raw -q | replace "QR-Code:" "" | base64 -d >secret-key.gpg
157 |
158 |
160 | Or if you have each QR code as a separate image, do this in sequence: 161 |
162 | EOF 163 | I=0 164 | for F in key-0[0-9].png; do 165 | I=$(($I + 1)) 166 | if [ $I -eq 1 ]; then 167 | E=" " 168 | else 169 | E=">" 170 | fi 171 | echo >>key.html "zbarimg file $I of $PARTS --raw -q | replace \"QR-Code:\" \"\" $E> secret-key.b64.txt" 172 | done 173 | cat >>key.html <176 | 177 | 178 |186 | EOF 187 | I=0 188 | for F in key-0[0-9].png; do 189 | I=$(($I + 1)) 190 | echo >>key.html "179 | Notes: 180 |
181 |
182 |
183 |
184 |
185 |Part $I of $PARTS - $DATE
" 191 | done 192 | echo >>key.html "