Add signed passive runtime updates and independent crash recovery
This commit is contained in:
@@ -0,0 +1,609 @@
|
||||
"""Trusted, self-contained runtime updater. Installed outside the runtime payload.
|
||||
|
||||
Only Python's standard library and OpenSSL are used. Never import hydra here.
|
||||
"""
|
||||
import argparse
|
||||
from contextlib import contextmanager
|
||||
import fcntl
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
import zipfile
|
||||
|
||||
LIMIT = 16 * 1024 * 1024
|
||||
MANIFEST_LIMIT = 256 * 1024
|
||||
FILE_LIMIT = 4 * 1024 * 1024
|
||||
MAX_FILES = 256
|
||||
ORIGIN = 'https://git.shanaty.ru'
|
||||
RELEASES = ORIGIN + '/api/v1/repos/Emil_Shanaty/Hydra-OS/releases?limit=20'
|
||||
EXACT = {'usr/lib/hydra/bin/hydra', 'etc/motd', 'etc/fastfetch/config.jsonc',
|
||||
'usr/share/hydra/fastfetch.txt'}
|
||||
|
||||
|
||||
class UpdateError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def digest(data):
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def allowed(name):
|
||||
return isinstance(name, str) and (name in EXACT or
|
||||
re.fullmatch(r'usr/lib/hydra/hydra/(?!update)[a-z_]+\.py', name) is not None)
|
||||
|
||||
|
||||
def integer(value, minimum=0, maximum=LIMIT):
|
||||
return type(value) is int and minimum <= value <= maximum
|
||||
|
||||
|
||||
def sha(value):
|
||||
return isinstance(value, str) and re.fullmatch('[0-9a-f]{64}', value) is not None
|
||||
|
||||
|
||||
def parse_json(data):
|
||||
def pairs(items):
|
||||
result = {}
|
||||
for key, value in items:
|
||||
if key in result:
|
||||
raise UpdateError('Duplicate JSON key')
|
||||
result[key] = value
|
||||
return result
|
||||
try:
|
||||
return json.loads(data, object_pairs_hook=pairs,
|
||||
parse_constant=lambda _: (_ for _ in ()).throw(UpdateError('Invalid JSON number')))
|
||||
except (UnicodeError, json.JSONDecodeError, RecursionError) as exc:
|
||||
raise UpdateError('Invalid JSON') from exc
|
||||
|
||||
|
||||
def read_bytes(path, limit):
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||||
with os.fdopen(fd, 'rb') as stream:
|
||||
info = os.fstat(stream.fileno())
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_size > limit:
|
||||
raise UpdateError('Oversized or nonregular file')
|
||||
data = stream.read(limit + 1)
|
||||
if len(data) > limit:
|
||||
raise UpdateError('Oversized file')
|
||||
return data
|
||||
|
||||
|
||||
def validate_manifest(raw, signature, public_key):
|
||||
if len(raw) > MANIFEST_LIMIT or len(signature) != 64:
|
||||
raise UpdateError('Invalid signature or manifest size')
|
||||
with tempfile.TemporaryDirectory(prefix='hydra-verify-') as directory:
|
||||
directory = Path(directory)
|
||||
(directory / 'manifest').write_bytes(raw)
|
||||
(directory / 'signature').write_bytes(signature)
|
||||
# Copy the key so a changing external file cannot race OpenSSL's read.
|
||||
(directory / 'key').write_bytes(read_bytes(public_key, 4096))
|
||||
result = subprocess.run(['openssl', 'pkeyutl', '-verify', '-pubin', '-inkey', str(directory / 'key'),
|
||||
'-rawin', '-in', str(directory / 'manifest'), '-sigfile', str(directory / 'signature')],
|
||||
capture_output=True, timeout=15, env={'PATH': '/usr/bin:/bin'})
|
||||
if result.returncode:
|
||||
raise UpdateError('Ed25519 signature verification failed')
|
||||
manifest = parse_json(raw)
|
||||
expected = {'format', 'product', 'scope', 'channel', 'architecture', 'sequence', 'version', 'source_commit', 'archive', 'files'}
|
||||
if not isinstance(manifest, dict) or set(manifest) != expected:
|
||||
raise UpdateError('Invalid manifest fields')
|
||||
for key, value in {'format': 1, 'product': 'Hydra OS', 'scope': 'runtime', 'channel': 'stable', 'architecture': 'x86_64'}.items():
|
||||
if type(manifest[key]) is not type(value) or manifest[key] != value:
|
||||
raise UpdateError('Unsupported manifest ' + key)
|
||||
if not integer(manifest['sequence'], 1, 2**63 - 1):
|
||||
raise UpdateError('Invalid release sequence')
|
||||
if not isinstance(manifest['version'], str) or not re.fullmatch(r'[0-9][A-Za-z0-9._-]{0,63}', manifest['version']):
|
||||
raise UpdateError('Invalid release version')
|
||||
if not isinstance(manifest['source_commit'], str) or not re.fullmatch('[0-9a-f]{40}', manifest['source_commit']):
|
||||
raise UpdateError('Invalid source commit')
|
||||
archive = manifest['archive']
|
||||
if (not isinstance(archive, dict) or set(archive) != {'name', 'size', 'sha256'} or
|
||||
archive['name'] != 'runtime.zip' or not integer(archive['size'], 1) or not sha(archive['sha256'])):
|
||||
raise UpdateError('Invalid archive metadata')
|
||||
files = manifest['files']
|
||||
if not isinstance(files, dict) or not 1 <= len(files) <= MAX_FILES:
|
||||
raise UpdateError('Invalid file count')
|
||||
for name, entry in files.items():
|
||||
if (not allowed(name) or not isinstance(entry, dict) or set(entry) != {'size', 'sha256', 'mode'} or
|
||||
not integer(entry['size'], 0, FILE_LIMIT) or not sha(entry['sha256']) or
|
||||
type(entry['mode']) is not int or entry['mode'] not in (0o644, 0o755)):
|
||||
raise UpdateError('Invalid or forbidden payload path/metadata')
|
||||
if sum(entry['size'] for entry in files.values()) > LIMIT:
|
||||
raise UpdateError('Oversized payload')
|
||||
if 'usr/lib/hydra/hydra/cli.py' not in files:
|
||||
raise UpdateError('Missing runtime CLI')
|
||||
return manifest
|
||||
|
||||
|
||||
def validate_archive(data, manifest):
|
||||
metadata = manifest['archive']
|
||||
if len(data) != metadata['size'] or digest(data) != metadata['sha256']:
|
||||
raise UpdateError('Archive checksum/size mismatch')
|
||||
payload = {}
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
||||
infos = archive.infolist()
|
||||
names = [entry.filename for entry in infos]
|
||||
if len(names) != len(set(names)) or set(names) != set(manifest['files']):
|
||||
raise UpdateError('Duplicate or unlisted archive entry')
|
||||
for info in infos:
|
||||
entry = manifest['files'][info.filename]
|
||||
mode = info.external_attr >> 16
|
||||
if (info.is_dir() or stat.S_IFMT(mode) != stat.S_IFREG or info.flag_bits & 1 or
|
||||
info.compress_type not in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED) or
|
||||
info.file_size != entry['size']):
|
||||
raise UpdateError('Invalid archive file type/size')
|
||||
with archive.open(info) as stream:
|
||||
content = stream.read(entry['size'] + 1)
|
||||
if len(content) != entry['size'] or digest(content) != entry['sha256']:
|
||||
raise UpdateError('Payload checksum/size mismatch')
|
||||
payload[info.filename] = content
|
||||
except (zipfile.BadZipFile, RuntimeError, NotImplementedError) as exc:
|
||||
raise UpdateError('Invalid archive') from exc
|
||||
return payload
|
||||
|
||||
|
||||
def fsync_dir(path):
|
||||
fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
||||
try:
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def atomic_write(path, data, mode=0o600, uid=None, gid=None):
|
||||
fd, temporary = tempfile.mkstemp(prefix='.hydra-update-', dir=path.parent)
|
||||
try:
|
||||
with os.fdopen(fd, 'wb') as stream:
|
||||
stream.write(data)
|
||||
stream.flush()
|
||||
if uid is not None:
|
||||
os.fchown(stream.fileno(), uid, gid)
|
||||
os.fchmod(stream.fileno(), mode)
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
fsync_dir(path.parent)
|
||||
finally:
|
||||
Path(temporary).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def write_json(path, value, mode=0o600):
|
||||
atomic_write(path, (json.dumps(value, sort_keys=True) + '\n').encode(), mode)
|
||||
|
||||
|
||||
class Updater:
|
||||
def __init__(self, root=Path('/'), public_key=None):
|
||||
self.root = Path(root).absolute()
|
||||
if self.root.resolve() != self.root or not self.root.is_dir():
|
||||
raise UpdateError('Unsafe target root')
|
||||
self.owner = 0 if self.root == Path('/') else os.geteuid()
|
||||
if self.root == Path('/') and public_key is not None:
|
||||
raise UpdateError('Production trust key cannot be overridden')
|
||||
self.public_key = Path(public_key) if public_key else self.root / 'etc/hydra/update.pub'
|
||||
self.base = self.root / 'var/lib/hydra-update'
|
||||
self.public_status = self.root / 'usr/share/hydra/update-status.json'
|
||||
|
||||
def path(self, name, *, leaf_link=True):
|
||||
parts = name.split('/')
|
||||
if any(part in ('', '.', '..') for part in parts):
|
||||
raise UpdateError('Unsafe path')
|
||||
current = self.root
|
||||
for index, part in enumerate(parts):
|
||||
current = current / part
|
||||
leaf = index == len(parts) - 1
|
||||
try:
|
||||
info = current.lstat()
|
||||
except FileNotFoundError:
|
||||
if leaf:
|
||||
return current
|
||||
raise UpdateError('Missing destination parent: ' + name)
|
||||
if info.st_uid != self.owner or (not stat.S_ISLNK(info.st_mode) and info.st_mode & 0o022):
|
||||
raise UpdateError('Unsafe path ownership/permissions: ' + name)
|
||||
if not leaf and not stat.S_ISDIR(info.st_mode):
|
||||
raise UpdateError('Unsafe symlink or non-directory parent: ' + name)
|
||||
if leaf and not (stat.S_ISREG(info.st_mode) or leaf_link and stat.S_ISLNK(info.st_mode)):
|
||||
raise UpdateError('Unsafe destination type: ' + name)
|
||||
return current
|
||||
|
||||
def target(self):
|
||||
self.path('usr/share/hydra/build-manifest.json', leaf_link=False)
|
||||
if not (self.root / 'usr/share/hydra/build-manifest.json').is_file():
|
||||
raise UpdateError('Target is not a Hydra installation')
|
||||
if self.root == Path('/'):
|
||||
if os.geteuid() != 0:
|
||||
raise UpdateError('Use sudo inside Hydra for update mutations')
|
||||
if os.uname().machine != 'x86_64':
|
||||
raise UpdateError('Unsupported architecture')
|
||||
release = read_bytes(self.path('etc/os-release', leaf_link=False), 65536)
|
||||
if not re.search(rb'^ID=(?:"hydra-os"|hydra-os)$', release, re.M):
|
||||
raise UpdateError('Target is not Hydra OS')
|
||||
self.path('etc/hydra/update.pub', leaf_link=False)
|
||||
|
||||
def state(self):
|
||||
if not self.base.exists():
|
||||
return {'highest_sequence': 0, 'current': None}
|
||||
path = self.path('var/lib/hydra-update/state.json', leaf_link=False)
|
||||
if not path.exists():
|
||||
return {'highest_sequence': 0, 'current': None}
|
||||
result = parse_json(read_bytes(path, MANIFEST_LIMIT))
|
||||
if not isinstance(result, dict) or set(result) != {'highest_sequence', 'current'} or not integer(result['highest_sequence'], 0, 2**63 - 1):
|
||||
raise UpdateError('Corrupt update state')
|
||||
self.validate_current(result['current'])
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def validate_current(value):
|
||||
if value is not None and (not isinstance(value, dict) or set(value) != {'transaction', 'sequence', 'version'} or
|
||||
not re.fullmatch('[0-9a-f]{32}', str(value.get('transaction'))) or
|
||||
not integer(value.get('sequence'), 1, 2**63 - 1) or not isinstance(value.get('version'), str)):
|
||||
raise UpdateError('Corrupt current update state')
|
||||
|
||||
def pending(self):
|
||||
directory = self.base / 'transactions'
|
||||
if not directory.exists():
|
||||
return []
|
||||
self.path('var/lib/hydra-update/transactions/.probe')
|
||||
result = []
|
||||
for item in sorted(directory.iterdir()):
|
||||
if not re.fullmatch('[0-9a-f]{32}', item.name):
|
||||
raise UpdateError('Unexpected transaction directory')
|
||||
path = self.path('var/lib/hydra-update/transactions/' + item.name + '/journal.json', leaf_link=False)
|
||||
if not path.exists():
|
||||
# Backup preparation interrupted before any destination mutation.
|
||||
continue
|
||||
journal = parse_json(read_bytes(path, MANIFEST_LIMIT))
|
||||
if not isinstance(journal, dict) or journal.get('phase') not in ('prepared', 'committed', 'restoring', 'recovered', 'rolled_back'):
|
||||
raise UpdateError('Corrupt journal')
|
||||
if journal['phase'] in ('prepared', 'restoring'):
|
||||
result.append(item.name)
|
||||
return result
|
||||
|
||||
def status(self):
|
||||
try:
|
||||
value = self.state()
|
||||
return dict(value, pending=self.pending())
|
||||
except PermissionError:
|
||||
value = parse_json(read_bytes(self.public_status, MANIFEST_LIMIT))
|
||||
return value
|
||||
|
||||
def publish(self):
|
||||
self.path('usr/share/hydra/update-status.json', leaf_link=False)
|
||||
write_json(self.public_status, dict(self.state(), pending=self.pending()), 0o644)
|
||||
|
||||
@contextmanager
|
||||
def lock(self):
|
||||
self.target()
|
||||
self.path('var/lib/.probe')
|
||||
if not self.base.exists():
|
||||
self.base.mkdir(mode=0o700)
|
||||
fsync_dir(self.base.parent)
|
||||
self.path('var/lib/hydra-update/.probe')
|
||||
if stat.S_IMODE(self.base.stat().st_mode) != 0o700:
|
||||
raise UpdateError('Update state must be private (0700)')
|
||||
path = self.path('var/lib/hydra-update/lock', leaf_link=False)
|
||||
fd = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
||||
try:
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError as exc:
|
||||
raise UpdateError('Another update operation holds the lock') from exc
|
||||
yield
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
def local(self, directory):
|
||||
directory = Path(directory)
|
||||
raw = read_bytes(directory / 'manifest.json', MANIFEST_LIMIT)
|
||||
signature = read_bytes(directory / 'manifest.sig', 64)
|
||||
manifest = validate_manifest(raw, signature, self.public_key)
|
||||
payload = validate_archive(read_bytes(directory / 'runtime.zip', LIMIT), manifest)
|
||||
return manifest, payload
|
||||
|
||||
def apply(self, directory):
|
||||
manifest, payload = self.local(directory)
|
||||
self.target()
|
||||
# Preflight every destination before state creation or backups.
|
||||
for name in payload:
|
||||
self.path(name)
|
||||
with self.lock():
|
||||
if self.pending():
|
||||
raise UpdateError('Recovery pending; run hydra-update-recover')
|
||||
state = self.state()
|
||||
if manifest['sequence'] <= state['highest_sequence']:
|
||||
raise UpdateError('Release sequence rejected (replay)')
|
||||
transaction = uuid.uuid4().hex
|
||||
directory = self.base / 'transactions'
|
||||
if not directory.exists():
|
||||
directory.mkdir(mode=0o700)
|
||||
fsync_dir(self.base)
|
||||
saved = directory / transaction
|
||||
saved.mkdir(mode=0o700)
|
||||
fsync_dir(directory)
|
||||
(saved / 'backup').mkdir(mode=0o700)
|
||||
fsync_dir(saved)
|
||||
records = {}
|
||||
for index, name in enumerate(sorted(payload)):
|
||||
dest = self.path(name)
|
||||
if not dest.exists() and not dest.is_symlink():
|
||||
records[name] = {'type': 'absent'}
|
||||
continue
|
||||
info = dest.lstat()
|
||||
record = {'mode': stat.S_IMODE(info.st_mode), 'uid': info.st_uid, 'gid': info.st_gid}
|
||||
if stat.S_ISLNK(info.st_mode):
|
||||
record.update(type='symlink', target=os.readlink(dest))
|
||||
else:
|
||||
content = read_bytes(dest, FILE_LIMIT)
|
||||
record.update(type='file', sha256=digest(content), size=len(content), backup=str(index))
|
||||
atomic_write(saved / 'backup' / str(index), content)
|
||||
records[name] = record
|
||||
journal = {'format': 1, 'transaction': transaction, 'phase': 'prepared',
|
||||
'previous': state['current'], 'release': {'transaction': transaction, 'sequence': manifest['sequence'], 'version': manifest['version']},
|
||||
'files': manifest['files'], 'originals': records}
|
||||
write_json(saved / 'journal.json', journal)
|
||||
# Reserve the sequence before mutation; recovery never lowers this floor.
|
||||
state['highest_sequence'] = manifest['sequence']
|
||||
write_json(self.base / 'state.json', state)
|
||||
self.publish()
|
||||
for name, content in sorted(payload.items()):
|
||||
atomic_write(self.path(name), content, manifest['files'][name]['mode'], self.owner, 0 if self.owner == 0 else os.getegid())
|
||||
state['current'] = journal['release']
|
||||
write_json(self.base / 'state.json', state)
|
||||
journal['phase'] = 'committed'
|
||||
write_json(saved / 'journal.json', journal)
|
||||
self.publish()
|
||||
return dict(journal['release'], applied=True)
|
||||
|
||||
def load_journal(self, transaction):
|
||||
if not re.fullmatch('[0-9a-f]{32}', transaction):
|
||||
raise UpdateError('Invalid transaction identifier')
|
||||
prefix = 'var/lib/hydra-update/transactions/' + transaction
|
||||
path = self.path(prefix + '/journal.json', leaf_link=False)
|
||||
journal = parse_json(read_bytes(path, MANIFEST_LIMIT))
|
||||
if (not isinstance(journal, dict) or set(journal) != {'format', 'transaction', 'phase', 'previous', 'release', 'files', 'originals'} or
|
||||
type(journal['format']) is not int or journal['format'] != 1 or journal['transaction'] != transaction or
|
||||
journal['phase'] not in ('prepared', 'committed', 'restoring', 'recovered', 'rolled_back')):
|
||||
raise UpdateError('Corrupt journal')
|
||||
self.validate_current(journal['previous'])
|
||||
self.validate_current(journal['release'])
|
||||
if journal['release'] is None or journal['release']['transaction'] != transaction:
|
||||
raise UpdateError('Corrupt journal release')
|
||||
files, records = journal['files'], journal['originals']
|
||||
if not isinstance(files, dict) or not isinstance(records, dict) or set(files) != set(records) or not 1 <= len(files) <= MAX_FILES:
|
||||
raise UpdateError('Corrupt journal records')
|
||||
contents = {}
|
||||
for name, record in records.items():
|
||||
if not allowed(name) or not isinstance(record, dict):
|
||||
raise UpdateError('Unsafe backup path')
|
||||
self.path(name)
|
||||
entry = files[name]
|
||||
if (not isinstance(entry, dict) or set(entry) != {'mode', 'size', 'sha256'} or
|
||||
not integer(entry['mode']) or entry['mode'] not in (0o644, 0o755) or
|
||||
not integer(entry['size'], 0, FILE_LIMIT) or not sha(entry['sha256'])):
|
||||
raise UpdateError('Corrupt journal file')
|
||||
kind = record.get('type')
|
||||
if kind == 'absent' and set(record) == {'type'}:
|
||||
continue
|
||||
if kind not in ('file', 'symlink') or not integer(record.get('mode'), 0, 0o7777) or not integer(record.get('uid'), 0, 2**32-1) or not integer(record.get('gid'), 0, 2**32-1):
|
||||
raise UpdateError('Corrupt backup metadata')
|
||||
if kind == 'symlink':
|
||||
if set(record) != {'type', 'mode', 'uid', 'gid', 'target'} or not isinstance(record['target'], str) or '\0' in record['target'] or not 1 <= len(record['target']) <= 4096:
|
||||
raise UpdateError('Corrupt backup symlink')
|
||||
else:
|
||||
if (set(record) != {'type', 'mode', 'uid', 'gid', 'sha256', 'size', 'backup'} or
|
||||
not isinstance(record['backup'], str) or not re.fullmatch('[0-9]{1,3}', record['backup']) or
|
||||
not integer(record['size'], 0, FILE_LIMIT) or not sha(record['sha256'])):
|
||||
raise UpdateError('Corrupt backup metadata')
|
||||
content = read_bytes(self.path(prefix + '/backup/' + record['backup'], leaf_link=False), FILE_LIMIT)
|
||||
if digest(content) != record['sha256'] or len(content) != record['size']:
|
||||
raise UpdateError('Corrupt backup bytes')
|
||||
contents[name] = content
|
||||
return path, journal, contents
|
||||
|
||||
def restore(self, transaction, explicit):
|
||||
path, journal, contents = self.load_journal(transaction)
|
||||
state = self.state()
|
||||
if explicit:
|
||||
if journal['phase'] != 'committed' or state['current'] != journal['release']:
|
||||
raise UpdateError('Only the latest current transaction can be rolled back')
|
||||
for name, entry in journal['files'].items():
|
||||
dest = self.path(name)
|
||||
if not dest.exists() or dest.is_symlink():
|
||||
raise UpdateError('Runtime was locally modified')
|
||||
info = dest.stat()
|
||||
if (stat.S_IMODE(info.st_mode) != entry['mode'] or info.st_uid != self.owner or
|
||||
info.st_gid != (0 if self.owner == 0 else os.getegid()) or digest(read_bytes(dest, FILE_LIMIT)) != entry['sha256']):
|
||||
raise UpdateError('Runtime was locally modified')
|
||||
journal['phase'] = 'restoring'
|
||||
write_json(path, journal)
|
||||
self.publish()
|
||||
elif journal['phase'] not in ('prepared', 'restoring'):
|
||||
raise UpdateError('Transaction does not require recovery')
|
||||
for name, record in sorted(journal['originals'].items()):
|
||||
dest = self.path(name)
|
||||
if record['type'] == 'absent':
|
||||
dest.unlink(missing_ok=True)
|
||||
fsync_dir(dest.parent)
|
||||
elif record['type'] == 'file':
|
||||
atomic_write(dest, contents[name], record['mode'], record['uid'], record['gid'])
|
||||
else:
|
||||
temporary = dest.parent / ('.hydra-update-' + uuid.uuid4().hex)
|
||||
try:
|
||||
temporary.symlink_to(record['target'])
|
||||
os.chown(temporary, record['uid'], record['gid'], follow_symlinks=False)
|
||||
fsync_dir(dest.parent)
|
||||
os.replace(temporary, dest)
|
||||
fsync_dir(dest.parent)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
state['current'] = journal['previous']
|
||||
state['highest_sequence'] = max(state['highest_sequence'], journal['release']['sequence'])
|
||||
write_json(self.base / 'state.json', state)
|
||||
journal['phase'] = 'rolled_back' if explicit or journal['phase'] == 'restoring' else 'recovered'
|
||||
write_json(path, journal)
|
||||
self.publish()
|
||||
return {'transaction': transaction, 'status': journal['phase']}
|
||||
|
||||
def rollback(self, transaction):
|
||||
with self.lock():
|
||||
if self.pending():
|
||||
raise UpdateError('Recovery pending')
|
||||
return self.restore(transaction, True)
|
||||
|
||||
def recover(self):
|
||||
with self.lock():
|
||||
pending = self.pending()
|
||||
if len(pending) > 1:
|
||||
raise UpdateError('Multiple pending journals require manual inspection')
|
||||
return [self.restore(transaction, False) for transaction in pending]
|
||||
|
||||
|
||||
def checked_url(url):
|
||||
if not isinstance(url, str) or len(url) > 2048 or any(ord(c) < 33 for c in url) or '\\' in url:
|
||||
raise UpdateError('Malformed release URL')
|
||||
parsed = urllib.parse.urlsplit(url)
|
||||
decoded = urllib.parse.unquote(parsed.path)
|
||||
if (parsed.scheme != 'https' or parsed.netloc != 'git.shanaty.ru' or parsed.fragment or
|
||||
not parsed.path.startswith('/') or decoded.startswith('//') or
|
||||
any(part in ('.', '..') for part in decoded.split('/')) or '\\' in decoded):
|
||||
raise UpdateError('Release URL must remain on the fixed HTTPS origin')
|
||||
return url
|
||||
|
||||
|
||||
class SafeRedirect(urllib.request.HTTPRedirectHandler):
|
||||
max_redirections = 3
|
||||
max_repeats = 1
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
checked_url(newurl)
|
||||
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
|
||||
|
||||
def fetch(url, limit):
|
||||
import time
|
||||
url = checked_url(url)
|
||||
# Do not inherit arbitrary proxy configuration in a privileged updater.
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), SafeRedirect())
|
||||
request = urllib.request.Request(url, headers={'Accept': 'application/octet-stream', 'User-Agent': 'Hydra-Update/1'})
|
||||
deadline = time.monotonic() + 60
|
||||
with opener.open(request, timeout=15) as response:
|
||||
if hasattr(response, 'geturl'):
|
||||
checked_url(response.geturl())
|
||||
chunks, total = [], 0
|
||||
reader = getattr(response, 'read1', response.read)
|
||||
while True:
|
||||
if time.monotonic() > deadline:
|
||||
raise UpdateError('Release download deadline exceeded')
|
||||
chunk = reader(min(65536, limit - total + 1))
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
total += len(chunk)
|
||||
if total > limit:
|
||||
raise UpdateError('Release download exceeds size limit')
|
||||
return b''.join(chunks)
|
||||
|
||||
|
||||
def discover(public_key):
|
||||
releases = parse_json(fetch(RELEASES, 1024 * 1024))
|
||||
if not isinstance(releases, list) or len(releases) > 20:
|
||||
raise UpdateError('Invalid release discovery response')
|
||||
candidates = []
|
||||
for release in releases:
|
||||
if not isinstance(release, dict):
|
||||
raise UpdateError('Invalid release record')
|
||||
if release.get('draft') or release.get('prerelease'):
|
||||
continue
|
||||
assets = release.get('assets', [])
|
||||
if not isinstance(assets, list) or len(assets) > 100:
|
||||
raise UpdateError('Invalid release assets')
|
||||
found = {}
|
||||
for asset in assets:
|
||||
if not isinstance(asset, dict):
|
||||
raise UpdateError('Invalid release asset')
|
||||
name = asset.get('name')
|
||||
if name in ('manifest.json', 'manifest.sig', 'runtime.zip'):
|
||||
if name in found:
|
||||
raise UpdateError('Duplicate release asset')
|
||||
found[name] = checked_url(asset.get('browser_download_url'))
|
||||
if not found:
|
||||
continue
|
||||
if set(found) != {'manifest.json', 'manifest.sig', 'runtime.zip'}:
|
||||
raise UpdateError('Incomplete runtime release')
|
||||
raw = fetch(found['manifest.json'], MANIFEST_LIMIT)
|
||||
signature = fetch(found['manifest.sig'], 64)
|
||||
manifest = validate_manifest(raw, signature, public_key)
|
||||
if release.get('tag_name') != manifest['version']:
|
||||
raise UpdateError('Release tag does not match signed immutable version')
|
||||
candidates.append((manifest, raw, signature, found['runtime.zip']))
|
||||
if not candidates:
|
||||
return None
|
||||
sequences = [item[0]['sequence'] for item in candidates]
|
||||
if len(sequences) != len(set(sequences)):
|
||||
raise UpdateError('Duplicate release sequence')
|
||||
return max(candidates, key=lambda item: item[0]['sequence'])
|
||||
|
||||
|
||||
def main(argv=None, *, recovery=False):
|
||||
parser = argparse.ArgumentParser(description='Verify, apply and recover signed Hydra runtime updates')
|
||||
if not recovery:
|
||||
parser.add_argument('action', choices=('check', 'status', 'apply', 'rollback'))
|
||||
parser.add_argument('transaction', nargs='?')
|
||||
parser.add_argument('--root', type=Path, default=Path('/'), help='Explicit isolated staging root for support/testing')
|
||||
parser.add_argument('--public-key', type=Path, help='Staging-only verification key; installed root always uses its pinned key')
|
||||
parser.add_argument('--artifacts', type=Path, help='Trusted local directory containing manifest.json, manifest.sig and runtime.zip')
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
if args.root.absolute() == Path('/') and args.public_key is not None:
|
||||
raise UpdateError('Production trust key cannot be overridden')
|
||||
updater = Updater(args.root, args.public_key)
|
||||
action = 'recover' if recovery else args.action
|
||||
if not recovery and (action == 'rollback') != (args.transaction is not None):
|
||||
raise UpdateError('Only rollback requires a transaction identifier')
|
||||
if action == 'status':
|
||||
result = updater.status()
|
||||
elif action == 'recover':
|
||||
result = updater.recover()
|
||||
elif action == 'rollback':
|
||||
result = updater.rollback(args.transaction)
|
||||
elif action == 'check':
|
||||
if args.artifacts:
|
||||
manifest, _ = updater.local(args.artifacts)
|
||||
else:
|
||||
found = discover(updater.public_key)
|
||||
manifest = found[0] if found else None
|
||||
state = updater.status()
|
||||
result = dict(state, available=manifest if manifest and manifest['sequence'] > state['highest_sequence'] else None)
|
||||
elif args.artifacts:
|
||||
result = updater.apply(args.artifacts)
|
||||
else:
|
||||
found = discover(updater.public_key)
|
||||
if found is None:
|
||||
result = {'applied': False, 'reason': 'No signed runtime releases available'}
|
||||
else:
|
||||
manifest, raw, signature, url = found
|
||||
data = fetch(url, manifest['archive']['size'])
|
||||
validate_archive(data, manifest)
|
||||
with tempfile.TemporaryDirectory(prefix='hydra-release-') as directory:
|
||||
directory = Path(directory)
|
||||
(directory / 'manifest.json').write_bytes(raw)
|
||||
(directory / 'manifest.sig').write_bytes(signature)
|
||||
(directory / 'runtime.zip').write_bytes(data)
|
||||
result = updater.apply(directory)
|
||||
print(json.dumps(result, indent=2))
|
||||
return 0
|
||||
except (OSError, ValueError, subprocess.SubprocessError, urllib.error.URLError) as exc:
|
||||
print('hydra-update: ' + str(exc), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user