Add read-only laptop doctor and trusted image recovery entrypoints
This commit is contained in:
@@ -21,6 +21,8 @@ def parser() -> argparse.ArgumentParser:
|
||||
help='Distribution preferences for newly created profiles')
|
||||
commands = cli.add_subparsers(dest='command')
|
||||
commands.add_parser('menu', help='Open the terminal VM menu')
|
||||
doctor = commands.add_parser('doctor', help='Read-only laptop hardware observations')
|
||||
doctor.add_argument('--json', action='store_true', help='Print structured observations')
|
||||
update = commands.add_parser('update', help='Signed runtime updates and rollback')
|
||||
update.add_argument('update_args', nargs=argparse.REMAINDER)
|
||||
download = commands.add_parser('download', help='Download and validate a direct ISO URL')
|
||||
@@ -95,6 +97,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||
cli = parser()
|
||||
args = cli.parse_args(argv)
|
||||
try:
|
||||
if args.command == 'doctor':
|
||||
from .doctor import doctor_main
|
||||
return doctor_main(['--json'] if args.json else [])
|
||||
if args.command == 'update':
|
||||
checkout = Path(__file__).resolve().parents[1]
|
||||
if checkout == Path('/usr/lib/hydra'):
|
||||
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
"""Bounded, read-only hardware observations; presence is not a functional test."""
|
||||
|
||||
import argparse
|
||||
import itertools
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
READ_LIMIT = 65536
|
||||
DEVICE_LIMIT = 64
|
||||
TOOLS = ('systemctl', 'nmcli', 'ip', 'iw', 'rfkill', 'wpctl', 'pactl',
|
||||
'aplay', 'brightnessctl', 'wlr-randr', 'qemu-system-x86_64', 'btrfs',
|
||||
'openssl', 'efibootmgr', 'lsblk', 'lspci')
|
||||
DRIVERS = frozenset(('i915', 'xe', 'nouveau', 'nvidia', 'amdgpu', 'radeon',
|
||||
'virtio_gpu', 'bochs', 'simpledrm', 'iwlwifi', 'ath9k',
|
||||
'ath10k_pci', 'ath11k_pci', 'mt7921e', 'mt7922', 'rtw89_pci',
|
||||
'r8169', 'e1000e', 'igc', 'virtio_net', 'snd_hda_intel'))
|
||||
MODULES = ('kvm', 'kvm_intel', 'kvm_amd', 'i915', 'xe', 'nouveau', 'nvidia',
|
||||
'amdgpu', 'iwlwifi', 'mt7921e', 'snd_hda_intel', 'snd_sof_pci_intel_tgl')
|
||||
MANUAL_CHECKS = [
|
||||
'Wi-Fi connection: connect to your access point and test connectivity.',
|
||||
'Brightness keys: check both keys and the visible panel brightness.',
|
||||
'144 Hz: select the panel mode and verify the actual refresh rate.',
|
||||
'Audio: play sound through speakers/headphones and record microphone input.',
|
||||
'Suspend/resume: test wake-up, graphics, network and audio after suspend.',
|
||||
'USB: attach your devices and verify input/storage without erasing data.',
|
||||
]
|
||||
|
||||
|
||||
def _read(path, limit=READ_LIMIT):
|
||||
try:
|
||||
with path.open('rb') as stream:
|
||||
return stream.read(limit).decode('ascii', errors='replace')
|
||||
except OSError:
|
||||
return ''
|
||||
|
||||
|
||||
def _entries(path):
|
||||
try:
|
||||
# Do not materialize an unbounded directory or emit its identifying names.
|
||||
with os.scandir(path) as entries:
|
||||
return [Path(entry.path) for entry in itertools.islice(entries, DEVICE_LIMIT)]
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def _number(path, *, base=10):
|
||||
text = _read(path, 128).strip()
|
||||
try:
|
||||
value = int(text, base)
|
||||
return value if 0 <= value <= 2**64 - 1 else None
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _driver(path):
|
||||
try:
|
||||
name = (path / 'driver').resolve(strict=True).name
|
||||
return name if name in DRIVERS else 'other'
|
||||
except OSError:
|
||||
return 'unknown'
|
||||
|
||||
|
||||
def _services(find_tool, run):
|
||||
systemctl = find_tool('systemctl')
|
||||
result = {}
|
||||
for unit, user in (('NetworkManager', False), ('greetd', False),
|
||||
('hydra-network', False), ('hydra-dnsmasq', False),
|
||||
('pipewire', True), ('wireplumber', True), ('pipewire-pulse', True)):
|
||||
key = unit + (':user' if user else ':system')
|
||||
if not systemctl:
|
||||
result[key] = 'unavailable'
|
||||
continue
|
||||
command = [systemctl] + (['--user'] if user else []) + ['is-active', '--quiet', unit + '.service']
|
||||
try:
|
||||
status = run(command, timeout=1, stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False).returncode
|
||||
result[key] = {0: 'active', 3: 'inactive', 4: 'unavailable'}.get(status, 'unknown')
|
||||
except subprocess.TimeoutExpired:
|
||||
result[key] = 'timeout'
|
||||
except OSError:
|
||||
result[key] = 'unavailable'
|
||||
return result
|
||||
|
||||
|
||||
def doctor_report(*, proc_root=Path('/proc'), sys_root=Path('/sys'), dev_root=Path('/dev'),
|
||||
find_tool=shutil.which, run=subprocess.run):
|
||||
"""Return JSON-safe facts without identifiers or hardware-changing commands.
|
||||
|
||||
Each file read is bounded to 64 KiB, each device class to 64 entries and
|
||||
the seven read-only service queries to one second each. Missing access
|
||||
produces unknown/absent facts, never a claim of functional success.
|
||||
"""
|
||||
proc_root, sys_root, dev_root = Path(proc_root), Path(sys_root), Path(dev_root)
|
||||
cpu = _read(proc_root / 'cpuinfo')
|
||||
flags = set(re.findall(r'\b(?:vmx|svm)\b', cpu))
|
||||
memory = {}
|
||||
meminfo = _read(proc_root / 'meminfo')
|
||||
for field, key in (('MemTotal', 'total_mib'), ('MemAvailable', 'available_mib')):
|
||||
match = re.search(r'^' + field + r':\s+([0-9]{1,16})\s+kB$', meminfo, re.M)
|
||||
memory[key] = int(match[1]) // 1024 if match else None
|
||||
efi = sys_root / 'firmware/efi'
|
||||
secure_boot = None
|
||||
variable = efi / 'efivars/SecureBoot-8be4df61-93ca-11d2-aa0d-00e098032b8c'
|
||||
try:
|
||||
with variable.open('rb') as stream:
|
||||
value = stream.read(6)
|
||||
if len(value) == 5 and value[4] in (0, 1):
|
||||
secure_boot = bool(value[4])
|
||||
except OSError:
|
||||
pass
|
||||
storage = []
|
||||
for path in _entries(sys_root / 'class/block'):
|
||||
if (path / 'partition').exists() or re.match(r'^(loop|ram|zram)', path.name):
|
||||
continue
|
||||
sectors = _number(path / 'size')
|
||||
storage.append({'size_bytes': sectors * 512 if sectors is not None else None,
|
||||
'removable': {0: False, 1: True}.get(_number(path / 'removable'))})
|
||||
graphics_drivers = set()
|
||||
for path in _entries(sys_root / 'bus/pci/devices'):
|
||||
pci_class = _number(path / 'class', base=16)
|
||||
if pci_class is not None and pci_class >> 16 == 3:
|
||||
graphics_drivers.add(_driver(path))
|
||||
connectors = []
|
||||
for path in _entries(sys_root / 'class/drm'):
|
||||
if not (path / 'status').exists():
|
||||
continue
|
||||
status = _read(path / 'status', 64).strip()
|
||||
modes = [line for line in _read(path / 'modes').splitlines()[:32]
|
||||
if re.fullmatch(r'[0-9]{1,5}x[0-9]{1,5}', line)]
|
||||
connectors.append({'status': status if status in ('connected', 'disconnected', 'unknown') else 'unknown',
|
||||
'modes': modes})
|
||||
network = _entries(sys_root / 'class/net')
|
||||
inputs = [p for p in _entries(sys_root / 'class/input') if re.fullmatch(r'event[0-9]+', p.name)]
|
||||
keyboards = 0
|
||||
for path in inputs:
|
||||
value = _read(path / 'device/capabilities/key', 256).strip().replace(' ', '')
|
||||
if re.fullmatch(r'[0-9a-fA-F]{1,256}', value):
|
||||
bits = int(value, 16)
|
||||
keyboards += bool(bits & (1 << 30) and bits & (1 << 44))
|
||||
return {
|
||||
'format': 1,
|
||||
'scope': 'Read-only observations; physical functionality requires manual tests.',
|
||||
'cpu': {'architecture': platform.machine(), 'logical_processors': len(re.findall(r'^processor\s*:', cpu, re.M)) or None,
|
||||
'virtualization_flags': sorted(flags)},
|
||||
'memory': memory,
|
||||
'firmware': {'uefi': efi.is_dir(), 'secure_boot': secure_boot},
|
||||
'kvm': {'device_present': (dev_root / 'kvm').exists(),
|
||||
'accessible': os.access(dev_root / 'kvm', os.R_OK | os.W_OK),
|
||||
'modules': [name for name in ('kvm', 'kvm_intel', 'kvm_amd') if (sys_root / 'module' / name).is_dir()]},
|
||||
'storage': {'devices': storage},
|
||||
'graphics': {'drivers': sorted(graphics_drivers), 'connectors': connectors,
|
||||
'refresh_rate_tested': False},
|
||||
'input': {'event_devices': len(inputs), 'keyboards': keyboards,
|
||||
'backlight_devices': len(_entries(sys_root / 'class/backlight'))},
|
||||
'network': {'devices': len(network), 'wireless_devices': sum((p / 'wireless').is_dir() for p in network),
|
||||
'drivers': sorted({_driver(p / 'device') for p in network if (p / 'device').exists()})},
|
||||
'audio': {'cards': sum(bool(re.fullmatch(r'card[0-9]+', p.name)) for p in _entries(sys_root / 'class/sound'))},
|
||||
'modules': [name for name in MODULES if (sys_root / 'module' / name).is_dir()],
|
||||
'tools': {name: bool(find_tool(name)) for name in TOOLS},
|
||||
'services': _services(find_tool, run),
|
||||
'manual_checks': list(MANUAL_CHECKS),
|
||||
}
|
||||
|
||||
|
||||
def doctor_main(argv=None):
|
||||
parser = argparse.ArgumentParser(prog='hydra doctor', description=__doc__)
|
||||
parser.add_argument('--json', action='store_true', help='Print structured observations')
|
||||
options = parser.parse_args(argv)
|
||||
report = doctor_report()
|
||||
if options.json:
|
||||
print(json.dumps(report, indent=2, sort_keys=True))
|
||||
else:
|
||||
print('Hydra laptop doctor — read-only observations')
|
||||
print(report['scope'])
|
||||
for key, value in report.items():
|
||||
if key not in ('format', 'scope', 'manual_checks'):
|
||||
print(f'{key}: {json.dumps(value, sort_keys=True)}')
|
||||
print('\nManual checks:')
|
||||
for check in report['manual_checks']:
|
||||
print('- ' + check)
|
||||
return 0
|
||||
+24
-5
@@ -269,8 +269,10 @@ def brand_os_release(root):
|
||||
def configure_rootfs(root, source):
|
||||
root, source = Path(root), Path(source)
|
||||
# Refuse incomplete integrations before changing the exported copy.
|
||||
for name in ('bin/hydra', 'bin/hydra-install', 'hydra/session.py', 'hydra/installer.py'):
|
||||
if not (source / name).is_file():
|
||||
for name in ('bin/hydra', 'bin/hydra-install', 'hydra/session.py', 'hydra/installer.py',
|
||||
'bin/hydra-update', 'bin/hydra-update-recover', 'hydra/update_engine.py',
|
||||
'bin/hydra-system', 'hydra/system_recovery.py', 'image/rootfs/etc/hydra/update.pub'):
|
||||
if (source / name).is_symlink() or not (source / name).is_file():
|
||||
raise ImageError(f'Required committed runtime/installer asset is missing: {name}')
|
||||
version = kernel_version(root)
|
||||
kernel = kernel_image(root)
|
||||
@@ -287,11 +289,28 @@ def configure_rootfs(root, source):
|
||||
for module in sorted((source / 'hydra').glob('*.py')):
|
||||
shutil.copyfile(module, root_path(root, 'usr/lib/hydra/hydra/' + module.name))
|
||||
# Session helpers derive this checkout-compatible launcher from __file__.
|
||||
for name in ('hydra', 'hydra-install'):
|
||||
for name in ('hydra', 'hydra-install', 'hydra-system'):
|
||||
write(root, 'usr/lib/hydra/bin/' + name, (source / 'bin' / name).read_text(), 0o755)
|
||||
for name, module in (('hydra', 'cli'), ('hydra-install', 'installer')):
|
||||
# The updater bootstrap and engine are independently installed trusted files.
|
||||
# They must retain their isolated runpy launchers, outside runtime imports.
|
||||
for destination, asset, mode in (
|
||||
('usr/lib/hydra-update/engine.py', 'hydra/update_engine.py', 0o644),
|
||||
('usr/bin/hydra-update', 'bin/hydra-update', 0o755),
|
||||
('usr/bin/hydra-update-recover', 'bin/hydra-update-recover', 0o755),
|
||||
('etc/hydra/update.pub', 'image/rootfs/etc/hydra/update.pub', 0o644),
|
||||
):
|
||||
remove(root, destination)
|
||||
path = write(root, destination, (source / asset).read_text(), mode)
|
||||
if os.geteuid() == 0: # Assembly runs as root; source fixtures remain unprivileged.
|
||||
os.chown(path, 0, 0)
|
||||
engine_directory = root_path(root, 'usr/lib/hydra-update')
|
||||
engine_directory.chmod(0o755)
|
||||
if os.geteuid() == 0:
|
||||
os.chown(engine_directory, 0, 0)
|
||||
for name, module in (('hydra', 'cli'), ('hydra-install', 'installer'),
|
||||
('hydra-system', 'system_recovery')):
|
||||
write(root, 'usr/bin/' + name,
|
||||
'#!/usr/bin/python3\nimport sys\nsys.path.insert(0, "/usr/lib/hydra")\n'
|
||||
'#!/usr/bin/python3 -I\nimport sys\nsys.path.insert(0, "/usr/lib/hydra")\n'
|
||||
f'from hydra.{module} import main\nraise SystemExit(main())\n', 0o755)
|
||||
write(root, 'usr/bin/hydra-session', '#!/bin/sh\nexec /usr/bin/hydra session "$@"\n', 0o755)
|
||||
write(root, 'usr/bin/hydra-network', '#!/usr/bin/python3\nimport sys\nsys.path.insert(0, "/usr/lib/hydra")\n'
|
||||
|
||||
Reference in New Issue
Block a user