linux-image-manager 2.2.0__tar.gz → 2.3.0__tar.gz

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.
Files changed (66) hide show
  1. {linux_image_manager-2.2.0/linux_image_manager.egg-info → linux_image_manager-2.3.0}/PKG-INFO +1 -1
  2. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/lim/catalog.py +4 -0
  3. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/lim/cli.py +24 -22
  4. linux_image_manager-2.3.0/lim/configuration/initramfs-tools/tor_bottom +19 -0
  5. linux_image_manager-2.3.0/lim/configuration/initramfs-tools/tor_hook +37 -0
  6. linux_image_manager-2.3.0/lim/configuration/initramfs-tools/tor_premount +55 -0
  7. linux_image_manager-2.3.0/lim/configuration/initramfs-tools/torrc +16 -0
  8. linux_image_manager-2.3.0/lim/configuration/packages/server/luks-debian.txt +5 -0
  9. linux_image_manager-2.3.0/lim/configuration/packages/server/tor-debian.txt +3 -0
  10. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/lim/data/sync.py +4 -13
  11. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/lim/device.py +1 -3
  12. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/lim/distributions.yml +13 -0
  13. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/lim/fsutil.py +17 -0
  14. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/lim/image/choosers.py +34 -19
  15. linux_image_manager-2.3.0/lim/image/crossarch.py +71 -0
  16. linux_image_manager-2.3.0/lim/image/encryption.py +42 -0
  17. linux_image_manager-2.3.0/lim/image/initramfs/__init__.py +21 -0
  18. linux_image_manager-2.3.0/lim/image/initramfs/base.py +42 -0
  19. linux_image_manager-2.3.0/lim/image/initramfs/initramfs_tools.py +117 -0
  20. linux_image_manager-2.3.0/lim/image/initramfs/keygen.py +54 -0
  21. linux_image_manager-2.3.0/lim/image/initramfs/mkinitcpio.py +122 -0
  22. linux_image_manager-2.3.0/lim/image/loopimg.py +28 -0
  23. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/lim/image/plan.py +6 -0
  24. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/lim/image/raspberry.py +3 -7
  25. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/lim/image/session.py +35 -19
  26. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/lim/image/setup.py +2 -5
  27. linux_image_manager-2.3.0/lim/image/transfer.py +236 -0
  28. linux_image_manager-2.3.0/lim/image/unlock.py +100 -0
  29. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/lim/image/verify.py +2 -4
  30. linux_image_manager-2.3.0/lim/image/wizard.py +206 -0
  31. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/lim/luks.py +3 -9
  32. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/lim/runner.py +2 -4
  33. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/lim/storage/raid1.py +1 -3
  34. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/lim/storage/single_drive.py +2 -6
  35. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0/linux_image_manager.egg-info}/PKG-INFO +1 -1
  36. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/linux_image_manager.egg-info/SOURCES.txt +15 -1
  37. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/pyproject.toml +2 -1
  38. linux_image_manager-2.2.0/lim/image/encryption.py +0 -129
  39. linux_image_manager-2.2.0/lim/image/tor.py +0 -79
  40. linux_image_manager-2.2.0/lim/image/transfer.py +0 -140
  41. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/LICENSE.txt +0 -0
  42. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/README.md +0 -0
  43. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/lim/__init__.py +0 -0
  44. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/lim/config.py +0 -0
  45. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/lim/configuration/initcpio/tor_hook +0 -0
  46. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/lim/configuration/initcpio/tor_install +0 -0
  47. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/lim/configuration/initcpio/torrc +0 -0
  48. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/lim/configuration/packages/general.txt +0 -0
  49. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/lim/configuration/packages/server/luks.txt +0 -0
  50. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/lim/configuration/packages/server/tor.txt +0 -0
  51. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/lim/data/__init__.py +0 -0
  52. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/lim/data/crypt.py +0 -0
  53. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/lim/errors.py +0 -0
  54. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/lim/image/__init__.py +0 -0
  55. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/lim/image/backup.py +0 -0
  56. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/lim/image/chroot.py +0 -0
  57. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/lim/packages.py +0 -0
  58. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/lim/storage/__init__.py +0 -0
  59. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/lim/storage/common.py +0 -0
  60. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/lim/system.py +0 -0
  61. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/lim/ui.py +0 -0
  62. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/linux_image_manager.egg-info/dependency_links.txt +0 -0
  63. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/linux_image_manager.egg-info/entry_points.txt +0 -0
  64. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/linux_image_manager.egg-info/requires.txt +0 -0
  65. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/linux_image_manager.egg-info/top_level.txt +0 -0
  66. {linux_image_manager-2.2.0 → linux_image_manager-2.3.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: linux-image-manager
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: CLI (`lim`) for downloading, verifying and flashing Linux images, LUKS/Btrfs encrypted storage (single drive and RAID1), image backups and chroot maintenance
5
5
  Author-email: Kevin Veen-Birkenbach <kevin@veen.world>
6
6
  Maintainer-email: Kevin Veen-Birkenbach <kevin@veen.world>
@@ -27,5 +27,9 @@ def retropie_images() -> dict[str, dict[str, str]]:
27
27
  return _load()["retropie_images"]
28
28
 
29
29
 
30
+ def raspios_images() -> dict[str, dict[str, str]]:
31
+ return _load()["raspios_images"]
32
+
33
+
30
34
  def mkinitcpio_modules_by_rpi() -> dict[str, str]:
31
35
  return _load()["mkinitcpio_modules_by_rpi"]
@@ -10,6 +10,8 @@ from lim.data import crypt, sync
10
10
  from lim.errors import LimError
11
11
  from lim.image import backup, chroot
12
12
  from lim.image import setup as image_setup
13
+ from lim.image import unlock as image_unlock
14
+ from lim.image import wizard as image_wizard
13
15
  from lim.storage import raid1, single_drive
14
16
 
15
17
 
@@ -29,6 +31,20 @@ COMMANDS: dict[str, Command] = {
29
31
  " - Configures boot and root partitions.",
30
32
  needs_root=True,
31
33
  ),
34
+ "guided": Command(
35
+ image_wizard.run_guided,
36
+ "Guided Encrypted Linux Image Setup (default command):\n"
37
+ " - Walks through flashing a Linux image (Arch, Raspberry Pi OS, ...) onto a device.\n"
38
+ " - Encrypts the root with LUKS and enables remote unlock over Tor.",
39
+ needs_root=True,
40
+ ),
41
+ "remote-unlock": Command(
42
+ image_unlock.remote_unlock,
43
+ "Remote LUKS Boot Unlock:\n"
44
+ " - Reaches a waiting initramfs over Tor (onion) or plain SSH.\n"
45
+ " - Runs cryptroot-unlock or presents the passphrase prompt.",
46
+ needs_root=False,
47
+ ),
32
48
  "single": Command(
33
49
  single_drive.setup,
34
50
  "Single Drive Encryption Setup:\n"
@@ -59,14 +75,12 @@ COMMANDS: dict[str, Command] = {
59
75
  ),
60
76
  "mount": Command(
61
77
  single_drive.mount,
62
- "Mount Encrypted Storage:\n"
63
- " - Unlocks a LUKS partition and mounts it.",
78
+ "Mount Encrypted Storage:\n - Unlocks a LUKS partition and mounts it.",
64
79
  needs_root=True,
65
80
  ),
66
81
  "umount": Command(
67
82
  single_drive.umount,
68
- "Unmount Encrypted Storage:\n"
69
- " - Unmounts a LUKS partition and closes the mapper.",
83
+ "Unmount Encrypted Storage:\n - Unmounts a LUKS partition and closes the mapper.",
70
84
  needs_root=True,
71
85
  ),
72
86
  "single-boot": Command(
@@ -83,26 +97,22 @@ COMMANDS: dict[str, Command] = {
83
97
  ),
84
98
  "unlock": Command(
85
99
  crypt.unlock,
86
- "Unlock Data:\n"
87
- " - Decrypts the encfs data store into the decrypted folder.",
100
+ "Unlock Data:\n - Decrypts the encfs data store into the decrypted folder.",
88
101
  needs_root=False,
89
102
  ),
90
103
  "lock": Command(
91
104
  crypt.lock,
92
- "Lock Data:\n"
93
- " - Unmounts the decrypted encfs folder.",
105
+ "Lock Data:\n - Unmounts the decrypted encfs folder.",
94
106
  needs_root=False,
95
107
  ),
96
108
  "import": Command(
97
109
  sync.import_from_system,
98
- "Import Data:\n"
99
- " - Copies personal data from the system into the encrypted store.",
110
+ "Import Data:\n - Copies personal data from the system into the encrypted store.",
100
111
  needs_root=False,
101
112
  ),
102
113
  "export": Command(
103
114
  sync.export_to_system,
104
- "Export Data:\n"
105
- " - Copies personal data from the encrypted store back to the system.",
115
+ "Export Data:\n - Copies personal data from the encrypted store back to the system.",
106
116
  needs_root=False,
107
117
  ),
108
118
  }
@@ -122,21 +132,15 @@ def build_parser() -> argparse.ArgumentParser:
122
132
  )
123
133
  parser.add_argument(
124
134
  "--type",
125
- required=True,
135
+ default="guided",
126
136
  choices=list(COMMANDS.keys()),
127
- help="Select the command to execute.",
137
+ help="Command to execute (default: guided).",
128
138
  )
129
139
  parser.add_argument(
130
140
  "--auto-confirm",
131
141
  action="store_true",
132
142
  help="Automatically confirm execution without prompting the user.",
133
143
  )
134
- parser.add_argument(
135
- "--extra",
136
- nargs=argparse.REMAINDER,
137
- default=[],
138
- help="Deprecated, ignored. Former extra parameters for the shell scripts.",
139
- )
140
144
  return parser
141
145
 
142
146
 
@@ -144,8 +148,6 @@ def main(argv: list[str] | None = None) -> None:
144
148
  args = build_parser().parse_args(argv)
145
149
  command = COMMANDS[args.type]
146
150
 
147
- if args.extra:
148
- ui.warning("--extra is deprecated and ignored.")
149
151
  if command.needs_root:
150
152
  system.ensure_root()
151
153
 
@@ -0,0 +1,19 @@
1
+ #!/bin/sh
2
+ # initramfs-tools cleanup hook: stop Tor before the pivot to the real root.
3
+ # Installed to /etc/initramfs-tools/scripts/init-bottom/tor by lim.
4
+ # Nothing from the initramfs may keep running once run-init replaces it.
5
+ PREREQ=""
6
+ prereqs() { echo "$PREREQ"; }
7
+ case "$1" in
8
+ prereqs) prereqs; exit 0 ;;
9
+ esac
10
+
11
+ # pidof/killall may be absent; scan /proc for the tor binary (no extra tools).
12
+ for pid in $(ls /proc 2>/dev/null); do
13
+ case "$pid" in
14
+ *[!0-9]*) continue ;;
15
+ esac
16
+ [ "$(readlink "/proc/$pid/exe" 2>/dev/null)" = "/usr/bin/tor" ] \
17
+ && kill "$pid" 2>/dev/null
18
+ done
19
+ exit 0
@@ -0,0 +1,37 @@
1
+ #!/bin/sh
2
+ # initramfs-tools build hook: bake the Tor onion service into the initramfs.
3
+ # Installed to /etc/initramfs-tools/hooks/tor by linux-image-manager.
4
+ PREREQ="dropbear"
5
+ prereqs() { echo "$PREREQ"; }
6
+ case "$1" in
7
+ prereqs) prereqs; exit 0 ;;
8
+ esac
9
+
10
+ . /usr/share/initramfs-tools/hook-functions
11
+
12
+ copy_exec /usr/bin/tor /usr/bin
13
+
14
+ # glibc resolves the NTP server hostname via getaddrinfo(), which dlopen()s
15
+ # these NSS modules at runtime; without them DNS silently fails and the clock
16
+ # (RTC-less boards) stays at 1970, so Tor rejects the consensus.
17
+ for nss in /usr/lib/*/libnss_dns.so.2 /usr/lib/*/libnss_files.so.2 \
18
+ /lib/*/libnss_dns.so.2 /lib/*/libnss_files.so.2; do
19
+ [ -e "$nss" ] && copy_exec "$nss"
20
+ done
21
+
22
+ # Full busybox for the ntpd applet (the initramfs busybox may lack it); a
23
+ # distinct path keeps the initramfs's own busybox untouched.
24
+ for bb in /bin/busybox /usr/bin/busybox; do
25
+ [ -x "$bb" ] && { copy_exec "$bb" /usr/local/bin/busybox; break; }
26
+ done
27
+
28
+ date -u +%s > "${DESTDIR}/etc/tor-clock-floor"
29
+
30
+ # Onion keys + torrc, staged on the system by lim.
31
+ mkdir -p "${DESTDIR}/etc/tor/onion"
32
+ for key in hostname hs_ed25519_public_key hs_ed25519_secret_key; do
33
+ cp -a "/etc/tor/initramfs-onion/${key}" "${DESTDIR}/etc/tor/onion/${key}"
34
+ done
35
+ chmod 0700 "${DESTDIR}/etc/tor/onion"
36
+ chmod 0600 "${DESTDIR}/etc/tor/onion/hs_ed25519_secret_key"
37
+ cp -a /etc/tor/initramfs-torrc "${DESTDIR}/etc/tor/torrc"
@@ -0,0 +1,55 @@
1
+ #!/bin/sh
2
+ # initramfs-tools runtime hook: bring up networking, sync the clock and start
3
+ # the Tor onion service so the dropbear unlock shell is reachable while
4
+ # cryptsetup waits. Installed to /etc/initramfs-tools/scripts/init-premount/tor.
5
+ PREREQ=""
6
+ prereqs() { echo "$PREREQ"; }
7
+ case "$1" in
8
+ prereqs) prereqs; exit 0 ;;
9
+ esac
10
+
11
+ . /scripts/functions
12
+
13
+ log_begin_msg "tor: bringing up networking and starting the onion service"
14
+
15
+ # init-premount runs BEFORE the cryptroot/dropbear networking, so Tor would
16
+ # start with no network and never publish the onion. Bring the interface up
17
+ # ourselves from the ip= cmdline (idempotent; the later dropbear setup reuses
18
+ # the lease in /run/net-*.conf).
19
+ configure_networking
20
+
21
+ # initramfs-tools does not export arbitrary cmdline params as shell vars.
22
+ tor_ntp=$(sed -n 's/.*\btor_ntp=\([^ ]*\).*/\1/p' /proc/cmdline)
23
+
24
+ # Best-effort DNS from the DHCP lease; extract ONLY the DNS fields with sed,
25
+ # never source the files (attacker-controllable DHCP option strings would run
26
+ # as root pre-boot).
27
+ if [ ! -s /etc/resolv.conf ]; then
28
+ for conf in /run/net-*.conf /tmp/net-*.conf; do
29
+ [ -f "$conf" ] || continue
30
+ for dns in $(sed -n 's/^IPV4DNS[01]=//p' "$conf"); do
31
+ [ -n "$dns" ] && [ "$dns" != "0.0.0.0" ] \
32
+ && echo "nameserver $dns" >> /etc/resolv.conf
33
+ done
34
+ done
35
+ fi
36
+
37
+ floor=$(cat /etc/tor-clock-floor 2>/dev/null || echo 0)
38
+ if [ "$(date +%s)" -lt "$floor" ]; then
39
+ /usr/local/bin/busybox date -s "@$floor" >/dev/null 2>&1 \
40
+ || date -s "@$floor" >/dev/null 2>&1 || true
41
+ fi
42
+
43
+ if [ -x /usr/local/bin/busybox ]; then
44
+ /usr/local/bin/busybox timeout 30 \
45
+ /usr/local/bin/busybox ntpd -n -q -p "${tor_ntp:-pool.ntp.org}" \
46
+ || log_warning_msg "tor: NTP sync failed, keeping current clock"
47
+ fi
48
+
49
+ mkdir -p /var/lib/tor
50
+ chmod 0700 /var/lib/tor /etc/tor/onion
51
+ chmod 0600 /etc/tor/onion/hs_ed25519_secret_key
52
+ tor -f /etc/tor/torrc --RunAsDaemon 1 --Log "notice file /run/tor.log" \
53
+ || log_warning_msg "tor: failed to start, unlock stays reachable via direct IP"
54
+
55
+ log_end_msg
@@ -0,0 +1,16 @@
1
+ # Tor configuration for the initramfs onion unlock service (initramfs-tools).
2
+ # Baked into the initramfs as /etc/tor/torrc by the "tor" hook; the onion keys
3
+ # come from /etc/tor/initramfs-onion on the system.
4
+ DataDirectory /var/lib/tor
5
+ HiddenServiceDir /etc/tor/onion
6
+ HiddenServicePort 22 127.0.0.1:22
7
+ SocksPort 0
8
+ # Single-hop, non-anonymous onion service. A boot-unlock onion needs
9
+ # reachability (behind NAT / a dynamic IP), NOT server-location anonymity, so
10
+ # it can skip the multi-hop, vanguards-restricted circuits that a fresh Tor
11
+ # with a limited relay view fails to build ("Giving up on launching a
12
+ # rendezvous circuit"). Single-hop builds trivial 1-hop circuits that publish
13
+ # and rendezvous reliably. Entry guards are likewise unnecessary here.
14
+ HiddenServiceNonAnonymousMode 1
15
+ HiddenServiceSingleHopMode 1
16
+ UseEntryGuards 0
@@ -0,0 +1,5 @@
1
+ # Packages for LUKS remote unlock on Debian / Raspberry Pi OS (initramfs-tools)
2
+ cryptsetup
3
+ cryptsetup-initramfs
4
+ dropbear-initramfs
5
+ busybox
@@ -0,0 +1,3 @@
1
+ # Packages for the Tor onion unlock service in the initramfs (Debian)
2
+ tor
3
+ busybox # full applet set: ntpd for clock sync on RTC-less boards
@@ -90,31 +90,22 @@ def execute_sync_plan(operations: list[SyncOperation]) -> None:
90
90
  if not operation.is_directory and Path(operation.destination).is_file():
91
91
  ui.info("The destination file already exists!")
92
92
  ui.info("Difference:")
93
- runner.run(
94
- ["diff", operation.destination, operation.source], check=False
95
- )
93
+ runner.run(["diff", operation.destination, operation.source], check=False)
96
94
  runner.run(rsync_command(operation))
97
95
 
98
96
 
99
97
  def ensure_unlocked() -> None:
100
98
  mounts = runner.output(["mount"], check=False)
101
99
  if str(config.DECRYPTED_PATH) not in mounts:
102
- ui.info(
103
- f"The decrypted folder {config.DECRYPTED_PATH} is locked. "
104
- "You need to unlock it!"
105
- )
100
+ ui.info(f"The decrypted folder {config.DECRYPTED_PATH} is locked. You need to unlock it!")
106
101
  crypt.unlock()
107
102
 
108
103
 
109
104
  def import_from_system(mode: str = "import") -> None:
110
105
  ensure_unlocked()
111
- backup_folder = (
112
- config.BACKUP_PATH / mode / time.strftime("%Y%m%d%H%M%S")
113
- )
106
+ backup_folder = config.BACKUP_PATH / mode / time.strftime("%Y%m%d%H%M%S")
114
107
  backup_folder.mkdir(parents=True, exist_ok=True)
115
- operations = build_sync_plan(
116
- mode, system.real_home(), config.DATA_PATH, backup_folder
117
- )
108
+ operations = build_sync_plan(mode, system.real_home(), config.DATA_PATH, backup_folder)
118
109
  execute_sync_plan(operations)
119
110
 
120
111
 
@@ -119,9 +119,7 @@ def overwrite_device(device: Device) -> None:
119
119
 
120
120
  def blkid_value(path: str, tag: str) -> str:
121
121
  """Return a blkid tag value ("TYPE", "UUID", ...) or "" when unavailable."""
122
- return runner.output(
123
- ["blkid", path, "-s", tag, "-o", "value"], sudo=True, check=False
124
- )
122
+ return runner.output(["blkid", path, "-s", tag, "-o", "value"], sudo=True, check=False)
125
123
 
126
124
 
127
125
  def is_mounted(path_fragment: str) -> bool:
@@ -57,6 +57,19 @@ retropie_images:
57
57
  checksum: b5daa6e7660a99c246966f3f09b4014b
58
58
  image: retropie-buster-4.8-rpi4_400.img.gz
59
59
 
60
+ # Raspberry Pi OS. The "_latest" endpoints 302-redirect to the current release;
61
+ # image is only the local filename (must end .img.xz for decompress_command).
62
+ raspios_images:
63
+ lite64:
64
+ source_url: https://downloads.raspberrypi.com/raspios_lite_arm64_latest
65
+ image: raspios_lite_arm64_latest.img.xz
66
+ desktop64:
67
+ source_url: https://downloads.raspberrypi.com/raspios_arm64_latest
68
+ image: raspios_arm64_latest.img.xz
69
+ lite32:
70
+ source_url: https://downloads.raspberrypi.com/raspios_lite_armhf_latest
71
+ image: raspios_lite_armhf_latest.img.xz
72
+
60
73
  # Extra kernel modules the initramfs needs for early network access,
61
74
  # see https://raspberrypi.stackexchange.com/questions/67051
62
75
  mkinitcpio_modules_by_rpi:
@@ -16,6 +16,23 @@ def replace_in_file(search: str, replace: str, path: str | Path) -> None:
16
16
  path.write_text(new_text)
17
17
 
18
18
 
19
+ def drop_fstab_mount(mount_point: str, path: str | Path) -> None:
20
+ """Remove any (non-comment) fstab line whose mount point field equals mount_point.
21
+
22
+ Lets a fresh mapper/boot entry replace a stock image's existing one instead
23
+ of colliding with it. No-op when the file or a matching line is absent.
24
+ """
25
+ path = Path(path)
26
+ if not path.exists():
27
+ return
28
+ kept = [
29
+ line
30
+ for line in path.read_text().splitlines()
31
+ if line.lstrip().startswith("#") or line.split()[1:2] != [mount_point]
32
+ ]
33
+ path.write_text("".join(f"{line}\n" for line in kept))
34
+
35
+
19
36
  def ensure_line_in_file(line: str, path: str | Path) -> bool:
20
37
  """Append ``line`` unless already present. Returns True when appended."""
21
38
  path = Path(path)
@@ -5,8 +5,21 @@ from lim.errors import LimError
5
5
  from lim.image.plan import ImagePlan
6
6
 
7
7
 
8
+ def _choose(prompt: str, options: list[str]) -> str:
9
+ """List options numbered; accept either a number or the name itself."""
10
+ ui.info(f"{prompt}:")
11
+ for index, name in enumerate(options, 1):
12
+ ui.info(f" {index}) {name}")
13
+ answer = ui.ask("Pick a number or name:").strip()
14
+ if answer in options: # name wins, so numeric names (e.g. arch "4") still work
15
+ return answer
16
+ if answer.isdigit() and 1 <= int(answer) <= len(options):
17
+ return options[int(answer) - 1]
18
+ return answer
19
+
20
+
8
21
  def choose_arch(plan: ImagePlan) -> None:
9
- version = ui.ask("Which Raspberry Pi will be used (e.g.: 1, 2, 3b, 3b+, 4...):")
22
+ version = _choose("Which Raspberry Pi version", list(catalog.arch_rpi_images()))
10
23
  entry = catalog.arch_rpi_images().get(version)
11
24
  if entry is None:
12
25
  raise LimError(f"Version {version} isn't supported.")
@@ -19,7 +32,7 @@ def choose_arch(plan: ImagePlan) -> None:
19
32
 
20
33
  def choose_manjaro(plan: ImagePlan) -> None:
21
34
  plan.boot_size = "+500M"
22
- flavour = ui.ask("Which version(e.g.:architect,gnome) should be used:")
35
+ flavour = _choose("Which Manjaro version", ["architect", "gnome"])
23
36
  if flavour == "architect":
24
37
  plan.image_checksum = "6b1c2fce12f244c1e32212767a9d3af2cf8263b2"
25
38
  plan.base_download_url = (
@@ -28,7 +41,7 @@ def choose_manjaro(plan: ImagePlan) -> None:
28
41
  )
29
42
  plan.image_name = "manjaro-architect-20.0-200426-linux56.iso"
30
43
  elif flavour == "gnome":
31
- release = ui.ask("Which release(e.g.:20,21,raspberrypi) should be used:")
44
+ release = _choose("Which Gnome release", list(catalog.manjaro_gnome_releases()))
32
45
  entry = catalog.manjaro_gnome_releases().get(release)
33
46
  if entry is None:
34
47
  raise LimError(f"Gnome release {release} isn't supported.")
@@ -44,32 +57,37 @@ def choose_manjaro(plan: ImagePlan) -> None:
44
57
  def choose_moode(plan: ImagePlan) -> None:
45
58
  plan.boot_size = "+200M"
46
59
  plan.image_checksum = "185cbc9a4994534bb7a4bc2744c78197"
47
- plan.base_download_url = (
48
- "https://github.com/moode-player/moode/releases/download/r651prod/"
49
- )
60
+ plan.base_download_url = "https://github.com/moode-player/moode/releases/download/r651prod/"
50
61
  plan.image_name = "moode-r651-iso.zip"
51
62
 
52
63
 
53
64
  def choose_retropie(plan: ImagePlan) -> None:
54
65
  plan.boot_size = "+500M"
55
- version = ui.ask("Which version(e.g.:1,2,3,4) should be used:")
66
+ version = _choose("Which RetroPie version", list(catalog.retropie_images()))
56
67
  entry = catalog.retropie_images().get(version)
57
68
  if entry is None:
58
69
  raise LimError(f"Version {version} isn't supported.")
59
70
  plan.raspberry_pi_version = version
60
- plan.base_download_url = (
61
- "https://github.com/RetroPie/RetroPie-Setup/releases/download/4.8/"
62
- )
71
+ plan.base_download_url = "https://github.com/RetroPie/RetroPie-Setup/releases/download/4.8/"
63
72
  plan.image_checksum = entry["checksum"]
64
73
  plan.image_name = entry["image"]
65
74
 
66
75
 
76
+ def choose_raspios(plan: ImagePlan) -> None:
77
+ plan.boot_size = "+512M"
78
+ plan.root_filesystem = "ext4"
79
+ variant = _choose("Which Raspberry Pi OS image", list(catalog.raspios_images()))
80
+ entry = catalog.raspios_images().get(variant)
81
+ if entry is None:
82
+ raise LimError(f"Raspberry Pi OS variant {variant} isn't supported.")
83
+ plan.source_url = entry["source_url"]
84
+ plan.image_name = entry["image"]
85
+
86
+
67
87
  def choose_torbox(plan: ImagePlan) -> None:
68
88
  plan.base_download_url = "https://www.torbox.ch/data/"
69
89
  plan.image_name = "torbox-20220102-v050.gz"
70
- plan.image_checksum = (
71
- "0E1BA7FFD14AAAE5F0462C8293D95B62C3BF1D9E726E26977BD04772C55680D3"
72
- )
90
+ plan.image_checksum = "0E1BA7FFD14AAAE5F0462C8293D95B62C3BF1D9E726E26977BD04772C55680D3"
73
91
  plan.boot_size = "+200M"
74
92
 
75
93
 
@@ -78,9 +96,7 @@ def choose_android_x86(plan: ImagePlan) -> None:
78
96
  "https://www.fosshub.com/Android-x86.html?dwl=android-x86_64-9.0-r2.iso"
79
97
  )
80
98
  plan.image_name = "android-x86_64-9.0-r2.iso"
81
- plan.image_checksum = (
82
- "f7eb8fc56f29ad5432335dc054183acf086c539f3990f0b6e9ff58bd6df4604e"
83
- )
99
+ plan.image_checksum = "f7eb8fc56f29ad5432335dc054183acf086c539f3990f0b6e9ff58bd6df4604e"
84
100
  plan.boot_size = "+500M"
85
101
 
86
102
 
@@ -91,13 +107,12 @@ DISTRIBUTION_CHOOSERS = {
91
107
  "manjaro": choose_manjaro,
92
108
  "moode": choose_moode,
93
109
  "retropie": choose_retropie,
110
+ "raspios": choose_raspios,
94
111
  }
95
112
 
96
113
 
97
114
  def choose_linux_image(plan: ImagePlan) -> None:
98
- distribution = ui.ask(
99
- "Which distribution should be used [arch,moode,retropie,manjaro,torbox...]?"
100
- )
115
+ distribution = _choose("Which distribution", list(DISTRIBUTION_CHOOSERS))
101
116
  chooser = DISTRIBUTION_CHOOSERS.get(distribution)
102
117
  if chooser is None:
103
118
  raise LimError(f"Distribution {distribution} isn't supported.")
@@ -0,0 +1,71 @@
1
+ """Ensure the host can chroot into a foreign-architecture image (qemu binfmt)."""
2
+
3
+ import platform
4
+ import shutil
5
+ from pathlib import Path
6
+
7
+ from lim import runner, ui
8
+ from lim.errors import LimError
9
+
10
+ # Building natively needs no qemu; on these the chroot runs directly.
11
+ _NATIVE_ARM = ("aarch64", "armv7l", "armv6l", "arm64")
12
+
13
+ # Repo-based installs only; AUR helpers refuse to run from this root process.
14
+ _BINFMT_INSTALLERS = {
15
+ "apt-get": ["apt-get", "install", "-y", "qemu-user-static", "binfmt-support"],
16
+ "dnf": ["dnf", "install", "-y", "qemu-user-static"],
17
+ "zypper": ["zypper", "--non-interactive", "install", "qemu-linux-user"],
18
+ "pacman": ["pacman", "-S", "--needed", "--noconfirm", "qemu-user-static-binfmt"],
19
+ }
20
+
21
+
22
+ def _entry_enabled(entry: Path) -> bool:
23
+ try:
24
+ return "enabled" in entry.read_text()
25
+ except OSError:
26
+ return False
27
+
28
+
29
+ def qemu_binfmt_enabled() -> bool:
30
+ binfmt = Path("/proc/sys/fs/binfmt_misc")
31
+ return binfmt.is_dir() and any(_entry_enabled(e) for e in binfmt.glob("qemu-*"))
32
+
33
+
34
+ def auto_enable() -> bool:
35
+ """Install qemu-user-static via the host package manager and register binfmt."""
36
+ command = next((cmd for tool, cmd in _BINFMT_INSTALLERS.items() if shutil.which(tool)), None)
37
+ if command is None:
38
+ ui.warning("No supported host package manager found (apt-get/dnf/zypper/pacman).")
39
+ return False
40
+ runner.run(command, sudo=True, check=False)
41
+ runner.run(["systemctl", "restart", "systemd-binfmt"], sudo=True, check=False)
42
+ return qemu_binfmt_enabled()
43
+
44
+
45
+ def ensure_ready() -> None:
46
+ """Make foreign-arch chroot work, or fail with actionable guidance.
47
+
48
+ Called before the target is erased, so a host that cannot run the image's
49
+ binaries aborts early instead of mid-build with a cryptic "Exec format error".
50
+ """
51
+ if platform.machine() in _NATIVE_ARM:
52
+ return
53
+ if qemu_binfmt_enabled():
54
+ ui.info("Cross-architecture build: qemu binfmt handlers are registered.")
55
+ return
56
+ ui.warning(
57
+ f"Cross-architecture build: this host ({platform.machine()}) cannot run the "
58
+ "image's ARM binaries in chroot yet."
59
+ )
60
+ if ui.confirm("Install qemu-user-static and register binfmt now?"):
61
+ if auto_enable():
62
+ ui.success("qemu binfmt handlers registered.")
63
+ return
64
+ ui.warning("Automatic setup did not register a handler.")
65
+ raise LimError(
66
+ "Cross-architecture chroot is not available. Install qemu-user-static + binfmt "
67
+ "manually, or build on the target itself (e.g. the Pi booted from USB).\n"
68
+ " Arch/Manjaro : qemu-user-static + qemu-user-static-binfmt (AUR), "
69
+ "then: sudo systemctl restart systemd-binfmt\n"
70
+ " Debian/Ubuntu: sudo apt install qemu-user-static binfmt-support"
71
+ )
@@ -0,0 +1,42 @@
1
+ """Remote-unlockable LUKS boot configuration.
2
+
3
+ Distro-agnostic orchestration; the init-system-specific steps live in the
4
+ initramfs backends (mkinitcpio for Arch/Manjaro, initramfs-tools for
5
+ Debian/Raspberry Pi OS). See lim/image/initramfs/.
6
+ """
7
+
8
+ from pathlib import Path
9
+
10
+ from lim import packages, ui
11
+ from lim.image.initramfs import get_backend
12
+ from lim.image.plan import ImagePlan
13
+ from lim.image.session import ImageSession, install_packages
14
+
15
+
16
+ def configure_encryption(
17
+ plan: ImagePlan, session: ImageSession, authorized_keys: Path
18
+ ) -> str | None:
19
+ """Configure the remote-unlock LUKS stack; return the onion address, if any."""
20
+ root = session.root_mount_path
21
+ backend = get_backend(plan.distribution)
22
+ ui.info("Setup encryption...")
23
+ ui.info("Installing neccessary software...")
24
+ install_packages(
25
+ plan.distribution,
26
+ root,
27
+ " ".join(packages.get_packages(backend.luks_package_collection())),
28
+ )
29
+
30
+ backend.install_authorized_key(root, authorized_keys)
31
+
32
+ onion_address = None
33
+ if plan.tor_unlock:
34
+ # Hook files and onion keys must exist before the initramfs is baked.
35
+ onion_address = backend.install_tor_unlock(plan, root)
36
+
37
+ # crypttab must be written before the initramfs is (re)generated so the
38
+ # Debian cryptsetup hook can bake the mapping in.
39
+ backend.register_encrypted_root(plan, session, root)
40
+ backend.configure_initramfs(plan, root)
41
+ backend.configure_bootloader(plan, session, root)
42
+ return onion_address
@@ -0,0 +1,21 @@
1
+ """Distro-specific initramfs backends for encrypted remote unlock."""
2
+
3
+ from lim.errors import LimError
4
+ from lim.image.initramfs.base import InitramfsBackend
5
+ from lim.image.initramfs.initramfs_tools import InitramfsToolsBackend
6
+ from lim.image.initramfs.mkinitcpio import MkinitcpioBackend
7
+
8
+ _MKINITCPIO = frozenset({"arch", "manjaro"})
9
+ _INITRAMFS_TOOLS = frozenset({"raspios", "moode", "retropie"})
10
+
11
+
12
+ def get_backend(distribution: str | None) -> InitramfsBackend:
13
+ """Pick the initramfs backend for a distribution's init system."""
14
+ if distribution in _MKINITCPIO:
15
+ return MkinitcpioBackend()
16
+ if distribution in _INITRAMFS_TOOLS:
17
+ return InitramfsToolsBackend()
18
+ raise LimError(f"No initramfs backend for distribution {distribution!r}.")
19
+
20
+
21
+ __all__ = ["InitramfsBackend", "get_backend"]
@@ -0,0 +1,42 @@
1
+ """The initramfs backend interface.
2
+
3
+ Encrypted remote-unlock images differ by init system: Arch/Manjaro use
4
+ mkinitcpio, Debian/Raspberry Pi OS use initramfs-tools. Each backend owns the
5
+ distro-specific bits (package set, crypttab syntax, initramfs generation,
6
+ bootloader cmdline, and how the Tor hook is baked in); the encryption flow
7
+ orchestrates them the same way for every distro.
8
+ """
9
+
10
+ from abc import ABC, abstractmethod
11
+ from pathlib import Path
12
+
13
+ from lim.image.plan import ImagePlan
14
+ from lim.image.session import ImageSession
15
+
16
+
17
+ class InitramfsBackend(ABC):
18
+ """Distro-specific steps to build an encrypted, remote-unlockable image."""
19
+
20
+ @abstractmethod
21
+ def luks_package_collection(self) -> str:
22
+ """Name of the package collection providing the LUKS/unlock stack."""
23
+
24
+ @abstractmethod
25
+ def install_authorized_key(self, root: Path, authorized_keys: Path) -> None:
26
+ """Place the SSH public key where the initramfs dropbear reads it."""
27
+
28
+ @abstractmethod
29
+ def install_tor_unlock(self, plan: ImagePlan, root: Path) -> str:
30
+ """Bake the Tor onion service into the initramfs; return the address."""
31
+
32
+ @abstractmethod
33
+ def register_encrypted_root(self, plan: ImagePlan, session: ImageSession, root: Path) -> None:
34
+ """Write fstab and crypttab in the distro's syntax."""
35
+
36
+ @abstractmethod
37
+ def configure_initramfs(self, plan: ImagePlan, root: Path) -> None:
38
+ """Configure the initramfs modules/hooks and (re)generate it."""
39
+
40
+ @abstractmethod
41
+ def configure_bootloader(self, plan: ImagePlan, session: ImageSession, root: Path) -> None:
42
+ """Point the bootloader at the encrypted root with early networking."""