From ccdef065df54c67b0a000a29217a445ae65b0734 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Tue, 14 Jul 2026 11:27:49 +0200 Subject: [PATCH] refactor!: port shell scripts to Python package Bash scripts were untestable and duplicated device/LUKS/mount logic; the lim/ package centralizes it behind one subprocess wrapper and a YAML image catalog (single point of truth). BREAKING CHANGE: scripts/*.sh removed. Use `lim --type `; new types mount/umount/single-boot/raid1-boot/lock/unlock/import/export replace direct script calls. --extra is deprecated and ignored. - distributions.yml + lim/catalog.py hold the image catalog (PyYAML) - pytest suite: 102 tests with mocked subprocess (tests/unit) and a 250-line max file-length guard (tests/lint) - ruff strict (select ALL), GitHub Actions CI, Dependabot; Travis gone - Makefile: install (symlink ~/.local/bin/lim) and test targets - fixes over bash: SUDO_USER-aware chown, mmcblk/nvme partition paths, sha512 checksum support, whole-pipeline failure detection, blkid UUID fallback for pre-mounted images, conditional fstab seeding for PARTUUID/LABEL images, clean errors for missing binaries --- .claude/settings.json | 7 +- .github/dependabot.yml | 10 + .github/workflows/test.yml | 22 + .gitignore | 3 + .travis.yml | 2 - Makefile | 12 + README.md | 146 ++-- distributions.yml | 67 ++ lim/__init__.py | 3 + lim/catalog.py | 31 + lim/cli.py | 167 ++++ lim/config.py | 11 + lim/data/__init__.py | 0 lim/data/crypt.py | 24 + lim/data/sync.py | 135 ++++ lim/device.py | 130 ++++ lim/errors.py | 2 + lim/fsutil.py | 32 + lim/image/__init__.py | 0 lim/image/backup.py | 32 + lim/image/choosers.py | 111 +++ lim/image/chroot.py | 23 + lim/image/encryption.py | 117 +++ lim/image/plan.py | 31 + lim/image/raspberry.py | 236 ++++++ lim/image/session.py | 190 +++++ lim/image/setup.py | 96 +++ lim/image/transfer.py | 140 ++++ lim/image/verify.py | 100 +++ lim/luks.py | 80 ++ lim/packages.py | 18 + lim/runner.py | 134 ++++ lim/storage/__init__.py | 0 lim/storage/common.py | 38 + lim/storage/raid1.py | 68 ++ lim/storage/single_drive.py | 88 +++ lim/system.py | 35 + lim/ui.py | 62 ++ main.py | 118 +-- pyproject.toml | 41 + scripts/base.sh | 161 ---- scripts/data/export-to-system.sh | 12 - scripts/data/import-from-system.sh | 75 -- scripts/encryption/data/lock.sh | 14 - scripts/encryption/data/unlock.sh | 17 - scripts/encryption/storage/Readme.md | 2 - scripts/encryption/storage/base.sh | 86 --- scripts/encryption/storage/raid1/base.sh | 22 - .../encryption/storage/raid1/mount_on_boot.sh | 20 - scripts/encryption/storage/raid1/setup.sh | 23 - .../encryption/storage/single_drive/base.sh | 3 - .../encryption/storage/single_drive/mount.sh | 18 - .../storage/single_drive/mount_on_boot.sh | 13 - .../encryption/storage/single_drive/setup.sh | 50 -- .../encryption/storage/single_drive/umount.sh | 15 - scripts/image/backup.sh | 30 - scripts/image/base.sh | 114 --- scripts/image/chroot.sh | 31 - scripts/image/setup.sh | 714 ------------------ tests/__init__.py | 0 tests/conftest.py | 108 +++ tests/lint/__init__.py | 0 tests/lint/test_file_length.py | 19 + tests/unit/__init__.py | 0 tests/unit/test_catalog.py | 24 + tests/unit/test_cli.py | 65 ++ tests/unit/test_device.py | 84 +++ tests/unit/test_fsutil.py | 35 + tests/unit/test_image_session.py | 62 ++ tests/unit/test_image_setup.py | 143 ++++ tests/unit/test_luks.py | 59 ++ tests/unit/test_packages.py | 30 + tests/unit/test_raspberry.py | 72 ++ tests/unit/test_runner.py | 92 +++ tests/unit/test_storage.py | 70 ++ tests/unit/test_sync.py | 119 +++ tests/unit/test_verify.py | 52 ++ 77 files changed, 3402 insertions(+), 1614 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/test.yml delete mode 100644 .travis.yml create mode 100644 Makefile create mode 100644 distributions.yml create mode 100644 lim/__init__.py create mode 100644 lim/catalog.py create mode 100644 lim/cli.py create mode 100644 lim/config.py create mode 100644 lim/data/__init__.py create mode 100644 lim/data/crypt.py create mode 100644 lim/data/sync.py create mode 100644 lim/device.py create mode 100644 lim/errors.py create mode 100644 lim/fsutil.py create mode 100644 lim/image/__init__.py create mode 100644 lim/image/backup.py create mode 100644 lim/image/choosers.py create mode 100644 lim/image/chroot.py create mode 100644 lim/image/encryption.py create mode 100644 lim/image/plan.py create mode 100644 lim/image/raspberry.py create mode 100644 lim/image/session.py create mode 100644 lim/image/setup.py create mode 100644 lim/image/transfer.py create mode 100644 lim/image/verify.py create mode 100644 lim/luks.py create mode 100644 lim/packages.py create mode 100644 lim/runner.py create mode 100644 lim/storage/__init__.py create mode 100644 lim/storage/common.py create mode 100644 lim/storage/raid1.py create mode 100644 lim/storage/single_drive.py create mode 100644 lim/system.py create mode 100644 lim/ui.py create mode 100644 pyproject.toml delete mode 100644 scripts/base.sh delete mode 100644 scripts/data/export-to-system.sh delete mode 100644 scripts/data/import-from-system.sh delete mode 100644 scripts/encryption/data/lock.sh delete mode 100644 scripts/encryption/data/unlock.sh delete mode 100644 scripts/encryption/storage/Readme.md delete mode 100644 scripts/encryption/storage/base.sh delete mode 100644 scripts/encryption/storage/raid1/base.sh delete mode 100644 scripts/encryption/storage/raid1/mount_on_boot.sh delete mode 100644 scripts/encryption/storage/raid1/setup.sh delete mode 100644 scripts/encryption/storage/single_drive/base.sh delete mode 100644 scripts/encryption/storage/single_drive/mount.sh delete mode 100644 scripts/encryption/storage/single_drive/mount_on_boot.sh delete mode 100644 scripts/encryption/storage/single_drive/setup.sh delete mode 100644 scripts/encryption/storage/single_drive/umount.sh delete mode 100644 scripts/image/backup.sh delete mode 100644 scripts/image/base.sh delete mode 100644 scripts/image/chroot.sh delete mode 100644 scripts/image/setup.sh create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/lint/__init__.py create mode 100644 tests/lint/test_file_length.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_catalog.py create mode 100644 tests/unit/test_cli.py create mode 100644 tests/unit/test_device.py create mode 100644 tests/unit/test_fsutil.py create mode 100644 tests/unit/test_image_session.py create mode 100644 tests/unit/test_image_setup.py create mode 100644 tests/unit/test_luks.py create mode 100644 tests/unit/test_packages.py create mode 100644 tests/unit/test_raspberry.py create mode 100644 tests/unit/test_runner.py create mode 100644 tests/unit/test_storage.py create mode 100644 tests/unit/test_sync.py create mode 100644 tests/unit/test_verify.py diff --git a/.claude/settings.json b/.claude/settings.json index 6965f2f..5272a4a 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,7 +1,12 @@ { + "env": { + "GIT_CONFIG_COUNT": "1", + "GIT_CONFIG_KEY_0": "commit.gpgsign", + "GIT_CONFIG_VALUE_0": "false" + }, "permissions": { "defaultMode": "dontAsk", - "deny": [ + "ask": [ "Bash(git push)", "Bash(git push:*)", "Bash(git commit)", diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..cbd920f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..12991c2 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,22 @@ +name: tests + +on: + push: + pull_request: + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/ruff-action@v3 + + pytest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install pytest pyyaml + - run: make test diff --git a/.gitignore b/.gitignore index 3ee4a41..fc6b817 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ decrypted/ .encrypted/ *package-lock.json log.txt +__pycache__/ +*.pyc +.pytest_cache/ diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 9c60b5d..0000000 --- a/.travis.yml +++ /dev/null @@ -1,2 +0,0 @@ -language: shell -script: shellcheck $(find . -type f -name '*.sh') diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9a1ae27 --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +.PHONY: install test + +PREFIX ?= $(HOME)/.local + +install: + chmod +x main.py + install -d $(PREFIX)/bin + ln -sf $(CURDIR)/main.py $(PREFIX)/bin/lim + @echo "Installed lim to $(PREFIX)/bin/lim" + +test: + python3 -m pytest diff --git a/README.md b/README.md index 40de0cf..63d1cae 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,16 @@ [![License: GPL v3](https://img.shields.io/badge/License-GPL%20v3-blue.svg)](./LICENSE.txt) [![GitHub stars](https://img.shields.io/github/stars/kevinveenbirkenbach/linux-image-manager.svg?style=social)](https://github.com/kevinveenbirkenbach/linux-image-manager/stargazers) -Linux Image Manager (lim) is a powerful collection of shell scripts for downloading, configuring, and managing Linux images. Whether you're setting up encrypted storage, configuring a virtual Btrfs RAID1, performing backups, or chrooting into an image, this tool makes Linux image administration simple and efficient. ๐Ÿš€ - -> **Note:** In this project, `lim` is an alias for the **main.py** wrapper script which orchestrates the execution of the various shell scripts. +Linux Image Manager (lim) is a Python tool for downloading, configuring, and managing Linux images. Whether you're setting up encrypted storage, configuring a virtual Btrfs RAID1, performing backups, or chrooting into an image, this tool makes Linux image administration simple and efficient. ๐Ÿš€ ## Features โœจ -- **Image Download & Setup:** Automatically download and prepare Linux distributions. +- **Image Download & Setup:** Automatically download, verify (checksum + GPG signature) and prepare Linux distributions. - **Encrypted Storage:** Configure LUKS encryption for secure image management. - **Virtual RAID1:** Easily set up virtual Btrfs RAID1 for data redundancy. - **Backup & Restore:** Create image backups from devices using dd. - **Chroot Environment:** Easily enter a chroot shell to maintain or modify Linux images. +- **Data Import/Export:** Sync personal data into an encfs-encrypted store and back. - **Automated Procedures:** Simplify partitioning, formatting, mounting, and more. ## Installation ๐Ÿ“ฆ @@ -25,96 +24,78 @@ Install Linux Image Manager quickly using [Kevin's Package Manager](https://gith package-manager install lim ``` -This command makes Linux Image Manager globally available as `lim` in your terminal. The `lim` alias points to the **main.py** wrapper script. +This command makes Linux Image Manager globally available as `lim` in your terminal. The `lim` alias points to the **main.py** entry point. + +There are no Python dependencies beyond the standard library. The commands call the usual system tools (`cryptsetup`, `fdisk`, `dd`, `rsync`, `wget`, `gpg`, `encfs`, `pv`, `bsdtar`, ...), so those need to be installed for the command you use. ## Usage โš™๏ธ -The **main.py** wrapper provides a unified interface to run the different shell scripts included in this project. It supports various script types and allows you to pass additional parameters. The built-in `--help` option displays detailed usage information. +`lim` provides a unified interface for all image and storage operations. Commands that need root privileges re-execute themselves with `sudo` automatically. The built-in `--help` option displays detailed usage information. -### Available Script Types +### Available Command Types -- **Image Setup (`--type image`):** - Executes the Linux image setup located at `scripts/image/setup.sh`. This setup: - - Creates partitions and formats them. - - Transfers the Linux image file to the device. - - Configures boot and root partitions. - -- **Single Drive Encryption Setup (`--type single`):** - Executes the single-drive encryption setup from `scripts/encryption/storage/single_drive/setup.sh`. This setup: - - Sets up disk encryption using LUKS on one drive. - - Configures a Btrfs file system for secure storage. - -- **RAID1 Encryption Setup (`--type raid1`):** - Executes the RAID1 encryption setup found at `scripts/encryption/storage/raid1/setup.sh`. This setup: - - Configures a virtual RAID1 with two drives. - - Uses LUKS encryption and a Btrfs RAID1 file system for redundancy. - -- **Backup Image Setup (`--type backup`):** - Executes the backup image setup located at `scripts/image/backup.sh`. This setup: - - Creates an image backup from a memory device to a file. - - Uses `dd` to transfer the image from the specified device to an image file. - -- **Chroot Environment Setup (`--type chroot`):** - Executes the chroot setup from `scripts/image/chroot.sh`. This setup: - - Mounts partitions and configures the chroot environment for a Linux image. - - Provides a shell within the Linux image for system maintenance. +| `--type` | Description | +|---------------|-----------------------------------------------------------------------------| +| `image` | Download, verify and transfer a Linux image to a device, incl. optional LUKS encryption and Raspberry Pi configuration. | +| `single` | Set up LUKS encryption with Btrfs on a single drive. | +| `raid1` | Set up an encrypted virtual Btrfs RAID1 across two drives. | +| `backup` | Create an image backup from a memory device using dd. | +| `chroot` | Mount an image and open a shell inside it. | +| `mount` | Unlock and mount an encrypted drive. | +| `umount` | Unmount an encrypted drive and close the mapper. | +| `single-boot` | Register a single encrypted drive for automount on boot (keyfile, crypttab, fstab). | +| `raid1-boot` | Register an encrypted RAID1 for automount on boot. | +| `unlock` | Decrypt the encfs data store. | +| `lock` | Lock the encfs data store. | +| `import` | Import personal data from the system into the encrypted store. | +| `export` | Export personal data from the encrypted store back to the system. | ### Command-Line Options -- **`--type`** - **(Required)** Choose the type of script to execute. Options include: `image`, `single`, `raid1`, `backup`, and `chroot`. - -- **`--extra`** - **(Optional)** Pass any extra parameters directly to the selected shell script. - -- **`--auto-confirm`** - **(Optional)** Automatically bypass the confirmation prompt before executing the selected script. - -- **`--help`** - **(Optional)** Displays detailed help information about the command-line options and usage of the wrapper. Simply run: - ```bash - lim --help - ``` - to view the complete help message. +- **`--type`** *(required)*: Choose the command to execute (see table above). +- **`--auto-confirm`** *(optional)*: Bypass the confirmation prompt before execution. +- **`--help`** *(optional)*: Display detailed help information. ### Example Commands -- **Display Help:** - ```bash - lim --help - ``` +```bash +# Display help +lim --help -- **Show Information About the Image Setup:** - ```bash - lim --type image --info - ``` +# Execute the Linux image setup +lim --type image -- **Execute the Linux Image Setup (with extra parameters):** - ```bash - lim --type image --extra --some-option value - ``` +# Run the single drive encryption setup without a confirmation prompt +lim --type single --auto-confirm -- **Run the Single Drive Encryption Setup without a confirmation prompt:** - ```bash - lim --type single --auto-confirm - ``` +# Set up an encrypted RAID1 +lim --type raid1 -- **Execute the RAID1 Encryption Setup:** - ```bash - lim --type raid1 - ``` +# Back up a memory device to an image file +lim --type backup -- **Perform a Backup of an Image:** - ```bash - lim --type backup - ``` +# Enter a chroot environment for a Linux image +lim --type chroot +``` -- **Enter a Chroot Environment for a Linux Image:** - ```bash - lim --type chroot - ``` +## Project Structure ๐Ÿ—‚๏ธ -For additional details on each script and further configuration options, please refer to the `scripts/` and `configuration/` directories. +``` +main.py # entry point (the `lim` alias) +distributions.yml # single point of truth for the image catalog +lim/ + cli.py # argument parsing and command dispatch + catalog.py # read-only access to distributions.yml + device.py # block device selection, dd, blkid helpers + luks.py # LUKS keyfiles, crypttab/fstab bookkeeping + runner.py # subprocess wrapper used by all modules + storage/ # single drive and RAID1 encryption setups + image/ # image setup, backup, chroot, verification + data/ # encfs lock/unlock and data import/export +configuration/ # package collections used during image setup +tests/unit/ # unit tests (all external commands mocked) +tests/lint/ # architecture guards (e.g. max file length) +``` ## Configuration & Customization ๐Ÿ”ง @@ -122,15 +103,22 @@ Customize your environment in the `configuration/` folder: - **General Packages:** Contains common packages for all setup scripts. - **Server LUKS Packages:** Contains packages needed for setting up LUKS encryption on servers. +## Development & Tests ๐Ÿงช + +The test suite mocks all external commands, so it runs safely on any machine: + +```bash +pytest +``` + ## License ๐Ÿ“œ This project is licensed under the GNU General Public License Version 3. See the [LICENSE.txt](./LICENSE.txt) file for details. ## Contact & Support ๐Ÿ’ฌ -- **Author:** Kevin Veen-Birkenbach -- **Email:** [kevin@veen.world](mailto:kevin@veen.world) +- **Author:** Kevin Veen-Birkenbach +- **Email:** [kevin@veen.world](mailto:kevin@veen.world) - **Website:** [https://www.veen.world/](https://www.veen.world/) Feel free to contribute, report issues, or get in touch. Happy Linux managing! ๐Ÿ˜Š -``` \ No newline at end of file diff --git a/distributions.yml b/distributions.yml new file mode 100644 index 0000000..99ef8d1 --- /dev/null +++ b/distributions.yml @@ -0,0 +1,67 @@ +# Single point of truth for the downloadable image catalog. +# Consumed by lim/catalog.py. + +# Image and LUKS --pbkdf-memory cost per Raspberry Pi version. +arch_rpi_images: + "1": + image: ArchLinuxARM-rpi-armv7-latest.tar.gz + luks_memory_cost: "64000" + "2": + image: ArchLinuxARM-rpi-armv7-latest.tar.gz + luks_memory_cost: "128000" + "3b": + image: ArchLinuxARM-rpi-aarch64-latest.tar.gz + luks_memory_cost: "128000" + "3b+": + image: ArchLinuxARM-rpi-aarch64-latest.tar.gz + luks_memory_cost: "128000" + "4": + image: ArchLinuxARM-rpi-aarch64-latest.tar.gz + luks_memory_cost: "256000" + +manjaro_gnome_releases: + "20": + checksum: 2df3697908483550d4a473815b08c1377e6b6892 + url: https://osdn.net/projects/manjaro-archive/storage/gnome/20.0/ + image: manjaro-gnome-20.0-200426-linux56.iso + "21": + url: https://download.manjaro.org/gnome/21.3.7/ + image: manjaro-gnome-21.3.7-220816-linux515.iso + "22": + url: https://download.manjaro.org/gnome/22.1.3/ + image: manjaro-gnome-22.1.3-230529-linux61.iso + "24": + url: https://download.manjaro.org/gnome/24.2.1/ + image: manjaro-gnome-24.2.1-241216-linux612.iso + "25": + url: https://download.manjaro.org/gnome/25.0.10/ + image: manjaro-gnome-25.0.10-251013-linux612.iso + # At the moment just optimized for Raspberry Pi 4. + raspberrypi: + url: https://github.com/manjaro-arm/rpi4-images/releases/download/23.02/ + image: Manjaro-ARM-gnome-rpi4-23.02.img.xz + luks_memory_cost: "256000" + raspberry_pi_version: "4" + +retropie_images: + "1": + checksum: 95a6f84453df36318830de7e8507170e + image: retropie-buster-4.8-rpi1_zero.img.gz + "2": + checksum: 224e64d8820fc64046ba3850f481c87e + image: retropie-buster-4.8-rpi2_3_zero2w.img.gz + "3": + checksum: 224e64d8820fc64046ba3850f481c87e + image: retropie-buster-4.8-rpi2_3_zero2w.img.gz + "4": + checksum: b5daa6e7660a99c246966f3f09b4014b + image: retropie-buster-4.8-rpi4_400.img.gz + +# Extra kernel modules the initramfs needs for early network access, +# see https://raspberrypi.stackexchange.com/questions/67051 +mkinitcpio_modules_by_rpi: + "1": "" + "2": "" + "3b": smsc95xx + "3b+": lan78xx + "4": lan78xx diff --git a/lim/__init__.py b/lim/__init__.py new file mode 100644 index 0000000..1ac6e82 --- /dev/null +++ b/lim/__init__.py @@ -0,0 +1,3 @@ +"""Linux Image Manager โ€” administration tool for Linux images and encrypted storage.""" + +__version__ = "1.0.0" diff --git a/lim/catalog.py b/lim/catalog.py new file mode 100644 index 0000000..ab2a222 --- /dev/null +++ b/lim/catalog.py @@ -0,0 +1,31 @@ +"""Read-only access to the image catalog (distributions.yml in the repo root).""" + +from functools import cache + +import yaml + +from lim import config + +CATALOG_PATH = config.REPOSITORY_PATH / "distributions.yml" + + +@cache +def _load() -> dict: + with CATALOG_PATH.open() as handle: + return yaml.safe_load(handle) + + +def arch_rpi_images() -> dict[str, dict[str, str]]: + return _load()["arch_rpi_images"] + + +def manjaro_gnome_releases() -> dict[str, dict[str, str]]: + return _load()["manjaro_gnome_releases"] + + +def retropie_images() -> dict[str, dict[str, str]]: + return _load()["retropie_images"] + + +def mkinitcpio_modules_by_rpi() -> dict[str, str]: + return _load()["mkinitcpio_modules_by_rpi"] diff --git a/lim/cli.py b/lim/cli.py new file mode 100644 index 0000000..5841dec --- /dev/null +++ b/lim/cli.py @@ -0,0 +1,167 @@ +"""Command line interface: `lim --type `.""" + +import argparse +import sys +from collections.abc import Callable +from dataclasses import dataclass + +from lim import system, ui +from lim.data import crypt, sync +from lim.errors import LimError +from lim.image import backup, chroot +from lim.image import setup as image_setup +from lim.storage import raid1, single_drive + + +@dataclass(frozen=True) +class Command: + func: Callable[[], None] + description: str + needs_root: bool + + +COMMANDS: dict[str, Command] = { + "image": Command( + image_setup.run_setup, + "Linux Image Setup:\n" + " - Creates partitions and formats them.\n" + " - Transfers the Linux image file to the device.\n" + " - Configures boot and root partitions.", + needs_root=True, + ), + "single": Command( + single_drive.setup, + "Single Drive Encryption Setup:\n" + " - Sets up disk encryption using LUKS on one drive.\n" + " - Configures a Btrfs file system for secure storage.", + needs_root=True, + ), + "raid1": Command( + raid1.setup, + "RAID1 Encryption Setup:\n" + " - Configures a virtual RAID1 with two drives.\n" + " - Uses LUKS encryption and a Btrfs RAID1 file system for redundancy.", + needs_root=True, + ), + "backup": Command( + backup.run_backup, + "Backup Image Setup:\n" + " - Creates an image backup from a memory device to a file.\n" + " - Uses dd to transfer the image from the specified device to an image file.", + needs_root=True, + ), + "chroot": Command( + chroot.run_chroot, + "Chroot Environment Setup:\n" + " - Mounts partitions and configures the chroot environment for a Linux image.\n" + " - Provides a shell within the Linux image for system maintenance.", + needs_root=True, + ), + "mount": Command( + single_drive.mount, + "Mount Encrypted Storage:\n" + " - Unlocks a LUKS partition and mounts it.", + needs_root=True, + ), + "umount": Command( + single_drive.umount, + "Unmount Encrypted Storage:\n" + " - Unmounts a LUKS partition and closes the mapper.", + needs_root=True, + ), + "single-boot": Command( + single_drive.mount_on_boot, + "Single Drive Automount Setup:\n" + " - Creates a LUKS keyfile and registers it in crypttab and fstab.", + needs_root=True, + ), + "raid1-boot": Command( + raid1.mount_on_boot, + "RAID1 Automount Setup:\n" + " - Creates LUKS keyfiles for both drives and registers them in crypttab/fstab.", + needs_root=True, + ), + "unlock": Command( + crypt.unlock, + "Unlock Data:\n" + " - Decrypts the encfs data store into the decrypted folder.", + needs_root=False, + ), + "lock": Command( + crypt.lock, + "Lock Data:\n" + " - Unmounts the decrypted encfs folder.", + needs_root=False, + ), + "import": Command( + sync.import_from_system, + "Import Data:\n" + " - Copies personal data from the system into the encrypted store.", + needs_root=False, + ), + "export": Command( + sync.export_to_system, + "Export Data:\n" + " - Copies personal data from the encrypted store back to the system.", + needs_root=False, + ), +} + + +def build_parser() -> argparse.ArgumentParser: + epilog_lines = ["Available script types:"] + epilog_lines += [ + f" {name:<12} - {command.description.splitlines()[0].rstrip(':')}" + for name, command in COMMANDS.items() + ] + parser = argparse.ArgumentParser( + prog="lim", + description="Linux Image Manager โ€” manages Linux images and encrypted storage.", + epilog="\n".join(epilog_lines), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--type", + required=True, + choices=list(COMMANDS.keys()), + help="Select the command to execute.", + ) + parser.add_argument( + "--auto-confirm", + action="store_true", + help="Automatically confirm execution without prompting the user.", + ) + parser.add_argument( + "--extra", + nargs=argparse.REMAINDER, + default=[], + help="Deprecated, ignored. Former extra parameters for the shell scripts.", + ) + return parser + + +def main(argv: list[str] | None = None) -> None: + args = build_parser().parse_args(argv) + command = COMMANDS[args.type] + + if args.extra: + ui.warning("--extra is deprecated and ignored.") + if command.needs_root: + system.ensure_root() + + ui.header() + ui.info(f"Selected command: {args.type}") + ui.info("Description:") + print(command.description) + + try: + if not args.auto_confirm: + input("Press Enter to execute or Ctrl+C to cancel...") + command.func() + except LimError as exc: + ui.error(f"{exc} -> Leaving program.") + sys.exit(1) + except KeyboardInterrupt: + print() + ui.error("Execution aborted by user.") + sys.exit(1) diff --git a/lim/config.py b/lim/config.py new file mode 100644 index 0000000..7bc5152 --- /dev/null +++ b/lim/config.py @@ -0,0 +1,11 @@ +"""Repository paths shared by all modules.""" + +from pathlib import Path + +REPOSITORY_PATH = Path(__file__).resolve().parent.parent +CONFIGURATION_PATH = REPOSITORY_PATH / "configuration" +PACKAGE_PATH = CONFIGURATION_PATH / "packages" +ENCRYPTED_PATH = REPOSITORY_PATH / ".encrypted" +DECRYPTED_PATH = REPOSITORY_PATH / "decrypted" +DATA_PATH = DECRYPTED_PATH / "data" +BACKUP_PATH = DECRYPTED_PATH / "backup" diff --git a/lim/data/__init__.py b/lim/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lim/data/crypt.py b/lim/data/crypt.py new file mode 100644 index 0000000..9d21ac4 --- /dev/null +++ b/lim/data/crypt.py @@ -0,0 +1,24 @@ +"""Lock/unlock the encfs-encrypted data directory.""" + +from lim import config, runner, ui + + +def unlock() -> None: + ui.info(f"Unlocking directory {config.DECRYPTED_PATH}...") + if not config.DECRYPTED_PATH.is_dir(): + ui.info(f"Creating directory {config.DECRYPTED_PATH}...") + config.DECRYPTED_PATH.mkdir() + ui.info(f"Decrypting directory {config.ENCRYPTED_PATH} to {config.DECRYPTED_PATH}...") + runner.run(["encfs", str(config.ENCRYPTED_PATH), str(config.DECRYPTED_PATH)]) + print("ATTENTION: DATA IS NOW DECRYPTED!") + + +def lock() -> None: + ui.info(f"Locking directory {config.DECRYPTED_PATH}...") + runner.run( + ["fusermount", "-u", str(config.DECRYPTED_PATH)], + error_msg="Unmounting failed.", + ) + ui.info("Data is now encrypted.") + ui.info(f"Removing directory {config.DECRYPTED_PATH}...") + config.DECRYPTED_PATH.rmdir() diff --git a/lim/data/sync.py b/lim/data/sync.py new file mode 100644 index 0000000..52cbda1 --- /dev/null +++ b/lim/data/sync.py @@ -0,0 +1,135 @@ +"""Import personal data from the system into the encrypted store, or export it back.""" + +import time +from dataclasses import dataclass +from pathlib import Path + +from lim import config, runner, system, ui +from lim.data import crypt +from lim.errors import LimError + +# Paths relative to $HOME; trailing "/" marks a directory tree. +BACKUP_ITEMS = [ + ".ssh/", + ".gitconfig", + ".atom/config.cson", + ".projectlibre/projectlibre.conf", + ".local/share/rhythmbox/rhythmdb.xml", + ".config/keepassxc/keepassxc.ini", + "Documents/certificates/", + "Documents/security/", + "Documents/identity/", + "Documents/health/", + "Documents/licenses/", +] + + +@dataclass(frozen=True) +class SyncOperation: + source: str + destination: str + backup_dir: str + is_directory: bool + + +def build_sync_plan( + mode: str, + home: Path, + data_path: Path, + backup_folder: Path, +) -> list[SyncOperation]: + """One rsync operation per existing backup item; missing sources are skipped.""" + operations = [] + for item in BACKUP_ITEMS: + is_directory = item.endswith("/") + system_path = home / item + # The store mirrors the absolute system path below DATA_PATH. + data_path_item = Path(f"{data_path}{system_path}") + if mode == "export": + source, destination = data_path_item, system_path + elif mode == "import": + source, destination = system_path, data_path_item + else: + raise LimError(f"Unknown sync mode: {mode}") + ui.info(f"{mode.capitalize()} data from {source} to {destination}...") + if not (source.is_file() or source.is_dir()): + ui.warning(f"{source} doesn't exist. Copying data is not possible.") + continue + if is_directory: + backup_dir = Path(f"{backup_folder}{system_path}") + else: + backup_dir = Path(f"{backup_folder}{system_path}").parent + operations.append( + SyncOperation( + source=f"{source}/" if is_directory else str(source), + destination=f"{destination}/" if is_directory else str(destination), + backup_dir=str(backup_dir), + is_directory=is_directory, + ) + ) + return operations + + +def rsync_command(operation: SyncOperation) -> list[str]: + command = ["rsync", "-abcEPuvW"] + if operation.is_directory: + command.append("--delete") + command += [f"--backup-dir={operation.backup_dir}", operation.source, operation.destination] + return command + + +def execute_sync_plan(operations: list[SyncOperation]) -> None: + for operation in operations: + Path(operation.backup_dir).mkdir(parents=True, exist_ok=True) + destination_dir = ( + Path(operation.destination) + if operation.is_directory + else Path(operation.destination).parent + ) + destination_dir.mkdir(parents=True, exist_ok=True) + if not operation.is_directory and Path(operation.destination).is_file(): + ui.info("The destination file already exists!") + ui.info("Difference:") + runner.run( + ["diff", operation.destination, operation.source], check=False + ) + runner.run(rsync_command(operation)) + + +def ensure_unlocked() -> None: + mounts = runner.output(["mount"], check=False) + if str(config.DECRYPTED_PATH) not in mounts: + ui.info( + f"The decrypted folder {config.DECRYPTED_PATH} is locked. " + "You need to unlock it!" + ) + crypt.unlock() + + +def import_from_system(mode: str = "import") -> None: + ensure_unlocked() + backup_folder = ( + config.BACKUP_PATH / mode / time.strftime("%Y%m%d%H%M%S") + ) + backup_folder.mkdir(parents=True, exist_ok=True) + operations = build_sync_plan( + mode, system.real_home(), config.DATA_PATH, backup_folder + ) + execute_sync_plan(operations) + + +def export_to_system() -> None: + import_from_system(mode="export") + user = system.real_user() + home = system.real_home() + ui.info("Setting right permissions for imported files...") + try: + (home / ".ssh").chmod(0o700) + for child in (home / ".ssh").rglob("*"): + child.chmod(0o700 if child.is_dir() else 0o600) + except OSError as exc: + raise LimError(f"Failed to set correct ssh permissions: {exc}") from exc + # Best effort without privilege escalation, like the original script. + chowned = runner.run(["chown", "-R", f"{user}:{user}", str(home)], check=False) + if chowned.returncode != 0: + ui.warning(f'Not all files could be owned by user "{user}"...') diff --git a/lim/device.py b/lim/device.py new file mode 100644 index 0000000..bd30469 --- /dev/null +++ b/lim/device.py @@ -0,0 +1,130 @@ +"""Block-device selection and low-level device helpers.""" + +import stat as stat_module +from dataclasses import dataclass +from pathlib import Path + +from lim import runner, ui +from lim.errors import LimError + +SYS_BLOCK_PATH = Path("/sys/block") + +PARTITION_TABLE_INFO = """\ +########################################################################################## +Note on Partition Table Deletion: +--------------------------------------------- +โ€ข MBR (Master Boot Record): + - Typically occupies the first sector (512 bytes), i.e., 1 block. + +โ€ข GPT (GUID Partition Table): + - Uses a protective MBR (1 block), a GPT header (1 block), + and usually a partition entry array that takes up about 32 blocks. + - Total: approximately 34 blocks (assuming a 512-byte block size). + +Recommendation: For deleting a GPT partition table, use a block size of 512 bytes + and overwrite at least 34 blocks to ensure the entire table is cleared. +##########################################################################################""" + + +def optimal_blocksize(name: str, sys_block_path: Path = SYS_BLOCK_PATH) -> str: + """64 * physical block size, or "4K" when the size cannot be read. + + See https://www.heise.de/ct/hotline/Optimale-Blockgroesse-fuer-dd-2056768.html + """ + size_path = sys_block_path / name / "queue" / "physical_block_size" + try: + return str(64 * int(size_path.read_text().strip())) + except (OSError, ValueError): + return "4K" + + +@dataclass(frozen=True) +class Device: + name: str # e.g. "sda" or "mmcblk0" + + @property + def path(self) -> str: + return f"/dev/{self.name}" + + @property + def optimal_blocksize(self) -> str: + return optimal_blocksize(self.name) + + def partition(self, number: int) -> str: + """/dev/sda -> /dev/sda1, /dev/mmcblk0 -> /dev/mmcblk0p1.""" + if self.name[-1].isdigit(): + return f"{self.path}p{number}" + return f"{self.path}{number}" + + +def is_block_device(path: str) -> bool: + try: + return stat_module.S_ISBLK(Path(path).stat().st_mode) + except OSError: + return False + + +def select_device() -> Device: + ui.info("Available devices:") + runner.run(["lsblk", "-o", "NAME,SIZE,TYPE,MODEL"], check=False) + name = ui.ask("Please type in the name of the device: /dev/") + device = Device(name) + if not name or not is_block_device(device.path): + raise LimError(f"{device.path} is not a valid device.") + ui.info(f"Device path set to: {device.path}") + ui.info(f"Optimal blocksize set to: {device.optimal_blocksize}") + return device + + +def overwrite_device(device: Device) -> None: + """Optionally overwrite the device (or its first blocks) with zeros.""" + print(PARTITION_TABLE_INFO) + answer = ui.ask( + f"Should {device.path} be overwritten with zeros before copying? (y/N/block count)" + ) + if answer == "y": + ui.info("Overwriting entire device...") + runner.run( + [ + "dd", + "if=/dev/zero", + f"of={device.path}", + f"bs={device.optimal_blocksize}", + "status=progress", + ], + sudo=True, + error_msg=f"Overwriting {device.path} failed.", + ) + runner.sync_disks() + elif answer in ("", "N"): + ui.info("Skipping Overwriting...") + elif answer.isdigit(): + ui.info(f"Overwriting {answer} blocks...") + runner.run( + [ + "dd", + "if=/dev/zero", + f"of={device.path}", + f"bs={device.optimal_blocksize}", + f"count={answer}", + "status=progress", + ], + sudo=True, + error_msg=f"Overwriting {device.path} failed.", + ) + runner.sync_disks() + else: + raise LimError("Invalid input. Block count must be a number.") + + +def blkid_value(path: str, tag: str) -> str: + """Return a blkid tag value ("TYPE", "UUID", ...) or "" when unavailable.""" + return runner.output( + ["blkid", path, "-s", tag, "-o", "value"], sudo=True, check=False + ) + + +def is_mounted(path_fragment: str) -> bool: + """Whether any current mount line contains the given fragment.""" + mounts = runner.output(["mount"], check=False) + return any(path_fragment in line for line in mounts.splitlines()) diff --git a/lim/errors.py b/lim/errors.py new file mode 100644 index 0000000..a2a640f --- /dev/null +++ b/lim/errors.py @@ -0,0 +1,2 @@ +class LimError(Exception): + """Fatal error that aborts the current lim command.""" diff --git a/lim/fsutil.py b/lim/fsutil.py new file mode 100644 index 0000000..1ebf17e --- /dev/null +++ b/lim/fsutil.py @@ -0,0 +1,32 @@ +"""Small file manipulation helpers.""" + +from pathlib import Path + +from lim import ui +from lim.errors import LimError + + +def replace_in_file(search: str, replace: str, path: str | Path) -> None: + """Replace every literal occurrence of ``search``; fail when absent.""" + path = Path(path) + text = path.read_text() + new_text = text.replace(search, replace) + if new_text == text: + raise LimError(f"Search string '{search}' not found in {path}.") + path.write_text(new_text) + + +def ensure_line_in_file(line: str, path: str | Path) -> bool: + """Append ``line`` unless already present. Returns True when appended.""" + path = Path(path) + content = path.read_text() if path.exists() else "" + if line in content.splitlines(): + ui.warning(f"File {path} already contains the following entry:") + print(line) + ui.info("Skipped.") + return False + with path.open("a") as handle: + if content and not content.endswith("\n"): + handle.write("\n") + handle.write(line + "\n") + return True diff --git a/lim/image/__init__.py b/lim/image/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lim/image/backup.py b/lim/image/backup.py new file mode 100644 index 0000000..6d3af13 --- /dev/null +++ b/lim/image/backup.py @@ -0,0 +1,32 @@ +"""Create an image backup from a memory device into a file.""" + +from pathlib import Path + +from lim import device as device_module +from lim import runner, ui + + +def run_backup() -> None: + ui.info("Backupscript for memory devices started...") + print() + device = device_module.select_device() + + working_dir = Path.cwd() + path = "" + while not path: + path = ui.ask(f"Please type in backup image path+name relative to {working_dir}:") + output_file = f"{path}.img" if path.startswith("/") else f"{working_dir}/{path}.img" + + ui.info(f"Input file: {device.path}") + ui.info(f"Output file: {output_file}") + ui.ask('Please confirm by pushing "Enter". To cancel use "Ctrl + C"') + + ui.info("Imagetransfer starts. This can take a while...") + runner.run( + ["dd", f"if={device.path}", f"of={output_file}", "bs=1M", "status=progress"], + sudo=True, + error_msg='"dd" failed.', + ) + runner.sync_disks() + + ui.success("Imagetransfer successfull.") diff --git a/lim/image/choosers.py b/lim/image/choosers.py new file mode 100644 index 0000000..f67f575 --- /dev/null +++ b/lim/image/choosers.py @@ -0,0 +1,111 @@ +"""Interactive selection of the distribution image to install.""" + +from lim import catalog, runner, ui +from lim.errors import LimError +from lim.image.plan import ImagePlan + + +def choose_arch(plan: ImagePlan) -> None: + version = ui.ask("Which Raspberry Pi will be used (e.g.: 1, 2, 3b, 3b+, 4...):") + entry = catalog.arch_rpi_images().get(version) + if entry is None: + raise LimError(f"Version {version} isn't supported.") + plan.raspberry_pi_version = version + plan.boot_size = "+500M" + plan.base_download_url = "http://os.archlinuxarm.org/os/" + plan.image_name = entry["image"] + plan.luks_memory_cost = entry["luks_memory_cost"] + + +def choose_manjaro(plan: ImagePlan) -> None: + plan.boot_size = "+500M" + flavour = ui.ask("Which version(e.g.:architect,gnome) should be used:") + if flavour == "architect": + plan.image_checksum = "6b1c2fce12f244c1e32212767a9d3af2cf8263b2" + plan.base_download_url = ( + "https://osdn.net/frs/redir.php?m=dotsrc&f=%2Fstorage%2Fg%2Fm%2Fma" + "%2Fmanjaro%2Farchitect%2F20.0%2F" + ) + plan.image_name = "manjaro-architect-20.0-200426-linux56.iso" + elif flavour == "gnome": + release = ui.ask("Which release(e.g.:20,21,raspberrypi) should be used:") + entry = catalog.manjaro_gnome_releases().get(release) + if entry is None: + raise LimError(f"Gnome release {release} isn't supported.") + plan.image_checksum = entry.get("checksum") + plan.base_download_url = entry["url"] + plan.image_name = entry["image"] + plan.luks_memory_cost = entry.get("luks_memory_cost") + plan.raspberry_pi_version = entry.get("raspberry_pi_version") + else: + raise LimError(f"Manjaro version {flavour} isn't supported.") + + +def choose_moode(plan: ImagePlan) -> None: + plan.boot_size = "+200M" + plan.image_checksum = "185cbc9a4994534bb7a4bc2744c78197" + plan.base_download_url = ( + "https://github.com/moode-player/moode/releases/download/r651prod/" + ) + plan.image_name = "moode-r651-iso.zip" + + +def choose_retropie(plan: ImagePlan) -> None: + plan.boot_size = "+500M" + version = ui.ask("Which version(e.g.:1,2,3,4) should be used:") + entry = catalog.retropie_images().get(version) + if entry is None: + raise LimError(f"Version {version} isn't supported.") + plan.raspberry_pi_version = version + plan.base_download_url = ( + "https://github.com/RetroPie/RetroPie-Setup/releases/download/4.8/" + ) + plan.image_checksum = entry["checksum"] + plan.image_name = entry["image"] + + +def choose_torbox(plan: ImagePlan) -> None: + plan.base_download_url = "https://www.torbox.ch/data/" + plan.image_name = "torbox-20220102-v050.gz" + plan.image_checksum = ( + "0E1BA7FFD14AAAE5F0462C8293D95B62C3BF1D9E726E26977BD04772C55680D3" + ) + plan.boot_size = "+200M" + + +def choose_android_x86(plan: ImagePlan) -> None: + plan.base_download_url = ( + "https://www.fosshub.com/Android-x86.html?dwl=android-x86_64-9.0-r2.iso" + ) + plan.image_name = "android-x86_64-9.0-r2.iso" + plan.image_checksum = ( + "f7eb8fc56f29ad5432335dc054183acf086c539f3990f0b6e9ff58bd6df4604e" + ) + plan.boot_size = "+500M" + + +DISTRIBUTION_CHOOSERS = { + "android-x86": choose_android_x86, + "torbox": choose_torbox, + "arch": choose_arch, + "manjaro": choose_manjaro, + "moode": choose_moode, + "retropie": choose_retropie, +} + + +def choose_linux_image(plan: ImagePlan) -> None: + distribution = ui.ask( + "Which distribution should be used [arch,moode,retropie,manjaro,torbox...]?" + ) + chooser = DISTRIBUTION_CHOOSERS.get(distribution) + if chooser is None: + raise LimError(f"Distribution {distribution} isn't supported.") + plan.distribution = distribution + chooser(plan) + + +def choose_local_image(plan: ImagePlan) -> None: + ui.info("Available images:") + runner.run(["ls", "-l", str(plan.image_folder)], check=False) + plan.image_name = ui.ask("Which image would you like to use?") diff --git a/lim/image/chroot.py b/lim/image/chroot.py new file mode 100644 index 0000000..01ec227 --- /dev/null +++ b/lim/image/chroot.py @@ -0,0 +1,23 @@ +"""Mount an image and open an interactive shell inside it.""" + +from lim import device as device_module +from lim import runner, ui +from lim.image.session import ImageSession + + +def run_chroot() -> None: + ui.info("Starting chroot...") + device = device_module.select_device() + session = ImageSession(device) + try: + session.make_working_folder() + session.make_mount_folders() + session.decrypt_root() + session.mount_partitions() + session.mount_chroot_binds() + session.copy_qemu() + session.copy_resolv_conf() + ui.info("Bash shell starts...") + runner.run(["chroot", str(session.root_mount_path), "/bin/bash"], sudo=True) + finally: + session.destructor() diff --git a/lim/image/encryption.py b/lim/image/encryption.py new file mode 100644 index 0000000..c57df9f --- /dev/null +++ b/lim/image/encryption.py @@ -0,0 +1,117 @@ +"""Remote-unlockable LUKS boot configuration (dropbear + mkinitcpio). + +See https://gist.github.com/gea0/4fc2be0cb7a74d0e7cc4322aed710d38 and +https://gist.github.com/EnigmaCurry/2f9bed46073da8e38057fe78a61e7994 +""" + +from pathlib import Path + +from lim import catalog, fsutil, packages, runner, ui +from lim.image.plan import ImagePlan +from lim.image.session import ImageSession, chroot_bash, install_packages + +MKINITCPIO_HOOKS_PREFIX = ( + "base udev autodetect microcode modconf kms keyboard keymap consolefont block" +) +MKINITCPIO_HOOKS_SUFFIX = "filesystems fsck" + + +def _configure_mkinitcpio(plan: ImagePlan, root: Path) -> None: + # Concerning mkinitcpio warnings, see + # https://gist.github.com/imrvelj/c65cd5ca7f5505a65e59204f5a3f7a6d + mkinitcpio_path = root / "etc/mkinitcpio.conf" + ui.info(f"Configuring {mkinitcpio_path}...") + additional_modules = catalog.mkinitcpio_modules_by_rpi().get( + plan.raspberry_pi_version + ) + if additional_modules is None: + ui.warning(f"Version {plan.raspberry_pi_version} isn't supported.") + additional_modules = "" + modules = f"g_cdc usb_f_acm usb_f_ecm {additional_modules} g_ether".replace(" ", " ") + fsutil.replace_in_file("MODULES=()", f"MODULES=({modules})", mkinitcpio_path) + fsutil.replace_in_file( + "BINARIES=()", "BINARIES=(/usr/lib/libgcc_s.so.1)", mkinitcpio_path + ) + fsutil.replace_in_file( + f"HOOKS=({MKINITCPIO_HOOKS_PREFIX} {MKINITCPIO_HOOKS_SUFFIX})", + f"HOOKS=({MKINITCPIO_HOOKS_PREFIX} sleep netconf dropbear encryptssh " + f"{MKINITCPIO_HOOKS_SUFFIX})", + mkinitcpio_path, + ) + ui.info(f"Content of {mkinitcpio_path}:{mkinitcpio_path.read_text()}") + ui.info("Generating mkinitcpio...") + chroot_bash(root, "mkinitcpio -vP") + + +def _register_encrypted_root(plan: ImagePlan, session: ImageSession, root: Path) -> None: + fstab_path = root / "etc/fstab" + fstab_line = ( + f"UUID={session.root_partition_uuid} / {plan.root_filesystem}" + " defaults,noatime 0 1" + ) + ui.info(f"Configuring {fstab_path}...") + fsutil.ensure_line_in_file(fstab_line, fstab_path) + ui.info(f"Content of {fstab_path}:{fstab_path.read_text()}") + + crypttab_path = root / "etc/crypttab" + crypttab_line = ( + f"{session.root_mapper_name} UUID={session.root_partition_uuid} none luks" + ) + ui.info(f"Configuring {crypttab_path}...") + fsutil.ensure_line_in_file(crypttab_line, crypttab_path) + ui.info(f"Content of {crypttab_path}:{crypttab_path.read_text()}") + + +def _configure_bootloader(plan: ImagePlan, session: ImageSession, root: Path) -> None: + cryptdevice = ( + f"cryptdevice=UUID={session.root_partition_uuid}:{session.root_mapper_name} " + f"root={session.root_mapper_path}" + ) + boot_txt_path = session.boot_mount_path / "boot.txt" + if boot_txt_path.is_file(): + ui.info(f"Configuring {boot_txt_path}...") + hostname = (root / "etc/hostname").read_text().strip() + fsutil.replace_in_file( + "part uuid ${devtype} ${devnum}:2 uuid", "", boot_txt_path + ) + fsutil.replace_in_file( + "setenv bootargs console=ttyS1,115200 console=tty0 root=PARTUUID=${uuid} " + 'rw rootwait smsc95xx.macaddr="${usbethaddr}"', + f"setenv bootargs console=ttyS1,115200 console=tty0 " + f"ip=::::{hostname}:eth0:dhcp {cryptdevice} rw rootwait " + # Concerning issues with network adapter names, see + # https://forum.iobroker.net/topic/40542/raspberry-pi4-kein-eth0-mehr/16 + 'smsc95xx.macaddr="${usbethaddr}" net.ifnames=0 biosdevname=0', + boot_txt_path, + ) + ui.info(f"Content of {boot_txt_path}:{boot_txt_path.read_text()}") + ui.info("Generating...") + chroot_bash(root, "cd /boot/ && ./mkscr || exit 1") + else: + cmdline_txt_path = session.boot_mount_path / "cmdline.txt" + ui.info(f"Configuring {cmdline_txt_path}...") + fsutil.replace_in_file( + "root=/dev/mmcblk0p2", + f"{cryptdevice} rootfstype={plan.root_filesystem}", + cmdline_txt_path, + ) + ui.info(f"Content of {cmdline_txt_path}:{cmdline_txt_path.read_text()}") + + +def configure_encryption( + plan: ImagePlan, session: ImageSession, authorized_keys: Path +) -> None: + root = session.root_mount_path + ui.info("Setup encryption...") + ui.info("Installing neccessary software...") + install_packages( + plan.distribution, root, " ".join(packages.get_packages("server/luks")) + ) + + dropbear_root_key_path = root / "etc/dropbear/root_key" + ui.info(f"Adding {authorized_keys} to dropbear...") + runner.run(["cp", "-v", str(authorized_keys), str(dropbear_root_key_path)], sudo=True) + + _configure_mkinitcpio(plan, root) + _register_encrypted_root(plan, session, root) + _configure_bootloader(plan, session, root) diff --git a/lim/image/plan.py b/lim/image/plan.py new file mode 100644 index 0000000..4d75259 --- /dev/null +++ b/lim/image/plan.py @@ -0,0 +1,31 @@ +"""The image setup plan collected from the user's answers.""" + +from dataclasses import dataclass, field +from pathlib import Path + +DEFAULT_BOOT_SIZE = "+500M" + + +@dataclass +class ImagePlan: + operation_system: str = "linux" + distribution: str | None = None + base_download_url: str | None = None + image_name: str | None = None + image_checksum: str | None = None + boot_size: str = "" + luks_memory_cost: str | None = None + raspberry_pi_version: str | None = None + encrypt_system: bool = False + root_filesystem: str = "" + image_folder: Path = field(default_factory=Path) + + @property + def download_url(self) -> str | None: + if self.base_download_url is None or self.image_name is None: + return None + return f"{self.base_download_url}{self.image_name}" + + @property + def image_path(self) -> Path: + return self.image_folder / self.image_name diff --git a/lim/image/raspberry.py b/lim/image/raspberry.py new file mode 100644 index 0000000..b460a84 --- /dev/null +++ b/lim/image/raspberry.py @@ -0,0 +1,236 @@ +"""Raspberry-Pi-specific configuration of a freshly transferred image.""" + +from pathlib import Path + +from lim import device as device_module +from lim import fsutil, runner, ui +from lim.errors import LimError +from lim.image.encryption import configure_encryption +from lim.image.plan import ImagePlan +from lim.image.session import ImageSession, chroot_bash, install_packages + +ADMINISTRATOR_USERNAME = "administrator" + + +def configure_sudoers(root_mount_path: Path, username: str) -> None: + sudo_config_dir = root_mount_path / "etc/sudoers.d" + sudo_config_file = sudo_config_dir / username + sudo_config_dir.mkdir(parents=True, exist_ok=True) + try: + sudo_config_file.write_text(f"{username} ALL=(ALL:ALL) ALL\n") + sudo_config_file.chmod(0o440) + except OSError as exc: + raise LimError(f"Failed to create sudoers file for {username}: {exc}") from exc + + +def configure_ssh_key( + public_key_path: str, target_ssh_folder: Path, authorized_keys: Path +) -> None: + source = Path(public_key_path) + if not source.is_file(): + raise LimError( + f'The ssh key "{public_key_path}" can\'t be copied to ' + f'"{authorized_keys}" because it doesn\'t exist.' + ) + ui.info("Copy ssh key to target...") + target_ssh_folder.mkdir(parents=True, exist_ok=True) + authorized_keys.write_text(source.read_text()) + ui.info(f"{authorized_keys} contains the following: {authorized_keys.read_text()}") + ui.info("Set permissions with chmod...") + target_ssh_folder.chmod(0o700) + authorized_keys.chmod(0o600) + + +def _ensure_image_mounted(session: ImageSession) -> None: + ui.info("Start regular mounting procedure...") + if device_module.is_mounted(session.boot_partition_path): + ui.info(f"{session.boot_partition_path} is allready mounted...") + elif device_module.is_mounted(session.root_mapper_path): + ui.info(f"{session.root_mapper_path} is allready mounted...") + else: + session.decrypt_root() + session.mount_partitions() + if session.boot_partition_uuid is None: + # Partitions were mounted by an earlier run; fetch what mount_partitions + # would have provided so fstab/crypttab never see a None UUID. + session.read_partition_uuids() + + +def _seed_boot_uuid(session: ImageSession) -> None: + fstab_path = session.root_mount_path / "etc/fstab" + if "/dev/mmcblk0p1" not in fstab_path.read_text(): + # PARTUUID=/LABEL= based images (e.g. RetroPie, Manjaro ARM) have + # nothing to seed โ€” that is a valid state, not an error. + ui.info(f"{fstab_path} does not reference /dev/mmcblk0p1. Skipping UUID seeding.") + return + ui.info(f"Seeding UUID to {fstab_path} to avoid path conflicts...") + fsutil.replace_in_file( + "/dev/mmcblk0p1", f"UUID={session.boot_partition_uuid}", fstab_path + ) + ui.info(f"Content of {fstab_path}:{fstab_path.read_text()}") + + +def _select_target_user(root: Path) -> tuple[str, str]: + """Return (default username, target username), renaming the home on request.""" + target_home_path = root / "home" + home_entries = sorted(path.name for path in target_home_path.iterdir()) + if not home_entries: + raise LimError(f"No home directory found under {target_home_path}.") + default_username = home_entries[0] + rename = default_username != ADMINISTRATOR_USERNAME and ui.confirm( + f"Should the {default_username} be renamed to {ADMINISTRATOR_USERNAME}? " + ) + if not rename: + return default_username, default_username + ui.info( + f"Rename home directory from {target_home_path / default_username} " + f"to {target_home_path / ADMINISTRATOR_USERNAME}..." + ) + runner.run( + [ + "mv", + "-v", + str(target_home_path / default_username), + str(target_home_path / ADMINISTRATOR_USERNAME), + ], + sudo=True, + error_msg="Failed to rename home directory", + ) + return default_username, ADMINISTRATOR_USERNAME + + +def _change_passwords(root: Path, target_username: str) -> None: + password = ui.ask( + f"Type in new password for user root and {target_username} (leave empty to skip): " + ) + if not password: + ui.info("No password change requested, skipped password change...") + return + repeated = ui.ask(f'Repeat new password for "{target_username}": ') + if password != repeated: + raise LimError("Passwords didn't match.") + ui.info("Changing passwords on target system...") + chroot_bash( + root, + f"( echo '{password}'; echo '{password}' ) | passwd {target_username}\n" + f"( echo '{password}'; echo '{password}' ) | passwd\n", + error_msg="Failed to change password.", + ) + + +def _configure_hostname(root: Path) -> None: + hostname_path = root / "etc/hostname" + hostname = ui.ask("Type in the hostname (leave empty to skip): ") + if hostname: + hostname_path.write_text(hostname + "\n") + else: + hostname = hostname_path.read_text().strip() + ui.info("No hostname change requested, skipped hostname change...") + ui.info(f"Used hostname is: {hostname}") + + +def _update_system(plan: ImagePlan, root: Path) -> None: + if not ui.confirm("Should the system be updated?"): + return + ui.info("Updating system...") + if plan.distribution in ("arch", "manjaro"): + chroot_bash(root, "pacman --noconfirm -Syyu") + elif plan.distribution in ("moode", "retropie"): + chroot_bash(root, "yes | apt update\nyes | apt upgrade\n") + else: + ui.warning( + f'System update for operation system "{plan.distribution}" ' + "is not supported yet. Skipped." + ) + + +def _run_retropie_procedures(session: ImageSession, public_key_path: str) -> None: + if public_key_path: + (session.boot_mount_path / "ssh").write_text("\n") + if not ui.confirm("Should the RetroFlag specific procedures be executed?"): + return + ui.info("Executing RetroFlag specific procedures...") + chroot_bash( + session.root_mount_path, + 'wget -O - "https://raw.githubusercontent.com/RetroFlag/' + 'retroflag-picase/master/install_gpi.sh" | bash\n', + ) + + +def _configure_users_and_keys(session: ImageSession) -> tuple[str, Path]: + """Handle user rename, sudo rights and SSH key; return (public key path, authorized_keys).""" + root = session.root_mount_path + ui.info("Define target paths...") + default_username, target_username = _select_target_user(root) + renamed = default_username != target_username + target_user_ssh_folder = root / "home" / target_username / ".ssh" + target_authorized_keys = target_user_ssh_folder / "authorized_keys" + + if ui.confirm(f"Should the {target_username} have sudo rights? "): + configure_sudoers(root, target_username) + + public_key_path = ui.ask( + "Enter the path to the SSH key to be added to the image (default: none):" + ) + if public_key_path: + configure_ssh_key(public_key_path, target_user_ssh_folder, target_authorized_keys) + else: + ui.info("Skipped SSH-key copying..") + + ui.info("Start chroot procedures...") + session.mount_chroot_binds() + session.copy_qemu() + session.copy_resolv_conf() + + chroot_user_home = f"/home/{target_username}/" + if renamed: + ui.info("Delete old user and create new user") + chroot_bash( + root, + f"userdel -r {default_username}\n" + f"useradd -m -d {chroot_user_home} -s /bin/bash {target_username}\n" + f"chown -R {target_username}:{target_username} {chroot_user_home}\n", + error_msg="Failed to delete old user and create new user", + ) + + if public_key_path: + ui.info("Chroot to set ownership...") + chroot_bash( + root, + f"chown -vR {target_username}:{target_username} {chroot_user_home}.ssh\n", + ) + + _change_passwords(root, target_username) + return public_key_path, target_authorized_keys + + +def configure_raspberry_image(plan: ImagePlan, session: ImageSession) -> None: + _ensure_image_mounted(session) + root = session.root_mount_path + _seed_boot_uuid(session) + + public_key_path, target_authorized_keys = _configure_users_and_keys(session) + + _configure_hostname(root) + + if plan.distribution in ("arch", "manjaro"): + ui.info("Populating keys...") + chroot_bash( + root, + "yes | pacman-key --init\nyes | pacman-key --populate archlinuxarm\n", + ) + + _update_system(plan, root) + + ui.info(f"Installing software for filesystem {plan.root_filesystem}...") + if plan.root_filesystem == "btrfs": + install_packages(plan.distribution, root, "btrfs-progs") + else: + ui.info("Skipped.") + + if plan.encrypt_system: + configure_encryption(plan, session, target_authorized_keys) + + ui.info("Running system specific procedures...") + if plan.distribution == "retropie": + _run_retropie_procedures(session, public_key_path) diff --git a/lim/image/session.py b/lim/image/session.py new file mode 100644 index 0000000..566444f --- /dev/null +++ b/lim/image/session.py @@ -0,0 +1,190 @@ +"""Mount/unmount lifecycle for working on an image on a block device.""" + +import time +from pathlib import Path + +from lim import device as device_module +from lim import runner, ui +from lim.device import Device +from lim.errors import LimError + + +class ImageSession: + """Working folder, partition paths and (chroot) mounts for one device. + + Call ``destructor()`` in a finally block: it unmounts and removes + everything best-effort, mirroring partially completed setups. + """ + + def __init__(self, device: Device) -> None: + self.device = device + self.working_folder: Path | None = None + self.boot_mount_path: Path | None = None + self.root_mount_path: Path | None = None + self.boot_partition_path = device.partition(1) + self.root_partition_path = device.partition(2) + # Overridden by decrypt_root() when the root partition is LUKS. + self.root_mapper_path = self.root_partition_path + self.root_mapper_name: str | None = None + self.boot_partition_uuid: str | None = None + self.root_partition_uuid: str | None = None + + def make_working_folder(self) -> None: + # Mount points must live on the root fs; mkdir() fails on collisions. + self.working_folder = Path(f"/tmp/linux-image-manager-{int(time.time())}") # noqa: S108 + ui.info(f"Create temporary working folder in {self.working_folder}") + self.working_folder.mkdir(parents=True) + + def make_mount_folders(self) -> None: + if self.working_folder is None: + self.make_working_folder() + ui.info("Preparing mount paths...") + self.boot_mount_path = self.working_folder / "boot" + self.root_mount_path = self.working_folder / "root" + self.boot_mount_path.mkdir() + self.root_mount_path.mkdir() + + def root_is_luks(self) -> bool: + return device_module.blkid_value(self.root_partition_path, "TYPE") == "crypto_LUKS" + + def read_partition_uuids(self) -> None: + """Fetch partition UUIDs via blkid; derive the LUKS mapper name if unset.""" + self.root_partition_uuid = device_module.blkid_value( + self.root_partition_path, "UUID" + ) + self.boot_partition_uuid = device_module.blkid_value( + self.boot_partition_path, "UUID" + ) + if self.root_mapper_name is None and self.root_is_luks(): + # Same deterministic name decrypt_root() would have used. + self.root_mapper_name = f"linux-image-manager-{self.root_partition_uuid}" + self.root_mapper_path = f"/dev/mapper/{self.root_mapper_name}" + + def decrypt_root(self) -> None: + if not self.root_is_luks(): + return + self.root_partition_uuid = device_module.blkid_value( + self.root_partition_path, "UUID" + ) + self.root_mapper_name = f"linux-image-manager-{self.root_partition_uuid}" + self.root_mapper_path = f"/dev/mapper/{self.root_mapper_name}" + ui.info(f"Decrypting of {self.root_partition_path} is neccessary...") + runner.run( + [ + "cryptsetup", + "-v", + "luksOpen", + self.root_partition_path, + self.root_mapper_name, + ], + sudo=True, + ) + + def mount_partitions(self) -> None: + if self.boot_mount_path is None: + raise LimError("Mount folders are not prepared yet.") + ui.info("Mount boot and root partition...") + runner.run( + ["mount", "-v", self.boot_partition_path, str(self.boot_mount_path)], + sudo=True, + ) + runner.run( + ["mount", "-v", self.root_mapper_path, str(self.root_mount_path)], sudo=True + ) + ui.info("Setting uuid variables...") + self.read_partition_uuids() + ui.info("The following mounts refering this setup exist:") + runner.run(["findmnt", "-R", str(self.working_folder)], check=False) + + def mount_chroot_binds(self) -> None: + ui.info("Mount chroot environments...") + root = self.root_mount_path + runner.run(["mount", "--bind", str(self.boot_mount_path), f"{root}/boot"], sudo=True) + runner.run(["mount", "--bind", "/dev", f"{root}/dev"], sudo=True) + runner.run(["mount", "--bind", "/sys", f"{root}/sys"], sudo=True) + runner.run(["mount", "--bind", "/proc", f"{root}/proc"], sudo=True) + runner.run(["mount", "--bind", "/dev/pts", f"{root}/dev/pts"], sudo=True) + + def copy_qemu(self) -> None: + ui.info("Copy qemu binary...") + runner.run( + ["cp", "-v", "/usr/bin/qemu-arm-static", f"{self.root_mount_path}/usr/bin/"], + sudo=True, + ) + + def copy_resolv_conf(self) -> None: + ui.info("Copy resolv.conf...") + copied = runner.run( + [ + "cp", + "--remove-destination", + "-v", + "/etc/resolv.conf", + f"{self.root_mount_path}/etc/", + ], + sudo=True, + check=False, + ) + if copied.returncode != 0: + ui.warning("Failed. Probably there is no internet connection available.") + + def _umount(self, path: str, *, lazy: bool = False) -> None: + flags = ["-lv"] if lazy else ["-v"] + result = runner.run(["umount", *flags, path], sudo=True, check=False) + if result.returncode != 0: + ui.warning(f"Umounting {path} failed!") + + def _rmdir(self, path: Path | None) -> None: + if path is None: + return + result = runner.run(["rmdir", "-v", str(path)], sudo=True, check=False) + if result.returncode != 0: + ui.warning(f"Removing {path} failed!") + + def destructor(self) -> None: + ui.info("Cleaning up...") + ui.info("Unmounting everything...") + root = self.root_mount_path + if root is not None: + self._umount(f"{root}/dev/pts", lazy=True) + self._umount(f"{root}/dev", lazy=True) + self._umount(f"{root}/proc") + self._umount(f"{root}/sys") + self._umount(f"{root}/boot") + self._umount(str(root)) + if self.boot_mount_path is not None: + self._umount(str(self.boot_mount_path)) + ui.info("Deleting mount folders...") + self._rmdir(self.root_mount_path) + self._rmdir(self.boot_mount_path) + self._rmdir(self.working_folder) + if self.root_mapper_name and self.root_is_luks(): + ui.info(f"Trying to close decrypted {self.root_mapper_name}...") + closed = runner.run( + ["cryptsetup", "-v", "luksClose", self.root_mapper_name], + sudo=True, + check=False, + ) + if closed.returncode != 0: + ui.warning("Failed.") + + +def chroot_bash(root_mount_path: Path | str, script: str, error_msg: str | None = None) -> None: + """Run a bash script inside the image via chroot.""" + runner.run( + ["chroot", str(root_mount_path), "/bin/bash"], + input_text=script, + sudo=True, + error_msg=error_msg, + ) + + +def install_packages(distribution: str, root_mount_path: Path, package_names: str) -> None: + """Install packages inside the image with the distribution's package manager.""" + ui.info(f"Installing {package_names}...") + if distribution in ("arch", "manjaro"): + chroot_bash(root_mount_path, f"pacman --noconfirm -S --needed {package_names}") + elif distribution in ("moode", "retropie"): + chroot_bash(root_mount_path, f"yes | apt install {package_names}") + else: + raise LimError("Package manager not supported.") diff --git a/lim/image/setup.py b/lim/image/setup.py new file mode 100644 index 0000000..469653a --- /dev/null +++ b/lim/image/setup.py @@ -0,0 +1,96 @@ +"""Interactive Linux image setup: download, verify, transfer, configure. + +Reference for encrypted Raspberry Pi images: +https://wiki.polaire.nl/doku.php?id=archlinux-raspberry-encrypted +""" + +import pwd +from pathlib import Path + +from lim import device as device_module +from lim import system, ui +from lim.errors import LimError +from lim.image import choosers, transfer, verify +from lim.image.plan import ImagePlan +from lim.image.raspberry import configure_raspberry_image +from lim.image.session import ImageSession + + +def _prepare_image_folder(plan: ImagePlan) -> None: + ui.info("Configure user...") + origin_username = ui.ask("Please type in a valid working username:") + try: + pwd.getpwnam(origin_username) + except KeyError: + raise LimError(f"User {origin_username} doesn't exist.") from None + + ui.info("Image routine starts...") + plan.image_folder = Path(f"/home/{origin_username}/Software/Images") + ui.info(f'The images will be stored in "{plan.image_folder}".') + if not plan.image_folder.is_dir(): + ui.info(f'Folder "{plan.image_folder}" doesn\'t exist. It will be created now.') + plan.image_folder.mkdir(parents=True) + + +def _select_unmounted_device() -> device_module.Device: + device = device_module.select_device() + if device_module.is_mounted(device.path): + raise LimError( + f'Device {device.path} is allready mounted. ' + f'Umount with "umount {device.path}*".' + ) + return device + + +def _choose_and_verify_image(plan: ImagePlan) -> None: + plan.operation_system = ui.ask( + "Which operation system would you like to use [linux,windows,...]?" + ) + if plan.operation_system == "linux": + choosers.choose_linux_image(plan) + plan.encrypt_system = ui.confirm("Should the system be encrypted?") + ui.info("Generating os-image...") + transfer.download_image(plan) + else: + choosers.choose_local_image(plan) + + ui.info("Verifying image...") + ui.info("Verifying checksum...") + if plan.image_checksum is None and plan.download_url is not None: + plan.image_checksum = verify.resolve_checksum(plan.download_url) + verify.verify_checksum(plan.image_path, plan.image_checksum) + if plan.download_url is not None: + verify.verify_signature(plan.download_url, plan.image_path, plan.image_folder) + + +def run_setup() -> None: + ui.info("Setupscript for images started...") + ui.info("Checking if root...") + if not system.is_root(): + raise LimError("This script must be executed as root!") + + plan = ImagePlan() + _prepare_image_folder(plan) + device = _select_unmounted_device() + _choose_and_verify_image(plan) + + session = ImageSession(device) + try: + session.make_working_folder() + session.make_mount_folders() + + plan.root_filesystem = ui.ask( + "Which filesystem should be used? E.g.:btrfs,ext4... (none):" + ) + + if ui.confirm(f"Should the image be transfered to {device.path}?"): + transfer.transfer_image(plan, session) + else: + ui.info("Skipping image transfer...") + + if plan.raspberry_pi_version: + configure_raspberry_image(plan, session) + finally: + session.destructor() + + ui.success("Setup successfull :)") diff --git a/lim/image/transfer.py b/lim/image/transfer.py new file mode 100644 index 0000000..ed98fc8 --- /dev/null +++ b/lim/image/transfer.py @@ -0,0 +1,140 @@ +"""Download the selected image and transfer it onto the target device.""" + +from pathlib import Path + +from lim import device as device_module +from lim import runner, ui +from lim.errors import LimError +from lim.image.plan import DEFAULT_BOOT_SIZE, ImagePlan +from lim.image.session import ImageSession + + +def download_image(plan: ImagePlan) -> None: + if ui.confirm("Should the image download be forced?"): + if plan.image_path.is_file(): + ui.info(f"Removing image {plan.image_path}.") + plan.image_path.unlink() + else: + ui.info( + "Forcing download wasn't neccessary. " + f"File {plan.image_path} doesn't exist." + ) + + ui.info("Start Download procedure...") + if plan.image_path.is_file(): + ui.info("Image exist local. Download skipped.") + return + ui.info(f'Image "{plan.image_name}" doesn\'t exist under local path "{plan.image_path}".') + ui.info(f'Image "{plan.image_name}" gets downloaded from "{plan.download_url}"...') + runner.run( + ["wget", plan.download_url, "-O", str(plan.image_path)], + error_msg=f'Download from "{plan.download_url}" failed.', + ) + + +def arch_partition_input(boot_size: str) -> str: + """Fdisk answers: FAT32 boot partition of boot_size plus root partition.""" + return ( + "o\n" # clear out any partitions on the drive + "p\n" # list partitions (should be empty) + "n\np\n1\n\n" # new primary partition 1, default start sector + f"{boot_size}\n" + "t\nc\n" # set partition 1 to type W95 FAT32 (LBA) + "n\np\n2\n\n\n" # new primary partition 2 over the remaining space + "w\n" # write partition table + ) + + +def decompress_command(image_path: Path) -> list[str]: + """Build the command that streams the raw image to stdout for dd.""" + suffix = image_path.suffix + if suffix == ".zip": + return ["unzip", "-p", str(image_path)] + if suffix == ".gz": + return ["gunzip", "-c", str(image_path)] + if suffix == ".iso": + return ["pv", str(image_path)] + if suffix == ".xz": + return ["unxz", "-c", str(image_path)] + raise LimError(f'Image transfer for "{image_path.name}" is not supported yet!') + + +def _luks_format_root(plan: ImagePlan, session: ImageSession) -> None: + luks_format = [ + "cryptsetup", "-v", "luksFormat", + "-c", "aes-xts-plain64", "-s", "512", "-h", "sha512", + "--use-random", "-i", "1000", + ] + if plan.luks_memory_cost: + ui.info( + f"Formating {session.root_partition_path} with LUKS " + f"with --pbkdf-memory set to {plan.luks_memory_cost}" + ) + luks_format += ["--pbkdf-memory", plan.luks_memory_cost] + else: + ui.info(f"Formating {session.root_partition_path} with LUKS") + runner.run([*luks_format, session.root_partition_path], sudo=True) + + +def transfer_arch_image(plan: ImagePlan, session: ImageSession) -> None: + boot_size = plan.boot_size or DEFAULT_BOOT_SIZE + ui.info(f"The boot partition will be set to {boot_size}.") + ui.info("Creating partitions...") + runner.run( + ["fdisk", session.device.path], + input_text=arch_partition_input(boot_size), + sudo=True, + ) + + ui.info("Format boot partition...") + runner.run(["mkfs.vfat", session.boot_partition_path], sudo=True) + + if plan.encrypt_system: + _luks_format_root(plan, session) + session.decrypt_root() + + ui.info("Format root partition...") + runner.run([f"mkfs.{plan.root_filesystem}", "-f", session.root_mapper_path], sudo=True) + session.mount_partitions() + + ui.info("Root files will be transfered to device...") + runner.run( + ["bsdtar", "-xpf", str(plan.image_path), "-C", str(session.root_mount_path)], + sudo=True, + ) + runner.sync_disks() + + ui.info("Boot files will be transfered to device...") + boot_source = session.root_mount_path / "boot" + for entry in sorted(boot_source.iterdir()): + runner.run(["mv", "-v", str(entry), str(session.boot_mount_path)], sudo=True) + + +def transfer_image(plan: ImagePlan, session: ImageSession) -> None: + if ui.confirm(f"Should the partition table of {session.device.path} be deleted?"): + ui.info("Deleting...") + runner.run(["wipefs", "-a", session.device.path], sudo=True) + else: + ui.info("Skipping partition table deletion...") + + device_module.overwrite_device(session.device) + + ui.info("Starting image transfer...") + if plan.distribution == "arch": + transfer_arch_image(plan, session) + return + blocksize = session.device.optimal_blocksize + ui.info(f"Transfering {plan.image_path.suffix} file...") + runner.pipeline( + decompress_command(plan.image_path), + [ + "dd", + f"of={session.device.path}", + f"bs={blocksize}", + "conv=fsync", + "status=progress", + ], + sudo_last=True, + error_msg=f"DD {plan.image_path} to {session.device.path} failed.", + ) + runner.sync_disks() diff --git a/lim/image/verify.py b/lim/image/verify.py new file mode 100644 index 0000000..6a77e73 --- /dev/null +++ b/lim/image/verify.py @@ -0,0 +1,100 @@ +"""Image integrity (checksums) and authenticity (GPG signatures) checks.""" + +import hashlib +import re +from pathlib import Path + +from lim import runner, ui +from lim.errors import LimError + +ALGORITHM_BY_DIGEST_LENGTH = { + 32: "md5", + 40: "sha1", + 64: "sha256", + 128: "sha512", +} + + +def url_exists(url: str) -> bool: + return runner.succeeds(["wget", "-q", "--method=HEAD", url]) + + +def resolve_checksum(download_url: str) -> str | None: + """Try to fetch a published checksum next to the image download.""" + for extension in ("sha1", "sha512", "md5"): + checksum_url = f"{download_url}.{extension}" + ui.info( + "Image Checksum is not defined. " + f"Try to download image signature from {checksum_url}." + ) + if url_exists(checksum_url): + content = runner.output(["wget", checksum_url, "-q", "-O", "-"]) + checksum = content.split()[0] if content.split() else "" + if checksum: + ui.info(f"Defined image_checksum as {checksum}") + return checksum + ui.warning(f"No checksum found under {checksum_url}.") + return None + + +def verify_checksum(image_path: str | Path, checksum: str | None) -> None: + """Verify the image against an md5/sha1/sha256/sha512 hex checksum.""" + if not checksum: + ui.warning("No checksum is defined. Skipping checksum verification.") + return + algorithm = ALGORITHM_BY_DIGEST_LENGTH.get(len(checksum)) + if algorithm is None: + raise LimError( + f"Checksum '{checksum}' has no recognized digest length " + "(expected md5, sha1, sha256 or sha512)." + ) + ui.info(f"Checking {algorithm} checksum...") + digest = hashlib.new(algorithm) + with Path(image_path).open("rb") as handle: + while chunk := handle.read(1024 * 1024): + digest.update(chunk) + if digest.hexdigest() != checksum.lower(): + raise LimError("Verification failed. HINT: Force the download of the image.") + ui.info(f"{algorithm} checksum verified.") + + +def verify_signature(download_url: str, image_path: str | Path, image_folder: Path) -> None: + """Best-effort GPG signature verification of the downloaded image.""" + ui.info("Note: Checksums verify integrity but do not confirm authenticity.") + ui.info( + "Proceeding to signature verification, " + "which ensures the file comes from a trusted source." + ) + signature_url = f"{download_url}.sig" + ui.info(f"Attempting to download the image signature from: {signature_url}") + if not url_exists(signature_url): + ui.warning(f"No signature found under {signature_url}.") + return + + signature_path = image_folder / (Path(str(image_path)).name + ".sig") + if not runner.succeeds(["wget", "-q", "-O", str(signature_path), signature_url]): + ui.warning("Failed to download the signature file.") + return + + ui.info("Extract the key ID from the signature file") + verification = runner.output( + ["gpg", "--status-fd", "1", "--verify", str(signature_path), str(image_path)], + check=False, + ) + missing_keys = re.findall(r"NO_PUBKEY (\S+)", verification) + if missing_keys: + key_id = missing_keys[-1] + if runner.succeeds(["gpg", "--list-keys", key_id]): + ui.info(f"Key {key_id} already in keyring.") + else: + ui.info("Import the public key") + runner.run( + ["gpg", "--keyserver", "keyserver.ubuntu.com", "--recv-keys", key_id], + check=False, + ) + + ui.info("Verify the signature") + if runner.succeeds(["gpg", "--verify", str(signature_path), str(image_path)]): + ui.info("Signature verification succeeded.") + else: + ui.warning("Signature verification failed.") diff --git a/lim/luks.py b/lim/luks.py new file mode 100644 index 0000000..b7c5202 --- /dev/null +++ b/lim/luks.py @@ -0,0 +1,80 @@ +"""LUKS key management plus crypttab/fstab bookkeeping.""" + +import re +from pathlib import Path + +from lim import fsutil, runner, ui +from lim.errors import LimError + +LUKS_KEY_DIRECTORY = Path("/etc/luks-keys") +CRYPTTAB_PATH = Path("/etc/crypttab") +FSTAB_PATH = Path("/etc/fstab") + + +def luks_uuid(partition_path: str) -> str: + dump = runner.output(["cryptsetup", "luksDump", partition_path], sudo=True) + match = re.search(r"UUID:\s*(\S+)", dump) + if not match: + raise LimError(f"Could not read LUKS UUID of {partition_path}.") + return match.group(1) + + +def create_luks_key_and_update_crypttab( + mapper_name: str, + partition_path: str, + *, + key_directory: Path = LUKS_KEY_DIRECTORY, + crypttab_path: Path = CRYPTTAB_PATH, +) -> None: + """Generate a random keyfile, register it with LUKS and /etc/crypttab.""" + ui.info("Creating luks-key-directory...") + key_directory.mkdir(parents=True, exist_ok=True) + secret_key_path = key_directory / f"{mapper_name}.keyfile" + ui.info(f"Generate secret key under: {secret_key_path}") + if secret_key_path.exists(): + ui.warning("File already exists. Overwriting!") + runner.run( + ["dd", "if=/dev/urandom", f"of={secret_key_path}", "bs=512", "count=8"], + sudo=True, + ) + runner.sync_disks() + + ui.info("Opening and closing device to verify that everything works fine...") + closed = runner.run( + ["cryptsetup", "-v", "luksClose", mapper_name], sudo=True, check=False + ) + if closed.returncode != 0: + ui.info(f"No need to luksClose {mapper_name}. Device isn't open.") + runner.run( + ["cryptsetup", "luksAddKey", partition_path, str(secret_key_path)], sudo=True + ) + runner.run( + [ + "cryptsetup", + "-v", + "luksOpen", + partition_path, + mapper_name, + f"--key-file={secret_key_path}", + ], + sudo=True, + ) + runner.run(["cryptsetup", "-v", "luksClose", mapper_name], sudo=True) + + ui.info("Reading UUID...") + uuid = luks_uuid(partition_path) + entry = f"{mapper_name} UUID={uuid} {secret_key_path} luks" + ui.info("Adding crypttab entry...") + fsutil.ensure_line_in_file(entry, crypttab_path) + ui.info(f"The file {crypttab_path} contains now the following:") + print(crypttab_path.read_text()) + + +def update_fstab( + mapper_path: str, mount_path: str, *, fstab_path: Path = FSTAB_PATH +) -> None: + entry = f"{mapper_path} {mount_path} btrfs defaults 0 2" + ui.info("Adding fstab entry...") + fsutil.ensure_line_in_file(entry, fstab_path) + ui.info(f"The file {fstab_path} contains now the following:") + print(fstab_path.read_text()) diff --git a/lim/packages.py b/lim/packages.py new file mode 100644 index 0000000..f84c953 --- /dev/null +++ b/lim/packages.py @@ -0,0 +1,18 @@ +"""Reads package collections from configuration/packages/.""" + +from lim import config +from lim.errors import LimError + + +def get_packages(*collections: str) -> list[str]: + """Package names from the given collections, comments stripped.""" + names: list[str] = [] + for collection in collections: + path = config.PACKAGE_PATH / f"{collection}.txt" + if not path.is_file(): + raise LimError(f"Package collection {path} does not exist.") + for line in path.read_text().splitlines(): + name = line.split("#", 1)[0].strip() + if name: + names.append(name) + return names diff --git a/lim/runner.py b/lim/runner.py new file mode 100644 index 0000000..d984bae --- /dev/null +++ b/lim/runner.py @@ -0,0 +1,134 @@ +"""Thin wrapper around subprocess for all external commands. + +Every module calls external tools through this module so tests can +replace ``run``/``output``/``succeeds``/``pipeline`` with fakes. + +Missing binaries never surface as tracebacks: actions (``check=True``, +``pipeline``) abort with a clear LimError, tolerated calls +(``check=False``, ``succeeds``) degrade to a warning plus shell-style +exit code 127. +""" + +import os +import shlex +import subprocess + +from lim import ui +from lim.errors import LimError + +COMMAND_NOT_FOUND = 127 + + +def _with_sudo(cmd: list[str], *, sudo: bool) -> list[str]: + parts = [str(part) for part in cmd] + if sudo and os.geteuid() != 0: + return ["sudo", *parts] + return parts + + +def _not_found_message(cmd: list[str]) -> str: + return f"Command not found: {cmd[0]} โ€” please install it." + + +def run( + cmd: list[str], + *, + sudo: bool = False, + input_text: str | None = None, + check: bool = True, + error_msg: str | None = None, +) -> subprocess.CompletedProcess: + """Run a command inheriting stdio so progress output stays visible.""" + cmd = _with_sudo(cmd, sudo=sudo) + ui.info(f"Running: {shlex.join(cmd)}") + try: + result = subprocess.run(cmd, input=input_text, text=True, check=False) + except FileNotFoundError: + if check: + raise LimError(error_msg or _not_found_message(cmd)) from None + ui.warning(_not_found_message(cmd)) + return subprocess.CompletedProcess(cmd, COMMAND_NOT_FOUND) + if check and result.returncode != 0: + raise LimError( + error_msg + or f"Command failed with code {result.returncode}: {shlex.join(cmd)}" + ) + return result + + +def output( + cmd: list[str], + *, + sudo: bool = False, + check: bool = True, + error_msg: str | None = None, +) -> str: + """Run a command and return its stripped stdout.""" + cmd = _with_sudo(cmd, sudo=sudo) + try: + result = subprocess.run(cmd, text=True, capture_output=True, check=False) + except FileNotFoundError: + if check: + raise LimError(error_msg or _not_found_message(cmd)) from None + ui.warning(_not_found_message(cmd)) + return "" + if check and result.returncode != 0: + raise LimError( + error_msg + or f"Command failed with code {result.returncode}: {shlex.join(cmd)}" + + (f"\n{result.stderr.strip()}" if result.stderr else "") + ) + return result.stdout.strip() + + +def succeeds(cmd: list[str], *, sudo: bool = False) -> bool: + """Run a command silently, report whether it exited with 0.""" + cmd = _with_sudo(cmd, sudo=sudo) + try: + return subprocess.run(cmd, capture_output=True, check=False).returncode == 0 + except FileNotFoundError: + ui.warning(_not_found_message(cmd)) + return False + + +def pipeline( + *cmds: list[str], + sudo_last: bool = False, + error_msg: str | None = None, +) -> None: + """Run commands as a shell-style pipeline (cmd1 | cmd2 | ...).""" + prepared = [ + _with_sudo(cmd, sudo=sudo_last and index == len(cmds) - 1) + for index, cmd in enumerate(cmds) + ] + pretty = " | ".join(shlex.join(cmd) for cmd in prepared) + ui.info(f"Running: {pretty}") + processes: list[subprocess.Popen] = [] + previous_stdout = None + try: + for index, cmd in enumerate(prepared): + last = index == len(prepared) - 1 + process = subprocess.Popen( + cmd, + stdin=previous_stdout, + stdout=None if last else subprocess.PIPE, + ) + if previous_stdout is not None: + previous_stdout.close() + previous_stdout = process.stdout + processes.append(process) + except FileNotFoundError as exc: + if previous_stdout is not None: + previous_stdout.close() + for process in processes: + process.kill() + process.wait() + raise LimError(f"Command not found: {exc.filename} โ€” please install it.") from None + for process in processes: + process.wait() + if any(process.returncode != 0 for process in processes): + raise LimError(error_msg or f"Pipeline failed: {pretty}") + + +def sync_disks() -> None: + run(["sync"]) diff --git a/lim/storage/__init__.py b/lim/storage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lim/storage/common.py b/lim/storage/common.py new file mode 100644 index 0000000..479967b --- /dev/null +++ b/lim/storage/common.py @@ -0,0 +1,38 @@ +"""Shared path derivation for encrypted storage setups.""" + +from dataclasses import dataclass + +from lim import device as device_module +from lim import ui +from lim.device import Device + + +@dataclass(frozen=True) +class StorageTarget: + """An encrypted drive plus all derived mapper/mount/partition paths.""" + + device: Device + + @property + def mapper_name(self) -> str: + return f"encrypteddrive-{self.device.name}" + + @property + def mapper_path(self) -> str: + return f"/dev/mapper/{self.mapper_name}" + + @property + def mount_path(self) -> str: + return f"/media/{self.mapper_name}" + + @property + def partition_path(self) -> str: + return self.device.partition(1) + + +def select_storage_target() -> StorageTarget: + target = StorageTarget(device_module.select_device()) + ui.info(f"mapper name set to : {target.mapper_name}") + ui.info(f"mapper path set to : {target.mapper_path}") + ui.info(f"mount path set to : {target.mount_path}") + return target diff --git a/lim/storage/raid1.py b/lim/storage/raid1.py new file mode 100644 index 0000000..0387261 --- /dev/null +++ b/lim/storage/raid1.py @@ -0,0 +1,68 @@ +"""Encrypted Btrfs RAID1 across two drives. + +See https://balaskas.gr/btrfs/raid1.html and +https://mutschler.eu/linux/install-guides/ubuntu-btrfs-raid1/ +""" + +from lim import luks, runner, ui +from lim.storage.common import StorageTarget, select_storage_target + + +def _select_pair() -> tuple[StorageTarget, StorageTarget]: + ui.info("RAID1 partition 1...") + first = select_storage_target() + ui.info("RAID1 partition 2...") + second = select_storage_target() + return first, second + + +def setup() -> None: + first, second = _select_pair() + + ui.info(f"Encrypting {first.device.path}...") + runner.run(["cryptsetup", "luksFormat", first.device.path], sudo=True) + ui.info(f"Encrypting {second.device.path}...") + runner.run(["cryptsetup", "luksFormat", second.device.path], sudo=True) + + runner.run(["cryptsetup", "luksOpen", first.device.path, first.mapper_name], sudo=True) + runner.run( + ["cryptsetup", "luksOpen", second.device.path, second.mapper_name], sudo=True + ) + runner.run(["cryptsetup", "status", first.mapper_path], sudo=True) + runner.run(["cryptsetup", "status", second.mapper_path], sudo=True) + + runner.run( + [ + "mkfs.btrfs", + "-m", + "raid1", + "-d", + "raid1", + first.mapper_path, + second.mapper_path, + ], + sudo=True, + ) + + ui.success("Encryption successfull :)") + + +def _show_luks_devices() -> None: + for name in runner.output(["lsblk", "-dno", "NAME"], check=False).split(): + if runner.succeeds(["cryptsetup", "isLuks", f"/dev/{name}"], sudo=True): + ui.info(f"/dev/{name} is a LUKS encrypted storage device.") + + +def mount_on_boot() -> None: + ui.info("Activate Automount raid1 encrypted storages...") + _show_luks_devices() + + first, second = _select_pair() + + luks.create_luks_key_and_update_crypttab(first.mapper_name, first.device.path) + ui.info(f'Creating mount folder under "{first.mount_path}"...') + runner.run(["mkdir", "-vp", first.mount_path], sudo=True) + luks.create_luks_key_and_update_crypttab(second.mapper_name, second.device.path) + luks.update_fstab(first.mapper_path, first.mount_path) + + ui.success("Installation finished. Please restart :)") diff --git a/lim/storage/single_drive.py b/lim/storage/single_drive.py new file mode 100644 index 0000000..273ae0f --- /dev/null +++ b/lim/storage/single_drive.py @@ -0,0 +1,88 @@ +"""Single-drive LUKS + Btrfs storage: setup, mount, umount, mount-on-boot.""" + +from lim import device as device_module +from lim import luks, runner, system, ui +from lim.storage.common import select_storage_target + +# fdisk answer sequences; empty lines accept the defaults. +CREATE_GPT_TABLE_INPUT = "g\nw\n" +CREATE_PARTITION_INPUT = "n\n\n\n\np\nw\n" + + +def setup() -> None: + print("Setups disk encryption") + target = select_storage_target() + + device_module.overwrite_device(target.device) + + ui.info("Creating new GPT partition table...") + runner.run( + ["fdisk", "--wipe", "always", target.device.path], + input_text=CREATE_GPT_TABLE_INPUT, + sudo=True, + ) + ui.info("Creating partition table...") + runner.run( + ["fdisk", "--wipe", "always", target.device.path], + input_text=CREATE_PARTITION_INPUT, + sudo=True, + ) + + ui.info(f"Encrypt {target.device.path}...") + runner.run(["cryptsetup", "-v", "-y", "luksFormat", target.partition_path], sudo=True) + + ui.info("Unlock partition...") + runner.run( + ["cryptsetup", "luksOpen", target.partition_path, target.mapper_name], sudo=True + ) + + ui.info("Create btrfs file system...") + runner.run(["mkfs.btrfs", target.mapper_path], sudo=True) + + ui.info(f'Creating mount folder under "{target.mount_path}"...') + runner.run(["mkdir", "-p", target.mount_path], sudo=True) + + ui.info("Mount partition...") + runner.run(["mount", target.mapper_path, target.mount_path], sudo=True) + + user = system.real_user() + ui.info("Own partition by user...") + runner.run(["chown", "-R", f"{user}:{user}", target.mount_path], sudo=True) + + ui.success("Encryption successfull :)") + + +def mount() -> None: + print("Mounts encrypted storages") + target = select_storage_target() + + ui.info("Unlock partition...") + runner.run( + ["cryptsetup", "luksOpen", target.partition_path, target.mapper_name], sudo=True + ) + + ui.info("Mount partition...") + runner.run(["mount", target.mapper_path, target.mount_path], sudo=True) + + ui.success("Mounting successfull :)") + + +def umount() -> None: + print("Unmount encrypted storages") + target = select_storage_target() + + ui.info(f"Unmount {target.mapper_path}...") + runner.run(["umount", target.mapper_path], sudo=True) + runner.run(["cryptsetup", "luksClose", target.mapper_name], sudo=True) + + ui.success("Successfull :)") + + +def mount_on_boot() -> None: + print("Automount encrypted storages") + target = select_storage_target() + + luks.create_luks_key_and_update_crypttab(target.mapper_name, target.partition_path) + luks.update_fstab(target.mapper_path, target.mount_path) + + ui.success("Installation finished. Please restart :)") diff --git a/lim/system.py b/lim/system.py new file mode 100644 index 0000000..958d343 --- /dev/null +++ b/lim/system.py @@ -0,0 +1,35 @@ +"""Process-level helpers: privilege handling and user resolution.""" + +import getpass +import os +import sys +from pathlib import Path + +from lim import ui + + +def is_root() -> bool: + return os.geteuid() == 0 + + +def ensure_root() -> None: + """Re-execute the current command with sudo when not running as root.""" + if is_root(): + return + ui.info("Root privileges required. Re-executing with sudo...") + script = str(Path(sys.argv[0]).resolve()) + # Deliberate privilege escalation: replace this process with sudo. + os.execvp("sudo", ["sudo", sys.executable, script, *sys.argv[1:]]) # noqa: S606 + + +def real_user() -> str: + """Return the invoking user, even when running under sudo.""" + return os.environ.get("SUDO_USER") or getpass.getuser() + + +def real_home() -> Path: + """Home directory of the invoking user, even when running under sudo.""" + sudo_user = os.environ.get("SUDO_USER") + if sudo_user: + return Path("/home") / sudo_user + return Path.home() diff --git a/lim/ui.py b/lim/ui.py new file mode 100644 index 0000000..a524d6c --- /dev/null +++ b/lim/ui.py @@ -0,0 +1,62 @@ +"""Colored console messages and interactive prompts.""" + +import sys + +_USE_COLOR = sys.stdout.isatty() + + +def _color(code: str) -> str: + return f"\033[{code}m" if _USE_COLOR else "" + + +COLOR_RED = _color("31") +COLOR_GREEN = _color("32") +COLOR_YELLOW = _color("33") +COLOR_BLUE = _color("34") +COLOR_MAGENTA = _color("35") +COLOR_CYAN = _color("36") +COLOR_WHITE = _color("37") +COLOR_RESET = _color("0") + + +def message(color: str, tag: str, text: str) -> None: + print(f"{color}[{tag}]:{COLOR_RESET} {text}") + + +def question(text: str) -> None: + message(COLOR_MAGENTA, "QUESTION", text) + + +def info(text: str) -> None: + message(COLOR_BLUE, "INFO", text) + + +def warning(text: str) -> None: + message(COLOR_YELLOW, "WARNING", text) + + +def success(text: str) -> None: + message(COLOR_GREEN, "SUCCESS", text) + + +def error(text: str) -> None: + message(COLOR_RED, "ERROR", text) + + +def ask(prompt: str) -> str: + question(prompt) + return input().strip() + + +def confirm(prompt: str) -> bool: + return ask(f"{prompt}(y/N)") == "y" + + +def header() -> None: + print( + f"\n{COLOR_YELLOW}The\n" + "LINUX IMAGE MANAGER\n" + "is an administration tool designed from and for Kevin Veen-Birkenbach.\n\n" + "Licensed under GNU GENERAL PUBLIC LICENSE Version 3" + f"{COLOR_RESET}\n" + ) diff --git a/main.py b/main.py index fa3fbc1..0193d8d 100755 --- a/main.py +++ b/main.py @@ -1,118 +1,12 @@ #!/usr/bin/env python3 -import subprocess -import os -import argparse +"""Entry point for the Linux Image Manager (lim).""" + import sys +from pathlib import Path -def run_script(script_path, extra_args): - if not os.path.exists(script_path): - print(f"[ERROR] Script not found at {script_path}") - exit(1) - command = ["sudo", "bash", script_path] + extra_args - print(f"[INFO] Running command: {' '.join(command)}") - # Pass the parent's stdout and stderr so that progress output shows in real time. - result = subprocess.run(command, stdout=sys.stdout, stderr=sys.stderr) - if result.returncode != 0: - print(f"[ERROR] Script exited with code {result.returncode}") - exit(result.returncode) - print("[SUCCESS] Script executed successfully.") +sys.path.insert(0, str(Path(__file__).resolve().parent)) -def main(): - # Use os.path.realpath to get the actual path of this file regardless of symlinks. - repo_root = os.path.dirname(os.path.realpath(__file__)) - - # Define available scripts along with their descriptions. - setup_scripts = { - "image": { - "path": os.path.join(repo_root, "scripts", "image", "setup.sh"), - "description": ( - "Linux Image Setup:\n" - " - Creates partitions and formats them.\n" - " - Transfers the Linux image file to the device.\n" - " - Configures boot and root partitions." - ) - }, - "single": { - "path": os.path.join(repo_root, "scripts", "encryption", "storage", "single_drive", "setup.sh"), - "description": ( - "Single Drive Encryption Setup:\n" - " - Sets up disk encryption using LUKS on one drive.\n" - " - Configures a Btrfs file system for secure storage." - ) - }, - "raid1": { - "path": os.path.join(repo_root, "scripts", "encryption", "storage", "raid1", "setup.sh"), - "description": ( - "RAID1 Encryption Setup:\n" - " - Configures a virtual RAID1 with two drives.\n" - " - Uses LUKS encryption and a Btrfs RAID1 file system for redundancy." - ) - }, - "backup": { - "path": os.path.join(repo_root, "scripts", "image", "backup.sh"), - "description": ( - "Backup Image Setup:\n" - " - Creates an image backup from a memory device to a file.\n" - " - Uses dd to transfer the image from the specified device to an image file." - ) - }, - "chroot": { - "path": os.path.join(repo_root, "scripts", "image", "chroot.sh"), - "description": ( - "Chroot Environment Setup:\n" - " - Mounts partitions and configures the chroot environment for a Linux image.\n" - " - Provides a shell within the Linux image for system maintenance." - ) - } - } - - parser = argparse.ArgumentParser( - description="Wrapper for executing various scripts from Linux Image Manager.", - epilog=( - "Available script types:\n" - " image - Linux Image Setup\n" - " single - Single Drive Encryption Setup\n" - " raid1 - RAID1 Encryption Setup\n" - " backup - Backup Image Setup\n" - " chroot - Chroot Environment Setup\n\n" - "Additional Options:\n" - " --extra Pass extra parameters to the selected script.\n" - " --auto-confirm Bypass the confirmation prompt before execution.\n" - " --help Display this help message and exit." - ), - formatter_class=argparse.RawDescriptionHelpFormatter - ) - parser.add_argument("--type", required=True, choices=list(setup_scripts.keys()), - help="Select the script type to execute. Options: " + ", ".join(setup_scripts.keys())) - parser.add_argument("--extra", nargs=argparse.REMAINDER, default=[], - help="Extra parameters to pass to the selected script.") - parser.add_argument("--auto-confirm", action="store_true", - help="Automatically confirm execution without prompting the user.") - - args = parser.parse_args() - - script_info = setup_scripts[args.type] - print("[INFO] Selected script type:", args.type) - print("[INFO] Description:") - print(script_info["description"]) - print("[INFO] Script path:", script_info["path"]) - if args.extra: - print("[INFO] Extra parameters provided:", " ".join(args.extra)) - else: - print("[INFO] No extra parameters provided.") - - if not args.auto_confirm: - try: - input("Press Enter to execute the script or Ctrl+C to cancel...") - except KeyboardInterrupt: - print("\n[ERROR] Execution aborted by user.") - exit(1) - - run_script(script_info["path"], args.extra) +from lim.cli import main if __name__ == "__main__": - try: - main() - except KeyboardInterrupt: - print("\n[ERROR] Execution aborted by user.") - exit(1) + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8e66d82 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,41 @@ +[project] +name = "lim" +version = "1.0.0" +description = "Linux Image Manager โ€” manages Linux images and encrypted storage" +requires-python = ">=3.10" +license = { file = "LICENSE.txt" } +dependencies = ["PyYAML>=6"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.ruff] +target-version = "py310" +line-length = 100 + +[tool.ruff.lint] +select = ["ALL"] +ignore = [ + # This tool's UI is print/prompt based and its purpose is orchestrating + # system commands โ€” these rules contradict the design: + "T201", # print used as console UI + "S603", # subprocess without shell is exactly what runner.py wraps + "S607", # system tools are resolved via PATH on purpose + # Style choices: + "D203", # conflicts with D211 (blank line before class docstring) + "D213", # conflicts with D212 (multi-line docstring summary position) + "D1", # docstrings are not required on every symbol + "COM812", # trailing commas are handled by the formatter + "TRY003", # exception messages are written inline + "EM101", # literal exception messages are fine + "EM102", # f-string exception messages are fine +] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = [ + "S101", # pytest asserts + "ANN", # test signatures need no annotations + "PLR2004", # literal expectations in assertions + "ARG", # fixtures are injected even when unused + "SLF001", # tests may exercise internal helpers directly +] diff --git a/scripts/base.sh b/scripts/base.sh deleted file mode 100644 index 04e010c..0000000 --- a/scripts/base.sh +++ /dev/null @@ -1,161 +0,0 @@ -#!/bin/bash -# -# This script contains the global program variables and functions -# -# shellcheck disable=SC2034 #Deactivate checking of unused variables -# shellcheck disable=SC2003 #Deactivate "expr is antiquated" -# shellcheck disable=SC2015 #Deactivate bool hint -# shellcheck disable=SC2005 #Remove useless echo hint -# shellcheck disable=SC2010 #Deactivate ls | grep hint -REPOSITORY_PATH="$(readlink -f "${0}" | sed -e 's/\/scripts\/.*//g')" -CONFIGURATION_PATH="$REPOSITORY_PATH""/configuration/" -PACKAGE_PATH="$CONFIGURATION_PATH""packages/" -TEMPLATE_PATH="$CONFIGURATION_PATH""templates/"; -HOME_TEMPLATE_PATH="$TEMPLATE_PATH""home/"; -ENCRYPTED_PATH="$REPOSITORY_PATH/.encrypted"; -DECRYPTED_PATH="$REPOSITORY_PATH/decrypted"; -SCRIPT_PATH="$REPOSITORY_PATH/scripts/"; -DATA_PATH="$DECRYPTED_PATH/data"; -BACKUP_PATH="$DECRYPTED_PATH/backup"; - -COLOR_RED=$(tput setaf 1) -COLOR_GREEN=$(tput setaf 2) -COLOR_YELLOW=$(tput setaf 3) -COLOR_BLUE=$(tput setaf 4) -COLOR_MAGENTA=$(tput setaf 5) -COLOR_CYAN=$(tput setaf 6) -COLOR_WHITE=$(tput setaf 7) -COLOR_RESET=$(tput sgr0) - -# FUNCTIONS - -message(){ - echo "$1[$2]:${COLOR_RESET} $3 "; -} - -question(){ - message "${COLOR_MAGENTA}" "QUESTION" "$1"; -} - -info(){ - message "${COLOR_BLUE}" "INFO" "$1"; -} - -warning(){ - message "${COLOR_YELLOW}" "WARNING" "$1"; -} - -success(){ - message "${COLOR_GREEN}" "SUCCESS" "$1"; -} - -error(){ - if [ -z "$1" ] - then - message="Failed." - else - message="$1" - fi - message "${COLOR_RED}" "ERROR" "$message -> Leaving program." - if declare -f "destructor" > /dev/null - then - info "Calling destructor..." - destructor - else - warning "No destructor defined." - info "Can be that this script left some waste." - fi - exit 1; -} - -# Routine to echo the full sd-card-path -set_device_path(){ - info "Available devices:" - ls -lasi /dev/ | grep -E "sd|mm" - question "Please type in the name of the device: /dev/" && read -r device - device_path="/dev/$device" - if [ ! -b "$device_path" ] - then - error "$device_path is not valid device." - fi - info "Device path set to: $device_path" - # @see https://www.heise.de/ct/hotline/Optimale-Blockgroesse-fuer-dd-2056768.html - PHYSICAL_BLOCK_SIZE_PATH="/sys/block/$device/queue/physical_block_size" - if [ -f "$PHYSICAL_BLOCK_SIZE_PATH" ]; then - PHYSICAL_BLOCK_SIZE=$(sudo cat $PHYSICAL_BLOCK_SIZE_PATH) - if [ $? -eq 0 ]; then - OPTIMAL_BLOCKSIZE=$((64 * PHYSICAL_BLOCK_SIZE)) || error - else - echo "Unable to read $PHYSICAL_BLOCK_SIZE_PATH" - OPTIMAL_BLOCKSIZE="4K" - fi - else - OPTIMAL_BLOCKSIZE="4K" - fi - info "Optimal blocksize set to: $OPTIMAL_BLOCKSIZE" || error -} - -print_partition_table_info() { - echo "##########################################################################################" - echo "Note on Partition Table Deletion:" - echo "---------------------------------------------" - echo "โ€ข MBR (Master Boot Record):" - echo " - Typically occupies the first sector (512 bytes), i.e., 1 block." - echo "" - echo "โ€ข GPT (GUID Partition Table):" - echo " - Uses a protective MBR (1 block), a GPT header (1 block)," - echo " and usually a partition entry array that takes up about 32 blocks." - echo " - Total: approximately 34 blocks (assuming a 512-byte block size)." - echo "" - echo "Recommendation: For deleting a GPT partition table, use a block size of 512 bytes" - echo " and overwrite at least 34 blocks to ensure the entire table is cleared." - echo "##########################################################################################" -} - -overwrite_device() { - # Call the function to display the information. - print_partition_table_info - - question "Should $device_path be overwritten with zeros before copying? (y/N/block count)" && read -r copy_zeros_to_device - case "$copy_zeros_to_device" in - y) - info "Overwriting entire device..." && - dd if=/dev/zero of="$device_path" bs="$OPTIMAL_BLOCKSIZE" status=progress && sync || error "Overwriting $device_path failed." - ;; - N|'') - info "Skipping Overwriting..." - ;; - ''|*[!0-9]*) - error "Invalid input." - ;; - *) - if [[ "$copy_zeros_to_device" =~ ^[0-9]+$ ]]; then - info "Overwriting $copy_zeros_to_device blocks..." && - dd if=/dev/zero of="$device_path" bs="$OPTIMAL_BLOCKSIZE" count="$copy_zeros_to_device" status=progress && sync || error "Overwriting $device_path failed." - else - error "Invalid input. Block count must be a number." - fi - ;; - esac -} - -get_packages(){ - for package_collection in "$@" - do - package_collection_path="$PACKAGE_PATH""$package_collection.txt" && - echo "$(sed -e "/^#/d" -e "s/#.*//" "$package_collection_path" | tr '\n' ' ')" || - error - done -} - -HEADER(){ - echo - echo "${COLOR_YELLOW}The" - echo "LINUX IMAGE MANAGER" - echo "is an administration tool designed from and for Kevin Veen-Birkenbach." - echo - echo "Licensed under GNU GENERAL PUBLIC LICENSE Version 3" - echo "${COLOR_RESET}" -} - -HEADER diff --git a/scripts/data/export-to-system.sh b/scripts/data/export-to-system.sh deleted file mode 100644 index 698b6ab..0000000 --- a/scripts/data/export-to-system.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash -# Executes the import script in reverse mode -# @author Kevin Veen-Birkenbach [aka. Frantz] -# shellcheck source=/dev/null # Deactivate SC1090 -# shellcheck disable=SC2015 # Deactivating bool hint -source "$(dirname "$(readlink -f "${0}")")/../base.sh" || (echo "Loading base.sh failed." && exit 1) -bash "$SCRIPT_PATH""data/import-from-system.sh" reverse -info "Setting right permissions for importet files..." && -chmod -R 700 ~/.ssh && -chmod 600 ~/.ssh/id_rsa && -chmod 600 ~/.ssh/id_rsa.pub || error "Failed to set correct ssh permissions" -chown -R "$USER":"$USER" ~ || warning "Not all files could be owned by user \"$USER\"..." diff --git a/scripts/data/import-from-system.sh b/scripts/data/import-from-system.sh deleted file mode 100644 index 96404c5..0000000 --- a/scripts/data/import-from-system.sh +++ /dev/null @@ -1,75 +0,0 @@ -#!/bin/bash -# -# Imports data from the system -# @param $1 If the first parameter is "reverse" the data will be exported to the system -# -# shellcheck source=/dev/null # Deactivate SC1090 -# shellcheck disable=SC2143 # Comparing with -z allowed -# shellcheck disable=SC2015 # Deactivating bool hint -source "$(dirname "$(readlink -f "${0}")")/../base.sh" || (echo "Loading base.sh failed." && exit 1) - -declare -a BACKUP_LIST=("$HOME/.ssh/" \ - "$HOME/.gitconfig" \ - "$HOME/.atom/config.cson" \ - "$HOME/.projectlibre/projectlibre.conf" \ - "$HOME/.local/share/rhythmbox/rhythmdb.xml" \ - "$HOME/.config/keepassxc/keepassxc.ini" \ - "$HOME/Documents/certificates/" \ - "$HOME/Documents/security/" \ - "$HOME/Documents/identity/" \ - "$HOME/Documents/health/" \ - "$HOME/Documents/licenses/"); - -if [ -z "$(mount | grep "$DECRYPTED_PATH")" ] - then - info "The decrypted folder $DECRYPTED_PATH is locked. You need to unlock it!" && - bash "$SCRIPT_PATH""encryption/data/unlock.sh" || error "Unlocking failed."; -fi -if [ "$1" = "reverse" ] - then - MODE="export" - else - MODE="import" -fi -CONCRETE_BACKUP_FOLDER="$BACKUP_PATH/$MODE/$(date '+%Y%m%d%H%M%S')" -mkdir -p "$CONCRETE_BACKUP_FOLDER" || error "Failed to create \"$CONCRETE_BACKUP_FOLDER\"." -for system_item_path in "${BACKUP_LIST[@]}"; -do - data_item_path="$DATA_PATH$system_item_path" - if [ "$MODE" = "export" ] - then - destination="$system_item_path" - source="$data_item_path" - info "Export data from $source to $destination..." - else - source="$system_item_path" - destination="$data_item_path" - info "Import data from $source to $destination..." - fi - if [ -f "$destination" ] - then - info "The destination file allready exists!" && - info "Difference:" && - diff "$destination" "$source" - fi - destination_dir=$(dirname "$destination") - mkdir -p "$destination_dir" || error "Failed to create \"$destination_dir\"." - if [ -f "$source" ] - then - backup_dir=$(dirname "$CONCRETE_BACKUP_FOLDER/$system_item_path"); - mkdir -p "$backup_dir" || error "Failed to create \"$backup_dir\"." - info "Copy data from $source to $destination..." - rsync -abcEPuvW --backup-dir="$backup_dir" "$source" "$destination" || error - else - if [ -d "$source" ] - then - mkdir -p "$destination" || error "Failed to create \"$destination\"." - backup_dir="$CONCRETE_BACKUP_FOLDER/$system_item_path"; - mkdir -p "$backup_dir" || error "Failed to create \"$backup_dir\"." - info "Copy data from directory $source to directory $destination..." - rsync -abcEPuvW --delete --backup-dir="$backup_dir" "$source" "$destination" || error - else - warning "$source doesn't exist. Copying data is not possible." - fi - fi -done diff --git a/scripts/encryption/data/lock.sh b/scripts/encryption/data/lock.sh deleted file mode 100644 index a449e74..0000000 --- a/scripts/encryption/data/lock.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/bash -# -# Locks the data -# @author Kevin Veen-Birkenbach [aka. Frantz] -# -# shellcheck disable=SC2015 # Deactivating bool hint -# shellcheck source=/dev/null # Deactivate SC1090 -source "$(dirname "$(readlink -f "${0}")")/../../base.sh" || (echo "Loading base.sh failed." && exit 1) -info "Locking directory $DECRYPTED_PATH..." && -fusermount -u "$DECRYPTED_PATH" || error "Unmounting failed." -info "Data is now encrypted." - -info "Removing directory $DECRYPTED_PATH..." && -rmdir "$DECRYPTED_PATH" || error diff --git a/scripts/encryption/data/unlock.sh b/scripts/encryption/data/unlock.sh deleted file mode 100644 index d0147ff..0000000 --- a/scripts/encryption/data/unlock.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/bash -# -# Unlocks the data -# @author Kevin Veen-Birkenbach [aka. Frantz] -# -# shellcheck source=/dev/null # Deactivate SC1090 -# shellcheck disable=SC2015 # Deactivating bool hint -source "$(dirname "$(readlink -f "${0}")")/../../base.sh" || (echo "Loading base.sh failed." && exit 1) -info "Unlocking directory $DECRYPTED_PATH..." -if [ ! -d "$DECRYPTED_PATH" ] - then - info "Creating directory $DECRYPTED_PATH..." && - mkdir "$DECRYPTED_PATH" || error -fi -info "Decrypting directory $DECRYPTED_PATH to $DECRYPTED_PATH..." && -encfs "$ENCRYPTED_PATH" "$DECRYPTED_PATH" || error -echo "ATTENTION: DATA IS NOW DECRYPTED!" diff --git a/scripts/encryption/storage/Readme.md b/scripts/encryption/storage/Readme.md deleted file mode 100644 index 844fec9..0000000 --- a/scripts/encryption/storage/Readme.md +++ /dev/null @@ -1,2 +0,0 @@ -# Storage -For security reasons storages **SHOULD** be encrypted with [LUKS](https://de.wikipedia.org/wiki/Dm-crypt#Erweiterung_mit_LUKS). To keep it standardized and easy this scripts will use [btrfs](https://de.wikipedia.org/wiki/Btrfs) as file system. diff --git a/scripts/encryption/storage/base.sh b/scripts/encryption/storage/base.sh deleted file mode 100644 index 9fc2258..0000000 --- a/scripts/encryption/storage/base.sh +++ /dev/null @@ -1,86 +0,0 @@ -#!/bin/bash -# shellcheck disable=SC2015 # Deactivating bool hint -# shellcheck disable=SC2034 # Unused variables -# shellcheck disable=SC2154 # Referenced but not assigned -# shellcheck disable=SC1090 # Can't follow non-constant source. Use a directive to specify location. -# shellcheck disable=SC2001 # See if you can use ${variable//search/replace} instead -source "$(dirname "$(readlink -f "${0}")")/../../../base.sh" || (echo "Loading base.sh failed." && exit 1) - -set_device_mount_partition_and_mapper_paths(){ - set_device_path && - mapper_name="encrypteddrive-$device" && - mapper_path="/dev/mapper/$mapper_name" && - mount_path="/media/$mapper_name" && - partition_path="$device_path""1" && - info "mapper name set to : $mapper_name" && - info "mapper path set to : $mapper_path" && - info "mount path set to : $mount_path" || - error -} - -# @var $1 mapper_path -# @var $2 partition_path -create_luks_key_and_update_cryptab(){ - LUKS_KEY_DIRECTORY="/etc/luks-keys/" && - info "Creating luks-key-directory..." && - sudo mkdir $LUKS_KEY_DIRECTORY || warning "Directory exists: $LUKS_KEY_DIRECTORY" || error - luks_key_name="$1.keyfile" && - secret_key_path="$LUKS_KEY_DIRECTORY$luks_key_name" && - info "Generate secret key under: $secret_key_path" || error - if [ -f "$secret_key_path" ] - then - warning "File already exists. Overwriting!" - fi - sudo dd if=/dev/urandom of="$secret_key_path" bs=512 count=8 && sync && - - info "Opening and closing device to verify that everything works fine..." && - sudo cryptsetup -v luksClose "$1" || info "No need to luksClose $1. Device isn't open." && - sudo cryptsetup luksAddKey $2 $secret_key_path && - sudo cryptsetup -v luksOpen "$2" "$1" --key-file="$secret_key_path" && - sudo cryptsetup -v luksClose "$1" && - info "Reading UUID..." && - uuid_line=$(sudo cryptsetup luksDump "$2" | grep "UUID") && - uuid=$(echo "${uuid_line/UUID:/""}"|sed -e "s/[[:space:]]\+//g") && - crypttab_path="/etc/crypttab" && - crypttab_entry="$1 UUID=$uuid $secret_key_path luks" && - info "Adding crypttab entry..." || error - if sudo grep -q "$crypttab_entry" "$crypttab_path"; - then - warning "File $crypttab_path already contains the following entry:" && - echo "$crypttab_entry" && - info "Skipped." || - error - else - sudo sh -c "echo '$crypttab_entry' >> $crypttab_path" || - error - fi - - info "The file $crypttab_path contains now the following:" && - sudo cat $crypttab_path || - error -} - - -# @var $1 mapper_name -# @var $2 mount_path -# -# If mount doesn't work adapt it manually to -# @see https://gist.github.com/MaxXor/ba1665f47d56c24018a943bb114640d7 -update_fstab(){ - fstab_path="/etc/fstab" - fstab_entry="$1 $2 btrfs defaults 0 2" - info "Adding fstab entry..." - if sudo grep -q "$fstab_entry" "$fstab_path"; then - warning "File $fstab_path contains allready a the following entry:" && - echo "$fstab_entry" && - info "Skipped." || - error - else - sudo sh -c "echo '$fstab_entry' >> $fstab_path" || - error - fi - - info "The file $fstab_path contains now the following:" && - sudo cat $fstab_path || - error -} diff --git a/scripts/encryption/storage/raid1/base.sh b/scripts/encryption/storage/raid1/base.sh deleted file mode 100644 index e2046a5..0000000 --- a/scripts/encryption/storage/raid1/base.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/bash -# shellcheck disable=SC1090 # Can't follow non-constant source. Use a directive to specify location. -# shellcheck disable=SC2015 # Deactivating bool hint -# shellcheck disable=SC2034 # Unused variables -# shellcheck disable=SC2154 # Referenced but not assigned -source "$(dirname "$(readlink -f "${0}")")/../base.sh" || (echo "Loading base.sh failed." && exit 1) -set_raid1_devices_mount_partition_and_mapper_paths(){ - info "RAID1 partition 1..." && - set_device_mount_partition_and_mapper_paths && - partition_path_1=$partition_path && - mapper_name_1=$mapper_name && - mapper_path_1=$mapper_path && - mount_path_1=$mount_path && - device_path_1=$device_path && - info "RAID1 partition 2..." && - set_device_mount_partition_and_mapper_paths && - partition_path_2=$partition_path && - mapper_name_2=$mapper_name && - mapper_path_2=$mapper_path && - mount_path_2=$mount_path && - device_path_2=$device_path || error -} diff --git a/scripts/encryption/storage/raid1/mount_on_boot.sh b/scripts/encryption/storage/raid1/mount_on_boot.sh deleted file mode 100644 index 90185da..0000000 --- a/scripts/encryption/storage/raid1/mount_on_boot.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/bin/bash -# shellcheck disable=SC1090 # Can't follow non-constant source. Use a directive to specify location. -# shellcheck disable=SC2154 # Referenced but not assigned -# shellcheck disable=SC2015 #Deactivate bool hint -source "$(dirname "$(readlink -f "${0}")")/base.sh" || (echo "Loading base.sh failed." && exit 1) -info "Activate Automount raid1 encrypted storages..." && -echo "" -for dev in $(lsblk -dno NAME); do - if sudo cryptsetup isLuks /dev/$dev 2>/dev/null; then - info "/dev/$dev is a LUKS encrypted storage device." - fi -done -set_raid1_devices_mount_partition_and_mapper_paths && -create_luks_key_and_update_cryptab "$mapper_name_1" "$device_path_1" && -info "Creating mount folder unter \"$mount_path_1\"..." && -sudo mkdir -vp "$mount_path_1" && -create_luks_key_and_update_cryptab "$mapper_name_2" "$device_path_2" && -update_fstab "$mapper_path_1" "$mount_path_1" && -success "Installation finished. Please restart :)" || -error diff --git a/scripts/encryption/storage/raid1/setup.sh b/scripts/encryption/storage/raid1/setup.sh deleted file mode 100644 index e5df98a..0000000 --- a/scripts/encryption/storage/raid1/setup.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/bin/bash -# @author Kevin Veen-Birkenbach [kevin@veen.world] -# @see https://balaskas.gr/btrfs/raid1.html -# @see https://mutschler.eu/linux/install-guides/ubuntu-btrfs-raid1/ -# shellcheck disable=SC1090 # Can't follow non-constant source. Use a directive to specify location. -# shellcheck disable=SC2015 # Deactivating bool hint -# shellcheck disable=SC2154 # Referenced but not assigned -source "$(dirname "$(readlink -f "${0}")")/base.sh" || (echo "Loading base.sh failed." && exit 1) - -set_raid1_devices_mount_partition_and_mapper_paths - -info "Encrypting $device_path_1..." && -cryptsetup luksFormat "$device_path_1" && -info "Encrypting $device_path_2..." && -cryptsetup luksFormat "$device_path_2" && -blkid | tail -2 && -cryptsetup luksOpen "$device_path_1" "$mapper_name_1" && -cryptsetup luksOpen "$device_path_2" "$mapper_name_2" && -cryptsetup status "$mapper_path_1" && -cryptsetup status "$mapper_path_2" && -mkfs.btrfs -m raid1 -d raid1 "$mapper_path_1" "$mapper_path_2" && -success "Encryption successfull :)" || -error diff --git a/scripts/encryption/storage/single_drive/base.sh b/scripts/encryption/storage/single_drive/base.sh deleted file mode 100644 index 3c00ca1..0000000 --- a/scripts/encryption/storage/single_drive/base.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash -# shellcheck disable=SC1090 # Can't follow non-constant source. Use a directive to specify location. -source "$(dirname "$(readlink -f "${0}")")/../base.sh" || (echo "Loading base.sh failed." && exit 1) diff --git a/scripts/encryption/storage/single_drive/mount.sh b/scripts/encryption/storage/single_drive/mount.sh deleted file mode 100644 index a54896a..0000000 --- a/scripts/encryption/storage/single_drive/mount.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/bash -# shellcheck disable=SC1090 # Can't follow non-constant source. Use a directive to specify location. -# shellcheck disable=SC2015 # Deactivating bool hint -# shellcheck disable=SC2154 # Referenced but not assigned -source "$(dirname "$(readlink -f "${0}")")/base.sh" || (echo "Loading base.sh failed." && exit 1) -echo "Mounts encrypted storages" - -set_device_mount_partition_and_mapper_paths - -info "Unlock partition..." && -sudo cryptsetup luksOpen "$partition_path" "$mapper_name" || -error - -info "Mount partition..." && -sudo mount "$mapper_path" "$mount_path" || -error - -success "Mounting successfull :)" diff --git a/scripts/encryption/storage/single_drive/mount_on_boot.sh b/scripts/encryption/storage/single_drive/mount_on_boot.sh deleted file mode 100644 index d44dff1..0000000 --- a/scripts/encryption/storage/single_drive/mount_on_boot.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash -# shellcheck disable=SC1090 # Can't follow non-constant source. Use a directive to specify location. -# shellcheck disable=SC2154 # Referenced but not assigned -source "$(dirname "$(readlink -f "${0}")")/base.sh" || (echo "Loading base.sh failed." && exit 1) -echo "Automount encrypted storages" -echo -set_device_mount_partition_and_mapper_paths - -create_luks_key_and_update_cryptab "$mapper_name" "$partition_path" - -update_fstab "$mapper_path" "$mount_path" - -success "Installation finished. Please restart :)" diff --git a/scripts/encryption/storage/single_drive/setup.sh b/scripts/encryption/storage/single_drive/setup.sh deleted file mode 100644 index 95e1cb0..0000000 --- a/scripts/encryption/storage/single_drive/setup.sh +++ /dev/null @@ -1,50 +0,0 @@ -#!/bin/bash -# shellcheck disable=SC1090 # Can't follow non-constant source. Use a directive to specify location. -# shellcheck disable=SC2015 # Deactivating bool hint -# shellcheck disable=SC2154 # Referenced but not assigned -source "$(dirname "$(readlink -f "${0}")")/base.sh" || (echo "Loading base.sh failed." && exit 1) -echo "Setups disk encryption" - -set_device_mount_partition_and_mapper_paths - -overwrite_device - -info "Creating new GPT partition table..." -( echo "g" # create a new empty GPT partition table - echo "w" # Write partition table -)| sudo fdisk --wipe always "$device_path" || -error - -info "Creating partition table..." -( echo "n" # Create new partition - echo "" # Accept default - echo "" # Accept default - echo "" # Accept default - echo "p" # Create GPT partition table - echo "w" # Write partition table -)| sudo fdisk --wipe always "$device_path" || -error - -info "Encrypt $device_path..." && -sudo cryptsetup -v -y luksFormat "$partition_path" || -error - -info "Unlock partition..." && -sudo cryptsetup luksOpen "$partition_path" "$mapper_name" || -error - -info "Create btrfs file system..." && -sudo mkfs.btrfs "$mapper_path" || error - -info "Creating mount folder unter \"$mount_path\"..." && -sudo mkdir -p "$mount_path" || error - -info "Mount partition..." && -sudo mount "$mapper_path" "$mount_path" || -error - -info "Own partition by user..." && -sudo chown -R "$USER":"$USER" "$mount_path" || -error - -success "Encryption successfull :)" diff --git a/scripts/encryption/storage/single_drive/umount.sh b/scripts/encryption/storage/single_drive/umount.sh deleted file mode 100644 index cd46017..0000000 --- a/scripts/encryption/storage/single_drive/umount.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/bash -# shellcheck disable=SC1090 # Can't follow non-constant source. Use a directive to specify location. -# shellcheck disable=SC2015 # Deactivating bool hint -# shellcheck disable=SC2154 # Referenced but not assigned -source "$(dirname "$(readlink -f "${0}")")/base.sh" || (echo "Loading base.sh failed." && exit 1) -echo "Unmount encrypted storages" - -set_device_mount_partition_and_mapper_paths - -info "Unmount $mapper_path..." -sudo umount "$mapper_path" && -sudo cryptsetup luksClose "$mapper_path" || -error - -success "Successfull :)" diff --git a/scripts/image/backup.sh b/scripts/image/backup.sh deleted file mode 100644 index ff74cff..0000000 --- a/scripts/image/backup.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/bin/bash -# shellcheck disable=SC2010 # ls | grep allowed -# shellcheck disable=SC2015 # Deactivating bool hint -# shellcheck source=/dev/null # Deactivate SC1090 -# shellcheck disable=SC2154 # Deactivate not referenced link -# shellcheck disable=SC2015 # Deactivate bools hints -source "$(dirname "$(readlink -f "${0}")")/base.sh" || (echo "Loading base.sh failed." && exit 1) -info "Backupscript for memory devices started..." -echo -set_device_path -while [ "$path" == "" ] - do - echo "Bitte Backupimagepfad+Namen zu $PWD eingeben:" - read -r path - if [ "${path:0:1}" == "/" ] - then - ofi="$path.img" - else - ofi="$PWD/$path.img" - fi -done -info "Input file: $device_path" -info "Output file: $ofi" -question "Please confirm by pushing \"Enter\". To cancel use \"Ctrl + Alt + C\"" -read -r bestaetigung && echo "$bestaetigung"; - -info "Imagetransfer starts. This can take a while..." && -dd if="$device_path" of="$ofi" bs=1M status=progress && sync || error "\"dd\" failed."; - -success "Imagetransfer successfull." && exit 0; diff --git a/scripts/image/base.sh b/scripts/image/base.sh deleted file mode 100644 index 734cd6b..0000000 --- a/scripts/image/base.sh +++ /dev/null @@ -1,114 +0,0 @@ -#!/bin/bash -# -# Offers base functions for the image management -# -# shellcheck disable=SC2034 #Deactivate checking of unused variables -# shellcheck disable=SC2010 # ls | grep allowed -# shellcheck source=/dev/null # Deactivate SC1090 -# shellcheck disable=SC2015 # Deactivate bools hints -# shellcheck disable=SC2154 # Deactivate referenced but not assigned hints -source "$(dirname "$(readlink -f "${0}")")/../base.sh" || (echo "Loading base.sh failed." && exit 1) - -# Writes the full partition name -# @parameter $1 is device path -# @parameter $2 is the partition number -echo_partition_name(){ - if [ "${device_path:5:1}" != "s" ] - then - echo "$device_path""p""$1" - else - echo "$device_path$1" - fi -} - -set_partition_paths(){ - info "Setting partition and mapper paths..." - boot_partition_path=$(echo_partition_name "1") - root_partition_path=$(echo_partition_name "2") - root_mapper_path=$root_partition_path -} - -make_mount_folders(){ - info "Preparing mount paths..." && - boot_mount_path="$working_folder_path""boot/" && - root_mount_path="$working_folder_path""root/" && - mkdir -v "$boot_mount_path" && - mkdir -v "$root_mount_path" || - error -} - -make_working_folder(){ - working_folder_path="/tmp/linux-image-manager-$(date +%s)/" && - info "Create temporary working folder in $working_folder_path" && - mkdir -v "$working_folder_path" || - error -} - -decrypt_root(){ - if [ "$(blkid "$root_partition_path" -s TYPE -o value)" == "crypto_LUKS" ] - then - root_partition_uuid=$(blkid "$root_partition_path" -s UUID -o value) && - root_mapper_name="linux-image-manager-$root_partition_uuid" && - root_mapper_path="/dev/mapper/$root_mapper_name" && - info "Decrypting of $root_partition_path is neccessary..." && - sudo cryptsetup -v luksOpen "$root_partition_path" "$root_mapper_name" || error - fi -} - -mount_partitions(){ - info "Mount boot and root partition..." && - mount -v "$boot_partition_path" "$boot_mount_path" && - mount -v "$root_mapper_path" "$root_mount_path" && - info "Settind uuid variables..." && - root_partition_uuid=$(blkid "$root_partition_path" -s UUID -o value) && - boot_partition_uuid=$(blkid "$boot_partition_path" -s UUID -o value) && - info "The following mounts refering this setup exist:" && mount | grep "$working_folder_path" || - error -} - -destructor(){ - info "Cleaning up..." - info "Unmounting everything..." - umount -lv "$chroot_dev_pts_mount_path" || warning "Umounting $chroot_dev_pts_mount_path failed!" - umount -lv "$chroot_dev_mount_path" || warning "Umounting $chroot_dev_mount_path failed!" - umount -v "$chroot_proc_mount_path" || warning "Umounting $chroot_proc_mount_path failed!" - umount -v "$chroot_sys_mount_path" || warning "Umounting $chroot_sys_mount_path failed!" - umount -v "$root_mount_path""boot/" || warning "Umounting $root_mount_path""boot/ failed!" - umount -v "$root_mount_path" || warning "Umounting $root_mount_path failed!" - umount -v "$boot_mount_path" || warning "Umounting $boot_mount_path failed!" - info "Deleting mount folders..." - rmdir -v "$root_mount_path" || warning "Removing $root_mount_path failed!" - rmdir -v "$boot_mount_path" || warning "Removing $boot_mount_path failed!" - rmdir -v "$working_folder_path" || warning "Removing $working_folder_path failed!" - if [ "$(blkid "$root_partition_path" -s TYPE -o value)" == "crypto_LUKS" ] - then - info "Trying to close decrypted $root_mapper_name..." && - sudo cryptsetup -v luksClose "$root_mapper_name" || warning "Failed." - fi -} - -mount_chroot_binds(){ - info "Mount chroot environments..." && - chroot_sys_mount_path="$root_mount_path""sys/" && - chroot_proc_mount_path="$root_mount_path""proc/" && - chroot_dev_mount_path="$root_mount_path""dev/" && - chroot_dev_pts_mount_path="$root_mount_path""dev/pts" && - mount --bind "$boot_mount_path" "$root_mount_path""boot" && - mount --bind /dev "$chroot_dev_mount_path" && - mount --bind /sys "$chroot_sys_mount_path" && - mount --bind /proc "$chroot_proc_mount_path" && - mount --bind /dev/pts "$chroot_dev_pts_mount_path" || - error -} - -copy_qemu(){ - info "Copy qemu binary..." && - cp -v /usr/bin/qemu-arm-static "$root_mount_path""usr/bin/" || - error -} - -copy_resolve_conf(){ - info "Copy resolve.conf..." && - cp --remove-destination -v /etc/resolv.conf "$root_mount_path""etc/" || - warning "Failed. Propably there is no internet connection available." -} diff --git a/scripts/image/chroot.sh b/scripts/image/chroot.sh deleted file mode 100644 index e0c6f59..0000000 --- a/scripts/image/chroot.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/bash -# shellcheck source=/dev/null # Deactivate SC1090 -# shellcheck disable=SC2015 # Deactivating bool hint -# shellcheck disable=SC2154 # Deactivate not referenced link -source "$(dirname "$(readlink -f "${0}")")/base.sh" || (echo "Loading base.sh failed." && exit 1) - -info "Starting chroot..." - -set_device_path - -make_working_folder - -make_mount_folders - -set_partition_paths - -decrypt_root - -mount_partitions - -mount_chroot_binds - -copy_qemu - -copy_resolve_conf - -info "Bash shell starts..." && -chroot "$root_mount_path" /bin/bash || -error - -destructor diff --git a/scripts/image/setup.sh b/scripts/image/setup.sh deleted file mode 100644 index ab607d9..0000000 --- a/scripts/image/setup.sh +++ /dev/null @@ -1,714 +0,0 @@ -#!/bin/bash -# shellcheck disable=SC2010 # ls | grep allowed -# shellcheck source=/dev/null # Deactivate SC1090 -# shellcheck disable=SC2015 # Deactivate bools hints -# shellcheck disable=SC2154 # Deactivate not referenced link -# @see https://wiki.polaire.nl/doku.php?id=archlinux-raspberry-encrypted -source "$(dirname "$(readlink -f "${0}")")/base.sh" || (echo "Loading base.sh failed." && exit 1) - -install(){ - info "Installing $1..." - case "$distribution" in - "arch"|"manjaro") - echo "pacman --noconfirm -S --needed $1" | chroot "$root_mount_path" /bin/bash || error - ;; - "moode"|"retropie") - echo "yes | apt install $1" | chroot "$root_mount_path" /bin/bash || error - ;; - *) - error "Package manager not supported." - ;; - esac -} - -replace_in_file() { - # Assign the first function argument to the local variable search_string - local search_string=$1 - # Assign the second function argument to the local variable replace_string - local replace_string=$2 - # Assign the third function argument to the local variable file_path - local file_path=$3 - - # Create a temporary file and store its path in temp_file - temp_file=$(mktemp) - - # Use sed to replace the search_string with replace_string in the file at file_path - # Write the output to the temporary file - sed "s/$search_string/$replace_string/g" "$file_path" > "$temp_file" - - # Compare the original file with the temporary file - if cmp -s "$file_path" "$temp_file"; then - # If files are identical, remove the temporary file and signal an error - rm -f "$temp_file" - error "Error: Search string '$search_string' not found in $file_path." - else - # If files are different, move the temporary file to overwrite the original file - mv "$temp_file" "$file_path" - fi -} - -info "Setupscript for images started..." - -info "Checking if root..." -if [ "$(id -u)" != "0" ];then - error "This script must be executed as root!" -fi - -make_working_folder - -info "Configure user..." && -question "Please type in a valid working username:" && read -r origin_username && -getent passwd "$origin_username" > /dev/null 2 || error "User $origin_username doesn't exist." -origin_user_home="/home/$origin_username/" - -info "Image routine starts..." -image_folder="$origin_user_home""Software/Images/"; -info "The images will be stored in \"$image_folder\"." -if [ ! -d "$image_folder" ]; then - info "Folder \"$image_folder\" doesn't exist. It will be created now." && - mkdir -v "$image_folder" || - error -fi - -set_device_path - -if mount | grep -q "$device_path" - then - error "Device $device_path is allready mounted. Umount with \"umount $device_path*\"." -fi - -question "Which operation system would you like to use [linux,windows,...]?" && read -r operation_system || error - -case "$operation_system" in - "linux") - question "Which distribution should be used [arch,moode,retropie,manjaro,torbox...]?" && read -r distribution || error - - case "$distribution" in - "android-x86") - base_download_url="https://www.fosshub.com/Android-x86.html?dwl=android-x86_64-9.0-r2.iso"; - image_name="android-x86_64-9.0-r2.iso" - image_checksum="f7eb8fc56f29ad5432335dc054183acf086c539f3990f0b6e9ff58bd6df4604e" - boot_size="+500M" - ;; - "torbox") - base_download_url="https://www.torbox.ch/data/"; - image_name="torbox-20220102-v050.gz" - image_checksum="0E1BA7FFD14AAAE5F0462C8293D95B62C3BF1D9E726E26977BD04772C55680D3" - boot_size="+200M" - ;; - "arch") - question "Which Raspberry Pi will be used (e.g.: 1, 2, 3b, 3b+, 4...):" && read -r raspberry_pi_version - boot_size="+500M" - base_download_url="http://os.archlinuxarm.org/os/"; - case "$raspberry_pi_version" in - "1") - image_name="ArchLinuxARM-rpi-armv7-latest.tar.gz" - luks_memory_cost="64000" - ;; - "2") - image_name="ArchLinuxARM-rpi-armv7-latest.tar.gz" - luks_memory_cost="128000" - ;; - "3b" | "3b+") - image_name="ArchLinuxARM-rpi-aarch64-latest.tar.gz" - luks_memory_cost="128000" - ;; - "4" ) - image_name="ArchLinuxARM-rpi-aarch64-latest.tar.gz" - luks_memory_cost="256000" - ;; - *) - error "Version $raspberry_pi_version isn't supported." - ;; - esac - ;; - "manjaro") - question "Which version(e.g.:architect,gnome) should be used:" && read -r gnome_version - boot_size="+500M" - case "$gnome_version" in - "architect") - image_checksum="6b1c2fce12f244c1e32212767a9d3af2cf8263b2" - base_download_url="https://osdn.net/frs/redir.php?m=dotsrc&f=%2Fstorage%2Fg%2Fm%2Fma%2Fmanjaro%2Farchitect%2F20.0%2F"; - image_name="manjaro-architect-20.0-200426-linux56.iso" - ;; - "gnome") - question "Which release(e.g.:20,21,raspberrypi) should be used:" && read -r release - case "$release" in - "20") - image_checksum="2df3697908483550d4a473815b08c1377e6b6892" - base_download_url="https://osdn.net/projects/manjaro-archive/storage/gnome/20.0/" - image_name="manjaro-gnome-20.0-200426-linux56.iso" - ;; - "21") - base_download_url="https://download.manjaro.org/gnome/21.3.7/" - image_name="manjaro-gnome-21.3.7-220816-linux515.iso" - ;; - "22") - base_download_url="https://download.manjaro.org/gnome/22.1.3/" - image_name="manjaro-gnome-22.1.3-230529-linux61.iso" - ;; - "24") - base_download_url="https://download.manjaro.org/gnome/24.2.1/" - image_name="manjaro-gnome-24.2.1-241216-linux612.iso" - ;; - "25") - base_download_url="https://download.manjaro.org/gnome/25.0.10/" - image_name="manjaro-gnome-25.0.10-251013-linux612.iso" - ;; - "raspberrypi") - # at the moment just optimized for raspberry pi 4 - base_download_url="https://github.com/manjaro-arm/rpi4-images/releases/download/23.02/" - image_name="Manjaro-ARM-gnome-rpi4-23.02.img.xz" - luks_memory_cost="256000" - raspberry_pi_version="4" - ;; - *) - error "Gnome Version $gnome_version isn't supported." - ;; - esac - ;; - esac - ;; - "moode") - boot_size="+200M" - image_checksum="185cbc9a4994534bb7a4bc2744c78197" - base_download_url="https://github.com/moode-player/moode/releases/download/r651prod/" - image_name="moode-r651-iso.zip"; - ;; - "retropie") - boot_size="+500M" - question "Which version(e.g.:1,2,3,4) should be used:" && read -r raspberry_pi_version - base_download_url="https://github.com/RetroPie/RetroPie-Setup/releases/download/4.8/"; - case "$raspberry_pi_version" in - "1") - image_checksum="95a6f84453df36318830de7e8507170e" - image_name="retropie-buster-4.8-rpi1_zero.img.gz" - ;; - "2" | "3") - image_checksum="224e64d8820fc64046ba3850f481c87e" - image_name="retropie-buster-4.8-rpi2_3_zero2w.img.gz" - ;; - - "4") - image_checksum="b5daa6e7660a99c246966f3f09b4014b" - image_name="retropie-buster-4.8-rpi4_400.img.gz" - ;; - esac - ;; - esac - - question "Should the system be encrypted?(y/N)" && read -r encrypt_system - - info "Generating os-image..." - download_url="$base_download_url$image_name" - image_path="$image_folder$image_name" - - question "Should the image download be forced?(y/N)" && read -r force_image_download - if [ "$force_image_download" = "y" ] - then - if [ -f "$image_path" ] - then - info "Removing image $image_path." && - rm "$image_path" || error "Removing image \"$image_path\" failed." - else - info "Forcing download wasn't neccessary. File $image_path doesn't exist." - fi - fi - - info "Start Download procedure..." - if [ -f "$image_path" ] - then - info "Image exist local. Download skipped." - else - info "Image \"$image_name\" doesn't exist under local path \"$image_path\"." && - info "Image \"$image_name\" gets downloaded from \"$download_url\"..." && - wget "$download_url" -O "$image_path" || error "Download from \"$download_url\" failed." - fi - ;; - *) - info "Available images:" - ls -l "$image_folder" - question "Which image would you like to use?" && read -r image_name || error - image_path="$image_folder$image_name" - ;; -esac - -info "Verifying image..." -info "Verifying checksum..." -if [ -z "$image_checksum" ]; then - for ext in sha1 sha512 md5; do - sha_download_url="$download_url.$ext" - info "Image Checksum is not defined. Try to download image signature from $sha_download_url." - if wget -q --method=HEAD "$sha_download_url"; then - image_checksum="$(wget $sha_download_url -q -O - | cut -d ' ' -f1)" - info "Defined image_checksum as $image_checksum" - break - else - warning "No checksum found under $sha_download_url." - fi - done -fi - -if [[ -v image_checksum ]]; then - info "A checksum is defined for the image." - info "Checksums verify file integrity to ensure that the file was not corrupted during download." - info "The script will try verifying the integrity using MD5, then SHA1, and finally SHA256 if needed." - - info "Trying MD5 checksum verification..." - (info "Checking md5 checksum..." && echo "$image_checksum $image_path" | md5sum -c -) || - (warning "MD5 verification failed. This may indicate data corruption." && - info "Trying SHA1 checksum verification for a secondary integrity check..." && - info "Checking sha1 checksum..." && echo "$image_checksum $image_path" | sha1sum -c -) || - (warning "SHA1 verification failed. Attempting SHA256 verification for thoroughness." && - info "SHA256 provides a more robust check and is used as a final integrity measure." && - info "Checking sha256 checksum..." && echo "$image_checksum $image_path" | sha256sum -c -) || - error "Verification failed. HINT: Force the download of the image." -else - warning "No checksum is defined. Skipping checksum verification." -fi - -info "Note: Checksums verify integrity but do not confirm authenticity." -info "Proceeding to signature verification, which ensures the file comes from a trusted source." -signature_download_url="$download_url.sig" -info "Attempting to download the image signature from: $signature_download_url" -info "Try to download image signature from $signature_download_url." - -if wget -q --method=HEAD "$signature_download_url"; then - signature_name="${image_name}.sig" - signature_path="${image_folder}${signature_name}" - - info "Download the signature file" - if wget -q -O "$signature_path" "$signature_download_url"; then - info "Extract the key ID from the signature file" - key_id=$(gpg --status-fd 1 --verify "$signature_path" "$image_path" 2>&1 | grep 'NO_PUBKEY' | awk '{print $NF}') - - if [ -n "$key_id" ]; then - info "Check if the key is already in the keyring" - if gpg --list-keys "$key_id" > /dev/null 2>&1; then - info "Key $key_id already in keyring." - else - info "Import the public key" - gpg --keyserver keyserver.ubuntu.com --recv-keys "$key_id" - fi - - info "Verify the signature again after importing the key" - if gpg --verify "$signature_path" "$image_path"; then - info "Signature verification succeeded." - else - warning "Signature verification failed." - fi - else - warning "No public key found in the signature file." - fi - else - warning "Failed to download the signature file." - fi -else - warning "No signature found under $signature_download_url." -fi - - -make_mount_folders - -set_partition_paths - -question "Which filesystem should be used? E.g.:btrfs,ext4... (none):" && read -r root_filesystem - -question "Should the image be transfered to $device_path?(y/N)" && read -r transfer_image -if [ "$transfer_image" = "y" ] - then - - question "Should the partition table of $device_path be deleted?(y/N)" && read -r delete_partition_table - if [ "$delete_partition_table" = "y" ] - then - info "Deleting..." && - wipefs -a "$device_path" || error - else - info "Skipping partition table deletion..." - fi - - overwrite_device - - info "Starting image transfer..." - if [ "$distribution" = "arch" ] - then - # Set default size of the boot partition - boot_size=${boot_size:-"+500M"} - - # Use the provided size or the default size - info "The boot partition will be set to $boot_size." - - # Partitioning with the specified size - info "Creating partitions..." && - ( - echo "o" # Type o. This will clear out any partitions on the drive. - echo "p" # Type p to list partitions. There should be no partitions left - echo "n" # Type n, - echo "p" # then p for primary, - echo "1" # 1 for the first partition on the drive, - echo "" # Default start sector - echo "$boot_size" # Size of the boot partition - echo "t" # Type t, - echo "c" # then c to set the first partition to type W95 FAT32 (LBA). - echo "n" # Type n, - echo "p" # then p for primary, - echo "2" # 2 for the second partition on the drive, - echo "" # Default start sector - echo "" # Default end sector - echo "w" # Write the partition table and exit by typing w. - ) | fdisk "$device_path" || error - - info "Format boot partition..." && - mkfs.vfat "$boot_partition_path" || error - - if [ "$encrypt_system" == "y" ] - then - # Check if luks_memory_cost is defined and set the luksAddKey command accordingly - # @see https://chatgpt.com/share/008ea5f1-670c-467c-8320-1ca67f25ac9a - if [ -n "$luks_memory_cost" ]; then - info "Formating $root_partition_path with LUKS with --pbkdf-memory set to $luks_memory_cost" && - sudo cryptsetup -v luksFormat -c aes-xts-plain64 -s 512 -h sha512 --use-random -i 1000 --pbkdf-memory "$luks_memory_cost" "$root_partition_path" || error - else - info "Formating $root_partition_path with LUKS" && - sudo cryptsetup -v luksFormat -c aes-xts-plain64 -s 512 -h sha512 --use-random -i 1000 "$root_partition_path" || error - fi - decrypt_root || error - fi - - info "Format root partition..." && - "mkfs.$root_filesystem" -f "$root_mapper_path" || error - mount_partitions; - - info "Root files will be transfered to device..." && - bsdtar -xpf "$image_path" -C "$root_mount_path" && - sync || - error - - info "Boot files will be transfered to device..." && - mv -v "$root_mount_path""boot/"* "$boot_mount_path" || - error - elif [ "${image_path: -4}" = ".zip" ] - then - info "Transfering .zip file..." && - unzip -p "$image_path" | sudo dd of="$device_path" bs="$OPTIMAL_BLOCKSIZE" conv=fsync status=progress || error "DD $image_path to $device_path failed." && - sync || - error - elif [ "${image_path: -3}" = ".gz" ] - then - info "Transfering .gz file..." && - gunzip -c "$image_path" | sudo dd of="$device_path" bs="$OPTIMAL_BLOCKSIZE" conv=fsync status=progress && - sync || - error - elif [ "${image_path: -4}" = ".iso" ] - then - info "Transfering .iso file..." && - pv "$image_path" | sudo dd of="$device_path" bs="$OPTIMAL_BLOCKSIZE" conv=fsync && - sync || - error - elif [ "${image_path: -3}" = ".xz" ] - then - info "Transferring .xz file..." && - unxz -c "$image_path" | sudo dd of="$device_path" bs="$OPTIMAL_BLOCKSIZE" conv=fsync status=progress && - sync || - error - else - error "Image transfer for operation system \"$distribution\" is not supported yet!"; - fi - else - info "Skipping image transfer..." -fi - -# Execute Raspberry Pi specific procedures -if [ -n "$raspberry_pi_version" ] - then - info "Start regular mounting procedure..." - if mount | grep -q "$boot_partition_path" - then - info "$boot_partition_path is allready mounted..." - else - if mount | grep -q "$root_mapper_path" - then - info "$root_mapper_path is allready mounted..." - else - decrypt_root - mount_partitions - fi - fi - - fstab_path="$root_mount_path""etc/fstab" && - fstab_search_string=$(echo "/dev/mmcblk0p1"| sed -e 's/[\/&]/\\&/g') && - fstab_replace_string=$(echo "UUID=$boot_partition_uuid"| sed -e 's/[\/&]/\\&/g') && - info "Seeding UUID to $fstab_path to avoid path conflicts..." && - sed -i "s/$fstab_search_string/$fstab_replace_string/g" "$fstab_path" && - info "Content of $fstab_path:$(cat "$fstab_path")" || error - - info "Define target paths..." && - administrator_username="administrator" - target_home_path="$root_mount_path""home/" && - default_username=$(ls "$target_home_path") && - - question "Should the $default_username be renamed to $administrator_username? (y/N):" && read -r rename_decision - if [ "$rename_decision" == "y" ]; - then - variable_old_username="$default_username" && - target_username="$administrator_username" && - info "Rename home directory from $target_home_path$variable_old_username to $target_home_path$target_username..." && - mv -v "$target_home_path$variable_old_username" "$target_home_path$target_username" || error "Failed to rename home directory" - else - target_username="$default_username" - fi - - target_user_home_folder_path="$target_home_path$target_username/" && - target_user_ssh_folder_path="$target_user_home_folder_path"".ssh/" && - target_authorized_keys="$target_user_ssh_folder_path""authorized_keys" && - - # Activate later. Here was a bug - question "Should the $target_username have sudo rights? (y/N):" && read -r sudo_decision - if [ "$sudo_decision" == "y" ]; then - sudo_config_dir="$root_mount_path""etc/sudoers.d/" - sudo_config_file="$sudo_config_dir$target_username" - mkdir -vp $sudo_config_dir - echo "$target_username ALL=(ALL:ALL) ALL" > "$sudo_config_file" || error "Failed to create sudoers file for $target_username" - chmod 440 "$sudo_config_file" || error "Failed to set permissions on sudoers file for $target_username" - fi - - question "Enter the path to the SSH key to be added to the image (default: none):" && read -r origin_user_rsa_pub || error - if [ -z "$origin_user_rsa_pub" ] - then - info "Skipped SSH-key copying.." - else - if [ -f "$origin_user_rsa_pub" ] - then - info "Copy ssh key to target..." - mkdir -v "$target_user_ssh_folder_path" || warning "Folder \"$target_user_ssh_folder_path\" exists. Can't be created." - cat "$origin_user_rsa_pub" > "$target_authorized_keys" && - target_authorized_keys_content=$(cat "$target_authorized_keys") && - info "$target_authorized_keys contains the following: $target_authorized_keys_content" && - info "Set permissions with chmod..." && - chmod -v 700 "$target_user_ssh_folder_path" && - chmod -v 600 "$target_authorized_keys" || error "Failed to set ownership and permissions on ssh folder" - else - error "The ssh key \"$origin_user_rsa_pub\" can't be copied to \"$target_authorized_keys\" because it doesn't exist." - fi - fi - - info "Start chroot procedures..." - - mount_chroot_binds - - copy_qemu - - copy_resolve_conf - - chroot_user_home_path="/home/$target_username/" - chroot_user_ssh_folder_path="$chroot_user_home_path.ssh" - if [ "$rename_decision" == "y" ]; then - info "Delete old user and create new user" && - ( - echo "userdel -r $variable_old_username" - echo "useradd -m -d $chroot_user_home_path -s /bin/bash $target_username" - echo "chown -R $target_username:$target_username $chroot_user_home_path" - ) | chroot "$root_mount_path" /bin/bash || error "Failed to delete old user and create new user" - fi - - if [ -n "$origin_user_rsa_pub" ] - then - info "Chroot to set ownership..." && - ( echo "chown -vR $target_username:$target_username $chroot_user_ssh_folder_path" ) | chroot "$root_mount_path" /bin/bash || error - fi - question "Type in new password for user root and $target_username (leave empty to skip): " && read -r password_1 - - if [ -n "$password_1" ]; then - question "Repeat new password for \"$target_username\": " && read -r password_2 - if [ "$password_1" = "$password_2" ]; then - info "Changing passwords on target system..." - ( - echo "( - echo '$password_1' - echo '$password_1' - ) | passwd $target_username" - echo "( - echo '$password_1' - echo '$password_1' - ) | passwd" - ) | chroot "$root_mount_path" /bin/bash || error "Failed to change password." - else - error "Passwords didn't match." - fi - else - info "No password change requested, skipped password change..." - fi - - - hostname_path="$root_mount_path/etc/hostname" - - question "Type in the hostname (leave empty to skip): " && read -r target_hostname - - if [ -n "$target_hostname" ]; then - echo "$target_hostname" > "$hostname_path" || error "Failed to set hostname." - else - target_hostname=$(cat "$hostname_path") - info "No hostname change requested, skipped hostname change..." - fi - - info "Used hostname is: $target_hostname" - - case "$distribution" in - "arch"|"manjaro") - info "Populating keys..." && - ( - echo "yes | pacman-key --init" - echo "yes | pacman-key --populate archlinuxarm" - ) | chroot "$root_mount_path" /bin/bash || error - ;; - esac - - question "Should the system be updated?(y/N)" && read -r update_system - if [ "$update_system" == "y" ] - then - info "Updating system..." - case "$distribution" in - "arch"|"manjaro") - echo "pacman --noconfirm -Syyu" | chroot "$root_mount_path" /bin/bash || error - ;; - "moode"|"retropie") - ( - echo "yes | apt update" - echo "yes | apt upgrade" - ) | chroot "$root_mount_path" /bin/bash || error - ;; - *) - warning "System update for operation system \"$distribution\" is not supported yet. Skipped." - ;; - esac - fi - - info "Installing software for filesystem $root_filesystem..." - if [ "$root_filesystem" == "btrfs" ] - then - install "btrfs-progs" - else - info "Skipped." - fi - - if [ "$encrypt_system" == "y" ] - then - # Adapted this instruction for setting up encrypted systems - # @see https://gist.github.com/gea0/4fc2be0cb7a74d0e7cc4322aed710d38 - # @see https://gist.github.com/EnigmaCurry/2f9bed46073da8e38057fe78a61e7994 - info "Setup encryption..." && - - info "Installing neccessary software..." && - install "$(get_packages "server/luks")" && - - dropbear_root_key_path="$root_mount_path""etc/dropbear/root_key" && - info "Adding $target_authorized_keys to dropbear..." && - cp -v "$target_authorized_keys" "$dropbear_root_key_path" && - - # Concerning mkinitcpio warning - # @see https://gist.github.com/imrvelj/c65cd5ca7f5505a65e59204f5a3f7a6d - mkinitcpio_path="$root_mount_path""etc/mkinitcpio.conf" && - info "Configuring $mkinitcpio_path..." && - mkinitcpio_search_modules="MODULES=()" || error - - # Concerning which moduls to load - # @see https://raspberrypi.stackexchange.com/questions/67051/raspberry-pi-3-with-archarm-and-encrypted-disk-will-not-boot-how-can-be-identif - - case "$raspberry_pi_version" in - "1" | "2") - mkinitcpio_additional_modules="" - ;; - "3b") - mkinitcpio_additional_modules="smsc95xx" - ;; - "3b+" | "4") - mkinitcpio_additional_modules="lan78xx" - ;; - *) - warning "Version $raspberry_pi_version isn't supported." - ;; - esac - - mkinitcpio_replace_modules="MODULES=(g_cdc usb_f_acm usb_f_ecm $mkinitcpio_additional_modules g_ether)" || error - - - mkinitcpio_search_binaries="BINARIES=()" && - mkinitcpio_replace_binaries=$(echo "BINARIES=(/usr/lib/libgcc_s.so.1)"| sed -e 's/[\/&]/\\&/g') && - mkinitcpio_encrypt_hooks="sleep netconf dropbear encryptssh" && - mkinitcpio_hooks_prefix="base udev autodetect microcode modconf kms keyboard keymap consolefont block" - mkinitcpio_hooks_suffix="filesystems fsck" - mkinitcpio_search_hooks="HOOKS=($mkinitcpio_hooks_prefix $mkinitcpio_hooks_suffix)" && - mkinitcpio_replace_hooks="HOOKS=($mkinitcpio_hooks_prefix $mkinitcpio_encrypt_hooks $mkinitcpio_hooks_suffix)" && - replace_in_file "$mkinitcpio_search_modules" "$mkinitcpio_replace_modules" "$mkinitcpio_path" && - replace_in_file "$mkinitcpio_search_binaries" "$mkinitcpio_replace_binaries" "$mkinitcpio_path" && - replace_in_file "$mkinitcpio_search_hooks" "$mkinitcpio_replace_hooks" "$mkinitcpio_path" && - info "Content of $mkinitcpio_path:$(cat "$mkinitcpio_path")" && - info "Generating mkinitcpio..." && - echo "mkinitcpio -vP" | chroot "$root_mount_path" /bin/bash && - - fstab_insert_line="UUID=$root_partition_uuid / $root_filesystem defaults,noatime 0 1" && - info "Configuring $fstab_path..." || error - if grep -q "$fstab_insert_line" "$fstab_path" - then - warning "$fstab_path contains allready $fstab_insert_line - Skipped." - else - echo "$fstab_insert_line" >> "$fstab_path" || error - fi - info "Content of $fstab_path:$(cat "$fstab_path")" && - - crypttab_path="$root_mount_path""etc/crypttab" && - crypttab_insert_line="$root_mapper_name UUID=$root_partition_uuid none luks" && - info "Configuring $crypttab_path..." || error - if grep -q "$crypttab_insert_line" "$crypttab_path" - then - warning "$crypttab_path contains allready $crypttab_insert_line - Skipped." - else - echo "$crypttab_insert_line" >> "$crypttab_path" || error - fi - info "Content of $crypttab_path:$(cat "$crypttab_path")" && - - boot_txt_path="$boot_mount_path""boot.txt" && - cryptdevice_configuration="cryptdevice=UUID=$root_partition_uuid:$root_mapper_name root=$root_mapper_path" || error - if [ -f "$boot_txt_path" ]; - then - info "Configuring $boot_txt_path..." && - boot_txt_delete_line=$(echo "part uuid \${devtype} \${devnum}:2 uuid" | sed -e 's/[]\/$*.^[]/\\&/g') && - boot_txt_setenv_origin=$(echo "setenv bootargs console=ttyS1,115200 console=tty0 root=PARTUUID=\${uuid} rw rootwait smsc95xx.macaddr=\"\${usbethaddr}\"" | sed -e 's/[]\/$*.^[]/\\&/g') && - # Concerning issues with network adapter names; - # @see https://forum.iobroker.net/topic/40542/raspberry-pi4-kein-eth0-mehr/16 - boot_txt_setenv_replace=$(echo "setenv bootargs console=ttyS1,115200 console=tty0 ip=::::$target_hostname:eth0:dhcp $cryptdevice_configuration rw rootwait smsc95xx.macaddr=\"\${usbethaddr}\" net.ifnames=0 biosdevname=0"| sed -e 's/[\/&]/\\&/g') && - replace_in_file "$boot_txt_delete_line" "" "$boot_txt_path" && - replace_in_file "$boot_txt_setenv_origin" "$boot_txt_setenv_replace" "$boot_txt_path" && - info "Content of $boot_txt_path:$(cat "$boot_txt_path")" && - info "Generating..." && - echo "cd /boot/ && ./mkscr || exit 1" | chroot "$root_mount_path" /bin/bash || error - else - cmdline_txt_path="$boot_mount_path""cmdline.txt" && - info "Configuring $cmdline_txt_path..." && - cmdline_search_string=$(echo "root=/dev/mmcblk0p2" | sed -e 's/[\/&]/\\&/g') && - cmdline_replace_string=$(echo "$cryptdevice_configuration rootfstype=$root_filesystem"| sed -e 's/[\/&]/\\&/g') && - replace_in_file "$cmdline_search_string" "$cmdline_replace_string" "$cmdline_txt_path" && - info "Content of $cmdline_txt_path:$(cat "$cmdline_txt_path")" || error - fi - fi - - info "Running system specific procedures..." - if [ "$distribution" = "retropie" ] - then - if [ -n "$origin_user_rsa_pub" ] - then - ssh_file="$boot_mount_path""ssh" && - echo "" > "$ssh_file" - fi - question "Should the RetroFlag specific procedures be executed?(y/N)" && read -r setup_retroflag - if [ "$setup_retroflag" == "y" ] - then - info "Executing RetroFlag specific procedures..." && - ( - echo 'wget -O - "https://raw.githubusercontent.com/RetroFlag/retroflag-picase/master/install_gpi.sh" | bash' - ) | chroot "$root_mount_path" /bin/bash || error - fi - fi -fi -destructor -success "Setup successfull :)" && exit 0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5625a96 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,108 @@ +import subprocess +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from lim import runner, ui +from lim.errors import LimError + + +class FakeRunner: + """Records every command instead of executing it. + + ``outputs``/``success_by_fragment``/``failures`` map a substring of the + joined command to the canned behavior. + """ + + def __init__(self): + self.calls: list[tuple[str, list, str | None]] = [] + self.outputs: dict[str, str] = {} + self.failures: set[str] = set() + self.success_by_fragment: dict[str, bool] = {} + self.sudo_log: list[tuple[list[str], bool]] = [] + + def _fails(self, cmd: list[str]) -> bool: + joined = " ".join(cmd) + return any(fragment in joined for fragment in self.failures) + + def run(self, cmd, *, sudo=False, input_text=None, check=True, error_msg=None): + cmd = [str(part) for part in cmd] + self.calls.append(("run", cmd, input_text)) + self.sudo_log.append((cmd, sudo)) + returncode = 1 if self._fails(cmd) else 0 + if check and returncode != 0: + raise LimError(error_msg or f"Command failed: {' '.join(cmd)}") + return subprocess.CompletedProcess(cmd, returncode) + + def output(self, cmd, *, sudo=False, check=True, error_msg=None): + cmd = [str(part) for part in cmd] + self.calls.append(("output", cmd, None)) + joined = " ".join(cmd) + for fragment, value in self.outputs.items(): + if fragment in joined: + return value + return "" + + def succeeds(self, cmd, *, sudo=False): + cmd = [str(part) for part in cmd] + self.calls.append(("succeeds", cmd, None)) + joined = " ".join(cmd) + for fragment, value in self.success_by_fragment.items(): + if fragment in joined: + return value + return False + + def pipeline(self, *cmds, sudo_last=False, error_msg=None): + cmds = [[str(part) for part in cmd] for cmd in cmds] + self.calls.append(("pipeline", cmds, None)) + for cmd in cmds: + if self._fails(cmd): + raise LimError(error_msg or "Pipeline failed") + + def sync_disks(self): + self.calls.append(("run", ["sync"], None)) + + def commands(self) -> list[list[str]]: + return [cmd for _, cmd, _ in self.calls] + + def find(self, *fragments: str) -> list[list[str]]: + """All recorded commands whose joined form contains every fragment.""" + result = [] + for command in self.commands(): + joined = " ".join( + " ".join(part) if isinstance(part, list) else part for part in command + ) + if all(fragment in joined for fragment in fragments): + result.append(command) + return result + + +@pytest.fixture +def fake_runner(monkeypatch): + fake = FakeRunner() + monkeypatch.setattr(runner, "run", fake.run) + monkeypatch.setattr(runner, "output", fake.output) + monkeypatch.setattr(runner, "succeeds", fake.succeeds) + monkeypatch.setattr(runner, "pipeline", fake.pipeline) + monkeypatch.setattr(runner, "sync_disks", fake.sync_disks) + return fake + + +@pytest.fixture +def answers(monkeypatch): + """Feed scripted answers to ui.ask (and thereby ui.confirm).""" + queue: list[str] = [] + + def fake_ask(prompt: str) -> str: + assert queue, f"No scripted answer left for prompt: {prompt}" + return queue.pop(0) + + monkeypatch.setattr(ui, "ask", fake_ask) + + def feed(*items: str) -> None: + queue.extend(items) + + return feed diff --git a/tests/lint/__init__.py b/tests/lint/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/lint/test_file_length.py b/tests/lint/test_file_length.py new file mode 100644 index 0000000..7607f84 --- /dev/null +++ b/tests/lint/test_file_length.py @@ -0,0 +1,19 @@ +"""Architecture guard: keep modules small (KISS/SRP).""" + +from pathlib import Path + +MAX_LINES = 250 +REPO_ROOT = Path(__file__).resolve().parents[2] +CHECKED_GLOBS = ("main.py", "lim/**/*.py", "tests/**/*.py") + + +def test_source_files_stay_below_max_lines(): + offenders = { + str(path.relative_to(REPO_ROOT)): length + for pattern in CHECKED_GLOBS + for path in sorted(REPO_ROOT.glob(pattern)) + if (length := len(path.read_text().splitlines())) > MAX_LINES + } + assert not offenders, ( + f"Files exceeding {MAX_LINES} lines (split them, see KISS/SRP): {offenders}" + ) diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_catalog.py b/tests/unit/test_catalog.py new file mode 100644 index 0000000..9631d20 --- /dev/null +++ b/tests/unit/test_catalog.py @@ -0,0 +1,24 @@ +from lim import catalog + + +def test_catalog_contains_all_sections(): + assert catalog.arch_rpi_images() + assert catalog.manjaro_gnome_releases() + assert catalog.retropie_images() + assert catalog.mkinitcpio_modules_by_rpi() + + +def test_arch_entries_are_complete(): + for version, entry in catalog.arch_rpi_images().items(): + assert entry["image"], version + assert entry["luks_memory_cost"].isdigit(), version + + +def test_mkinitcpio_covers_every_arch_rpi_version(): + assert set(catalog.mkinitcpio_modules_by_rpi()) == set(catalog.arch_rpi_images()) + + +def test_manjaro_entries_have_url_and_image(): + for release, entry in catalog.manjaro_gnome_releases().items(): + assert entry["url"].startswith("https://"), release + assert entry["image"], release diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py new file mode 100644 index 0000000..cad7663 --- /dev/null +++ b/tests/unit/test_cli.py @@ -0,0 +1,65 @@ +import pytest + +from lim import cli, system +from lim.cli import Command +from lim.errors import LimError + + +@pytest.fixture(autouse=True) +def no_input(monkeypatch): + monkeypatch.setattr( + "builtins.input", lambda *args: (_ for _ in ()).throw(AssertionError("unexpected prompt")) + ) + + +def test_every_command_dispatches_to_its_function(monkeypatch): + executed = [] + monkeypatch.setitem( + cli.COMMANDS, "lock", Command(lambda: executed.append("lock"), "d", needs_root=False) + ) + cli.main(["--type", "lock", "--auto-confirm"]) + assert executed == ["lock"] + + +def test_needs_root_command_requests_root(monkeypatch): + escalated = [] + monkeypatch.setattr(system, "ensure_root", lambda: escalated.append(True)) + monkeypatch.setitem( + cli.COMMANDS, "backup", Command(lambda: None, "d", needs_root=True) + ) + cli.main(["--type", "backup", "--auto-confirm"]) + assert escalated == [True] + + +def test_confirmation_prompt_waits_for_enter(monkeypatch): + prompts = [] + executed = [] + monkeypatch.setattr("builtins.input", lambda *args: prompts.append(args) or "") + monkeypatch.setitem( + cli.COMMANDS, "lock", Command(lambda: executed.append(True), "d", needs_root=False) + ) + cli.main(["--type", "lock"]) + assert len(prompts) == 1 + assert executed == [True] + + +def test_lim_error_exits_with_code_1(monkeypatch): + def failing(): + raise LimError("boom") + + monkeypatch.setitem(cli.COMMANDS, "lock", Command(failing, "d", needs_root=False)) + with pytest.raises(SystemExit) as excinfo: + cli.main(["--type", "lock", "--auto-confirm"]) + assert excinfo.value.code == 1 + + +def test_unknown_type_is_rejected_by_argparse(): + with pytest.raises(SystemExit) as excinfo: + cli.main(["--type", "does-not-exist"]) + assert excinfo.value.code == 2 + + +def test_all_registered_commands_have_descriptions(): + for name, command in cli.COMMANDS.items(): + assert command.description, name + assert callable(command.func), name diff --git a/tests/unit/test_device.py b/tests/unit/test_device.py new file mode 100644 index 0000000..6608b97 --- /dev/null +++ b/tests/unit/test_device.py @@ -0,0 +1,84 @@ +import pytest + +from lim import device +from lim.device import Device +from lim.errors import LimError + + +class TestPartitionNaming: + def test_letter_suffix_devices_append_number(self): + assert Device("sda").partition(1) == "/dev/sda1" + assert Device("sdb").partition(2) == "/dev/sdb2" + + def test_digit_suffix_devices_get_p_infix(self): + assert Device("mmcblk0").partition(1) == "/dev/mmcblk0p1" + assert Device("nvme0n1").partition(2) == "/dev/nvme0n1p2" + + +class TestOptimalBlocksize: + def test_uses_64_times_physical_block_size(self, tmp_path): + queue = tmp_path / "sda" / "queue" + queue.mkdir(parents=True) + (queue / "physical_block_size").write_text("512\n") + assert device.optimal_blocksize("sda", tmp_path) == str(64 * 512) + + def test_falls_back_to_4k_when_missing(self, tmp_path): + assert device.optimal_blocksize("sda", tmp_path) == "4K" + + def test_falls_back_to_4k_on_garbage(self, tmp_path): + queue = tmp_path / "sda" / "queue" + queue.mkdir(parents=True) + (queue / "physical_block_size").write_text("not-a-number") + assert device.optimal_blocksize("sda", tmp_path) == "4K" + + +class TestOverwriteDevice: + @pytest.fixture + def sda(self, monkeypatch): + monkeypatch.setattr(Device, "optimal_blocksize", property(lambda self: "4K")) + return Device("sda") + + def test_full_overwrite(self, sda, fake_runner, answers): + answers("y") + device.overwrite_device(sda) + dd_calls = fake_runner.find("dd", "if=/dev/zero", "of=/dev/sda") + assert len(dd_calls) == 1 + assert not any("count=" in part for part in dd_calls[0]) + + @pytest.mark.parametrize("answer", ["", "N"]) + def test_skip(self, sda, fake_runner, answers, answer): + answers(answer) + device.overwrite_device(sda) + assert fake_runner.find("dd") == [] + + def test_block_count(self, sda, fake_runner, answers): + answers("34") + device.overwrite_device(sda) + assert len(fake_runner.find("dd", "count=34", "bs=4K")) == 1 + + def test_invalid_input_raises(self, sda, fake_runner, answers): + answers("nonsense") + with pytest.raises(LimError): + device.overwrite_device(sda) + + +class TestSelectDevice: + def test_valid_device(self, fake_runner, answers, monkeypatch): + answers("sda") + monkeypatch.setattr(device, "is_block_device", lambda path: path == "/dev/sda") + assert device.select_device() == Device("sda") + + def test_invalid_device_raises(self, fake_runner, answers, monkeypatch): + answers("nope") + monkeypatch.setattr(device, "is_block_device", lambda path: False) + with pytest.raises(LimError): + device.select_device() + + +def test_is_mounted(fake_runner): + fake_runner.outputs["mount"] = ( + "/dev/sda1 on /boot type vfat (rw)\n/dev/mapper/x on /media/x type btrfs (rw)" + ) + assert device.is_mounted("/dev/sda1") + assert device.is_mounted("/media/x") + assert not device.is_mounted("/dev/sdb") diff --git a/tests/unit/test_fsutil.py b/tests/unit/test_fsutil.py new file mode 100644 index 0000000..73fd1eb --- /dev/null +++ b/tests/unit/test_fsutil.py @@ -0,0 +1,35 @@ +import pytest + +from lim import fsutil +from lim.errors import LimError + + +def test_replace_in_file_replaces_all_occurrences(tmp_path): + target = tmp_path / "conf" + target.write_text("MODULES=()\nHOOKS=(base)\nMODULES=()\n") + fsutil.replace_in_file("MODULES=()", "MODULES=(x)", target) + assert target.read_text() == "MODULES=(x)\nHOOKS=(base)\nMODULES=(x)\n" + + +def test_replace_in_file_fails_when_search_missing(tmp_path): + target = tmp_path / "conf" + target.write_text("nothing here\n") + with pytest.raises(LimError): + fsutil.replace_in_file("MODULES=()", "MODULES=(x)", target) + assert target.read_text() == "nothing here\n" + + +def test_ensure_line_appends_once(tmp_path): + target = tmp_path / "fstab" + target.write_text("existing entry\n") + assert fsutil.ensure_line_in_file("new entry", target) is True + assert fsutil.ensure_line_in_file("new entry", target) is False + assert target.read_text() == "existing entry\nnew entry\n" + + +def test_ensure_line_creates_file_and_handles_missing_newline(tmp_path): + target = tmp_path / "crypttab" + assert fsutil.ensure_line_in_file("first", target) is True + target.write_text("no newline at end") + assert fsutil.ensure_line_in_file("second", target) is True + assert target.read_text() == "no newline at end\nsecond\n" diff --git a/tests/unit/test_image_session.py b/tests/unit/test_image_session.py new file mode 100644 index 0000000..cf5b5a7 --- /dev/null +++ b/tests/unit/test_image_session.py @@ -0,0 +1,62 @@ +from lim.device import Device +from lim.image.session import ImageSession + + +def test_partition_paths_for_sd_card(): + session = ImageSession(Device("mmcblk0")) + assert session.boot_partition_path == "/dev/mmcblk0p1" + assert session.root_partition_path == "/dev/mmcblk0p2" + assert session.root_mapper_path == "/dev/mmcblk0p2" + + +def test_decrypt_root_on_luks_partition(fake_runner): + fake_runner.outputs["-s TYPE"] = "crypto_LUKS" + fake_runner.outputs["-s UUID"] = "uuid-1" + session = ImageSession(Device("sda")) + session.decrypt_root() + assert session.root_mapper_name == "linux-image-manager-uuid-1" + assert session.root_mapper_path == "/dev/mapper/linux-image-manager-uuid-1" + assert len(fake_runner.find("cryptsetup", "luksOpen", "/dev/sda2")) == 1 + + +def test_decrypt_root_skips_plain_partition(fake_runner): + fake_runner.outputs["-s TYPE"] = "ext4" + session = ImageSession(Device("sda")) + session.decrypt_root() + assert session.root_mapper_name is None + assert session.root_mapper_path == "/dev/sda2" + assert fake_runner.find("cryptsetup") == [] + + +def test_destructor_unmounts_everything_despite_failures(tmp_path, fake_runner): + session = ImageSession(Device("sda")) + session.working_folder = tmp_path + session.boot_mount_path = tmp_path / "boot" + session.root_mount_path = tmp_path / "root" + fake_runner.failures.add("umount") # every umount fails; cleanup must go on + + session.destructor() + + umounted = [cmd[-1] for cmd in fake_runner.find("umount")] + root = str(session.root_mount_path) + # chroot binds first (deepest path first), then the partitions + assert umounted == [ + f"{root}/dev/pts", + f"{root}/dev", + f"{root}/proc", + f"{root}/sys", + f"{root}/boot", + root, + str(session.boot_mount_path), + ] + removed = [cmd[-1] for cmd in fake_runner.find("rmdir")] + assert removed == [root, str(session.boot_mount_path), str(tmp_path)] + + +def test_destructor_closes_luks_mapper(tmp_path, fake_runner): + fake_runner.outputs["-s TYPE"] = "crypto_LUKS" + fake_runner.outputs["-s UUID"] = "uuid-1" + session = ImageSession(Device("sda")) + session.decrypt_root() + session.destructor() + assert len(fake_runner.find("luksClose", "linux-image-manager-uuid-1")) == 1 diff --git a/tests/unit/test_image_setup.py b/tests/unit/test_image_setup.py new file mode 100644 index 0000000..b47d6e9 --- /dev/null +++ b/tests/unit/test_image_setup.py @@ -0,0 +1,143 @@ +import pytest + +from lim.errors import LimError +from lim.image import choosers, raspberry, transfer +from lim.image.plan import ImagePlan +from lim.image.session import install_packages + + +@pytest.fixture +def plan(): + return ImagePlan() + + +class TestDistributionChoosers: + def test_arch_rpi4_uses_aarch64_and_high_memory_cost(self, plan, answers): + answers("4") + choosers.choose_arch(plan) + assert plan.image_name == "ArchLinuxARM-rpi-aarch64-latest.tar.gz" + assert plan.luks_memory_cost == "256000" + assert plan.raspberry_pi_version == "4" + assert plan.download_url == ( + "http://os.archlinuxarm.org/os/ArchLinuxARM-rpi-aarch64-latest.tar.gz" + ) + + def test_arch_rpi1_uses_armv7_and_low_memory_cost(self, plan, answers): + answers("1") + choosers.choose_arch(plan) + assert plan.image_name == "ArchLinuxARM-rpi-armv7-latest.tar.gz" + assert plan.luks_memory_cost == "64000" + + def test_arch_unknown_version_raises(self, plan, answers): + answers("99") + with pytest.raises(LimError): + choosers.choose_arch(plan) + + def test_manjaro_gnome_25(self, plan, answers): + answers("gnome", "25") + choosers.choose_manjaro(plan) + assert plan.image_name == "manjaro-gnome-25.0.10-251013-linux612.iso" + assert plan.base_download_url == "https://download.manjaro.org/gnome/25.0.10/" + assert plan.image_checksum is None + + def test_manjaro_raspberrypi_release_sets_rpi_version(self, plan, answers): + answers("gnome", "raspberrypi") + choosers.choose_manjaro(plan) + assert plan.raspberry_pi_version == "4" + assert plan.luks_memory_cost == "256000" + assert plan.image_name == "Manjaro-ARM-gnome-rpi4-23.02.img.xz" + + def test_manjaro_unknown_flavour_raises(self, plan, answers): + answers("kde") + with pytest.raises(LimError): + choosers.choose_manjaro(plan) + + def test_retropie_rpi3_shares_rpi2_image(self, plan, answers): + answers("3") + choosers.choose_retropie(plan) + assert plan.image_name == "retropie-buster-4.8-rpi2_3_zero2w.img.gz" + assert plan.image_checksum == "224e64d8820fc64046ba3850f481c87e" + + def test_unknown_distribution_raises(self, plan, answers): + answers("gentoo") + with pytest.raises(LimError): + choosers.choose_linux_image(plan) + + +class TestPartitionInput: + def test_contains_boot_size_and_writes_table(self): + script = transfer.arch_partition_input("+500M") + assert script.startswith("o\n") + assert "\n+500M\n" in script + assert script.endswith("w\n") + + +class TestDecompressCommand: + @pytest.mark.parametrize( + ("name", "expected"), + [ + ("image.zip", ["unzip", "-p"]), + ("image.img.gz", ["gunzip", "-c"]), + ("image.iso", ["pv"]), + ("image.img.xz", ["unxz", "-c"]), + ], + ) + def test_known_formats(self, tmp_path, name, expected): + path = tmp_path / name + command = transfer.decompress_command(path) + assert command[: len(expected)] == expected + assert command[-1] == str(path) + + def test_unknown_format_raises(self, tmp_path): + with pytest.raises(LimError): + transfer.decompress_command(tmp_path / "image.rar") + + +class TestInstallPackages: + def test_pacman_for_arch(self, tmp_path, fake_runner): + install_packages("arch", tmp_path, "btrfs-progs") + chroot_calls = [ + input_text + for kind, cmd, input_text in fake_runner.calls + if kind == "run" and cmd[0] == "chroot" + ] + assert chroot_calls == ["pacman --noconfirm -S --needed btrfs-progs"] + + def test_apt_for_retropie(self, tmp_path, fake_runner): + install_packages("retropie", tmp_path, "btrfs-progs") + chroot_calls = [ + input_text + for kind, cmd, input_text in fake_runner.calls + if kind == "run" and cmd[0] == "chroot" + ] + assert chroot_calls == ["yes | apt install btrfs-progs"] + + def test_unsupported_distribution_raises(self, tmp_path, fake_runner): + with pytest.raises(LimError): + install_packages("gentoo", tmp_path, "btrfs-progs") + + +class TestConfigureHelpers: + def test_configure_sudoers(self, tmp_path): + raspberry.configure_sudoers(tmp_path, "administrator") + sudoers = tmp_path / "etc/sudoers.d/administrator" + assert sudoers.read_text() == "administrator ALL=(ALL:ALL) ALL\n" + assert (sudoers.stat().st_mode & 0o777) == 0o440 + + def test_configure_ssh_key(self, tmp_path): + public_key = tmp_path / "id_rsa.pub" + public_key.write_text("ssh-rsa AAAA test@host\n") + ssh_folder = tmp_path / "root/home/user/.ssh" + authorized_keys = ssh_folder / "authorized_keys" + raspberry.configure_ssh_key(str(public_key), ssh_folder, authorized_keys) + assert authorized_keys.read_text() == "ssh-rsa AAAA test@host\n" + assert (ssh_folder.stat().st_mode & 0o777) == 0o700 + assert (authorized_keys.stat().st_mode & 0o777) == 0o600 + + def test_configure_ssh_key_missing_source_raises(self, tmp_path): + with pytest.raises(LimError): + raspberry.configure_ssh_key( + str(tmp_path / "missing.pub"), + tmp_path / ".ssh", + tmp_path / ".ssh/authorized_keys", + ) diff --git a/tests/unit/test_luks.py b/tests/unit/test_luks.py new file mode 100644 index 0000000..1cf4499 --- /dev/null +++ b/tests/unit/test_luks.py @@ -0,0 +1,59 @@ +import pytest + +from lim import luks +from lim.errors import LimError + +LUKS_DUMP = """\ +LUKS header information +Version: 2 +UUID: 1234-abcd-5678 +""" + + +def test_luks_uuid_parsed_from_dump(fake_runner): + fake_runner.outputs["luksDump"] = LUKS_DUMP + assert luks.luks_uuid("/dev/sda1") == "1234-abcd-5678" + + +def test_luks_uuid_missing_raises(fake_runner): + fake_runner.outputs["luksDump"] = "no uuid here" + with pytest.raises(LimError): + luks.luks_uuid("/dev/sda1") + + +def test_update_fstab_is_idempotent(tmp_path): + fstab = tmp_path / "fstab" + fstab.write_text("# existing\n") + luks.update_fstab("/dev/mapper/x", "/media/x", fstab_path=fstab) + luks.update_fstab("/dev/mapper/x", "/media/x", fstab_path=fstab) + lines = fstab.read_text().splitlines() + assert lines.count("/dev/mapper/x /media/x btrfs defaults 0 2") == 1 + + +def test_create_luks_key_and_update_crypttab(tmp_path, fake_runner): + fake_runner.outputs["luksDump"] = LUKS_DUMP + key_dir = tmp_path / "luks-keys" + crypttab = tmp_path / "crypttab" + + luks.create_luks_key_and_update_crypttab( + "encrypteddrive-sda", + "/dev/sda1", + key_directory=key_dir, + crypttab_path=crypttab, + ) + + keyfile = key_dir / "encrypteddrive-sda.keyfile" + assert len(fake_runner.find("dd", "if=/dev/urandom", f"of={keyfile}")) == 1 + assert len(fake_runner.find("cryptsetup", "luksAddKey", "/dev/sda1")) == 1 + assert len(fake_runner.find("cryptsetup", "luksOpen", f"--key-file={keyfile}")) == 1 + expected_entry = f"encrypteddrive-sda UUID=1234-abcd-5678 {keyfile} luks" + assert expected_entry in crypttab.read_text().splitlines() + + # A second run must not duplicate the crypttab entry. + luks.create_luks_key_and_update_crypttab( + "encrypteddrive-sda", + "/dev/sda1", + key_directory=key_dir, + crypttab_path=crypttab, + ) + assert crypttab.read_text().splitlines().count(expected_entry) == 1 diff --git a/tests/unit/test_packages.py b/tests/unit/test_packages.py new file mode 100644 index 0000000..4e0283f --- /dev/null +++ b/tests/unit/test_packages.py @@ -0,0 +1,30 @@ +import pytest + +from lim import config, packages +from lim.errors import LimError + + +@pytest.fixture +def package_dir(tmp_path, monkeypatch): + monkeypatch.setattr(config, "PACKAGE_PATH", tmp_path) + return tmp_path + + +def test_strips_comments_and_blank_lines(package_dir): + (package_dir / "general.txt").write_text( + "# header comment\nnano\ntree# inline comment\n\nhtop\n" + ) + assert packages.get_packages("general") == ["nano", "tree", "htop"] + + +def test_multiple_collections_are_concatenated(package_dir): + (package_dir / "a.txt").write_text("one\n") + subdir = package_dir / "server" + subdir.mkdir() + (subdir / "luks.txt").write_text("two\nthree\n") + assert packages.get_packages("a", "server/luks") == ["one", "two", "three"] + + +def test_missing_collection_raises(package_dir): + with pytest.raises(LimError): + packages.get_packages("does-not-exist") diff --git a/tests/unit/test_raspberry.py b/tests/unit/test_raspberry.py new file mode 100644 index 0000000..9c87bdb --- /dev/null +++ b/tests/unit/test_raspberry.py @@ -0,0 +1,72 @@ +import pytest + +from lim.device import Device +from lim.image import raspberry +from lim.image.session import ImageSession + + +@pytest.fixture +def session(tmp_path): + session = ImageSession(Device("mmcblk0")) + session.root_mount_path = tmp_path + session.boot_partition_uuid = "BOOT-UUID" + return session + + +class TestSeedBootUuid: + def test_replaces_mmcblk_reference(self, tmp_path, session): + fstab = tmp_path / "etc/fstab" + fstab.parent.mkdir() + fstab.write_text("/dev/mmcblk0p1 /boot vfat defaults 0 0\n") + raspberry._seed_boot_uuid(session) + assert fstab.read_text() == "UUID=BOOT-UUID /boot vfat defaults 0 0\n" + + def test_skips_partuuid_based_images(self, tmp_path, session): + fstab = tmp_path / "etc/fstab" + fstab.parent.mkdir() + content = "PARTUUID=6c586e13-01 /boot vfat defaults 0 0\n" + fstab.write_text(content) + raspberry._seed_boot_uuid(session) # must not raise + assert fstab.read_text() == content + + +class TestEnsureImageMounted: + def test_reads_uuids_when_boot_already_mounted(self, fake_runner): + session = ImageSession(Device("sda")) + fake_runner.outputs["mount"] = "/dev/sda1 on /tmp/x/boot type vfat (rw)" + # Distinct per-partition values so a boot/root swap cannot pass. + fake_runner.outputs["/dev/sda1 -s UUID"] = "boot-uuid" + fake_runner.outputs["/dev/sda2 -s UUID"] = "root-uuid" + fake_runner.outputs["-s TYPE"] = "crypto_LUKS" + + raspberry._ensure_image_mounted(session) + + assert session.boot_partition_uuid == "boot-uuid" + assert session.root_partition_uuid == "root-uuid" + assert session.root_mapper_name == "linux-image-manager-root-uuid" + assert session.root_mapper_path == "/dev/mapper/linux-image-manager-root-uuid" + + def test_plain_root_keeps_partition_as_mapper(self, fake_runner): + session = ImageSession(Device("sda")) + fake_runner.outputs["mount"] = "/dev/sda1 on /tmp/x/boot type vfat (rw)" + fake_runner.outputs["-s UUID"] = "uuid-7" + fake_runner.outputs["-s TYPE"] = "ext4" + + raspberry._ensure_image_mounted(session) + + assert session.root_mapper_name is None + assert session.root_mapper_path == "/dev/sda2" + + def test_mounts_when_nothing_is_mounted(self, fake_runner, tmp_path): + session = ImageSession(Device("sda")) + session.boot_mount_path = tmp_path / "boot" + session.root_mount_path = tmp_path / "root" + fake_runner.outputs["-s TYPE"] = "ext4" + fake_runner.outputs["/dev/sda1 -s UUID"] = "boot-uuid" + fake_runner.outputs["/dev/sda2 -s UUID"] = "root-uuid" + + raspberry._ensure_image_mounted(session) + + assert len(fake_runner.find("mount", "-v")) == 2 + assert session.boot_partition_uuid == "boot-uuid" + assert session.root_partition_uuid == "root-uuid" diff --git a/tests/unit/test_runner.py b/tests/unit/test_runner.py new file mode 100644 index 0000000..ec0675c --- /dev/null +++ b/tests/unit/test_runner.py @@ -0,0 +1,92 @@ +"""Tests against the real runner module (no fake) using harmless commands.""" + +import subprocess + +import pytest + +from lim import runner, ui +from lim.errors import LimError + +MISSING = "lim-definitely-missing-binary-x" + + +class TestMissingBinaries: + def test_run_raises_lim_error(self): + with pytest.raises(LimError, match="Command not found"): + runner.run([MISSING]) + + def test_run_tolerates_when_check_is_false(self): + result = runner.run([MISSING], check=False) + assert result.returncode == runner.COMMAND_NOT_FOUND + + def test_output_raises_lim_error(self): + with pytest.raises(LimError, match="Command not found"): + runner.output([MISSING]) + + def test_output_tolerates_when_check_is_false(self): + assert runner.output([MISSING], check=False) == "" + + def test_succeeds_returns_false(self): + assert runner.succeeds([MISSING]) is False + + def test_tolerated_paths_emit_a_warning(self, monkeypatch): + warnings = [] + monkeypatch.setattr(ui, "warning", warnings.append) + runner.run([MISSING], check=False) + runner.output([MISSING], check=False) + runner.succeeds([MISSING]) + assert len(warnings) == 3 + assert all("Command not found" in text for text in warnings) + + def test_pipeline_raises_lim_error(self): + with pytest.raises(LimError, match="Command not found"): + runner.pipeline(["echo", "x"], [MISSING]) + + def test_pipeline_raises_when_first_member_is_missing(self): + with pytest.raises(LimError, match=f"Command not found: {MISSING}"): + runner.pipeline([MISSING], ["cat"]) + + def test_pipeline_reaps_started_members(self, monkeypatch): + spawned = [] + original_popen = subprocess.Popen + + def spying_popen(*args, **kwargs): + process = original_popen(*args, **kwargs) + spawned.append(process) + return process + + monkeypatch.setattr(subprocess, "Popen", spying_popen) + with pytest.raises(LimError, match="Command not found"): + runner.pipeline(["sleep", "60"], [MISSING]) + assert len(spawned) == 1 + assert spawned[0].poll() is not None # killed and reaped, not running + assert spawned[0].stdout.closed + + def test_run_prefers_explicit_error_message(self): + with pytest.raises(LimError, match="custom message"): + runner.run([MISSING], error_msg="custom message") + + def test_output_prefers_explicit_error_message(self): + with pytest.raises(LimError, match="custom message"): + runner.output([MISSING], error_msg="custom message") + + +class TestHappyPath: + def test_run_returns_completed_process(self): + assert runner.run(["true"]).returncode == 0 + + def test_run_raises_on_nonzero_exit(self): + with pytest.raises(LimError, match="Command failed with code 1"): + runner.run(["false"]) + + def test_output_returns_stripped_stdout(self): + assert runner.output(["echo", "hello"]) == "hello" + + def test_succeeds_reflects_exit_code(self): + assert runner.succeeds(["true"]) is True + assert runner.succeeds(["false"]) is False + + def test_pipeline_runs_and_checks_every_member(self): + runner.pipeline(["echo", "x"], ["cat"]) # must not raise + with pytest.raises(LimError, match="Pipeline failed"): + runner.pipeline(["false"], ["cat"]) diff --git a/tests/unit/test_storage.py b/tests/unit/test_storage.py new file mode 100644 index 0000000..8f7cab6 --- /dev/null +++ b/tests/unit/test_storage.py @@ -0,0 +1,70 @@ +import pytest + +from lim import device +from lim.device import Device +from lim.storage import raid1, single_drive +from lim.storage.common import StorageTarget + + +@pytest.fixture +def block_devices(monkeypatch): + monkeypatch.setattr(device, "is_block_device", lambda path: True) + monkeypatch.setattr(Device, "optimal_blocksize", property(lambda self: "4K")) + + +def test_storage_target_derives_all_paths(): + target = StorageTarget(Device("sdb")) + assert target.mapper_name == "encrypteddrive-sdb" + assert target.mapper_path == "/dev/mapper/encrypteddrive-sdb" + assert target.mount_path == "/media/encrypteddrive-sdb" + assert target.partition_path == "/dev/sdb1" + + +def test_single_drive_setup_command_sequence( + fake_runner, answers, block_devices, monkeypatch +): + monkeypatch.setattr("lim.system.real_user", lambda: "kevin") + answers("sdb", "N") # device name, skip overwrite + + single_drive.setup() + + fdisk_calls = [ + (cmd, input_text) + for kind, cmd, input_text in fake_runner.calls + if kind == "run" and cmd[0] == "fdisk" + ] + assert [input_text for _, input_text in fdisk_calls] == [ + single_drive.CREATE_GPT_TABLE_INPUT, + single_drive.CREATE_PARTITION_INPUT, + ] + assert len(fake_runner.find("cryptsetup", "-y", "luksFormat", "/dev/sdb1")) == 1 + assert len(fake_runner.find("cryptsetup", "luksOpen", "encrypteddrive-sdb")) == 1 + assert len(fake_runner.find("mkfs.btrfs", "/dev/mapper/encrypteddrive-sdb")) == 1 + assert len(fake_runner.find("mount", "/media/encrypteddrive-sdb")) == 1 + assert len(fake_runner.find("chown", "kevin:kevin")) == 1 + + +def test_single_drive_umount(fake_runner, answers, block_devices): + answers("sdb") + single_drive.umount() + assert len(fake_runner.find("umount", "/dev/mapper/encrypteddrive-sdb")) == 1 + assert len(fake_runner.find("cryptsetup", "luksClose", "encrypteddrive-sdb")) == 1 + + +def test_raid1_setup_uses_both_whole_devices(fake_runner, answers, block_devices): + answers("sdb", "sdc") + raid1.setup() + assert len(fake_runner.find("cryptsetup", "luksFormat", "/dev/sdb")) == 1 + assert len(fake_runner.find("cryptsetup", "luksFormat", "/dev/sdc")) == 1 + mkfs = fake_runner.find("mkfs.btrfs", "-m raid1", "-d raid1") + assert mkfs == [ + [ + "mkfs.btrfs", + "-m", + "raid1", + "-d", + "raid1", + "/dev/mapper/encrypteddrive-sdb", + "/dev/mapper/encrypteddrive-sdc", + ] + ] diff --git a/tests/unit/test_sync.py b/tests/unit/test_sync.py new file mode 100644 index 0000000..921aeea --- /dev/null +++ b/tests/unit/test_sync.py @@ -0,0 +1,119 @@ +from pathlib import Path + +import pytest + +from lim import config, system +from lim.data import sync +from lim.errors import LimError + + +@pytest.fixture +def home(tmp_path): + home = tmp_path / "home/kevin" + (home / ".ssh").mkdir(parents=True) + (home / ".ssh/id_rsa").write_text("key") + (home / ".gitconfig").write_text("[user]") + return home + + +@pytest.fixture +def data_path(tmp_path): + return tmp_path / "decrypted/data" + + +@pytest.fixture +def backup_folder(tmp_path): + return tmp_path / "decrypted/backup/import/20260707000000" + + +def test_import_plan_only_covers_existing_items(home, data_path, backup_folder): + operations = sync.build_sync_plan("import", home, data_path, backup_folder) + sources = [operation.source for operation in operations] + assert sources == [f"{home}/.ssh/", f"{home}/.gitconfig"] + for operation in operations: + assert operation.destination.startswith(str(data_path)) + + +def test_export_swaps_source_and_destination(home, data_path, backup_folder): + exported = Path(f"{data_path}{home}/.gitconfig") + exported.parent.mkdir(parents=True) + exported.write_text("[user]") + operations = sync.build_sync_plan("export", home, data_path, backup_folder) + assert operations == [ + sync.SyncOperation( + source=str(exported), + destination=f"{home}/.gitconfig", + backup_dir=str(Path(f"{backup_folder}{home}")), + is_directory=False, + ) + ] + + +def test_unknown_mode_raises(home, data_path, backup_folder): + with pytest.raises(LimError): + sync.build_sync_plan("sideways", home, data_path, backup_folder) + + +def test_rsync_command_uses_delete_only_for_directories(): + directory_op = sync.SyncOperation("src/", "dst/", "backup", is_directory=True) + file_op = sync.SyncOperation("src", "dst", "backup", is_directory=False) + assert sync.rsync_command(directory_op) == [ + "rsync", "-abcEPuvW", "--delete", "--backup-dir=backup", "src/", "dst/" + ] + assert sync.rsync_command(file_op) == [ + "rsync", "-abcEPuvW", "--backup-dir=backup", "src", "dst" + ] + + +def test_import_syncs_from_real_home(tmp_path, fake_runner, monkeypatch): + real_home = tmp_path / "home/kevin" + real_home.mkdir(parents=True) + (real_home / ".gitconfig").write_text("[user]") + monkeypatch.setattr(system, "real_home", lambda: real_home) + monkeypatch.setattr(config, "DECRYPTED_PATH", tmp_path / "decrypted") + monkeypatch.setattr(config, "DATA_PATH", tmp_path / "decrypted/data") + monkeypatch.setattr(config, "BACKUP_PATH", tmp_path / "decrypted/backup") + fake_runner.outputs["mount"] = f"encfs on {tmp_path / 'decrypted'} type fuse" + + sync.import_from_system() + + rsync_calls = fake_runner.find("rsync") + assert len(rsync_calls) == 1 + assert rsync_calls[0][-2] == str(real_home / ".gitconfig") + + +def test_export_chowns_real_home_without_sudo(tmp_path, fake_runner, monkeypatch): + real_home = tmp_path / "home/kevin" + (real_home / ".ssh").mkdir(parents=True) + (real_home / ".ssh/id_rsa").write_text("key") + monkeypatch.setattr(system, "real_home", lambda: real_home) + monkeypatch.setattr(system, "real_user", lambda: "kevin") + monkeypatch.setattr(config, "DECRYPTED_PATH", tmp_path / "decrypted") + monkeypatch.setattr(config, "DATA_PATH", tmp_path / "decrypted/data") + monkeypatch.setattr(config, "BACKUP_PATH", tmp_path / "decrypted/backup") + fake_runner.outputs["mount"] = f"encfs on {tmp_path / 'decrypted'} type fuse" + + sync.export_to_system() + + chown_calls = [ + (cmd, sudo) for cmd, sudo in fake_runner.sudo_log if cmd[0] == "chown" + ] + assert chown_calls == [ + (["chown", "-R", "kevin:kevin", str(real_home)], False) + ] + assert (real_home / ".ssh/id_rsa").stat().st_mode & 0o777 == 0o600 + + +def test_execute_sync_plan_creates_folders_and_runs_rsync( + tmp_path, fake_runner +): + operation = sync.SyncOperation( + source=str(tmp_path / "src.txt"), + destination=str(tmp_path / "deep/nested/dst.txt"), + backup_dir=str(tmp_path / "backup/deep"), + is_directory=False, + ) + sync.execute_sync_plan([operation]) + assert (tmp_path / "deep/nested").is_dir() + assert (tmp_path / "backup/deep").is_dir() + assert len(fake_runner.find("rsync", "-abcEPuvW")) == 1 diff --git a/tests/unit/test_verify.py b/tests/unit/test_verify.py new file mode 100644 index 0000000..721c0d1 --- /dev/null +++ b/tests/unit/test_verify.py @@ -0,0 +1,52 @@ +import hashlib + +import pytest + +from lim.errors import LimError +from lim.image import verify + +CONTENT = b"fake image content" + + +@pytest.fixture +def image(tmp_path): + path = tmp_path / "image.img" + path.write_bytes(CONTENT) + return path + + +@pytest.mark.parametrize("algorithm", ["md5", "sha1", "sha256", "sha512"]) +def test_matching_checksum_passes(image, algorithm): + checksum = hashlib.new(algorithm, CONTENT).hexdigest() + verify.verify_checksum(image, checksum) + + +def test_uppercase_checksum_passes(image): + checksum = hashlib.sha256(CONTENT).hexdigest().upper() + verify.verify_checksum(image, checksum) + + +def test_wrong_checksum_raises(image): + checksum = hashlib.sha256(b"other content").hexdigest() + with pytest.raises(LimError): + verify.verify_checksum(image, checksum) + + +def test_unrecognized_digest_length_raises(image): + with pytest.raises(LimError): + verify.verify_checksum(image, "abc123") + + +def test_missing_checksum_is_skipped(image): + verify.verify_checksum(image, None) # must not raise + + +def test_resolve_checksum_takes_first_available(fake_runner): + fake_runner.success_by_fragment["image.img.sha1"] = False + fake_runner.success_by_fragment["image.img.sha512"] = True + fake_runner.outputs["-q -O -"] = "deadbeef image.img" + assert verify.resolve_checksum("https://example.org/image.img") == "deadbeef" + + +def test_resolve_checksum_returns_none_without_sources(fake_runner): + assert verify.resolve_checksum("https://example.org/image.img") is None