grub: replace legacy grub install scripts

Translate old legacy grub scripts into grub.py
Implement ogGrubInstallMbr as install_main_grub() and
ogGrubInstallPartition as install_linux_grub().

Add grub configuration file generator through the classes
GrubConfig and MenuEntry.

Ensure EFI tree structure compatibility with legacy code.
The structure of the created folders in the ESP is non-standard,
efi binaries are usually located in the folder below the EFI/
directory.

Structure used by ogClient:

EFI/
├── grub/
│   └── Boot/
│       ├── BOOTX64.CSV
│       ├── grub.cfg
│       ├── mmx64.efi
│       ├── shimx64.efi
│       ├── BOOTX64.EFI
│       ├── grubx64.efi
│       └── ogloader.efi
...

The function _mangle_efi_folder handles the folder structure after
grub-install to comply with the location expected by ogLive.

install_linux_grub() installs a grub local to each Linux install
to enable chainloading, each grub is located in EFI/Part-xx-yy/ in
UEFI. The local linux BIOS grub in legacy scripts is unreliable,
grub-install reports a failure during the install process.

install_main_grub() installs a global grub in EFI/grub/ to show a
grub menu when the pxe boot fails. The global grub contains entries
to every installed os. No global grub is installed for BIOS
systems, a Boot partition would be required to store the grub
configuration.
master
Alejandro Sirgo Rica 2024-10-11 12:15:09 +02:00
parent 2fcdf89606
commit 373c1b2a72
4 changed files with 401 additions and 31 deletions

373
src/utils/grub.py 100644
View File

@ -0,0 +1,373 @@
#
# Copyright (C) 2024 Soleta Networks <info@soleta.eu>
#
# This program is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the
# Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
import subprocess
import logging
import shutil
import shlex
import os
from enum import Enum
from src.utils.probe import *
from src.utils.bios import *
from src.utils.uefi import *
from src.utils.disk import *
from src.log import OgError
GRUB_TIMEOUT = 5
class GrubConfig:
def __init__(self, timeout):
self.timeout = timeout
self.entries = []
def add_entry(self, entry):
self.entries.append(entry)
def __str__(self):
res = f'set timeout={self.timeout}\n'
res += f'set default=0\n'
res += '\n'
for entry in self.entries:
res += str(entry)
res += '\n'
return res
class MenuEntry:
class PartType(Enum):
MBR = 'part_msdos'
GPT = 'part_gpt'
class FsType(Enum):
EXT = 'ext2'
FAT = 'fat'
def __init__(self, name, disk_num, part_num, part_type, fs):
self.name = self._escape_menuentry_name(name)
self.disk_num = disk_num
self.part_num = part_num
self.part_type = part_type
self.fs = fs
self.efipath = None
self.initrd = None
self.vmlinuz = None
self.vmlinuz_root = None
self.params = None
def set_chainload_data(self, efipath):
self.efipath = efipath
def set_linux_data(self, initrd, vmlinuz, vmlinuz_root, params):
self.initrd = initrd
self.vmlinuz = vmlinuz
self.vmlinuz_root = vmlinuz_root
self.params = params
def _set_root_str(self):
if self.part_type == MenuEntry.PartType.GPT:
return f'set root=(hd{self.disk_num - 1},gpt{self.part_num})'
else:
return f'set root=(hd{self.disk_num - 1},{self.part_num})'
def _get_chainload_str(self):
res = f'menuentry "{self.name}" {{\n'
res += f' insmod {self.part_type.value}\n'
res += ' insmod chain\n'
res += f' insmod {self.fs.value}\n'
res += f' {self._set_root_str()}\n'
res += f' chainloader {self.efipath}\n'
res += '}\n'
return res
def _get_linux_str(self):
res = f'menuentry "{self.name}" {{\n'
res += f' insmod {self.part_type.value}\n'
res += ' insmod linux\n'
res += f' insmod {self.fs.value}\n'
res += f' {self._set_root_str()}\n'
res += f' linux {self.vmlinuz} root={self.vmlinuz_root} {self.params}\n'
res += f' initrd {self.initrd}\n'
res += '}\n'
return res
def __str__(self):
if self.efipath:
return self._get_chainload_str()
else:
return self._get_linux_str()
@staticmethod
def _escape_menuentry_name(entry_name):
entry_name = entry_name.replace('"', r'\"')
entry_name = entry_name.replace('\\', r'\\')
entry_name = entry_name.replace('$', r'\$')
entry_name = entry_name.replace('*', r'\*')
entry_name = entry_name.replace('!', r'\!')
return entry_name
def _get_linux_data(disk_num, part_num, mountpoint):
os_entry = {}
os_entry['name'] = f'{os_probe(mountpoint)} ({disk_num}, {part_num})'
os_entry['device'] = get_partition_device(disk_num, part_num)
os_entry['part_type'] = get_disk_part_type(disk_num)
kernel_path = get_vmlinuz_path(mountpoint)
os_entry['vmlinuz'] = '/' + os.path.relpath(kernel_path, mountpoint)
initrd_path = get_initrd_path(mountpoint)
os_entry['initrd'] = '/' + os.path.relpath(initrd_path, mountpoint)
return os_entry
def _generate_linux_grub_config(disk_num, part_num, mountpoint, grub_cfg):
grub_config = GrubConfig(timeout=0)
os_entry = _get_linux_data(disk_num, part_num, mountpoint)
part_type = get_disk_part_type(disk_num)
menu_entry = MenuEntry(name=os_entry['name'],
disk_num=disk_num,
part_num=part_num,
part_type=part_type,
fs=MenuEntry.FsType.EXT)
menu_entry.set_linux_data(initrd=os_entry['initrd'],
vmlinuz=os_entry['vmlinuz'],
vmlinuz_root=os_entry['device'],
params='ro quiet splash')
grub_config.add_entry(menu_entry)
with open(grub_cfg, 'w') as f:
f.write(str(grub_config))
def _mangle_efi_folder(entry_dir, boot_dir):
efi_boot_src = f'{entry_dir}/EFI/BOOT'
for file_name in os.listdir(efi_boot_src):
shutil.move(f'{efi_boot_src}/{file_name}', boot_dir)
shutil.rmtree(f'{entry_dir}/EFI')
shutil.copyfile("/usr/lib/shim/shimx64.efi.signed",
f'{boot_dir}/shimx64.efi')
shutil.copyfile(f'{boot_dir}/grubx64.efi',
f'{boot_dir}/ogloader.efi')
def _install_linux_grub_efi(disk_num, part_num, device, mountpoint):
if interpreter_is64bit():
arch = 'x86_64-efi'
else:
logging.warning("Old 32-bit UEFI system found here")
arch = 'i386-efi'
_esp_disk_num = 1
esp, _esp_disk, _esp_part_number = get_efi_partition(_esp_disk_num, enforce_gpt=False)
esp_mountpoint = esp.replace('dev', 'mnt')
if not mount_mkdir(esp, esp_mountpoint):
raise OgError(f'Unable to mount detected EFI System Partition at {esp} into {esp_mountpoint}')
_bootlabel = f'Part-{disk_num:02d}-{part_num:02d}'
entry_dir = f'{esp_mountpoint}/EFI/{_bootlabel}'
boot_dir = f'{entry_dir}/Boot'
if os.path.exists(entry_dir):
shutil.rmtree(entry_dir)
os.makedirs(boot_dir)
logging.info(f'Calling grub-install with target {arch} in {entry_dir}')
grub_install_cmd = (f'grub-install --removable --no-nvram --uefi-secure-boot '
f'--target={arch} --efi-directory={entry_dir} '
f'--root-directory={entry_dir} --recheck')
try:
subprocess.run(shlex.split(grub_install_cmd), check=True)
except subprocess.CalledProcessError as e:
umount(esp_mountpoint)
raise OgError(f"Error during GRUB install: {e}") from e
_mangle_efi_folder(entry_dir, boot_dir)
logging.info(f'Generating grub.cfg in {boot_dir}')
grub_cfg = f'{boot_dir}/grub.cfg'
try:
_generate_linux_grub_config(disk_num, part_num, mountpoint, grub_cfg)
except Exception as e:
raise OgError(f'Error generating {grub_cfg}: {e}') from e
finally:
umount(esp_mountpoint)
def _install_linux_grub_bios(disk_num, part_num, device, mountpoint):
arch = 'i386-pc'
entry_dir = f'{mountpoint}/boot'
grub_dir = f'{entry_dir}/grub'
if os.path.exists(grub_dir):
shutil.rmtree(grub_dir)
os.makedirs(grub_dir)
logging.info(f'Calling grub-install with target {arch} in {entry_dir}')
grub_install_cmd = f'grub-install --force --target={arch} --boot-directory={entry_dir} --recheck {device}'
try:
subprocess.run(shlex.split(grub_install_cmd), check=True)
except subprocess.CalledProcessError as e:
umount(mountpoint)
raise OgError(f"Error during GRUB install: {e}") from e
grub_cfg = f'{grub_dir}/grub.cfg'
logging.info(f'Generating grub.cfg in {grub_dir}')
try:
_generate_linux_grub_config(disk_num, part_num, mountpoint, grub_cfg)
except Exception as e:
raise OgError(f'Error generating {grub_cfg}: {e}') from e
finally:
umount(mountpoint)
def install_linux_grub(disk_num, part_num):
device = get_partition_device(disk_num, part_num)
mountpoint = device.replace('dev', 'mnt')
if not mount_mkdir(device, mountpoint):
raise OgError(f'Unable to mount {device} into {mountpoint}')
try:
if is_uefi_supported():
logging.info(f'Installing GRUB for UEFI Linux at {device}...')
_install_linux_grub_efi(disk_num, part_num, device, mountpoint)
else:
logging.info(f'Installing GRUB for BIOS Linux at {device}...')
_install_linux_grub_bios(disk_num, part_num, device, mountpoint)
finally:
umount(mountpoint)
def _get_os_entries(esp_mountpoint):
os_entries = []
available_disks = get_disks()
for disk_num, diskname in enumerate(available_disks, start=1):
disk_device = f'/dev/{diskname}'
partitions_data = get_partition_data(device=disk_device)
for p in partitions_data:
part_num = p.partno + 1
mountpoint = p.padev.replace('dev', 'mnt')
if mountpoint == esp_mountpoint:
continue
if not mount_mkdir(p.padev, mountpoint):
raise OgError(f'Unable to mount {p.padev} into {mountpoint}')
try:
os_family = get_os_family(mountpoint)
system_name = os_probe(mountpoint)
except Exception as e:
umount(mountpoint)
raise
if os_family == OSFamily.UNKNOWN:
umount(mountpoint)
continue
os_entry = {}
os_entry['name'] = f'{system_name} ({disk_num}, {part_num})'
_bootlabel = f'Part-{disk_num:02d}-{part_num:02d}'
try:
if os_family == OSFamily.WINDOWS:
efi_loader = find_windows_efi_loader(esp_mountpoint, _bootlabel)
elif os_family == OSFamily.LINUX:
efi_loader = find_linux_efi_loader(esp_mountpoint, _bootlabel)
os_entry['efipath'] = '/' + os.path.relpath(efi_loader, esp_mountpoint)
finally:
umount(mountpoint)
os_entries.append(os_entry)
return os_entries
def _generate_main_grub_config(grub_cfg, esp_disk_num, esp_part_num, esp_mountpoint):
os_entries = _get_os_entries(esp_mountpoint)
esp_part_type = get_disk_part_type(disk_num=1)
grub_config = GrubConfig(timeout=GRUB_TIMEOUT)
for os_entry in os_entries:
menu_entry = MenuEntry(name=os_entry['name'],
disk_num=esp_disk_num,
part_num=esp_part_num,
part_type=esp_part_type,
fs=MenuEntry.FsType.FAT)
menu_entry.set_chainload_data(efipath=os_entry['efipath'])
grub_config.add_entry(menu_entry)
with open(grub_cfg, 'w') as f:
f.write(str(grub_config))
def get_disk_part_type(disk_num):
device = get_disks()[disk_num - 1]
cxt = fdisk.Context(f'/dev/{device}')
return MenuEntry.PartType.MBR if cxt.label.name == 'dos' else MenuEntry.PartType.GPT
def _update_nvram(esp_disk, esp_part_number):
loader_path = '/EFI/grub/Boot/shimx64.efi'
bootlabel = 'grub'
efibootmgr_delete_bootentry(bootlabel)
efibootmgr_create_bootentry(esp_disk, esp_part_number, loader_path, bootlabel, add_to_bootorder=False)
efibootmgr_set_entry_order(bootlabel, 1)
def install_main_grub():
disk_device = f'/dev/{get_disks()[0]}'
is_uefi = is_uefi_supported()
if not is_uefi:
logging.info(f'Global GRUB install not supported in legacy BIOS')
return
logging.info(f'Installing GRUB at {disk_device}')
_esp_disk_num = 1
esp, _esp_disk, _esp_part_number = get_efi_partition(_esp_disk_num, enforce_gpt=False)
esp_mountpoint = esp.replace('dev', 'mnt')
if not mount_mkdir(esp, esp_mountpoint):
raise OgError(f'Unable to mount detected EFI System Partition at {esp} into {esp_mountpoint}')
entry_dir = f'{esp_mountpoint}/EFI/grub'
boot_dir = f'{entry_dir}/Boot'
if os.path.exists(entry_dir):
shutil.rmtree(entry_dir)
os.makedirs(boot_dir)
if interpreter_is64bit():
arch = 'x86_64-efi'
else:
logging.warning("Old 32-bit UEFI system found here")
arch = 'i386-efi'
logging.info(f'Calling grub-install with target {arch} in {entry_dir}')
grub_install_cmd = (f'grub-install --removable --no-nvram --uefi-secure-boot '
f'--target={arch} --efi-directory={entry_dir} '
f'--root-directory={entry_dir} --recheck')
try:
subprocess.run(shlex.split(grub_install_cmd), check=True)
except subprocess.CalledProcessError as e:
umount(esp_mountpoint)
raise OgError(f"Error during GRUB install: {e}") from e
_mangle_efi_folder(entry_dir, boot_dir)
logging.info(f'Generating grub.cfg in {boot_dir}')
grub_cfg = f'{boot_dir}/grub.cfg'
try:
_generate_main_grub_config(grub_cfg, _esp_disk_num, _esp_part_number, esp_mountpoint)
except Exception as e:
umount(esp_mountpoint)
raise OgError(f'Error generating {grub_cfg}: {e}') from e
logging.info('Updating grub UEFI NVRAM entry')
try:
_update_nvram(_esp_disk, _esp_part_number)
except Exception as e:
logging.info(f'Error updating NVRAM: {e}')
umount(esp_mountpoint)

View File

@ -12,6 +12,7 @@ import logging
import hivex
import shlex
from src.log import OgError
from src.utils.grub import install_main_grub, install_linux_grub
from src.utils.bcd import update_bcd
from src.utils.probe import *
from src.utils.disk import *
@ -152,19 +153,6 @@ def configure_mbr_boot_sector(disk, partition):
logging.warning(f'{cmd_configure} returned non-zero exit status {proc.returncode}')
def configure_grub_in_mbr(disk, partition):
cmd_configure = f"ogGrubInstallMbr {disk} {partition} TRUE"
proc = subprocess.run(cmd_configure,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
encoding='utf-8',
shell=True,
check=True)
if proc.returncode != 0:
logging.warning(f'{cmd_configure} returned non-zero exit status {proc.returncode}')
def configure_fstab(disk, partition):
logging.info(f'Configuring /etc/fstab')
device = get_partition_device(disk, partition)
@ -178,31 +166,17 @@ def configure_fstab(disk, partition):
finally:
umount(mountpoint)
def install_grub(disk, partition):
cmd_configure = f"ogGrubInstallPartition {disk} {partition}"
proc = subprocess.run(cmd_configure,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
encoding='utf-8',
shell=True,
check=True)
if proc.returncode != 0:
logging.warning(f'{cmd_configure} returned non-zero exit status {proc.returncode}')
def configure_os_linux(disk, partition):
hostname = gethostname()
set_linux_hostname(disk, partition, hostname)
configure_fstab(disk, partition)
install_linux_grub(disk, partition)
if is_uefi_supported():
_, _, esp_part_number = get_efi_partition(disk, enforce_gpt=True)
configure_grub_in_mbr(disk, esp_part_number)
install_grub(disk, partition)
install_main_grub()
def configure_os_windows(disk, partition):
@ -213,7 +187,7 @@ def configure_os_windows(disk, partition):
restore_windows_efi_bootloader(disk, partition)
_, _, esp_part_number = get_efi_partition(disk, enforce_gpt=True)
configure_grub_in_mbr(disk, esp_part_number)
install_main_grub()
else:
configure_mbr_boot_sector(disk, partition)

View File

@ -10,6 +10,7 @@ import os
import subprocess
import platform
import logging
import sys
from enum import Enum
from subprocess import PIPE
@ -76,6 +77,10 @@ def getwindowsversion(winreghives):
return 'Microsoft Windows'
def interpreter_is64bit():
return sys.maxsize > 2**32
def windows_is64bit(winreghives):
"""
Check for 64 bit Windows by means of retrieving the value of

View File

@ -125,6 +125,24 @@ def efibootmgr_create_bootentry(disk, part, loader, label, add_to_bootorder=True
except OSError as e:
raise OgError(f'Unexpected error adding boot entry to nvram. UEFI firmware might be buggy') from e
def efibootmgr_set_entry_order(label, position):
logging.info(f'Setting {label} entry to position {position} of boot order')
boot_info = run_efibootmgr_json(validate=False)
boot_entries = boot_info.get('vars', [])
boot_order = boot_info.get('BootOrder', [])
entry = _find_bootentry(boot_entries, label)
target_grub_entry = _strip_boot_prefix(entry)
if target_grub_entry in boot_order:
boot_order.remove(target_grub_entry)
boot_order.insert(position, target_grub_entry)
try:
proc = subprocess.run([EFIBOOTMGR_BIN, "-o", ",".join(boot_order)], check=True, text=True)
except OSError as e:
raise OgError(f'Unexpected error setting boot order to NVRAM. UEFI firmware might be buggy') from e
def _find_efi_loader(loader_paths):
for efi_app in loader_paths: