core-admin-client/qubesadmin/tools/qvm_template.py

434 lines
16 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
import argparse
import datetime
import enum
import os
import shutil
import subprocess
import sys
import tempfile
import time
import qubesadmin
import qubesadmin.tools
import rpm
2020-07-11 15:42:58 +02:00
import tqdm
import xdg.BaseDirectory
PATH_PREFIX = '/var/lib/qubes/vm-templates'
TEMP_DIR = '/var/tmp'
PACKAGE_NAME_PREFIX = 'qubes-template-'
CACHE_DIR = os.path.join(xdg.BaseDirectory.xdg_cache_home, 'qvm-template')
2020-07-11 16:31:38 +02:00
UNVERIFIED_SUFFIX = '.unverified'
def qubes_release():
2020-07-08 20:38:36 +02:00
if os.path.exists('/usr/share/qubes/marker-vm'):
2020-07-11 17:12:57 +02:00
with open('/usr/share/qubes/marker-vm', 'r') as fd:
2020-07-08 20:38:36 +02:00
# Get last line (in the format `x.x`)
2020-07-11 17:12:57 +02:00
return fd.readlines()[-1].strip()
2020-07-08 20:38:36 +02:00
return subprocess.check_output(['lsb_release', '-sr'],
encoding='UTF-8').strip()
parser = argparse.ArgumentParser(description='Qubes Template Manager')
2020-07-11 17:12:57 +02:00
parser.add_argument('operation', type=str)
parser.add_argument('templates', nargs='*')
# qrexec related
parser.add_argument('--repo-files', action='append',
default=['/etc/yum.repos.d/qubes-templates.repo'],
help='Specify files containing DNF repository configuration.')
parser.add_argument('--updatevm', default='sys-firewall',
help='Specify VM to download updates from.')
# DNF-related options
parser.add_argument('--enablerepo', action='append',
help='Enable additional repositories.')
parser.add_argument('--disablerepo', action='append',
help='Disable certain repositories.')
parser.add_argument('--repoid', action='append',
help='Enable just specific repositories.')
parser.add_argument('--releasever', default=qubes_release(),
help='Override distro release version.')
parser.add_argument('--cachedir', default=CACHE_DIR,
help='Override cache directory.')
# qvm-template install
parser.add_argument('--nogpgcheck', action='store_true',
help='Disable signature checks.')
parser.add_argument('--allow-pv', action='store_true',
help='Allow setting virt_mode to pv in configuration file.')
# qvm-template download
parser.add_argument('--downloaddir', default='.',
help='Override download directory.')
parser.add_argument('--retries', default=5, type=int,
help='Override number of retries for downloads.')
# qvm-template list
parser.add_argument('--all', action='store_true')
parser.add_argument('--installed', action='store_true')
parser.add_argument('--available', action='store_true')
parser.add_argument('--extras', action='store_true')
parser.add_argument('--upgrades', action='store_true')
class TemplateState(enum.Enum):
INSTALLED = 'installed'
AVAILABLE = 'available'
EXTRA = 'extra'
UPGRADABLE = 'upgradable'
# NOTE: Verifying RPMs this way is prone to TOCTOU. This is okay for local
# files, but may create problems if multiple instances of `qvm-template` are
# downloading the same file, so a lock is needed in that case.
def verify_rpm(path, nogpgcheck=False, transaction_set=None):
if transaction_set is None:
transaction_set = rpm.TransactionSet()
2020-07-11 17:12:57 +02:00
with open(path, 'rb') as fd:
try:
2020-07-11 17:12:57 +02:00
hdr = transaction_set.hdrFromFdno(fd)
if hdr[rpm.RPMTAG_SIGSIZE] is None \
and hdr[rpm.RPMTAG_SIGPGP] is None \
and hdr[rpm.RPMTAG_SIGGPG] is None:
return nogpgcheck
except rpm.error as e:
if str(e) == 'public key not trusted' \
or str(e) == 'public key not available':
return nogpgcheck
return False
return True
def get_package_hdr(path, transaction_set=None):
if transaction_set is None:
transaction_set = rpm.TransactionSet()
2020-07-11 17:12:57 +02:00
with open(path, 'rb') as fd:
hdr = transaction_set.hdrFromFdno(fd)
return hdr
def extract_rpm(name, path, target):
rpm2cpio = subprocess.Popen(['rpm2cpio', path], stdout=subprocess.PIPE)
# `-D` is GNUism
cpio = subprocess.Popen([
'cpio',
'-idm',
'-D',
target,
'.%s/%s/*' % (PATH_PREFIX, name)
], stdin=rpm2cpio.stdout, stdout=subprocess.DEVNULL)
return rpm2cpio.wait() == 0 and cpio.wait() == 0
def parse_config(path):
2020-07-11 17:12:57 +02:00
with open(path, 'r') as fd:
return dict(line.rstrip('\n').split('=', 1) for line in fd)
def install(args, app):
# TODO: Lock, mentioned in the note above
transaction_set = rpm.TransactionSet()
rpm_list = []
for template in args.templates:
if template.endswith('.rpm'):
if not os.path.exists(template):
parser.error('RPM file \'%s\' not found.' % template)
rpm_list.append(template)
os.makedirs(args.cachedir, exist_ok=True)
dl_list = get_dl_list(args, app)
dl_list_copy = dl_list.copy()
# Verify that the templates are not yet installed
for name, (ver, _) in dl_list.items():
if name in app.domains:
print(('Template \'%s\' already installed, skipping...'
' (You may want to use the {reinstall,upgrade,downgrade}'
' operations.)') % name, file=sys.stderr)
del dl_list_copy[name]
else:
version_str = build_version_str(ver)
target_file = \
'%s%s-%s.rpm' % (PACKAGE_NAME_PREFIX, name, version_str)
rpm_list.append(os.path.join(args.cachedir, target_file))
dl_list = dl_list_copy
2020-07-11 16:31:38 +02:00
download(args, app, path_override=args.cachedir,
dl_list=dl_list, suffix=UNVERIFIED_SUFFIX)
2020-07-11 17:12:57 +02:00
for rpmfile in rpm_list:
2020-07-11 16:31:38 +02:00
path = rpmfile + UNVERIFIED_SUFFIX
if not verify_rpm(path, args.nogpgcheck, transaction_set):
2020-07-11 17:12:57 +02:00
parser.error('Package \'%s\' verification failed.' % rpmfile)
2020-07-11 16:31:38 +02:00
os.rename(path, rpmfile)
for rpmfile in rpm_list:
with tempfile.TemporaryDirectory(dir=TEMP_DIR) as target:
package_hdr = get_package_hdr(rpmfile)
package_name = package_hdr[rpm.RPMTAG_NAME]
if not package_name.startswith(PACKAGE_NAME_PREFIX):
parser.error(
'Illegal package name for package \'%s\'.' % rpmfile)
# Remove prefix to get the real template name
name = package_name[len(PACKAGE_NAME_PREFIX):]
# Another check for already-downloaded RPMs
if name in app.domains:
print(('Template \'%s\' already installed, skipping...'
' (You may want to use the {reinstall,upgrade,downgrade}'
' operations.)') % name, file=sys.stderr)
continue
print('Installing template \'%s\'...' % name, file=sys.stderr)
extract_rpm(name, rpmfile, target)
cmdline = [
'qvm-template-postprocess',
'--really',
'--no-installed-by-rpm',
]
if args.allow_pv:
cmdline.append('--allow-pv')
subprocess.check_call(cmdline + [
'post-install',
name,
target + PATH_PREFIX + '/' + name])
app.domains.refresh_cache(force=True)
tpl = app.domains[name]
tpl.features['template-epoch'] = \
package_hdr[rpm.RPMTAG_EPOCHNUM]
tpl.features['template-version'] = \
package_hdr[rpm.RPMTAG_VERSION]
tpl.features['template-release'] = \
package_hdr[rpm.RPMTAG_RELEASE]
tpl.features['template-install-date'] = \
str(datetime.datetime.today())
tpl.features['template-name'] = name
# TODO: Store source repo
def qrexec_popen(args, app, service, stdout=subprocess.PIPE, filter_esc=True):
if args.updatevm:
return app.domains[args.updatevm].run_service(
service,
filter_esc=filter_esc,
stdout=stdout)
2020-07-11 17:12:57 +02:00
return subprocess.Popen([
'/etc/qubes-rpc/%s' % service,
],
stdin=subprocess.PIPE,
stdout=stdout,
stderr=subprocess.PIPE)
def qrexec_payload(args, app, spec):
2020-07-11 17:12:57 +02:00
_ = app # unused
def check_newline(string, name):
if '\n' in string:
parser.error(f"Malformed {name}:" +
" argument should not contain '\\n'.")
payload = ''
2020-07-11 17:12:57 +02:00
for repo in args.enablerepo if args.enablerepo else []:
check_newline(repo, '--enablerepo')
payload += '--enablerepo=%s\n' % repo
for repo in args.disablerepo if args.disablerepo else []:
check_newline(repo, '--disablerepo')
payload += '--disablerepo=%s\n' % repo
for repo in args.repoid if args.repoid else []:
check_newline(repo, '--repoid')
payload += '--repoid=%s\n' % repo
check_newline(args.releasever, '--releasever')
payload += '--releasever=%s\n' % args.releasever
check_newline(spec, 'template name')
payload += spec + '\n'
payload += '---\n'
2020-07-11 17:12:57 +02:00
for path in args.repo_files:
with open(path, 'r') as fd:
payload += fd.read() + '\n'
return payload
def qrexec_repoquery(args, app, spec='*'):
proc = qrexec_popen(args, app, 'qubes.TemplateSearch')
payload = qrexec_payload(args, app, spec)
2020-07-11 17:12:57 +02:00
stdout, _ = proc.communicate(payload.encode('UTF-8'))
stdout = stdout.decode('ASCII')
if proc.wait() != 0:
raise ConnectionError("qrexec call 'qubes.TemplateSearch' failed.")
result = []
for line in stdout.strip().split('\n'):
entry = line.split(':')
if not entry[0].startswith(PACKAGE_NAME_PREFIX):
continue
entry[0] = entry[0][len(PACKAGE_NAME_PREFIX):]
result.append(entry)
return result
def qrexec_download(args, app, spec, path, dlsize=None):
2020-07-11 17:12:57 +02:00
with open(path, 'wb') as fd:
# Don't filter ESCs for binary files
proc = qrexec_popen(args, app, 'qubes.TemplateDownload',
2020-07-11 17:12:57 +02:00
stdout=fd, filter_esc=False)
payload = qrexec_payload(args, app, spec)
proc.stdin.write(payload.encode('UTF-8'))
proc.stdin.close()
2020-07-11 15:42:58 +02:00
with tqdm.tqdm(desc=spec, total=dlsize, unit_scale=True,
unit_divisor=1000, unit='B') as pbar:
last = 0
2020-07-11 17:12:57 +02:00
while proc.poll() is None:
cur = fd.tell()
2020-07-11 15:42:58 +02:00
pbar.update(cur - last)
last = cur
time.sleep(0.1)
if proc.wait() != 0:
raise ConnectionError(
"qrexec call 'qubes.TemplateDownload' failed.")
return True
def build_version_str(evr):
return '%s:%s-%s' % evr
def do_list(args, app):
# TODO: Check local template name actually matches to account for renames
# TODO: Also display repo like `dnf list`
tpl_list = []
if not (args.installed or args.available or args.extras or args.upgrades):
args.all = True
if args.all or args.available or args.extras or args.upgrades:
query_res = qrexec_repoquery(args, app)
if args.installed or args.all:
for vm in app.domains:
if 'template-install-date' in vm.features:
version_str = build_version_str((
vm.features['template-epoch'],
vm.features['template-version'],
vm.features['template-release']))
tpl_list.append(
(vm.name, version_str, TemplateState.INSTALLED.value))
if args.available or args.all:
2020-07-11 17:12:57 +02:00
#pylint: disable=unused-variable
for name, epoch, version, release, reponame, dlsize, summary \
in query_res:
version_str = build_version_str((epoch, version, release))
tpl_list.append((name, version_str, TemplateState.AVAILABLE.value))
if args.extras:
remote = set()
for name, epoch, version, release, reponame, dlsize, summary \
in query_res:
remote.add(name)
for vm in app.domains:
if 'template-name' in vm.features and \
vm.features['template-name'] not in remote:
version_str = build_version_str((
vm.features['template-epoch'],
vm.features['template-version'],
vm.features['template-release']))
tpl_list.append(
(vm.name, version_str, TemplateState.EXTRA.value))
if args.upgrades:
local = {}
for vm in app.domains:
if 'template-name' in vm.features:
local[vm.features['template-name']] = (
vm.features['template-epoch'],
vm.features['template-version'],
vm.features['template-release'])
for name, epoch, version, release, reponame, dlsize, summary \
in query_res:
if name in local:
if rpm.labelCompare(local[name], (epoch, version, release)) < 0:
version_str = build_version_str((epoch, version, release))
tpl_list.append(
(name, version_str, TemplateState.UPGRADABLE.value))
qubesadmin.tools.print_table(tpl_list)
def get_dl_list(args, app):
candid = {}
for template in args.templates:
# Skip local RPMs
if template.endswith('.rpm'):
continue
query_res = qrexec_repoquery(args, app, PACKAGE_NAME_PREFIX + template)
if len(query_res) == 0:
parser.error('Package \'%s\' not found.' % template)
sys.exit(1)
# We only select one (latest) package for each distinct package name
2020-07-11 17:12:57 +02:00
#pylint: disable=unused-variable
for name, epoch, version, release, reponame, dlsize, summary \
in query_res:
ver = (epoch, version, release)
if name not in candid or rpm.labelCompare(candid[name], ver) < 0:
candid[name] = (ver, int(dlsize))
return candid
2020-07-11 16:31:38 +02:00
def download(args, app, path_override=None, dl_list=None, suffix=''):
if dl_list is None:
dl_list = get_dl_list(args, app)
2020-07-11 17:12:57 +02:00
path = path_override if path_override is not None else args.downloaddir
for name, (ver, dlsize) in dl_list.items():
version_str = build_version_str(ver)
spec = PACKAGE_NAME_PREFIX + name + '-' + version_str
target = os.path.join(path, '%s.rpm' % spec)
2020-07-11 16:31:38 +02:00
target_suffix = target + suffix
if suffix != '' and os.path.exists(target_suffix):
print('\'%s\' already exists, skipping...' % target,
file=sys.stderr)
if os.path.exists(target):
print('\'%s\' already exists, skipping...' % target,
file=sys.stderr)
2020-07-11 16:31:38 +02:00
if suffix != '':
os.rename(target, target_suffix)
else:
print('Downloading \'%s\'...' % spec, file=sys.stderr)
2020-07-11 17:12:57 +02:00
done = False
for attempt in range(args.retries):
try:
2020-07-11 16:31:38 +02:00
qrexec_download(args, app, spec, target_suffix, dlsize)
2020-07-11 17:12:57 +02:00
done = True
break
except ConnectionError:
if attempt + 1 < args.retries:
print('\'%s\' download failed, retrying...' % spec,
file=sys.stderr)
2020-07-11 17:12:57 +02:00
if not done:
print('\'%s\' download failed.' % spec, file=sys.stderr)
2020-07-11 16:31:38 +02:00
os.remove(target_suffix)
sys.exit(1)
def remove(args, app):
2020-07-11 17:12:57 +02:00
_ = app # unused
# Use exec so stdio can be shared easily
os.execvp('qvm-remove', ['qvm-remove'] + args.templates)
def clean(args, app):
# TODO: More fine-grained options
2020-07-11 17:12:57 +02:00
_ = app # unused
shutil.rmtree(args.cachedir)
def main(args=None, app=None):
args = parser.parse_args(args)
if app is None:
app = qubesadmin.Qubes()
if args.operation == 'install':
2020-07-11 17:12:57 +02:00
install(args, app)
elif args.operation == 'list':
2020-07-11 17:12:57 +02:00
do_list(args, app)
elif args.operation == 'download':
2020-07-11 17:12:57 +02:00
download(args, app)
elif args.operation == 'remove':
remove(args, app)
elif args.operation == 'clean':
clean(args, app)
else:
2020-07-11 17:12:57 +02:00
parser.error('Operation \'%s\' not supported.' % args.operation)
return 0
if __name__ == '__main__':
sys.exit(main())