#!/usr/bin/env python3 """Install/update a local release binary on Linux/systemd. No builds.""" from __future__ import annotations from deploy_core import * # stable public helper surface used by release checks from deploy_runtime import * # stable install/update helper surface def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('mode', choices=['install', 'update']) parser.add_argument('--binary', type=Path, default=ROOT / 'target/release/rust-proxy') parser.add_argument('--prefix', default='/opt/rustproxy') parser.add_argument('--data-dir', default='/var/lib/rustproxy') parser.add_argument('--user', default='rustproxy') parser.add_argument('--http', default='0.0.0.0:80') tls = parser.add_mutually_exclusive_group() tls.add_argument('--https', default='0.0.0.0:443', help='TLS address (default: 0.0.0.0:443); first certificate is self-signed') tls.add_argument('--no-https', action='store_true', help='Disable the default 443 TLS listener') parser.add_argument('--admin', default='127.0.0.1:9090') parser.add_argument('--allow-remote-admin', action='store_true', help='Allow --admin on 0.0.0.0/:: instead of loopback') parser.add_argument('--sha256', help='Expected SHA-256 of the supplied binary') parser.add_argument('--no-start', action='store_true', help='Leave the service stopped after install/update') parser.add_argument('--plan', action='store_true', help='Print a plan without root privileges, builds or host changes') parser.add_argument('--skip-geoip', action='store_true', help='Skip GeoIP install/update verification (country allow lists may use stale/missing data)') parser.add_argument('--reset-config', action='store_true', help='UPDATE ONLY, development recovery: discard saved vhosts/settings after backup while preserving account/certificates/private files') args = parser.parse_args() os.umask(0o077) try: prefix, data = safe_path(args.prefix), safe_path(args.data_dir) if args.reset_config and args.mode != 'update': raise ValueError('--reset-config is only valid with update.sh.') if prefix == data or prefix in data.parents or data in prefix.parents: raise ValueError('Code and private data must use separate non-nested directories.') validate_user(args.user) admin, remote_admin = admin_address(args.admin, args.allow_remote_admin) record = dict(schema=1, prefix=str(prefix), data_dir=str(data), user=args.user, http=address(args.http), https=None if args.no_https else address(args.https), admin=admin, remote_admin=remote_admin) ports = [x.rsplit(':', 1)[1] for x in (record['http'], record['https'], record['admin']) if x] if len(set(ports)) != len(ports): raise ValueError('HTTP, TLS and administration ports must differ.') binary = args.binary.absolute() if binary.is_symlink() or not binary.is_file() or not os.access(binary, os.X_OK): raise ValueError('Build the release binary as an ordinary user first (bash dev.sh release); provide --binary.') sha = digest(binary) if args.sha256 and args.sha256.lower() != sha: raise ValueError('Binary SHA-256 mismatch; no changes made.') if args.plan: print(json.dumps({'operation':args.mode, 'binary':str(binary), 'sha256':sha, 'install_defaults':record, 'update_uses_saved_settings':args.mode == 'update', 'backup_before_update':True, 'rollback_code_and_data':True, 'start_service':not args.no_start, 'geoip_action':'skip' if args.skip_geoip else ('download-and-verify' if args.mode == 'install' else 'verify-and-refresh-if-stale'), 'shell_path_profile':str(SHELL_PATH_PROFILE), 'shell_path_action':'install' if args.mode == 'install' else 'check-only', 'reset_saved_config':bool(args.reset_config), 'changes_made':False}, indent=2)); return if sys.platform != 'linux' or os.geteuid() != 0 or not shutil.which('systemctl') or not shutil.which('runuser'): raise ValueError('Installation requires root on Linux with systemd and runuser. Use --plan for inspection.') prefix.mkdir(parents=True, mode=0o755, exist_ok=True) if prefix.stat().st_uid != 0 or prefix.stat().st_mode & 0o022: raise ValueError('Installation prefix must be root-owned and not group/world-writable.') fd = os.open(prefix / '.deploy.lock', os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o600) with os.fdopen(fd, 'w') as lock: fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) result = install(record, binary, sha, not args.no_start, not args.skip_geoip) if args.mode == 'install' else update( read_record(prefix), binary, sha, not args.no_start, not args.skip_geoip, args.reset_config ) print(f'RustProxy {result["version"]} installed. Administration: http://{result["admin"]}') print(f'Generate setup token: sudo -u {result["user"]} {prefix}/current/rust-proxy --data-dir {result["data_dir"]} admin setup-token') print(f'Initialize account: sudo -u {result["user"]} {prefix}/current/rust-proxy --data-dir {result["data_dir"]} admin init') if result.get('remote_admin'): print('WARNING: remote administration is plain HTTP. Restrict it with a firewall/private network; a protected tunnel remains safer.') else: print('Use an SSH tunnel for remote administration.') print('No demo backend or firewall changes were installed.') except (OSError, ValueError, RuntimeError, subprocess.SubprocessError, KeyError) as error: parser.exit(1, f'Deployment failed: {error}\n') if __name__ == '__main__': main()