initial commit

This commit is contained in:
brent s. 2021-01-20 17:43:19 -05:00
commit 9ca03aaf08
Signed by: bts
GPG Key ID: 8C004C2F93481F6B
7 changed files with 656 additions and 0 deletions

.gitignore vendored Normal file
View File

@ -0,0 +1,27 @@
# Example .gitignore files:

README Normal file
View File

@ -0,0 +1,5 @@
This mini-project allows you to maintain segregated ESP failover in a multi-disk setup.

If you have just one disk in your system, this project is useless to you. I promise.

The examples assume use of UEFI and GRUB2, but it can easily be tweaked to use other bootloaders with a little research and modification to the configuration (and commenting out/modifying the writeConfs() function; I'll maybe make that a little smarter in the future).

bootsync.hook Normal file
View File

@ -0,0 +1,27 @@
# The following would be placed in /etc/pacman.d/hooks/ directory (you may need to create it if it doesn't exist) as bootsync.hook
# It assumes you have:
# * a properly configured /etc/bootsync.xml
# * /usr/local/bin/bootsync symlinked to <OpTools>/sys/BootSync/
Operation = Install
Operation = Upgrade
Operation = Remove
Type = File
Target = boot/*
Target = usr/lib/modules/*/vmlinuz
Target = usr/lib/initcpio/*

Operation = Install
Operation = Upgrade
Operation = Remove
Type = Package
Target = linux
Target = mkinitcpio

When = PostTransaction
Exec = /usr/local/bin/bootsync
Depends = python-magic
Depends = python-psutil
Depends = python-lxml

327 Executable file
View File

@ -0,0 +1,327 @@
#!/usr/bin/env python3

import argparse
import hashlib
import json
import os
import platform
import re
import shutil
import subprocess
import magic # From, not
import psutil
from lxml import etree

class BootSync(object):
def __init__(self, cfg = None, validate = True, dryrun = False, *args, **kwargs):
if not cfg:
self.cfgfile = '/etc/bootsync.xml'
self.cfgfile = os.path.abspath(os.path.expanduser(cfg))
self.ns = None
self.cfg = None
self.xml = None
self.schema = None
# This is the current live kernel.
self.currentKernVer = self._getRunningKernel()
# This is the installed kernel from the package manager.
self.kernelFile = None
self.installedKernVer = None
self.RequireReboot = False # If a reboot is needed (WARN, don't execute!)
self.blkids = {}
self.dummy_uuid = None
self.syncs = {}
self.getCfg(validate = validate)
self.chkMounts(dryrun = dryrun)

def getCfg(self, validate = True):
if not os.path.isfile(self.cfgfile):
raise FileNotFoundError('Configuration file {0} does not exist!'.format(self.cfgfile))
with open(self.cfgfile, 'rb') as f:
self.xml = etree.parse(f)
self.cfg = self.xml.getroot()
except etree.XMLSyntaxError:
# self.logger.error('{0} is invalid XML'.format(self.cfgfile))
raise ValueError(('{0} does not seem to be valid XML. '
'See sample.config.xml for an example configuration.').format(self.cfgfile))
self.ns = self.cfg.nsmap.get(None, '')
self.ns = '{{{0}}}'.format(self.ns)
if validate:
if not self.schema:
from urllib.request import urlopen
xsi = self.cfg.nsmap.get('xsi', '')
schemaLocation = '{{{0}}}schemaLocation'.format(xsi)
schemaURL = self.cfg.attrib.get(schemaLocation,
with urlopen(schemaURL) as url:
self.schema =
self.schema = etree.XMLSchema(etree.XML(self.schema))

def chkMounts(self, dryrun = False):
if not dryrun:
if os.geteuid() != 0:
raise PermissionError('You must be root to write to the appropriate destinations')
_mounts = {m.device: m.mountpoint for m in psutil.disk_partitions(all = True)}
for esp in self.cfg.findall('{0}partitions/{0}part'.format(self.ns)):
disk = esp.attrib['path']
mount = os.path.abspath(os.path.expanduser(esp.attrib['mount']))
if not dryrun:
if not os.path.isdir(mount):
os.makedirs(mount, exist_ok = True)
if disk not in _mounts:
with open(os.devnull, 'w') as devnull:
c =['/usr/bin/mount', mount],
stderr = devnull)
if c.returncode == 1: # Not specified in fstab['/usr/bin/mount', disk, mount],
stderr = devnull)
elif c.returncode == 32: # Already mounted

def chkReboot(self):
if not self.kernelFile:
return() # No isKernel="true" was specified in the config.
if self.installedKernVer != self.currentKernVer:
self.RequireReboot = True
# TODO: logger instead?
'New kernel is {0}. '
'Running kernel is {1}.').format(self.installedKernVer,

def getBlkids(self):
cmd = ['/usr/bin/blkid',
'-o', 'export']
if os.geteuid() != 0:
# TODO: logger?
print(('sudo is required to get device information. '
'You may be prompted to enter your sudo password.'))
cmd.insert(0, 'sudo')
c =,
stdout = subprocess.PIPE)
if c.returncode != 0:
raise RuntimeError('Could not fetch block ID information')
for p in c.stdout.decode('utf-8').split('\n\n'):
line = [i.strip() for i in p.splitlines()]
d = dict(map(lambda i: i.split('='), line))
if d.get('TYPE') == 'squashfs':
self.blkids[d['DEVNAME']] = d.get('UUID', d['PARTUUID'])
except KeyError:
self.blkids[d['DEVNAME']] = d['UUID']
except KeyError:
cmd = ['/usr/bin/findmnt',
'-T', '/boot']
# if os.geteuid() != 0:
# cmd.insert(0, 'sudo')
c =,
stdout = subprocess.PIPE)
self.dummy_uuid = self.blkids[json.loads(c.stdout.decode('utf-8'))['filesystems'][0]['source']]

def getChecks(self):
# Get the default hashtype (if one exists)
fc = self.cfg.find('{0}fileChecks'.format(self.ns))
default_hashtype = fc.attrib.get('hashtype', 'md5').lower()
for f in fc.findall('{0}file'.format(self.ns)):
# We do /boot files manually in case it isn't specified as a
# separate mount.
file_hashtype = f.attrib.get('hashtype', default_hashtype).lower()
rel_fpath = f.text
fpath = os.path.join('/boot', rel_fpath)
canon_hash = self._get_hash(fpath, file_hashtype)
for esp in self.cfg.findall('{0}partitions/{0}part'.format(self.ns)):
mount = os.path.abspath(os.path.expanduser(esp.attrib['mount']))
new_fpath = os.path.join(mount, rel_fpath)
file_hash = self._get_hash(new_fpath, file_hashtype)
if not file_hashtype or file_hash != canon_hash or not file_hash:
if rel_fpath not in self.syncs:
self.syncs[rel_fpath] = []

def sync(self, dryrun = False, *args, **kwargs):
if not dryrun:
if os.geteuid() != 0:
raise PermissionError('You must be root to write to the appropriate destinations')
# fileChecks are a *lot* easier.
for rel_fpath, mounts in self.syncs.items():
for bootdir in mounts:
source = os.path.join('/boot', rel_fpath)
target = os.path.join(bootdir, rel_fpath)
destdir = os.path.dirname(target)
if not dryrun:
os.makedirs(destdir, exist_ok = True)
shutil.copy2(source, target)
bootmounts = [e.attrib['mount'] for e in self.cfg.findall('{0}partitions/{0}part'.format(self.ns))]
# syncPaths
syncpaths = self.cfg.find('{0}syncPaths'.format(self.ns))
default_hashtype = syncpaths.attrib.get('hashtype', 'md5').lower()
for syncpath in syncpaths.findall('{0}path'.format(self.ns)):
source = os.path.abspath(os.path.expanduser(syncpath.attrib['source']))
target = syncpath.attrib['target']
pattern = syncpath.attrib['pattern']
file_hashtype = syncpath.attrib.get('hashtype', default_hashtype)
# We don't use filecmp for this because:
# - dircmp doesn't recurse
# - the reports/lists don't retain relative paths
# - we can't regex out files
for root, dirs, files in os.walk(source):
prefix = re.sub(r'/?{0}/?'.format(source), '', root)
ptrn = re.compile(pattern)
for f in files:
fname_path = os.path.join(prefix, f)
bootsource = os.path.join(source, fname_path)
boottarget = os.path.join(target, fname_path)
# Compare the contents.
orig_hash = self._get_hash(bootsource, file_hashtype)
for bootdir in bootmounts:
bootfile = os.path.join(bootdir, boottarget)
if not dryrun:
if not os.path.isfile(bootfile):
exist_ok = True)
shutil.copy2(bootsource, bootfile)
dest_hash = self._get_hash(bootfile, file_hashtype)
if not file_hashtype or orig_hash != dest_hash:
shutil.copy2(bootsource, bootfile)

def writeConfs(self, dryrun = False, *args, **kwargs):
if not dryrun:
if os.geteuid() != 0:
raise PermissionError('You must be root to write to the appropriate destinations')
# Get a fresh config in place.
with open(os.devnull, 'wb') as DEVNULL:
c =['/usr/bin/grub-mkconfig',
'-o', '/boot/grub/grub.cfg'],
stdout = DEVNULL,
stderr = DEVNULL)
if c.returncode != 0:
raise RuntimeError('An error occurred when generating the GRUB configuration file.')
with open('/boot/grub/grub.cfg', 'r') as f:
_grubcfg =
for esp in self.cfg.findall('{0}partitions/{0}part'.format(self.ns)):
mount = os.path.abspath(os.path.expanduser(esp.attrib['mount']))
disk = os.path.abspath(os.path.expanduser(esp.attrib['path']))
with open(os.path.join(mount, 'grub/grub.cfg'), 'w') as f:
for line in _grubcfg.splitlines():
# If the array is in a degraded state, this will still let us at LEAST boot.
line = re.sub(r'\s+--hint=[\'"]?mduuid/[a-f0-9]{32}[\'"]?', '', line)
line = re.sub(r'^(\s*set\s+root=){0}$'.format(self.dummy_uuid),
line = re.sub(r'(?<!\=UUID\=){0}'.format(self.dummy_uuid),
line = re.sub('(^\s*|\s+)/boot', '', line)

def _get_hash(self, fpathname, hashtype):
if hashtype.lower() == 'false':
return (None)
if not os.path.isfile(fpathname):
if hashtype not in hashlib.algorithms_available:
raise ValueError('Hashtype {0} is not supported on this system'.format(hashtype))
hasher = getattr(hashlib, hashtype)
fpathname = os.path.abspath(os.path.expanduser(fpathname))
_hash = hasher()
with open(fpathname, 'rb') as fh:
return (_hash.hexdigest())

def _getRunningKernel(self):
_vers = []
# If we change the version string capture in get_file_kernel_ver(),
# this will need to be expanded as well.
# Really we only need to pick one, but #YOLO; why not sanity-check.
# ALL of these should match, hence the reduction with set() down to (what SHOULD be) just 1 item.
_vers = sorted(list(set(_vers)))
if len(_vers) != 1:
raise RuntimeError('Cannot reliably determine current running kernel version!')

def _getInstalledKernel(self):
# Could we maybe remove the dependency for the "magic" module with a struct?
except TypeError:
raise RuntimeError('Tried to find the isKernel with no config set up and parsed')
for f in self.cfg.findall('{0}fileChecks/{0}file'.format(self.ns)):
isKernel = (True
if f.attrib.get('isKernel', 'false').lower() in ('true', '1')
if isKernel:
self.kernelFile = f.text
if self.kernelFile:
with open(os.path.join('/boot', self.kernelFile), 'rb') as fh:
magicname = magic.detect_from_content(
names = [i.strip().split(None, 1) for i in',') if i.strip() != '']
for n in names:
if len(n) != 2:
k, v = n
# Note: this only grabs the version number.
# If we want to get e.g. the build user/machine, date, etc.,
# then we need to do a join. Shouldn't be necessary, though.
if k.lower() == 'version':
self.installedKernVer = v.split(None, 1)[0]

def parseArgs():
args = argparse.ArgumentParser(description = ('Sync files to assist using mdadm RAID arrays with UEFI'))
args.add_argument('-V', '--no-validate',
dest = 'validate',
action = 'store_false',
help = ('If specified, do not attempt to validate the configuration file (-c/--cfg) against'
'its schema (otherwise it is fetched dynamically and requires network connection)'))
args.add_argument('-c', '--cfg',
dest = 'cfg',
default = '/etc/bootsync.xml',
help = ('The path to the bootsync configuration file. Default is /etc/bootsync.xml'))
args.add_argument('-n', '--dry-run',
dest = 'dryrun',
action = 'store_true',
help = ('If specified, don\'t write any changes'))

def main():
args = vars(parseArgs().parse_args())
bs = BootSync(**args)

if __name__ == '__main__':

bootsync.xsd Normal file
View File

@ -0,0 +1,123 @@
<?xml version="1.0" encoding="UTF-8" ?>
<xs:schema xmlns:xs=""

<xs:simpleType name="diskdev">
<xs:restriction base="xs:string">
<xs:pattern value="/dev/([A-Za-z0-9+_-]+/)?[A-Za-z0-9+_-]+[0-9]?"/>

<xs:simpleType name="path">
<xs:restriction base="xs:string">
<xs:pattern value="/([A-Za-z0-9+_.-]+/)*[A-Za-z0-9+_.-]+"/>

<xs:simpleType name="relpath">
<xs:restriction base="xs:string">
<xs:pattern value="([A-Za-z0-9+_.-]+/)*[A-Za-z0-9+_.-]+"/>

<xs:simpleType name="hashtype_choice">
<xs:restriction base="xs:string">
<xs:enumeration value="false"/>
<!-- -->
<xs:enumeration value="md5"/><!-- Not available on FIPS-compliant Python -->
<xs:enumeration value="sha1"/>
<xs:enumeration value="sha224"/>
<xs:enumeration value="sha256"/>
<xs:enumeration value="sha384"/>
<xs:enumeration value="sha512"/>
<xs:enumeration value="blake2b"/>
<xs:enumeration value="blake2s"/>
<!-- The below are only available for more recent versions of system's OpenSSL. -->
<xs:enumeration value="sha3_224"/>
<xs:enumeration value="sha3_256"/>
<xs:enumeration value="sha3_384"/>
<xs:enumeration value="sha3_512"/>
<xs:enumeration value="shake_128"/>
<xs:enumeration value="shake_256"/>

<xs:element name="bootsync">
<xs:element name="partitions" maxOccurs="1" minOccurs="1">
<xs:element name="part" minOccurs="2" maxOccurs="unbounded">
<xs:attribute name="path" type="diskdev" use="required"/>
<xs:attribute name="mount" type="path" use="required"/>
<xs:unique name="partition_unique_mount">
<xs:selector xpath="bootsync:part"/>
<xs:field xpath="@mount"/>
<xs:unique name="partition_unique_path">
<xs:selector xpath="bootsync:part"/>
<xs:field xpath="@path"/>
<xs:element name="fileChecks" maxOccurs="1" minOccurs="0">
<xs:element name="file" maxOccurs="unbounded" minOccurs="1">
<xs:extension base="relpath">
<xs:attribute name="isKernel" type="xs:boolean"
use="optional" default="false"/>
<xs:attribute name="hashtype" type="hashtype_choice"
use="optional" default="md5"/>
<xs:attribute name="hashtype" type="hashtype_choice" use="optional" default="md5"/>
<xs:unique name="filechk_unique">
<xs:selector xpath="bootsync:file"/>
<xs:field xpath="."/>
<xs:element name="syncPaths" maxOccurs="1" minOccurs="1">
<xs:element name="path" maxOccurs="unbounded" minOccurs="1">
<xs:attribute name="source" type="path" use="required"/>
<xs:attribute name="target" type="relpath" use="required"/>
<!-- TODO: make this optional? -->
<xs:attribute name="pattern" type="xs:string" use="required"/>
<xs:attribute name="hashtype" type="hashtype_choice"
use="optional" default="md5"/>
<xs:attribute name="hashtype" type="hashtype_choice" use="optional" default="md5"/>
<xs:unique name="syncpath_unique_source">
<xs:selector xpath="bootsync:path"/>
<xs:field xpath="@source"/>
<xs:unique name="syncpath_unique_target">
<xs:selector xpath="bootsync:path"/>
<xs:field xpath="@target"/>

prep.txt Normal file
View File

@ -0,0 +1,57 @@
0.) Comment out all /boot mounts in /etc/fstab and umount /boot if mounted as a separate mountpoint.
You want to use *the /boot on your / mount*.

1.) Prepare each target partition (partitions/part below) as an ESP

2.) Install GRUB2 to *each ESP*. See sample.config.xml for context for the below examples.

grub-install \
--boot-directory=/mnt/boot1 \
--bootloader-id=Arch \
--efi-directory=/mnt/boot1/ \
--target=x86_64-efi \
--no-nvram \

grub-install \
--boot-directory=/mnt/boot2 \
--bootloader-id="Arch" \
--efi-directory=/mnt/boot2/ \
--target=x86_64-efi \
--no-nvram \

# These are not strictly necessary, as the same path is used in efibootmgr for the primary and the fallback.
# grub-install \
# --boot-directory=/mnt/boot1 \
# --bootloader-id="Arch (Fallback)" \
# --efi-directory=/mnt/boot1/ \
# --target=x86_64-efi \
# --no-nvram \
# --recheck
# grub-install \
# --boot-directory=/mnt/boot2 \
# --bootloader-id="Arch (Fallback)" \
# --efi-directory=/mnt/boot2/ \
# --target=x86_64-efi \
# --no-nvram \
# --recheck

3.) Prepare the ESPs. See sample.config.xml for context for the below examples.

efibootmgr \
--create \
--disk /dev/sdd \
--part 1 \
--loader /EFI/Arch/grubx64.efi \
--label "Arch (Fallback)"

efibootmgr \
--create \
--disk /dev/sdb \
--part 1 \
--loader /EFI/Arch/grubx64.efi \
--label "Arch"

sample.config.xml Normal file
View File

@ -0,0 +1,90 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!-- This example assumes Grub2 and UEFI. It should be flexible enough to tweak for other use cases
if you know what the hell you're doing. -->
<bootsync xmlns:xsi=""
<!-- The actual EFI System Partitions (ESP). At least two are required. -->
<!-- Each drive should be prepared ahead of time with efibootmgr and grub-install (see prep.txt). -->
<!-- These should all be unique values.
Don't do something dumb like mount different partitions on the same mountpoint or the
the same partition on multiple mountpoints. -->
<!-- Future versions of this script may support things like UUIDs or labels as an alternative to path=,
but in the meanwhile it relies on device paths explicitly. -->
<part path="/dev/sdb1" mount="/mnt/boot1"/>
<part path="/dev/sdd1" mount="/mnt/boot2"/>
If fileChecks isn't provided, we will still sync syncPaths (which IS required).
These files will be evaluated to and synced to the ESP partitions with the same relative path.
i.e. (part[@mount])/(file)

fileChecks[@hashtype] attribute is used to checksum all <file> children,
unless they specify their own hashtype attribute (the default is hashtype="md5" for speed optimizations).

Guaranteed valid hashtype values are:


If you have a non-FIPS-compliant Python (you very most likely do), the following hashtype is also available:


If you have a recent enough OpenSSL (and python3), you have the additional hashtype values available:


Additionally, the value "false" is always available to disable hashing for a specific <file> object or to set
the default. No checksumming will be done in this case and the file will always be overwritten on every run.

This is *highly not recommended* for solid-state disks or if you have large files you plan on syncing to
the alternate ESPs (e.g. loop-mounted ISO/IMG files).

"isKernel" is by default false, but if true it is treated specially to get a kernel version.
This is to notify you if a reboot is required. If no <file> item is isKernel="true", no reboot requirement
detection will be done.
Only *the last* <file> with isKernel="true" listed is used as the kernel identifier.
<fileChecks hashtype="sha1">
<!-- RELATIVE paths to /boot on your / mount. -->
<file isKernel="true" hashtype="sha512">vmlinuz-linux</file>
These are system paths to sync to the ESPs. They are synced recursively.

"hashtype" is also supported for syncPaths and path objects just as the same with fileChecks
and file objects (see above).

"source" should be absolute.
"target" should be relative (to the ESP mount).
"pattern" is a regex to match to restrict *filenames* to sync. Use ".*" for all files.
<syncPaths hashtype="sha1">
<!-- For example, these are grub theme files. -->
<path source="/usr/share/grub/themes" target="grub/themes" pattern=".*"/>
<!-- These are grub modules - specifically, UEFI. -->
<path source="/usr/lib/grub/x86_64-efi" target="grub/x86_64-efi" pattern="^.*\.(mod|lst|sh)$"/>
<!-- And these are ISO files, in case you're like me and do loop mounting in Grub for rescue situations.
(e.g. -->
<!-- <path hashtype="false" source="/boot/iso" target="iso" pattern="^.*\.(iso|img)$"/> -->
<path hashtype="md5" source="/boot/iso" target="iso" pattern=".*"/>