linux-image-manager 2.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lim/__init__.py +3 -0
- lim/catalog.py +31 -0
- lim/cli.py +167 -0
- lim/config.py +18 -0
- lim/configuration/packages/general.txt +6 -0
- lim/configuration/packages/server/luks.txt +8 -0
- lim/data/__init__.py +0 -0
- lim/data/crypt.py +24 -0
- lim/data/sync.py +135 -0
- lim/device.py +130 -0
- lim/distributions.yml +67 -0
- lim/errors.py +2 -0
- lim/fsutil.py +32 -0
- lim/image/__init__.py +0 -0
- lim/image/backup.py +32 -0
- lim/image/choosers.py +111 -0
- lim/image/chroot.py +23 -0
- lim/image/encryption.py +117 -0
- lim/image/plan.py +31 -0
- lim/image/raspberry.py +236 -0
- lim/image/session.py +190 -0
- lim/image/setup.py +96 -0
- lim/image/transfer.py +140 -0
- lim/image/verify.py +100 -0
- lim/luks.py +80 -0
- lim/packages.py +18 -0
- lim/runner.py +134 -0
- lim/storage/__init__.py +0 -0
- lim/storage/common.py +38 -0
- lim/storage/raid1.py +68 -0
- lim/storage/single_drive.py +88 -0
- lim/system.py +35 -0
- lim/ui.py +62 -0
- linux_image_manager-2.1.0.dist-info/METADATA +142 -0
- linux_image_manager-2.1.0.dist-info/RECORD +39 -0
- linux_image_manager-2.1.0.dist-info/WHEEL +5 -0
- linux_image_manager-2.1.0.dist-info/entry_points.txt +2 -0
- linux_image_manager-2.1.0.dist-info/licenses/LICENSE.txt +674 -0
- linux_image_manager-2.1.0.dist-info/top_level.txt +1 -0
lim/image/session.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Mount/unmount lifecycle for working on an image on a block device."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from lim import device as device_module
|
|
7
|
+
from lim import runner, ui
|
|
8
|
+
from lim.device import Device
|
|
9
|
+
from lim.errors import LimError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ImageSession:
|
|
13
|
+
"""Working folder, partition paths and (chroot) mounts for one device.
|
|
14
|
+
|
|
15
|
+
Call ``destructor()`` in a finally block: it unmounts and removes
|
|
16
|
+
everything best-effort, mirroring partially completed setups.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, device: Device) -> None:
|
|
20
|
+
self.device = device
|
|
21
|
+
self.working_folder: Path | None = None
|
|
22
|
+
self.boot_mount_path: Path | None = None
|
|
23
|
+
self.root_mount_path: Path | None = None
|
|
24
|
+
self.boot_partition_path = device.partition(1)
|
|
25
|
+
self.root_partition_path = device.partition(2)
|
|
26
|
+
# Overridden by decrypt_root() when the root partition is LUKS.
|
|
27
|
+
self.root_mapper_path = self.root_partition_path
|
|
28
|
+
self.root_mapper_name: str | None = None
|
|
29
|
+
self.boot_partition_uuid: str | None = None
|
|
30
|
+
self.root_partition_uuid: str | None = None
|
|
31
|
+
|
|
32
|
+
def make_working_folder(self) -> None:
|
|
33
|
+
# Mount points must live on the root fs; mkdir() fails on collisions.
|
|
34
|
+
self.working_folder = Path(f"/tmp/linux-image-manager-{int(time.time())}") # noqa: S108
|
|
35
|
+
ui.info(f"Create temporary working folder in {self.working_folder}")
|
|
36
|
+
self.working_folder.mkdir(parents=True)
|
|
37
|
+
|
|
38
|
+
def make_mount_folders(self) -> None:
|
|
39
|
+
if self.working_folder is None:
|
|
40
|
+
self.make_working_folder()
|
|
41
|
+
ui.info("Preparing mount paths...")
|
|
42
|
+
self.boot_mount_path = self.working_folder / "boot"
|
|
43
|
+
self.root_mount_path = self.working_folder / "root"
|
|
44
|
+
self.boot_mount_path.mkdir()
|
|
45
|
+
self.root_mount_path.mkdir()
|
|
46
|
+
|
|
47
|
+
def root_is_luks(self) -> bool:
|
|
48
|
+
return device_module.blkid_value(self.root_partition_path, "TYPE") == "crypto_LUKS"
|
|
49
|
+
|
|
50
|
+
def read_partition_uuids(self) -> None:
|
|
51
|
+
"""Fetch partition UUIDs via blkid; derive the LUKS mapper name if unset."""
|
|
52
|
+
self.root_partition_uuid = device_module.blkid_value(
|
|
53
|
+
self.root_partition_path, "UUID"
|
|
54
|
+
)
|
|
55
|
+
self.boot_partition_uuid = device_module.blkid_value(
|
|
56
|
+
self.boot_partition_path, "UUID"
|
|
57
|
+
)
|
|
58
|
+
if self.root_mapper_name is None and self.root_is_luks():
|
|
59
|
+
# Same deterministic name decrypt_root() would have used.
|
|
60
|
+
self.root_mapper_name = f"linux-image-manager-{self.root_partition_uuid}"
|
|
61
|
+
self.root_mapper_path = f"/dev/mapper/{self.root_mapper_name}"
|
|
62
|
+
|
|
63
|
+
def decrypt_root(self) -> None:
|
|
64
|
+
if not self.root_is_luks():
|
|
65
|
+
return
|
|
66
|
+
self.root_partition_uuid = device_module.blkid_value(
|
|
67
|
+
self.root_partition_path, "UUID"
|
|
68
|
+
)
|
|
69
|
+
self.root_mapper_name = f"linux-image-manager-{self.root_partition_uuid}"
|
|
70
|
+
self.root_mapper_path = f"/dev/mapper/{self.root_mapper_name}"
|
|
71
|
+
ui.info(f"Decrypting of {self.root_partition_path} is neccessary...")
|
|
72
|
+
runner.run(
|
|
73
|
+
[
|
|
74
|
+
"cryptsetup",
|
|
75
|
+
"-v",
|
|
76
|
+
"luksOpen",
|
|
77
|
+
self.root_partition_path,
|
|
78
|
+
self.root_mapper_name,
|
|
79
|
+
],
|
|
80
|
+
sudo=True,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def mount_partitions(self) -> None:
|
|
84
|
+
if self.boot_mount_path is None:
|
|
85
|
+
raise LimError("Mount folders are not prepared yet.")
|
|
86
|
+
ui.info("Mount boot and root partition...")
|
|
87
|
+
runner.run(
|
|
88
|
+
["mount", "-v", self.boot_partition_path, str(self.boot_mount_path)],
|
|
89
|
+
sudo=True,
|
|
90
|
+
)
|
|
91
|
+
runner.run(
|
|
92
|
+
["mount", "-v", self.root_mapper_path, str(self.root_mount_path)], sudo=True
|
|
93
|
+
)
|
|
94
|
+
ui.info("Setting uuid variables...")
|
|
95
|
+
self.read_partition_uuids()
|
|
96
|
+
ui.info("The following mounts refering this setup exist:")
|
|
97
|
+
runner.run(["findmnt", "-R", str(self.working_folder)], check=False)
|
|
98
|
+
|
|
99
|
+
def mount_chroot_binds(self) -> None:
|
|
100
|
+
ui.info("Mount chroot environments...")
|
|
101
|
+
root = self.root_mount_path
|
|
102
|
+
runner.run(["mount", "--bind", str(self.boot_mount_path), f"{root}/boot"], sudo=True)
|
|
103
|
+
runner.run(["mount", "--bind", "/dev", f"{root}/dev"], sudo=True)
|
|
104
|
+
runner.run(["mount", "--bind", "/sys", f"{root}/sys"], sudo=True)
|
|
105
|
+
runner.run(["mount", "--bind", "/proc", f"{root}/proc"], sudo=True)
|
|
106
|
+
runner.run(["mount", "--bind", "/dev/pts", f"{root}/dev/pts"], sudo=True)
|
|
107
|
+
|
|
108
|
+
def copy_qemu(self) -> None:
|
|
109
|
+
ui.info("Copy qemu binary...")
|
|
110
|
+
runner.run(
|
|
111
|
+
["cp", "-v", "/usr/bin/qemu-arm-static", f"{self.root_mount_path}/usr/bin/"],
|
|
112
|
+
sudo=True,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def copy_resolv_conf(self) -> None:
|
|
116
|
+
ui.info("Copy resolv.conf...")
|
|
117
|
+
copied = runner.run(
|
|
118
|
+
[
|
|
119
|
+
"cp",
|
|
120
|
+
"--remove-destination",
|
|
121
|
+
"-v",
|
|
122
|
+
"/etc/resolv.conf",
|
|
123
|
+
f"{self.root_mount_path}/etc/",
|
|
124
|
+
],
|
|
125
|
+
sudo=True,
|
|
126
|
+
check=False,
|
|
127
|
+
)
|
|
128
|
+
if copied.returncode != 0:
|
|
129
|
+
ui.warning("Failed. Probably there is no internet connection available.")
|
|
130
|
+
|
|
131
|
+
def _umount(self, path: str, *, lazy: bool = False) -> None:
|
|
132
|
+
flags = ["-lv"] if lazy else ["-v"]
|
|
133
|
+
result = runner.run(["umount", *flags, path], sudo=True, check=False)
|
|
134
|
+
if result.returncode != 0:
|
|
135
|
+
ui.warning(f"Umounting {path} failed!")
|
|
136
|
+
|
|
137
|
+
def _rmdir(self, path: Path | None) -> None:
|
|
138
|
+
if path is None:
|
|
139
|
+
return
|
|
140
|
+
result = runner.run(["rmdir", "-v", str(path)], sudo=True, check=False)
|
|
141
|
+
if result.returncode != 0:
|
|
142
|
+
ui.warning(f"Removing {path} failed!")
|
|
143
|
+
|
|
144
|
+
def destructor(self) -> None:
|
|
145
|
+
ui.info("Cleaning up...")
|
|
146
|
+
ui.info("Unmounting everything...")
|
|
147
|
+
root = self.root_mount_path
|
|
148
|
+
if root is not None:
|
|
149
|
+
self._umount(f"{root}/dev/pts", lazy=True)
|
|
150
|
+
self._umount(f"{root}/dev", lazy=True)
|
|
151
|
+
self._umount(f"{root}/proc")
|
|
152
|
+
self._umount(f"{root}/sys")
|
|
153
|
+
self._umount(f"{root}/boot")
|
|
154
|
+
self._umount(str(root))
|
|
155
|
+
if self.boot_mount_path is not None:
|
|
156
|
+
self._umount(str(self.boot_mount_path))
|
|
157
|
+
ui.info("Deleting mount folders...")
|
|
158
|
+
self._rmdir(self.root_mount_path)
|
|
159
|
+
self._rmdir(self.boot_mount_path)
|
|
160
|
+
self._rmdir(self.working_folder)
|
|
161
|
+
if self.root_mapper_name and self.root_is_luks():
|
|
162
|
+
ui.info(f"Trying to close decrypted {self.root_mapper_name}...")
|
|
163
|
+
closed = runner.run(
|
|
164
|
+
["cryptsetup", "-v", "luksClose", self.root_mapper_name],
|
|
165
|
+
sudo=True,
|
|
166
|
+
check=False,
|
|
167
|
+
)
|
|
168
|
+
if closed.returncode != 0:
|
|
169
|
+
ui.warning("Failed.")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def chroot_bash(root_mount_path: Path | str, script: str, error_msg: str | None = None) -> None:
|
|
173
|
+
"""Run a bash script inside the image via chroot."""
|
|
174
|
+
runner.run(
|
|
175
|
+
["chroot", str(root_mount_path), "/bin/bash"],
|
|
176
|
+
input_text=script,
|
|
177
|
+
sudo=True,
|
|
178
|
+
error_msg=error_msg,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def install_packages(distribution: str, root_mount_path: Path, package_names: str) -> None:
|
|
183
|
+
"""Install packages inside the image with the distribution's package manager."""
|
|
184
|
+
ui.info(f"Installing {package_names}...")
|
|
185
|
+
if distribution in ("arch", "manjaro"):
|
|
186
|
+
chroot_bash(root_mount_path, f"pacman --noconfirm -S --needed {package_names}")
|
|
187
|
+
elif distribution in ("moode", "retropie"):
|
|
188
|
+
chroot_bash(root_mount_path, f"yes | apt install {package_names}")
|
|
189
|
+
else:
|
|
190
|
+
raise LimError("Package manager not supported.")
|
lim/image/setup.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Interactive Linux image setup: download, verify, transfer, configure.
|
|
2
|
+
|
|
3
|
+
Reference for encrypted Raspberry Pi images:
|
|
4
|
+
https://wiki.polaire.nl/doku.php?id=archlinux-raspberry-encrypted
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import pwd
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from lim import device as device_module
|
|
11
|
+
from lim import system, ui
|
|
12
|
+
from lim.errors import LimError
|
|
13
|
+
from lim.image import choosers, transfer, verify
|
|
14
|
+
from lim.image.plan import ImagePlan
|
|
15
|
+
from lim.image.raspberry import configure_raspberry_image
|
|
16
|
+
from lim.image.session import ImageSession
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _prepare_image_folder(plan: ImagePlan) -> None:
|
|
20
|
+
ui.info("Configure user...")
|
|
21
|
+
origin_username = ui.ask("Please type in a valid working username:")
|
|
22
|
+
try:
|
|
23
|
+
pwd.getpwnam(origin_username)
|
|
24
|
+
except KeyError:
|
|
25
|
+
raise LimError(f"User {origin_username} doesn't exist.") from None
|
|
26
|
+
|
|
27
|
+
ui.info("Image routine starts...")
|
|
28
|
+
plan.image_folder = Path(f"/home/{origin_username}/Software/Images")
|
|
29
|
+
ui.info(f'The images will be stored in "{plan.image_folder}".')
|
|
30
|
+
if not plan.image_folder.is_dir():
|
|
31
|
+
ui.info(f'Folder "{plan.image_folder}" doesn\'t exist. It will be created now.')
|
|
32
|
+
plan.image_folder.mkdir(parents=True)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _select_unmounted_device() -> device_module.Device:
|
|
36
|
+
device = device_module.select_device()
|
|
37
|
+
if device_module.is_mounted(device.path):
|
|
38
|
+
raise LimError(
|
|
39
|
+
f'Device {device.path} is allready mounted. '
|
|
40
|
+
f'Umount with "umount {device.path}*".'
|
|
41
|
+
)
|
|
42
|
+
return device
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _choose_and_verify_image(plan: ImagePlan) -> None:
|
|
46
|
+
plan.operation_system = ui.ask(
|
|
47
|
+
"Which operation system would you like to use [linux,windows,...]?"
|
|
48
|
+
)
|
|
49
|
+
if plan.operation_system == "linux":
|
|
50
|
+
choosers.choose_linux_image(plan)
|
|
51
|
+
plan.encrypt_system = ui.confirm("Should the system be encrypted?")
|
|
52
|
+
ui.info("Generating os-image...")
|
|
53
|
+
transfer.download_image(plan)
|
|
54
|
+
else:
|
|
55
|
+
choosers.choose_local_image(plan)
|
|
56
|
+
|
|
57
|
+
ui.info("Verifying image...")
|
|
58
|
+
ui.info("Verifying checksum...")
|
|
59
|
+
if plan.image_checksum is None and plan.download_url is not None:
|
|
60
|
+
plan.image_checksum = verify.resolve_checksum(plan.download_url)
|
|
61
|
+
verify.verify_checksum(plan.image_path, plan.image_checksum)
|
|
62
|
+
if plan.download_url is not None:
|
|
63
|
+
verify.verify_signature(plan.download_url, plan.image_path, plan.image_folder)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def run_setup() -> None:
|
|
67
|
+
ui.info("Setupscript for images started...")
|
|
68
|
+
ui.info("Checking if root...")
|
|
69
|
+
if not system.is_root():
|
|
70
|
+
raise LimError("This script must be executed as root!")
|
|
71
|
+
|
|
72
|
+
plan = ImagePlan()
|
|
73
|
+
_prepare_image_folder(plan)
|
|
74
|
+
device = _select_unmounted_device()
|
|
75
|
+
_choose_and_verify_image(plan)
|
|
76
|
+
|
|
77
|
+
session = ImageSession(device)
|
|
78
|
+
try:
|
|
79
|
+
session.make_working_folder()
|
|
80
|
+
session.make_mount_folders()
|
|
81
|
+
|
|
82
|
+
plan.root_filesystem = ui.ask(
|
|
83
|
+
"Which filesystem should be used? E.g.:btrfs,ext4... (none):"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if ui.confirm(f"Should the image be transfered to {device.path}?"):
|
|
87
|
+
transfer.transfer_image(plan, session)
|
|
88
|
+
else:
|
|
89
|
+
ui.info("Skipping image transfer...")
|
|
90
|
+
|
|
91
|
+
if plan.raspberry_pi_version:
|
|
92
|
+
configure_raspberry_image(plan, session)
|
|
93
|
+
finally:
|
|
94
|
+
session.destructor()
|
|
95
|
+
|
|
96
|
+
ui.success("Setup successfull :)")
|
lim/image/transfer.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Download the selected image and transfer it onto the target device."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from lim import device as device_module
|
|
6
|
+
from lim import runner, ui
|
|
7
|
+
from lim.errors import LimError
|
|
8
|
+
from lim.image.plan import DEFAULT_BOOT_SIZE, ImagePlan
|
|
9
|
+
from lim.image.session import ImageSession
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def download_image(plan: ImagePlan) -> None:
|
|
13
|
+
if ui.confirm("Should the image download be forced?"):
|
|
14
|
+
if plan.image_path.is_file():
|
|
15
|
+
ui.info(f"Removing image {plan.image_path}.")
|
|
16
|
+
plan.image_path.unlink()
|
|
17
|
+
else:
|
|
18
|
+
ui.info(
|
|
19
|
+
"Forcing download wasn't neccessary. "
|
|
20
|
+
f"File {plan.image_path} doesn't exist."
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
ui.info("Start Download procedure...")
|
|
24
|
+
if plan.image_path.is_file():
|
|
25
|
+
ui.info("Image exist local. Download skipped.")
|
|
26
|
+
return
|
|
27
|
+
ui.info(f'Image "{plan.image_name}" doesn\'t exist under local path "{plan.image_path}".')
|
|
28
|
+
ui.info(f'Image "{plan.image_name}" gets downloaded from "{plan.download_url}"...')
|
|
29
|
+
runner.run(
|
|
30
|
+
["wget", plan.download_url, "-O", str(plan.image_path)],
|
|
31
|
+
error_msg=f'Download from "{plan.download_url}" failed.',
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def arch_partition_input(boot_size: str) -> str:
|
|
36
|
+
"""Fdisk answers: FAT32 boot partition of boot_size plus root partition."""
|
|
37
|
+
return (
|
|
38
|
+
"o\n" # clear out any partitions on the drive
|
|
39
|
+
"p\n" # list partitions (should be empty)
|
|
40
|
+
"n\np\n1\n\n" # new primary partition 1, default start sector
|
|
41
|
+
f"{boot_size}\n"
|
|
42
|
+
"t\nc\n" # set partition 1 to type W95 FAT32 (LBA)
|
|
43
|
+
"n\np\n2\n\n\n" # new primary partition 2 over the remaining space
|
|
44
|
+
"w\n" # write partition table
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def decompress_command(image_path: Path) -> list[str]:
|
|
49
|
+
"""Build the command that streams the raw image to stdout for dd."""
|
|
50
|
+
suffix = image_path.suffix
|
|
51
|
+
if suffix == ".zip":
|
|
52
|
+
return ["unzip", "-p", str(image_path)]
|
|
53
|
+
if suffix == ".gz":
|
|
54
|
+
return ["gunzip", "-c", str(image_path)]
|
|
55
|
+
if suffix == ".iso":
|
|
56
|
+
return ["pv", str(image_path)]
|
|
57
|
+
if suffix == ".xz":
|
|
58
|
+
return ["unxz", "-c", str(image_path)]
|
|
59
|
+
raise LimError(f'Image transfer for "{image_path.name}" is not supported yet!')
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _luks_format_root(plan: ImagePlan, session: ImageSession) -> None:
|
|
63
|
+
luks_format = [
|
|
64
|
+
"cryptsetup", "-v", "luksFormat",
|
|
65
|
+
"-c", "aes-xts-plain64", "-s", "512", "-h", "sha512",
|
|
66
|
+
"--use-random", "-i", "1000",
|
|
67
|
+
]
|
|
68
|
+
if plan.luks_memory_cost:
|
|
69
|
+
ui.info(
|
|
70
|
+
f"Formating {session.root_partition_path} with LUKS "
|
|
71
|
+
f"with --pbkdf-memory set to {plan.luks_memory_cost}"
|
|
72
|
+
)
|
|
73
|
+
luks_format += ["--pbkdf-memory", plan.luks_memory_cost]
|
|
74
|
+
else:
|
|
75
|
+
ui.info(f"Formating {session.root_partition_path} with LUKS")
|
|
76
|
+
runner.run([*luks_format, session.root_partition_path], sudo=True)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def transfer_arch_image(plan: ImagePlan, session: ImageSession) -> None:
|
|
80
|
+
boot_size = plan.boot_size or DEFAULT_BOOT_SIZE
|
|
81
|
+
ui.info(f"The boot partition will be set to {boot_size}.")
|
|
82
|
+
ui.info("Creating partitions...")
|
|
83
|
+
runner.run(
|
|
84
|
+
["fdisk", session.device.path],
|
|
85
|
+
input_text=arch_partition_input(boot_size),
|
|
86
|
+
sudo=True,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
ui.info("Format boot partition...")
|
|
90
|
+
runner.run(["mkfs.vfat", session.boot_partition_path], sudo=True)
|
|
91
|
+
|
|
92
|
+
if plan.encrypt_system:
|
|
93
|
+
_luks_format_root(plan, session)
|
|
94
|
+
session.decrypt_root()
|
|
95
|
+
|
|
96
|
+
ui.info("Format root partition...")
|
|
97
|
+
runner.run([f"mkfs.{plan.root_filesystem}", "-f", session.root_mapper_path], sudo=True)
|
|
98
|
+
session.mount_partitions()
|
|
99
|
+
|
|
100
|
+
ui.info("Root files will be transfered to device...")
|
|
101
|
+
runner.run(
|
|
102
|
+
["bsdtar", "-xpf", str(plan.image_path), "-C", str(session.root_mount_path)],
|
|
103
|
+
sudo=True,
|
|
104
|
+
)
|
|
105
|
+
runner.sync_disks()
|
|
106
|
+
|
|
107
|
+
ui.info("Boot files will be transfered to device...")
|
|
108
|
+
boot_source = session.root_mount_path / "boot"
|
|
109
|
+
for entry in sorted(boot_source.iterdir()):
|
|
110
|
+
runner.run(["mv", "-v", str(entry), str(session.boot_mount_path)], sudo=True)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def transfer_image(plan: ImagePlan, session: ImageSession) -> None:
|
|
114
|
+
if ui.confirm(f"Should the partition table of {session.device.path} be deleted?"):
|
|
115
|
+
ui.info("Deleting...")
|
|
116
|
+
runner.run(["wipefs", "-a", session.device.path], sudo=True)
|
|
117
|
+
else:
|
|
118
|
+
ui.info("Skipping partition table deletion...")
|
|
119
|
+
|
|
120
|
+
device_module.overwrite_device(session.device)
|
|
121
|
+
|
|
122
|
+
ui.info("Starting image transfer...")
|
|
123
|
+
if plan.distribution == "arch":
|
|
124
|
+
transfer_arch_image(plan, session)
|
|
125
|
+
return
|
|
126
|
+
blocksize = session.device.optimal_blocksize
|
|
127
|
+
ui.info(f"Transfering {plan.image_path.suffix} file...")
|
|
128
|
+
runner.pipeline(
|
|
129
|
+
decompress_command(plan.image_path),
|
|
130
|
+
[
|
|
131
|
+
"dd",
|
|
132
|
+
f"of={session.device.path}",
|
|
133
|
+
f"bs={blocksize}",
|
|
134
|
+
"conv=fsync",
|
|
135
|
+
"status=progress",
|
|
136
|
+
],
|
|
137
|
+
sudo_last=True,
|
|
138
|
+
error_msg=f"DD {plan.image_path} to {session.device.path} failed.",
|
|
139
|
+
)
|
|
140
|
+
runner.sync_disks()
|
lim/image/verify.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Image integrity (checksums) and authenticity (GPG signatures) checks."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from lim import runner, ui
|
|
8
|
+
from lim.errors import LimError
|
|
9
|
+
|
|
10
|
+
ALGORITHM_BY_DIGEST_LENGTH = {
|
|
11
|
+
32: "md5",
|
|
12
|
+
40: "sha1",
|
|
13
|
+
64: "sha256",
|
|
14
|
+
128: "sha512",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def url_exists(url: str) -> bool:
|
|
19
|
+
return runner.succeeds(["wget", "-q", "--method=HEAD", url])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def resolve_checksum(download_url: str) -> str | None:
|
|
23
|
+
"""Try to fetch a published checksum next to the image download."""
|
|
24
|
+
for extension in ("sha1", "sha512", "md5"):
|
|
25
|
+
checksum_url = f"{download_url}.{extension}"
|
|
26
|
+
ui.info(
|
|
27
|
+
"Image Checksum is not defined. "
|
|
28
|
+
f"Try to download image signature from {checksum_url}."
|
|
29
|
+
)
|
|
30
|
+
if url_exists(checksum_url):
|
|
31
|
+
content = runner.output(["wget", checksum_url, "-q", "-O", "-"])
|
|
32
|
+
checksum = content.split()[0] if content.split() else ""
|
|
33
|
+
if checksum:
|
|
34
|
+
ui.info(f"Defined image_checksum as {checksum}")
|
|
35
|
+
return checksum
|
|
36
|
+
ui.warning(f"No checksum found under {checksum_url}.")
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def verify_checksum(image_path: str | Path, checksum: str | None) -> None:
|
|
41
|
+
"""Verify the image against an md5/sha1/sha256/sha512 hex checksum."""
|
|
42
|
+
if not checksum:
|
|
43
|
+
ui.warning("No checksum is defined. Skipping checksum verification.")
|
|
44
|
+
return
|
|
45
|
+
algorithm = ALGORITHM_BY_DIGEST_LENGTH.get(len(checksum))
|
|
46
|
+
if algorithm is None:
|
|
47
|
+
raise LimError(
|
|
48
|
+
f"Checksum '{checksum}' has no recognized digest length "
|
|
49
|
+
"(expected md5, sha1, sha256 or sha512)."
|
|
50
|
+
)
|
|
51
|
+
ui.info(f"Checking {algorithm} checksum...")
|
|
52
|
+
digest = hashlib.new(algorithm)
|
|
53
|
+
with Path(image_path).open("rb") as handle:
|
|
54
|
+
while chunk := handle.read(1024 * 1024):
|
|
55
|
+
digest.update(chunk)
|
|
56
|
+
if digest.hexdigest() != checksum.lower():
|
|
57
|
+
raise LimError("Verification failed. HINT: Force the download of the image.")
|
|
58
|
+
ui.info(f"{algorithm} checksum verified.")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def verify_signature(download_url: str, image_path: str | Path, image_folder: Path) -> None:
|
|
62
|
+
"""Best-effort GPG signature verification of the downloaded image."""
|
|
63
|
+
ui.info("Note: Checksums verify integrity but do not confirm authenticity.")
|
|
64
|
+
ui.info(
|
|
65
|
+
"Proceeding to signature verification, "
|
|
66
|
+
"which ensures the file comes from a trusted source."
|
|
67
|
+
)
|
|
68
|
+
signature_url = f"{download_url}.sig"
|
|
69
|
+
ui.info(f"Attempting to download the image signature from: {signature_url}")
|
|
70
|
+
if not url_exists(signature_url):
|
|
71
|
+
ui.warning(f"No signature found under {signature_url}.")
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
signature_path = image_folder / (Path(str(image_path)).name + ".sig")
|
|
75
|
+
if not runner.succeeds(["wget", "-q", "-O", str(signature_path), signature_url]):
|
|
76
|
+
ui.warning("Failed to download the signature file.")
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
ui.info("Extract the key ID from the signature file")
|
|
80
|
+
verification = runner.output(
|
|
81
|
+
["gpg", "--status-fd", "1", "--verify", str(signature_path), str(image_path)],
|
|
82
|
+
check=False,
|
|
83
|
+
)
|
|
84
|
+
missing_keys = re.findall(r"NO_PUBKEY (\S+)", verification)
|
|
85
|
+
if missing_keys:
|
|
86
|
+
key_id = missing_keys[-1]
|
|
87
|
+
if runner.succeeds(["gpg", "--list-keys", key_id]):
|
|
88
|
+
ui.info(f"Key {key_id} already in keyring.")
|
|
89
|
+
else:
|
|
90
|
+
ui.info("Import the public key")
|
|
91
|
+
runner.run(
|
|
92
|
+
["gpg", "--keyserver", "keyserver.ubuntu.com", "--recv-keys", key_id],
|
|
93
|
+
check=False,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
ui.info("Verify the signature")
|
|
97
|
+
if runner.succeeds(["gpg", "--verify", str(signature_path), str(image_path)]):
|
|
98
|
+
ui.info("Signature verification succeeded.")
|
|
99
|
+
else:
|
|
100
|
+
ui.warning("Signature verification failed.")
|
lim/luks.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""LUKS key management plus crypttab/fstab bookkeeping."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from lim import fsutil, runner, ui
|
|
7
|
+
from lim.errors import LimError
|
|
8
|
+
|
|
9
|
+
LUKS_KEY_DIRECTORY = Path("/etc/luks-keys")
|
|
10
|
+
CRYPTTAB_PATH = Path("/etc/crypttab")
|
|
11
|
+
FSTAB_PATH = Path("/etc/fstab")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def luks_uuid(partition_path: str) -> str:
|
|
15
|
+
dump = runner.output(["cryptsetup", "luksDump", partition_path], sudo=True)
|
|
16
|
+
match = re.search(r"UUID:\s*(\S+)", dump)
|
|
17
|
+
if not match:
|
|
18
|
+
raise LimError(f"Could not read LUKS UUID of {partition_path}.")
|
|
19
|
+
return match.group(1)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def create_luks_key_and_update_crypttab(
|
|
23
|
+
mapper_name: str,
|
|
24
|
+
partition_path: str,
|
|
25
|
+
*,
|
|
26
|
+
key_directory: Path = LUKS_KEY_DIRECTORY,
|
|
27
|
+
crypttab_path: Path = CRYPTTAB_PATH,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Generate a random keyfile, register it with LUKS and /etc/crypttab."""
|
|
30
|
+
ui.info("Creating luks-key-directory...")
|
|
31
|
+
key_directory.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
secret_key_path = key_directory / f"{mapper_name}.keyfile"
|
|
33
|
+
ui.info(f"Generate secret key under: {secret_key_path}")
|
|
34
|
+
if secret_key_path.exists():
|
|
35
|
+
ui.warning("File already exists. Overwriting!")
|
|
36
|
+
runner.run(
|
|
37
|
+
["dd", "if=/dev/urandom", f"of={secret_key_path}", "bs=512", "count=8"],
|
|
38
|
+
sudo=True,
|
|
39
|
+
)
|
|
40
|
+
runner.sync_disks()
|
|
41
|
+
|
|
42
|
+
ui.info("Opening and closing device to verify that everything works fine...")
|
|
43
|
+
closed = runner.run(
|
|
44
|
+
["cryptsetup", "-v", "luksClose", mapper_name], sudo=True, check=False
|
|
45
|
+
)
|
|
46
|
+
if closed.returncode != 0:
|
|
47
|
+
ui.info(f"No need to luksClose {mapper_name}. Device isn't open.")
|
|
48
|
+
runner.run(
|
|
49
|
+
["cryptsetup", "luksAddKey", partition_path, str(secret_key_path)], sudo=True
|
|
50
|
+
)
|
|
51
|
+
runner.run(
|
|
52
|
+
[
|
|
53
|
+
"cryptsetup",
|
|
54
|
+
"-v",
|
|
55
|
+
"luksOpen",
|
|
56
|
+
partition_path,
|
|
57
|
+
mapper_name,
|
|
58
|
+
f"--key-file={secret_key_path}",
|
|
59
|
+
],
|
|
60
|
+
sudo=True,
|
|
61
|
+
)
|
|
62
|
+
runner.run(["cryptsetup", "-v", "luksClose", mapper_name], sudo=True)
|
|
63
|
+
|
|
64
|
+
ui.info("Reading UUID...")
|
|
65
|
+
uuid = luks_uuid(partition_path)
|
|
66
|
+
entry = f"{mapper_name} UUID={uuid} {secret_key_path} luks"
|
|
67
|
+
ui.info("Adding crypttab entry...")
|
|
68
|
+
fsutil.ensure_line_in_file(entry, crypttab_path)
|
|
69
|
+
ui.info(f"The file {crypttab_path} contains now the following:")
|
|
70
|
+
print(crypttab_path.read_text())
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def update_fstab(
|
|
74
|
+
mapper_path: str, mount_path: str, *, fstab_path: Path = FSTAB_PATH
|
|
75
|
+
) -> None:
|
|
76
|
+
entry = f"{mapper_path} {mount_path} btrfs defaults 0 2"
|
|
77
|
+
ui.info("Adding fstab entry...")
|
|
78
|
+
fsutil.ensure_line_in_file(entry, fstab_path)
|
|
79
|
+
ui.info(f"The file {fstab_path} contains now the following:")
|
|
80
|
+
print(fstab_path.read_text())
|
lim/packages.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Reads package collections from configuration/packages/."""
|
|
2
|
+
|
|
3
|
+
from lim import config
|
|
4
|
+
from lim.errors import LimError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_packages(*collections: str) -> list[str]:
|
|
8
|
+
"""Package names from the given collections, comments stripped."""
|
|
9
|
+
names: list[str] = []
|
|
10
|
+
for collection in collections:
|
|
11
|
+
path = config.PACKAGE_PATH / f"{collection}.txt"
|
|
12
|
+
if not path.is_file():
|
|
13
|
+
raise LimError(f"Package collection {path} does not exist.")
|
|
14
|
+
for line in path.read_text().splitlines():
|
|
15
|
+
name = line.split("#", 1)[0].strip()
|
|
16
|
+
if name:
|
|
17
|
+
names.append(name)
|
|
18
|
+
return names
|