"""Runtime install/update operations for deploy.py.""" from __future__ import annotations from deploy_core import * # re-exported internal deployment primitives ACME_RUNTIME_NAMES = frozenset({'acme-python', 'acme-python.installing', 'acme-python.virtualenv-bootstrap'}) RUNTIME_STATE_NAMES = frozenset({'.geoip-update.lock'}) CERTBOT_PACKAGES = ('certbot==5.8.0', 'certbot-dns-cloudflare==5.8.0', 'certbot-dns-ovh==5.8.0', 'certbot-dns-rfc2136==5.8.0') def _under_acme_runtime(source: Path, path: Path) -> bool: relative = path.relative_to(source) return bool(relative.parts) and relative.parts[0] in ACME_RUNTIME_NAMES def checked_tree(source: Path) -> None: """Accept durable data; managed ACME Python environments are rebuildable.""" boundary = source.resolve(strict=True) if source.is_symlink(): raise ValueError('Data root cannot be a symlink.') for p in source.rglob('*'): if _under_acme_runtime(source, p): continue st = p.lstat() if stat.S_ISLNK(st.st_mode): target = os.readlink(p) if os.path.isabs(target) or not p.resolve(strict=True).is_relative_to(boundary): raise ValueError('Data backup contains an external or absolute symlink.') elif not (stat.S_ISDIR(st.st_mode) or stat.S_ISREG(st.st_mode)): raise ValueError('Data backup contains a special file.') def copy_data(source: Path, target: Path) -> None: checked_tree(source) root = source.resolve(strict=True) def ignore(directory: str, names: list[str]) -> set[str]: if Path(directory).resolve(strict=True) != root: return set() return set(names) & (ACME_RUNTIME_NAMES | RUNTIME_STATE_NAMES) shutil.copytree(source, target, symlinks=True, ignore=ignore) os.chmod(target, 0o700) def _run_user(account, data: Path, args: list[str], timeout: int) -> subprocess.CompletedProcess: env = os.environ.copy(); env.update(HOME=str(data), PATH='/usr/local/bin:/usr/bin:/bin') env.pop('PYTHONHOME', None); env.pop('PYTHONPATH', None) return subprocess.run(['/usr/sbin/runuser', '-u', account.pw_name, '--', *args], check=False, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=timeout, env=env) def _acme_python(data: Path) -> Path: return data / 'acme-python' / 'bin/python' def _acme_certbot(data: Path) -> Path: return data / 'acme-python' / 'bin/certbot' def _acme_runtime_valid(data: Path, account) -> bool: python = _acme_python(data) if not python.is_file(): return False certbot = _acme_certbot(data) if not certbot.is_file(): return False checks = ([str(python), '-I', '-c', 'import certbot, certbot_dns_cloudflare, certbot_dns_ovh, certbot_dns_rfc2136'], [str(certbot), '--version']) try: return all(_run_user(account, data, command, 30).returncode == 0 for command in checks) except (OSError, subprocess.SubprocessError): return False def prepare_managed_acme_runtime(data: Path, account) -> None: if _acme_runtime_valid(data, account): return for name in ACME_RUNTIME_NAMES: path = data / name if path.is_symlink() or (path.exists() and not path.is_dir()): path.unlink(missing_ok=True) elif path.is_dir(): shutil.rmtree(path, ignore_errors=True) system_python = shutil.which('python3') or shutil.which('python') if not system_python: print('WARNING: managed ACME runtime rebuild skipped: Python is unavailable.', file=sys.stderr); return target = data / 'acme-python' try: created = _run_user(account, data, [system_python, '-m', 'venv', str(target)], 180) if created.returncode != 0: bootstrap = data / 'acme-python.virtualenv-bootstrap' pip_ok = _run_user(account, data, [system_python, '-m', 'pip', '--version'], 30) if pip_ok.returncode != 0: raise RuntimeError(created.stderr.strip()[-1200:]) install_ve = _run_user(account, data, [system_python, '-m', 'pip', 'install', '--disable-pip-version-check', '--no-input', '--no-cache-dir', '--retries', '3', '--timeout', '30', '--target', str(bootstrap), 'virtualenv>=20,<21'], 600) if install_ve.returncode != 0: raise RuntimeError(install_ve.stderr.strip()[-1200:]) create_ve = _run_user(account, data, [system_python, '-c', "import runpy,sys;sys.path.insert(0,sys.argv[1]);sys.argv=['virtualenv',sys.argv[2]];runpy.run_module('virtualenv',run_name='__main__')", str(bootstrap), str(target)], 180) shutil.rmtree(bootstrap, ignore_errors=True) if create_ve.returncode != 0: raise RuntimeError(create_ve.stderr.strip()[-1200:]) python = _acme_python(data) if _run_user(account, data, [str(python), '-m', 'pip', '--version'], 30).returncode != 0: ensured = _run_user(account, data, [str(python), '-m', 'ensurepip', '--upgrade'], 120) if ensured.returncode != 0: raise RuntimeError(ensured.stderr.strip()[-1200:]) installed = _run_user(account, data, [str(python), '-m', 'pip', 'install', '--disable-pip-version-check', '--no-input', '--no-cache-dir', '--retries', '3', '--timeout', '30', *CERTBOT_PACKAGES], 600) if installed.returncode != 0 or not _acme_runtime_valid(data, account): raise RuntimeError(installed.stderr.strip()[-1200:]) print(f'Managed ACME runtime ready: {target}') except (OSError, RuntimeError, subprocess.SubprocessError) as error: for name in ACME_RUNTIME_NAMES: shutil.rmtree(data / name, ignore_errors=True) print(f'WARNING: managed ACME runtime rebuild failed; system Certbot fallback remains available: {error}', file=sys.stderr) def restore_managed_acme_runtime(failed: Path, restored: Path) -> None: for name in ACME_RUNTIME_NAMES: source, target = failed / name, restored / name if source.is_dir() and not source.is_symlink() and not target.exists(): try: source.rename(target) except OSError: shutil.copytree(source, target, symlinks=True) def chown_tree(path: Path, uid: int, gid: int) -> None: os.chown(path, uid, gid) for child in path.rglob('*'): os.chown(child, uid, gid, follow_symlinks=False) def ensure_service_data_access(data: Path, account) -> None: """Repair the managed data-root metadata and verify service-user traversal. systemd applies WorkingDirectory after dropping to User=/Group=. A stale root-owned 0700 data directory therefore fails as status=200/CHDIR before RustProxy itself can emit diagnostics. Repair only the managed data root; never chmod/chown parent directories. Then fail before service downtime if a parent directory still prevents traversal. """ try: st = data.lstat() except FileNotFoundError as error: raise ValueError(f'Data directory is missing: {data}') from error if stat.S_ISLNK(st.st_mode) or not stat.S_ISDIR(st.st_mode): raise ValueError(f'Data directory must be a real directory: {data}') os.chown(data, account.pw_uid, account.pw_gid) os.chmod(data, 0o700) probe = subprocess.run( ['runuser', '-u', account.pw_name, '--', 'test', '-x', str(data)], check=False, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) if probe.returncode != 0: raise ValueError( f'Service user {account.pw_name} cannot traverse data directory {data}; ' 'check execute/search permissions on its parent directories (for example with namei -l).' ) def switch(prefix: Path, release: str) -> None: if not re.fullmatch(r'[A-Za-z0-9_.-]+', release) or '..' in release: raise ValueError('Invalid release name.') temporary = prefix / ('current-' + uuid.uuid4().hex) os.symlink('releases/' + release, temporary) os.replace(temporary, prefix / 'current') def running_release_matches(record: dict) -> bool: """Check the actual systemd process executable without publishing a version API. This installer targets Linux/systemd. Fail closed if /proc or MainPID cannot establish that the service runs the exact staged, version-checked binary. """ try: result = subprocess.run(['systemctl', 'show', '--property=MainPID', '--value', SERVICE], check=False, capture_output=True, text=True, timeout=3) if result.returncode != 0 or not result.stdout.strip().isdigit(): return False pid = int(result.stdout.strip()) if pid <= 1: return False expected = Path(record['prefix']) / 'releases' / record['release'] / 'rust-proxy' return Path(f'/proc/{pid}/exe').samefile(expected) except (OSError, ValueError, KeyError, subprocess.SubprocessError): return False def admin_health_endpoint(record: dict) -> tuple[str, bool, str]: """Resolve the active persisted administration listener and scheme. The installer record contains only the startup CLI address. Since 0.9.x the dashboard can persist both a different admin bind and HTTPS in config.sqlite3. Update health checks must follow that active intent or a healthy HTTPS panel is mistaken for a failed release and rollback can fail for the same reason. """ raw = record['admin'] tls = False allowed_hosts: list[str] = [] database = Path(record['data_dir']) / 'config.sqlite3' try: connection = sqlite3.connect(f'file:{database}?mode=ro', uri=True, timeout=2) try: row = connection.execute('SELECT document FROM configuration WHERE id = 1').fetchone() finally: connection.close() if row: document = json.loads(row[0]) administration = document.get('administration') or {} if isinstance(administration, dict): if administration.get('listen'): raw = address(str(administration['listen'])) tls = bool(administration.get('tls', False)) values = administration.get('allowed_hosts') or [] if isinstance(values, list): allowed_hosts = [str(value).strip() for value in values if str(value).strip()] except (OSError, sqlite3.Error, ValueError, TypeError, json.JSONDecodeError): # Old releases and damaged/uninitialized databases used the startup HTTP # listener. The real startup diagnostics remain authoritative if this # compatibility read cannot resolve persisted settings. pass host, port = (raw[1:].split(']:') if raw.startswith('[') else raw.rsplit(':', 1)) ip = ipaddress.ip_address(host) connect_host = ('::1' if ip.version == 6 else '127.0.0.1') if ip.is_unspecified else host authority_host = allowed_hosts[0] if allowed_hosts else connect_host try: authority_ip = ipaddress.ip_address(authority_host) authority = f'[{authority_ip}]:{port}' if authority_ip.version == 6 else f'{authority_ip}:{port}' except ValueError: authority = f'{authority_host}:{port}' connect_raw = f'[{connect_host}]:{port}' if ':' in connect_host else f'{connect_host}:{port}' return connect_raw, tls, authority def healthy(record: dict, version: str, attempts: int = 30) -> bool: raw, tls, health_host = admin_health_endpoint(record) host, port = (raw[1:].split(']:') if raw.startswith('[') else raw.rsplit(':', 1)) for _ in range(attempts): if tls: # Local deployment health is process identity/liveness, not PKI validation. # The configured admin certificate may intentionally be self-signed or for # a DNS name while we connect to the local socket. conn = http.client.HTTPSConnection(host, int(port), timeout=2, context=ssl._create_unverified_context()) else: conn = http.client.HTTPConnection(host, int(port), timeout=2) try: conn.request('GET', '/healthz', headers={'Host': health_host}) response = conn.getresponse(); content = response.read(4097) active = subprocess.run(['systemctl', 'is-active', '--quiet', SERVICE], check=False, stdout=subprocess.DEVNULL).returncode == 0 if (active and response.status == 200 and len(content) <= 4096 and json.loads(content).get('status') == 'ok' and record.get('version') == version and running_release_matches(record)): return True except (OSError, ValueError, http.client.HTTPException, ssl.SSLError): pass finally: conn.close() time.sleep(1) return False def stage(binary: Path, prefix: Path, user: str, sha: str) -> tuple[str, str]: release = 'build-' + sha[:24] releases = prefix / 'releases'; releases.mkdir(mode=0o755, exist_ok=True); os.chmod(releases, 0o755) directory = releases / release if not directory.exists(): directory.mkdir(mode=0o755); os.chmod(directory, 0o755) try: shutil.copyfile(binary, directory / 'rust-proxy') os.chmod(directory / 'rust-proxy', 0o755) except BaseException: shutil.rmtree(directory); raise if directory.is_symlink() or (directory / 'rust-proxy').is_symlink() or digest(directory / 'rust-proxy') != sha: raise ValueError('Staged binary checksum mismatch.') # Never execute the supplied binary as root, even for its version banner. result = run(['runuser', '-u', user, '--', str(directory / 'rust-proxy'), '--version'], capture_output=True, text=True, timeout=15) matched = re.fullmatch(r'(?:rust-proxy|RustProxy) (\d+\.\d+\.\d+(?:[-+][\w.-]+)?)\s*', result.stdout) if not matched: raise ValueError('Binary did not return a RustProxy version.') return release, matched[1] def backup_stopped(record: dict, prefix: Path) -> Path: # Service stop must happen before this call: ACME files and SQLite form one backup. data = Path(record['data_dir']) lock_fd = os.open(data / 'process.lock', os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o600) try: fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) account = pwd.getpwnam(record['user']); os.fchown(lock_fd, account.pw_uid, account.pw_gid) backups = prefix / 'backups'; backups.mkdir(mode=0o700, exist_ok=True) backup = backups / ('before-' + record['version'] + '-' + uuid.uuid4().hex[:12]); backup.mkdir(mode=0o700) copy_data(data, backup / 'data') write_private(backup / 'install.json', json.dumps(record, indent=2).encode()) if UNIT_PATH.exists(): shutil.copyfile(UNIT_PATH, backup / 'rustproxy.service'); os.chmod(backup / 'rustproxy.service', 0o600) return backup finally: os.close(lock_fd) def restore_service_unit(backup: Path) -> None: saved = backup / 'rustproxy.service' if saved.exists(): copy_private(saved, UNIT_PATH, 0o644) else: UNIT_PATH.unlink(missing_ok=True) run(['systemctl', 'daemon-reload']) def reset_saved_config(data: Path) -> None: """Development-only reset of desired config while preserving auth/certs/audit data.""" database = data / 'config.sqlite3' if database.is_symlink() or not database.is_file(): raise ValueError('Cannot reset configuration: config.sqlite3 is missing or unsafe.') connection = sqlite3.connect(f'file:{database}?mode=rw', uri=True, timeout=5) try: row = connection.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='configuration'").fetchone() if row is None: raise ValueError('Cannot reset configuration: configuration table is missing.') connection.execute('DELETE FROM configuration WHERE id = 1') connection.commit() finally: connection.close() print('Stored vhost/settings configuration cleared; account, certificates and other private data were preserved.') def service_failure_details() -> str: chunks: list[str] = [] commands = ( ['systemctl', 'show', SERVICE, '--property=ActiveState,SubState,ExecMainStatus', '--no-pager'], ['journalctl', '-u', SERVICE, '-n', '25', '--no-pager', '--output=cat'], ) for command in commands: try: result = subprocess.run(command, check=False, capture_output=True, text=True, timeout=5) output = ((result.stdout or '') + (result.stderr or '')).strip() if output: chunks.append(output) except (OSError, subprocess.SubprocessError): pass return '\n'.join(chunks)[-8000:] def stop_for_managed_update(data: Path) -> None: """Ask RustProxy 0.14.0+ to force hot cache into the enabled cold tier before exit. Older installed versions ignore the marker and still follow their ordinary graceful shutdown policy, so the first upgrade to 0.14.0 remains backward compatible. The marker is removed before backup so it never becomes persisted application data. """ marker = data / '.managed-update' marker.unlink(missing_ok=True) write_private(marker, b'1\n', 0o644) try: run(['systemctl', 'stop', SERVICE]) finally: marker.unlink(missing_ok=True) def update(record: dict, binary: Path, sha: str, start: bool, refresh_geoip: bool = False, reset_config: bool = False) -> dict: prefix = Path(record['prefix']); data = Path(record['data_dir']); account = pwd.getpwnam(record['user']) geoip_lock_fd: int | None = None geoip_stage: Path | None = None try: # Repair missing or stale installer-owned shell setup on upgrades. install_shell_path(prefix) check_shell_path(prefix) # Repair stale root:root/0700 metadata before staging/network work and before # stopping the currently healthy service. ensure_service_data_access(data, account) release, version = stage(binary, prefix, record['user'], sha) geoip_names: list[str] = [] if refresh_geoip: # Keep the same lock for checksum/download staging and publication. The # runtime worker uses this file too, so manual upgrades cannot race it. geoip_lock_fd = acquire_geoip_update_lock(data, account.pw_uid, account.pw_gid) geoip_stage, geoip_names = prepare_geoip_update(data, prefix) active = subprocess.run(['systemctl', 'is-active', '--quiet', SERVICE], check=False, stdout=subprocess.DEVNULL).returncode == 0 stop_for_managed_update(data) try: backup = backup_stopped(record, prefix) except BaseException: if active: run(['systemctl', 'start', SERVICE]) raise replacement = dict(record, release=release, version=version, sha256=sha) try: if reset_config: reset_saved_config(data) apply_geoip_update(data, geoip_stage, geoip_names, account.pw_uid, account.pw_gid) prepare_managed_acme_runtime(data, account) switch(prefix, release) write_private(UNIT_PATH, service_unit(replacement).encode(), 0o644) run(['systemctl', 'daemon-reload']) if start: run(['systemctl', 'start', SERVICE]) if not healthy(replacement, version): raise RuntimeError('New release failed the local health/version check.') write_private(prefix / 'install.json', json.dumps(replacement, indent=2).encode()) except BaseException as error: details = service_failure_details() if details: print('New release diagnostics:\n' + details, file=sys.stderr) stop_for_managed_update(data) failed = data.with_name(data.name + '.failed-' + uuid.uuid4().hex[:12]) data.rename(failed) # keep the failed generation for diagnosis; do not delete it copy_data(backup / 'data', data) restore_managed_acme_runtime(failed, data) chown_tree(data, account.pw_uid, account.pw_gid) switch(prefix, record['release']) restore_service_unit(backup) write_private(prefix / 'install.json', json.dumps(record, indent=2).encode()) if active: run(['systemctl', 'start', SERVICE]) if not healthy(record, record['version']): raise RuntimeError(f'Update failed: {error}. Rollback restored code/data/unit but previous service health also failed. Backup: {backup}; failed state: {failed}') from error raise RuntimeError(f'Update failed: {error}. Previous code, private data and service unit were restored. Installation is still present; do not run install.sh. Fix the cause and rerun update.sh. Backup: {backup}; failed state: {failed}') from error install_shell_path(prefix) install_logrotate(replacement) print(f'Backup (contains private keys): {backup}') return replacement finally: if geoip_stage is not None: shutil.rmtree(geoip_stage, ignore_errors=True) release_geoip_update_lock(geoip_lock_fd) def install(record: dict, binary: Path, sha: str, start: bool, install_geoip: bool = False) -> dict: import grp prefix = Path(record['prefix']); data = Path(record['data_dir']) if (prefix / 'install.json').exists() or (prefix / 'current').exists() or UNIT_PATH.exists(): raise ValueError('Installation/service already exists. A failed update rolls back to the previous installed release, so rerun update.sh after fixing the reported cause. install.sh never overwrites an existing installation.') if data.exists() and any(data.iterdir()): raise ValueError('First installation requires an empty data directory; existing data will never be reset.') try: account = pwd.getpwnam(record['user']) if account.pw_uid == 0 or account.pw_shell not in ('/usr/sbin/nologin', '/sbin/nologin', '/bin/false'): raise ValueError('Existing service account must be non-root with a non-login shell.') except KeyError: run(['useradd', '--system', '--user-group', '--home-dir', str(data), '--no-create-home', '--shell', '/usr/sbin/nologin', record['user']]) account = pwd.getpwnam(record['user']) record['group'] = grp.getgrgid(account.pw_gid).gr_name prefix.mkdir(parents=True, mode=0o755, exist_ok=True); os.chmod(prefix, 0o755) data.mkdir(parents=True, mode=0o700, exist_ok=True); os.chmod(data, 0o700); os.chown(data, account.pw_uid, account.pw_gid) if install_geoip: download_geoip(data, account.pw_uid, account.pw_gid) release, version = stage(binary, prefix, record['user'], sha) record.update(release=release, version=version, sha256=sha) switch(prefix, release) install_shell_path(prefix) install_logrotate(record) # Record is written even if initial startup fails, so an update can repair it. write_private(prefix / 'install.json', json.dumps(record, indent=2).encode()) write_private(UNIT_PATH, service_unit(record).encode(), 0o644) run(['systemctl', 'daemon-reload']); run(['systemctl', 'enable', SERVICE]) if start: run(['systemctl', 'start', SERVICE]) if not healthy(record, version): run(['systemctl', 'stop', SERVICE]) raise RuntimeError('Installed but startup failed. Data was retained; inspect journalctl -u rustproxy.') return record