Files
RustProxy/scripts/deploy/deploy.py
T
2026-10-11 22:12:27 +02:00

82 lines
5.9 KiB
Python
Executable File

#!/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-or-repair',
'logrotate_path':str(LOGROTATE_PATH), 'logrotate_action':'install-or-repair',
'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()