feat(cli): guided encrypted-image wizard (default) + remote-unlock
Add a guided setup: one interactive command that asks everything up front then builds an encrypted, Tor-remote-unlockable image unattended (distribution, target device, hostname, login user + key, password), creating or renaming the login user and installing the SSH key for both unlock and post-boot login. - wizard.py: _collect (all prompts) + _execute (autonomous build); renames a stock pi/alarm user or creates one, grants sudo, installs the login key. - unlock.py + `lim --type remote-unlock`: reach the initramfs over Tor (onion, torsocks) or plain SSH (host/IP), run cryptroot-unlock or the passphrase prompt; the wizard persists a target record under ~/.config/lim/unlocks. - cli.py: register guided (default --type) and remote-unlock; drop the deprecated --extra argument. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
100
lim/image/unlock.py
Normal file
100
lim/image/unlock.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Remote LUKS boot unlock over the initramfs SSH/dropbear session.
|
||||
|
||||
Reaches the waiting initramfs either through a Tor onion address (wrapped in
|
||||
torsocks) or a plain host/IP, then either runs cryptroot-unlock (initramfs-tools
|
||||
targets) or drops into the passphrase prompt (mkinitcpio encryptssh).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from lim import runner, ui
|
||||
from lim.errors import LimError
|
||||
|
||||
RECORDS_SUBPATH = ".config/lim/unlocks"
|
||||
|
||||
|
||||
def records_dir(home: Path | None = None) -> Path:
|
||||
return (home or Path.home()) / RECORDS_SUBPATH
|
||||
|
||||
|
||||
def build_unlock_argv(target: str, key: str, unlock_command: str) -> list[str]:
|
||||
"""SSH invocation for the unlock; torsocks-wrapped for .onion targets.
|
||||
|
||||
The initramfs dropbear key differs from the booted host key, so host-key
|
||||
checking is disabled; a v3 onion address authenticates the endpoint itself.
|
||||
"""
|
||||
ssh = ["ssh", "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null"]
|
||||
if key:
|
||||
ssh += ["-i", str(Path(key).expanduser())]
|
||||
if unlock_command:
|
||||
ssh += ["-t"] # PTY so the remote passphrase prompt is interactive
|
||||
ssh += [f"root@{target}"]
|
||||
if unlock_command:
|
||||
ssh += [unlock_command]
|
||||
if target.endswith(".onion"):
|
||||
return ["torsocks", *ssh]
|
||||
return ssh
|
||||
|
||||
|
||||
def save_record(home: Path, uid: int, gid: int, name: str, record: dict) -> None:
|
||||
"""Persist an unlock target under the target user's home, owned by them."""
|
||||
directory = records_dir(home)
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
path = directory / f"{name}.json"
|
||||
path.write_text(json.dumps(record, indent=2) + "\n")
|
||||
try:
|
||||
for entry in (path, directory, directory.parent, directory.parent.parent):
|
||||
os.chown(entry, uid, gid)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _read_record(path: Path) -> dict | None:
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _load_records() -> list[tuple[str, dict]]:
|
||||
directory = records_dir()
|
||||
if not directory.is_dir():
|
||||
return []
|
||||
loaded = ((path.stem, _read_record(path)) for path in sorted(directory.glob("*.json")))
|
||||
return [(name, record) for name, record in loaded if record is not None]
|
||||
|
||||
|
||||
def _select_record() -> dict | None:
|
||||
records = _load_records()
|
||||
if not records:
|
||||
return None
|
||||
ui.info("Saved unlock targets:")
|
||||
for index, (name, record) in enumerate(records, 1):
|
||||
ui.info(f" {index}) {name} -> {record.get('target')}")
|
||||
answer = ui.ask("Pick a number, or Enter for manual entry:")
|
||||
if answer.isdigit() and 1 <= int(answer) <= len(records):
|
||||
return records[int(answer) - 1][1]
|
||||
return None
|
||||
|
||||
|
||||
def remote_unlock() -> None:
|
||||
record = _select_record()
|
||||
if record:
|
||||
target = record["target"]
|
||||
key = record.get("key", "")
|
||||
unlock_command = record.get("unlock_command", "")
|
||||
else:
|
||||
target = ui.ask("Onion address or host/IP to unlock:")
|
||||
if not target:
|
||||
raise LimError("No target given.")
|
||||
key = ui.ask("Path to the SSH private key (empty for ssh defaults):")
|
||||
unlock_command = (
|
||||
"cryptroot-unlock"
|
||||
if ui.confirm("Debian / Raspberry Pi OS target (run cryptroot-unlock)?")
|
||||
else ""
|
||||
)
|
||||
if target.endswith(".onion"):
|
||||
ui.info("Routing through Tor (torsocks); a local tor must be running.")
|
||||
runner.run(build_unlock_argv(target, key, unlock_command), check=False)
|
||||
Reference in New Issue
Block a user