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 <cmd>`; 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
This commit is contained in:
@@ -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)",
|
||||
|
||||
10
.github/dependabot.yml
vendored
Normal file
10
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
22
.github/workflows/test.yml
vendored
Normal file
22
.github/workflows/test.yml
vendored
Normal file
@@ -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
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,3 +2,6 @@ decrypted/
|
||||
.encrypted/
|
||||
*package-lock.json
|
||||
log.txt
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
language: shell
|
||||
script: shellcheck $(find . -type f -name '*.sh')
|
||||
12
Makefile
Normal file
12
Makefile
Normal file
@@ -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
|
||||
146
README.md
146
README.md
@@ -4,17 +4,16 @@
|
||||
|
||||
[](./LICENSE.txt) [](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! 😊
|
||||
```
|
||||
67
distributions.yml
Normal file
67
distributions.yml
Normal file
@@ -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
|
||||
3
lim/__init__.py
Normal file
3
lim/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Linux Image Manager — administration tool for Linux images and encrypted storage."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
31
lim/catalog.py
Normal file
31
lim/catalog.py
Normal file
@@ -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"]
|
||||
167
lim/cli.py
Normal file
167
lim/cli.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""Command line interface: `lim --type <command>`."""
|
||||
|
||||
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)
|
||||
11
lim/config.py
Normal file
11
lim/config.py
Normal file
@@ -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"
|
||||
0
lim/data/__init__.py
Normal file
0
lim/data/__init__.py
Normal file
24
lim/data/crypt.py
Normal file
24
lim/data/crypt.py
Normal file
@@ -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()
|
||||
135
lim/data/sync.py
Normal file
135
lim/data/sync.py
Normal file
@@ -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}"...')
|
||||
130
lim/device.py
Normal file
130
lim/device.py
Normal file
@@ -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())
|
||||
2
lim/errors.py
Normal file
2
lim/errors.py
Normal file
@@ -0,0 +1,2 @@
|
||||
class LimError(Exception):
|
||||
"""Fatal error that aborts the current lim command."""
|
||||
32
lim/fsutil.py
Normal file
32
lim/fsutil.py
Normal file
@@ -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
|
||||
0
lim/image/__init__.py
Normal file
0
lim/image/__init__.py
Normal file
32
lim/image/backup.py
Normal file
32
lim/image/backup.py
Normal file
@@ -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.")
|
||||
111
lim/image/choosers.py
Normal file
111
lim/image/choosers.py
Normal file
@@ -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?")
|
||||
23
lim/image/chroot.py
Normal file
23
lim/image/chroot.py
Normal file
@@ -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()
|
||||
117
lim/image/encryption.py
Normal file
117
lim/image/encryption.py
Normal file
@@ -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)
|
||||
31
lim/image/plan.py
Normal file
31
lim/image/plan.py
Normal file
@@ -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
|
||||
236
lim/image/raspberry.py
Normal file
236
lim/image/raspberry.py
Normal file
@@ -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)
|
||||
190
lim/image/session.py
Normal file
190
lim/image/session.py
Normal file
@@ -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.")
|
||||
96
lim/image/setup.py
Normal file
96
lim/image/setup.py
Normal file
@@ -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 :)")
|
||||
140
lim/image/transfer.py
Normal file
140
lim/image/transfer.py
Normal file
@@ -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()
|
||||
100
lim/image/verify.py
Normal file
100
lim/image/verify.py
Normal file
@@ -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.")
|
||||
80
lim/luks.py
Normal file
80
lim/luks.py
Normal file
@@ -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())
|
||||
18
lim/packages.py
Normal file
18
lim/packages.py
Normal file
@@ -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
|
||||
134
lim/runner.py
Normal file
134
lim/runner.py
Normal file
@@ -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"])
|
||||
0
lim/storage/__init__.py
Normal file
0
lim/storage/__init__.py
Normal file
38
lim/storage/common.py
Normal file
38
lim/storage/common.py
Normal file
@@ -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
|
||||
68
lim/storage/raid1.py
Normal file
68
lim/storage/raid1.py
Normal file
@@ -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 :)")
|
||||
88
lim/storage/single_drive.py
Normal file
88
lim/storage/single_drive.py
Normal file
@@ -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 :)")
|
||||
35
lim/system.py
Normal file
35
lim/system.py
Normal file
@@ -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()
|
||||
62
lim/ui.py
Normal file
62
lim/ui.py
Normal file
@@ -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"
|
||||
)
|
||||
118
main.py
118
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()
|
||||
|
||||
41
pyproject.toml
Normal file
41
pyproject.toml
Normal file
@@ -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
|
||||
]
|
||||
161
scripts/base.sh
161
scripts/base.sh
@@ -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
|
||||
@@ -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\"..."
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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!"
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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 :)"
|
||||
@@ -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 :)"
|
||||
@@ -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 :)"
|
||||
@@ -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 :)"
|
||||
@@ -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;
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
108
tests/conftest.py
Normal file
108
tests/conftest.py
Normal file
@@ -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
|
||||
0
tests/lint/__init__.py
Normal file
0
tests/lint/__init__.py
Normal file
19
tests/lint/test_file_length.py
Normal file
19
tests/lint/test_file_length.py
Normal file
@@ -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}"
|
||||
)
|
||||
0
tests/unit/__init__.py
Normal file
0
tests/unit/__init__.py
Normal file
24
tests/unit/test_catalog.py
Normal file
24
tests/unit/test_catalog.py
Normal file
@@ -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
|
||||
65
tests/unit/test_cli.py
Normal file
65
tests/unit/test_cli.py
Normal file
@@ -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
|
||||
84
tests/unit/test_device.py
Normal file
84
tests/unit/test_device.py
Normal file
@@ -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")
|
||||
35
tests/unit/test_fsutil.py
Normal file
35
tests/unit/test_fsutil.py
Normal file
@@ -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"
|
||||
62
tests/unit/test_image_session.py
Normal file
62
tests/unit/test_image_session.py
Normal file
@@ -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
|
||||
143
tests/unit/test_image_setup.py
Normal file
143
tests/unit/test_image_setup.py
Normal file
@@ -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",
|
||||
)
|
||||
59
tests/unit/test_luks.py
Normal file
59
tests/unit/test_luks.py
Normal file
@@ -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
|
||||
30
tests/unit/test_packages.py
Normal file
30
tests/unit/test_packages.py
Normal file
@@ -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")
|
||||
72
tests/unit/test_raspberry.py
Normal file
72
tests/unit/test_raspberry.py
Normal file
@@ -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"
|
||||
92
tests/unit/test_runner.py
Normal file
92
tests/unit/test_runner.py
Normal file
@@ -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"])
|
||||
70
tests/unit/test_storage.py
Normal file
70
tests/unit/test_storage.py
Normal file
@@ -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",
|
||||
]
|
||||
]
|
||||
119
tests/unit/test_sync.py
Normal file
119
tests/unit/test_sync.py
Normal file
@@ -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
|
||||
52
tests/unit/test_verify.py
Normal file
52
tests/unit/test_verify.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user