Files
DetectionLab/Vagrant/resources/malcolm/shared/bin/sensor-capture-disk-config.py
2021-08-06 10:35:01 +02:00

487 lines
24 KiB
Python
Executable File

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Copyright (c) 2021 Battelle Energy Alliance, LLC. All rights reserved.
###################################################################################################
# Detect, partition, and format devices to be used for sensor packet/log captures.
#
# Run the script with --help for options
###################################################################################################
import os
import re
import glob
import sys
import uuid
import argparse
import fileinput
from collections import defaultdict
from sensorcommon import *
from fstab import Fstab
MINIMUM_CAPTURE_DEVICE_BYTES = 100*1024*1024*1024 # 100GiB
CAPTURE_MOUNT_ROOT_PATH = "/capture"
CAPTURE_MOUNT_PCAP_DIR = "pcap"
CAPTURE_MOUNT_ZEEK_DIR = "bro"
FSTAB_FILE = "/etc/fstab"
CRYPTTAB_FILE = "/etc/crypttab"
CAPTURE_GROUP_OWNER = "netdev"
CAPTURE_USER_UID = 1000
CAPTURE_DIR_PERMS = 0o750
CAPTURE_SUBDIR_PERMS = 0o770
SENSOR_CAPTURE_CONFIG = '/opt/sensor/sensor_ctl/control_vars.conf'
CAPTURE_CRYPT_KEYFILE = '/etc/capture_crypt.key'
CAPTURE_CRYPT_KEYFILE_PERMS = 0o600
CAPTURE_CRYPT_DEV_PREFIX = 'capture_vault_'
debug = False
###################################################################################################
# used to map output of lsblk
class PartitionInfo:
__slots__ = ('device', 'partition', 'mapper', 'uuid', 'mount')
def __init__(self, device=None, partition=None, mapper=None, uuid=None, mount=None):
self.device = device
self.partition = partition
self.mapper = mapper
self.uuid = uuid
self.mount = mount
###################################################################################################
# get interactive user response to Y/N question
def YesOrNo(question):
reply = str(input(question+' (y/n): ')).lower().strip()
if reply[0] == 'y':
return True
elif reply[0] == 'n':
return False
else:
return YesOrNo(question)
###################################################################################################
# create a name we can use for a mapper device name for encryption
def CreateMapperName(device):
return f"{CAPTURE_CRYPT_DEV_PREFIX}{''.join([c if c.isalnum() else '_' for c in remove_prefix(device, '/dev/')])}"
def CreateMapperDeviceName(device):
return f"/dev/mapper/{CreateMapperName(device)}"
###################################################################################################
###################################################################################################
# determine if a device (eg., sda) is an internal (True) or removable (False) device
def IsInternalDevice(name):
rootdir_pattern = re.compile(r'^.*?/devices')
removableFlagFile = '/sys/block/%s/device/block/%s/removable' % (name, name)
if not os.path.isfile(removableFlagFile):
removableFlagFile = '/sys/block/%s/removable' % (name)
if os.path.isfile(removableFlagFile):
with open(removableFlagFile) as f:
if f.read(1) == '1':
return False
path = rootdir_pattern.sub('', os.readlink('/sys/block/%s' % name))
hotplug_buses = ("usb", "ieee1394", "mmc", "pcmcia", "firewire")
for bus in hotplug_buses:
if os.path.exists('/sys/bus/%s' % bus):
for device_bus in os.listdir('/sys/bus/%s/devices' % bus):
device_link = rootdir_pattern.sub('', os.readlink('/sys/bus/%s/devices/%s' % (bus, device_bus)))
if re.search(device_link, path):
return False
return True
###################################################################################################
# return a list of internal storage devices (eg., [sda, sdb])
def GetInternalDevices():
devs = []
for path in glob.glob('/sys/block/*/device'):
name = re.sub('.*/(.*?)/device', r'\g<1>', path)
if IsInternalDevice(name):
devs.append(name)
return devs
###################################################################################################
# given a device (any file descriptor, actually) return size in bytes by seeking to the end
def GetDeviceSize(device):
fd = os.open(device, os.O_RDONLY)
try:
return os.lseek(fd, 0, os.SEEK_END)
finally:
os.close(fd)
###################################################################################################
# main
###################################################################################################
def main():
# to parse fdisk output, look for partitions after partitions header line
fdisk_pars_begin_pattern = re.compile(r'^Device\s+Start\s+End\s+Sectors\s+Size\s+Type\s*$')
# to parse partitions from fdisk output after parted creates partition table
fdisk_par_pattern = re.compile(r'^(?P<device>\S+)\s+(?P<start>\d+)\s+(?P<end>\d+)\s+(?P<sectors>\d+)\s+(?P<size>\S+)\s+(?P<type>.*)$')
# extract arguments from the command line
parser = argparse.ArgumentParser(description='sensor-capture-disk-config.py', add_help=False, usage='sensor-capture-disk-config.py [options]')
parser.add_argument('-i', '--interactive', dest='interactive', type=str2bool, nargs='?', const=True, default=False, help="Interactive")
parser.add_argument('-u', '--umount', dest='umount', type=str2bool, nargs='?', const=True, default=False, help="Unmount capture directories before determining candidate drives")
parser.add_argument('-v', '--verbose', dest='debug', type=str2bool, nargs='?', const=True, default=False, help="Verbose output")
parser.add_argument('-n', '--dry-run', dest='dryrun', type=str2bool, nargs='?', const=True, default=False, help="Dry run (don't perform actions)")
parser.add_argument('-c', '--crypto', dest='encrypt', type=str2bool, nargs='?', const=True, default=False, help="Encrypt formatted volumes")
try:
parser.error = parser.exit
args = parser.parse_args()
except SystemExit:
parser.print_help()
exit(2)
debug = args.debug
if debug: eprint(f"Arguments: {sys.argv[1:]}")
if debug: eprint(f"Arguments: {args}")
# unmount existing mounts if requested
if args.umount and (not args.dryrun):
if (not args.interactive) or YesOrNo(f'Unmount any mounted capture path(s)?'):
if debug: eprint(f"Attempting unmount of capture path(s)...")
run_process(f"umount {os.path.join(CAPTURE_MOUNT_ROOT_PATH, CAPTURE_MOUNT_PCAP_DIR)}")
run_process(f"umount {os.path.join(CAPTURE_MOUNT_ROOT_PATH, CAPTURE_MOUNT_ZEEK_DIR)}")
run_process(f"umount {CAPTURE_MOUNT_ROOT_PATH}")
# also luksClose any luks volumes devices we might have set up
for cryptDev in [remove_prefix(x, '/dev/mapper/') for x in glob.glob(f"/dev/mapper/{CAPTURE_CRYPT_DEV_PREFIX}*")]:
if debug: eprint(f"Running crypsetup luksClose on {cryptDev}...")
_, cryptOut = run_process(f"/sbin/cryptsetup --verbose luksClose {cryptDev}", stdout=True, stderr=True, timeout=300)
if debug:
for line in cryptOut:
eprint(f"\t{line}")
_, reloadOut = run_process(f"systemctl daemon-reload")
# check existing mounts, if the capture path(s) are already mounted, then abort
with open('/proc/mounts', 'r') as f:
for line in f.readlines():
mountDetails = line.split()
if (len(mountDetails) >= 2):
mountPoint = mountDetails[1]
if mountPoint.startswith(CAPTURE_MOUNT_ROOT_PATH):
eprint(f"It appears there is already a device mounted under {CAPTURE_MOUNT_ROOT_PATH} at {mountPoint}.")
eprint(f"If you wish to continue, you may run this script with the '-u|--umount' option to umount first.")
eprint()
parser.print_help()
exit(2)
# get physical disks, partitions, device maps, and any mountpoints and UUID associated
allDisks = defaultdict(list)
if debug: eprint(f"Block devices:")
for device in GetInternalDevices():
ecode, deviceTree = run_process(f'/bin/lsblk -o name,uuid,mountpoint --paths --noheadings /dev/{device}', stdout=True, stderr=False)
if (ecode == 0):
currentDev = None
currentPar = None
currentMapper = None
for line in deviceTree:
line = line.rstrip()
if (len(line) > 0):
if debug: eprint(f"\t{line}")
if (line == f"/dev/{device}"):
currentDev = line
currentPar = None
currentMapper = None
allDisks[currentDev].append(PartitionInfo(device=currentDev))
elif (currentDev is not None) and (line[2:2+len(f"/dev/{device}")] == f"/dev/{device}"):
parInfo = f"/{line.split('/', 1)[-1]}".split()
if (len(parInfo) >= 2):
currentPar = PartitionInfo(device=currentDev, partition=parInfo[0], uuid=parInfo[1], mount=parInfo[2] if (len(parInfo) > 2) else None)
currentMapper = None
allDisks[currentDev].append(currentPar)
elif (currentPar is not None) and ("/dev/mapper/" in line):
parInfo = f"/{line.split('/', 1)[-1]}".split()
if (len(parInfo) >= 2):
currentMapper = PartitionInfo(device=currentDev, partition=currentPar.partition, mapper=parInfo[0], uuid=parInfo[1], mount=parInfo[2] if (len(parInfo) > 2) else None)
allDisks[currentDev].append(currentMapper)
# at this point allDisks might look like this:
# defaultdict(<class 'list'>,
# {'/dev/sda': [PartitionInfo(device='/dev/sda', partition=None, mapper=None, uuid=None, mount=None),
# PartitionInfo(device='/dev/sda', partition='/dev/sda1', mapper=None, uuid='B42B-7414', mount=None),
# PartitionInfo(device='/dev/sda', partition='/dev/sda2', mapper=None, uuid='6DF8-D966', mount='/boot/efi'),
# PartitionInfo(device='/dev/sda', partition='/dev/sda3', mapper=None, uuid='f6b575e4-0ec2-47ab-8d0a-9d677ac4fe3c', mount='/boot'),
# PartitionInfo(device='/dev/sda', partition='/dev/sda4', mapper=None, uuid='Lmx30A-U9qR-kDZF-WOju-zlOi-otrR-WNjh7j', mount=None),
# PartitionInfo(device='/dev/sda', partition='/dev/sda4', mapper='/dev/mapper/main-swap', uuid='00987200-7157-45d1-a233-90cbb22554aa', mount='[SWAP]'),
# PartitionInfo(device='/dev/sda', partition='/dev/sda4', mapper='/dev/mapper/main-root', uuid='b53ea5c3-8771-4717-9d3d-ef9c5b18bbe4', mount='/'),
# PartitionInfo(device='/dev/sda', partition='/dev/sda4', mapper='/dev/mapper/main-var', uuid='45aec3eb-68be-4eaa-bf79-de3f2a85c103', mount='/var'),
# PartitionInfo(device='/dev/sda', partition='/dev/sda4', mapper='/dev/mapper/main-audit', uuid='339ee49c-0e45-4510-8447-55f46f2a3653', mount='/var/log/audit'),
# PartitionInfo(device='/dev/sda', partition='/dev/sda4', mapper='/dev/mapper/main-tmp', uuid='b305d781-263f-4016-8422-301f61c11472', mount='/tmp'),
# PartitionInfo(device='/dev/sda', partition='/dev/sda4', mapper='/dev/mapper/main-opt', uuid='5e7cbfb8-760e-4526-90d5-ab103ae626a5', mount='/opt'),
# PartitionInfo(device='/dev/sda', partition='/dev/sda4', mapper='/dev/mapper/main-home', uuid='1b089fc0-f3a4-400b-955c-d3fa6b1e2a5f', mount='/home')],
# '/dev/sdb': [PartitionInfo(device='/dev/sdb', partition=None, mapper=None, uuid=None, mount=None)]})
candidateDevs = []
formattedDevs = []
# determine candidate storage devices, which are any disks that do not have a mount point associated with
# it in any way, (no partitions, mappings, etc. that are mounted) and is at least 100 gigabytes
for device, entries in allDisks.items():
deviceMounts = list(set([par.mount for par in entries if par.mount is not None]))
if (len(deviceMounts) == 0) and (GetDeviceSize(device) >= MINIMUM_CAPTURE_DEVICE_BYTES):
candidateDevs.append(device)
# sort candidate devices largest to smallest
candidateDevs = sorted(candidateDevs, key=lambda x: GetDeviceSize(x), reverse=True)
if debug: eprint(f"Device candidates: {[(x, sizeof_fmt(GetDeviceSize(x))) for x in candidateDevs]}")
if len(candidateDevs) > 0:
if args.encrypt:
# create keyfile (will be on the encrypted system drive, and used to automatically unlock the encrypted capture drives)
with open(CAPTURE_CRYPT_KEYFILE, 'wb') as f:
f.write(os.urandom(4096))
os.chown(CAPTURE_CRYPT_KEYFILE, 0, 0)
os.chmod(CAPTURE_CRYPT_KEYFILE, CAPTURE_CRYPT_KEYFILE_PERMS)
# partition/format each candidate device
for device in candidateDevs:
# we only need at most two drives (one for pcap, one for zeek), or at least one
if (len(formattedDevs) >= 2): break
if (not args.interactive) or YesOrNo(f'Partition and format {device}{" (dry-run)" if args.dryrun else ""}?'):
if args.dryrun:
eprint(f"Partitioning {device} (dry run only)...")
eprint(f'\t/sbin/parted --script --align optimal {device} -- mklabel gpt \\\n\t\tunit mib mkpart primary 1 100%')
ecode = 0
partedOut = []
else:
# use parted to create a gpt partition table with a single partition consuming 100% of the disk minus one megabyte at the beginning
if debug: eprint(f"Partitioning {device}...")
ecode, partedOut = run_process(f'/sbin/parted --script --align optimal {device} -- mklabel gpt \\\n unit mib mkpart primary 1 100%', stdout=True, stderr=True, timeout=300)
if debug: eprint(partedOut)
if (ecode == 0):
if debug: eprint(f"Success partitioning {device}")
# get the list of partitions from the newly partitioned device (should be just one)
_, fdiskOut = run_process(f'fdisk -l {device}')
pars = []
parsList = False
for line in fdiskOut:
if debug: eprint(f"\t{line}")
if (not parsList) and fdisk_pars_begin_pattern.search(line):
parsList = True
elif parsList:
match = fdisk_par_pattern.search(line)
if match is not None:
pars.append(match.group('device'))
if len(pars) == 1:
parDev = pars[0]
parUuid = str(uuid.uuid4())
parMapperDev = None
okToFormat = True
if args.encrypt:
okToFormat = False
# remove this device from /etc/crypttab
if os.path.isfile(CRYPTTAB_FILE):
with fileinput.FileInput(CRYPTTAB_FILE, inplace=True, backup='.bak') as f:
for line in f:
line = line.rstrip("\n")
if line.startswith(f"{CreateMapperName(parDev)}"):
if debug: eprint(f"removed {line} from {CRYPTTAB_FILE}")
else:
print(line)
_, reloadOut = run_process(f"systemctl daemon-reload")
# for good measure, run luksErase in case it was a previous luks volume
if debug: eprint(f"Running crypsetup luksErase on {parDev}...")
_, cryptOut = run_process(f"/sbin/cryptsetup --verbose --batch-mode luksErase {parDev}", stdout=True, stderr=True, timeout=600)
if debug:
for line in cryptOut:
eprint(f"\t{line}")
_, reloadOut = run_process(f"systemctl daemon-reload")
# luks volume creation
# format device as a luks volume
if debug: eprint(f"Running crypsetup luksFormat on {device}...")
ecode, cryptOut = run_process(f"/sbin/cryptsetup --verbose --batch-mode luksFormat {parDev} --uuid='{parUuid}' --key-file {CAPTURE_CRYPT_KEYFILE}", stdout=True, stderr=True, timeout=3600)
if debug or (ecode != 0):
for line in cryptOut:
eprint(f"\t{line}")
if (ecode == 0):
# open the luks volume in /dev/mapper/
if debug: eprint(f"Running crypsetup luksOpen on {device}...")
parMapperDev = CreateMapperDeviceName(parDev)
ecode, cryptOut = run_process(f"/sbin/cryptsetup --verbose luksOpen {parDev} {CreateMapperName(parDev)} --key-file {CAPTURE_CRYPT_KEYFILE}", stdout=True, stderr=True, timeout=180)
if debug or (ecode != 0):
for line in cryptOut:
eprint(f"\t{line}")
if (ecode == 0):
# we have everything we need for luks
okToFormat = True
else:
eprint(f"Error {ecode} opening LUKS on {parDev}, giving up on {device}")
else:
eprint(f"Error {ecode} formatting LUKS on {parDev}, giving up on {device}")
# format the partition as an XFS file system
if okToFormat:
if debug: eprint(f'Created {parDev}, assigning {parUuid}')
if args.encrypt:
formatCmd = f"/sbin/mkfs.xfs -f {parMapperDev}"
else:
formatCmd = f"/sbin/mkfs.xfs -f -m uuid='{parUuid}' {parDev}"
if debug: eprint(f"Formatting: {formatCmd}")
ecode, mkfsOut = run_process(formatCmd, stdout=True, stderr=True, timeout=3600)
if debug:
for line in mkfsOut:
eprint(f"\t{line}")
if (ecode == 0):
eprint(f"Success formatting {parMapperDev if args.encrypt else parDev}")
formattedDevs.append(PartitionInfo(device=device, partition=parDev, mapper=parMapperDev, uuid=parUuid, mount=None))
else:
eprint(f"Error {ecode} formatting {formatPath}, giving up on {device}")
else:
eprint(f"Error partitioning {device}, unexpected partitions after running parted, giving up on {device}")
elif (ecode != 0):
eprint(f"Error {ecode} partitioning {device}, giving up on {device}")
# now that we have formatted our device(s), decide where they're going to mount (these are already sorted)
if len(formattedDevs) >= 2:
formattedDevs[0].mount = os.path.join(CAPTURE_MOUNT_ROOT_PATH, CAPTURE_MOUNT_PCAP_DIR)
formattedDevs[1].mount = os.path.join(CAPTURE_MOUNT_ROOT_PATH, CAPTURE_MOUNT_ZEEK_DIR)
elif len(formattedDevs) == 1:
formattedDevs[0].mount = CAPTURE_MOUNT_ROOT_PATH
if debug: eprint(formattedDevs)
# mountpoints are probably not already mounted, but this will make sure
run_process(f"umount {os.path.join(CAPTURE_MOUNT_ROOT_PATH, CAPTURE_MOUNT_PCAP_DIR)}")
run_process(f"umount {os.path.join(CAPTURE_MOUNT_ROOT_PATH, CAPTURE_MOUNT_ZEEK_DIR)}")
run_process(f"umount {CAPTURE_MOUNT_ROOT_PATH}")
_, reloadOut = run_process(f"systemctl daemon-reload")
# clean out any previous fstab entries that might be interfering from previous configurations
if Fstab.remove_by_mountpoint(os.path.join(CAPTURE_MOUNT_ROOT_PATH, CAPTURE_MOUNT_PCAP_DIR), path=FSTAB_FILE):
if debug: eprint(f"Removed previous {os.path.join(CAPTURE_MOUNT_ROOT_PATH, CAPTURE_MOUNT_PCAP_DIR)} mount from {FSTAB_FILE}")
if Fstab.remove_by_mountpoint(os.path.join(CAPTURE_MOUNT_ROOT_PATH, CAPTURE_MOUNT_ZEEK_DIR), path=FSTAB_FILE):
if debug: eprint(f"Removed previous {os.path.join(CAPTURE_MOUNT_ROOT_PATH, CAPTURE_MOUNT_ZEEK_DIR)} mount from {FSTAB_FILE}")
if Fstab.remove_by_mountpoint(CAPTURE_MOUNT_ROOT_PATH, path=FSTAB_FILE):
if debug: eprint(f"Removed previous {CAPTURE_MOUNT_ROOT_PATH} mount from {FSTAB_FILE}")
# reload tab files with systemctl
_, reloadOut = run_process(f"systemctl daemon-reload")
# get the GID of the group of the user(s) that will be doing the capture
try:
ecode, guidGetOut = run_process(f"getent group {CAPTURE_GROUP_OWNER}", stdout=True, stderr=True)
if (ecode == 0) and (len(guidGetOut) > 0):
netdevGuid = int(guidGetOut[0].split(':')[2])
else:
netdevGuid = -1
except:
netdevGuid = -1
# rmdir any mount directories that might be interfering from previous configurations
if os.path.isdir(CAPTURE_MOUNT_ROOT_PATH):
for root, dirs, files in os.walk(CAPTURE_MOUNT_ROOT_PATH, topdown=False):
for name in dirs:
if debug: eprint(f"Removing {os.path.join(root, name)}")
os.rmdir(os.path.join(root, name))
if debug: eprint(f"Removing {CAPTURE_MOUNT_ROOT_PATH}")
os.rmdir(CAPTURE_MOUNT_ROOT_PATH)
if debug: eprint(f"Creating {CAPTURE_MOUNT_ROOT_PATH}")
os.makedirs(CAPTURE_MOUNT_ROOT_PATH, exist_ok=True)
os.chown(CAPTURE_MOUNT_ROOT_PATH, -1, netdevGuid)
os.chmod(CAPTURE_MOUNT_ROOT_PATH, CAPTURE_DIR_PERMS)
# add crypttab entries
if args.encrypt:
with open(CRYPTTAB_FILE, 'a' if os.path.isfile(CRYPTTAB_FILE) else 'w') as f:
for par in formattedDevs:
crypttabLine = f"{CreateMapperName(par.partition)} UUID={par.uuid} {CAPTURE_CRYPT_KEYFILE} luks\n"
f.write(crypttabLine)
if debug: eprint(f'Added "{crypttabLine}" to {CRYPTTAB_FILE}')
# recreate mount directories and add fstab entries
for par in formattedDevs:
if debug: eprint(f"Creating {par.mount}")
os.makedirs(par.mount, exist_ok=True)
if args.encrypt:
entry = Fstab.add(device=f"{par.mapper}", mountpoint=par.mount, options=f"defaults,inode64,noatime,rw,auto,user,x-systemd.device-timeout=600s", fs_passno=2, filesystem='xfs', path=FSTAB_FILE)
else:
entry = Fstab.add(device=f"UUID={par.uuid}", mountpoint=par.mount, options=f"defaults,inode64,noatime,rw,auto,user,x-systemd.device-timeout=600s", fs_passno=2, filesystem='xfs', path=FSTAB_FILE)
eprint(f'Added "{entry}" to {FSTAB_FILE} for {par.partition}')
# reload tab files with systemctl
_, reloadOut = run_process(f"systemctl daemon-reload")
# mount the partitions and create a directory with user permissions
for par in formattedDevs:
ecode, mountOut = run_process(f"mount {par.mount}")
if (ecode == 0):
if debug: eprint(f'Mounted {par.partition} at {par.mount}')
userDirs = []
if par.mount == CAPTURE_MOUNT_ROOT_PATH:
# only one drive, so we're mounted at /capture, create user directories for CAPTURE_MOUNT_ZEEK_DIR and CAPTURE_MOUNT_PCAP_DIR
userDirs.append(os.path.join(par.mount, CAPTURE_MOUNT_PCAP_DIR))
userDirs.append(os.path.join(par.mount, CAPTURE_MOUNT_ZEEK_DIR))
else:
# we're mounted somewhere *underneath* /capture, so create a user-writeable subdirectory where we are
userDirs.append(os.path.join(par.mount, 'capture'))
# set permissions on user dirs
pcapDir = None
zeekDir = None
for userDir in userDirs:
os.makedirs(userDir, exist_ok=True)
os.chown(userDir, CAPTURE_USER_UID, netdevGuid)
os.chmod(userDir, CAPTURE_SUBDIR_PERMS)
if debug: eprint(f'Created "{userDir}" for writing by capture user')
if f"{os.path.sep}{CAPTURE_MOUNT_PCAP_DIR}{os.path.sep}" in userDir:
pcapDir = userDir
elif f"{os.path.sep}{CAPTURE_MOUNT_ZEEK_DIR}{os.path.sep}" in userDir:
zeekDir = userDir
# replace capture paths in-place in SENSOR_CAPTURE_CONFIG
if os.path.isfile(SENSOR_CAPTURE_CONFIG):
capture_re = re.compile(r"\b(?P<key>PCAP_PATH|ZEEK_LOG_PATH)\s*=\s*.*?$")
with fileinput.FileInput(SENSOR_CAPTURE_CONFIG, inplace=True, backup='.bak') as f:
for line in f:
line = line.rstrip("\n")
log_path_match = capture_re.search(line)
if (log_path_match is not None):
if (log_path_match.group('key') == 'PCAP_PATH') and (pcapDir is not None):
print(capture_re.sub(r"\1=%s" % pcapDir, line))
elif (log_path_match.group('key') == 'ZEEK_LOG_PATH') and (zeekDir is not None):
print(capture_re.sub(r"\1=%s" % zeekDir, line))
else:
print(line)
else:
print(line)
else:
eprint(f"Error {ecode} mounting {par.partition}")
else:
eprint(f"Could not find any unmounted devices greater than 100GB, giving up")
if __name__ == '__main__':
main()