"""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())