#!/usr/bin/env python3 """Install/update a local release binary on Linux/systemd. No builds. First install downloads the PDDL user-country GeoIP CSV files from the sapics/ip-location-db GitHub releases and verifies publisher SHA-256 files. Updates compare the installed GeoIP files with current publisher checksums and stage only stale assets before service downtime. Updates stop the service before copying all private data, refresh the managed systemd unit, switch an immutable release symlink, check health/version and restore code, data and unit on failure. Use --plan to inspect operations without changing the host. """ from __future__ import annotations import argparse import fcntl import hashlib import http.client import ipaddress import json import os from pathlib import Path import pwd import re import shutil import sqlite3 import ssl import stat import subprocess import sys import time import uuid import urllib.request import urllib.parse ROOT = Path(__file__).resolve().parents[1] UNIT_PATH = Path('/etc/systemd/system/rustproxy.service') SHELL_PATH_PROFILE = Path('/etc/profile.d/rustproxy-path.sh') SERVICE = 'rustproxy.service' GEOIP_ASSETS = ( 'user-country-ipv4.csv', 'user-country-ipv6.csv', ) GEOIP_RELEASE = 'https://github.com/sapics/ip-location-db/releases/download/latest/' GEOIP_CHECKSUM = 'https://github.com/sapics/ip-location-db/releases/download/checksum/' GEOIP_MAX_BYTES = 256 * 1024 * 1024 def run(args: list[str], **kwargs) -> subprocess.CompletedProcess: return subprocess.run(args, check=True, stdin=subprocess.DEVNULL, **kwargs) def safe_path(raw: str) -> Path: if not re.fullmatch(r'/[A-Za-z0-9_./-]+', raw) or '..' in raw.split('/') or raw in ('/', '/etc', '/var', '/opt', '/usr', '/var/lib'): raise ValueError('Use a dedicated absolute path with no spaces, traversal or shell characters.') p = Path(raw) for part in (p, *p.parents): if part.is_symlink(): raise ValueError(f'Symlinked installation/data path is not allowed: {part}') return p def address(raw: str, loopback: bool = False, allow_remote: bool = False) -> str: if raw.startswith('['): host, port = raw[1:].split(']:', 1) else: host, port = raw.rsplit(':', 1) ip = ipaddress.ip_address(host) if not port.isdecimal() or not 1 <= int(port) <= 65535: raise ValueError('Invalid listener IP/port.') if loopback and not ip.is_loopback and not allow_remote: raise ValueError('Administration must use loopback unless remote administration is explicitly accepted.') return f'[{ip}]:{int(port)}' if ip.version == 6 else f'{ip}:{int(port)}' def listener_ip(raw: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address: host = raw[1:].split(']:', 1)[0] if raw.startswith('[') else raw.rsplit(':', 1)[0] return ipaddress.ip_address(host) def admin_address(raw: str, allow_remote: bool = False) -> tuple[str, bool]: value = address(raw) ip = listener_ip(value) remote = not ip.is_loopback if remote and not allow_remote: raise ValueError('Remote administration requires --allow-remote-admin.') return value, remote def digest(path: Path) -> str: with path.open('rb') as stream: result = hashlib.sha256() for block in iter(lambda: stream.read(1024 * 1024), b''): result.update(block) return result.hexdigest() def fetch_https(url: str, max_bytes: int) -> bytes: if urllib.parse.urlparse(url).scheme != 'https': raise ValueError('GeoIP downloads require HTTPS.') request = urllib.request.Request(url, headers={'User-Agent': 'RustProxy-installer/0.25.1'}) with urllib.request.urlopen(request, timeout=30) as response: if urllib.parse.urlparse(response.geturl()).scheme != 'https': raise ValueError('GeoIP download redirected away from HTTPS.') length = response.headers.get('Content-Length') if length and int(length) > max_bytes: raise ValueError('GeoIP download is larger than the configured safety limit.') result = bytearray() while True: block = response.read(min(1024 * 1024, max_bytes + 1 - len(result))) if not block: break result.extend(block) if len(result) > max_bytes: raise ValueError('GeoIP download exceeded the configured safety limit.') return bytes(result) def publisher_geoip_checksums() -> dict[str, str]: expected: dict[str, str] = {} for name in GEOIP_ASSETS: checksum = fetch_https(GEOIP_CHECKSUM + name + '.sha256', 4096).decode('ascii', 'strict').strip() matched = re.search(r'\b([0-9a-fA-F]{64})\b', checksum) if not matched: raise ValueError(f'Invalid publisher checksum for {name}.') expected[name] = matched.group(1).lower() return expected def geoip_file_digest(path: Path) -> str | None: try: st = path.lstat() except FileNotFoundError: return None if not stat.S_ISREG(st.st_mode) or path.is_symlink(): return None return digest(path) def prepare_geoip_update(data: Path, prefix: Path) -> tuple[Path | None, list[str]]: """Verify local GeoIP files against current publisher checksums. Network/download work happens before the service is stopped. Changed files are staged under the code prefix and are copied into the private data transaction only after its backup exists. """ expected = publisher_geoip_checksums() directory = data / 'geoip' if directory.exists() and directory.is_symlink(): raise ValueError('GeoIP directory cannot be a symlink.') stale = [name for name in GEOIP_ASSETS if geoip_file_digest(directory / name) != expected[name]] if not stale: print('GeoIP database is current (publisher SHA-256 matches).') return None, [] staging = prefix / ('.geoip-stage-' + uuid.uuid4().hex) staging.mkdir(mode=0o700) try: for name in stale: payload = fetch_https(GEOIP_RELEASE + name, GEOIP_MAX_BYTES) actual = hashlib.sha256(payload).hexdigest() if actual != expected[name]: raise ValueError(f'GeoIP SHA-256 mismatch for {name}.') write_private(staging / name, payload, 0o600) print('GeoIP update prepared: ' + ', '.join(stale)) return staging, stale except BaseException: shutil.rmtree(staging, ignore_errors=True) raise def copy_private(source: Path, target: Path, mode: int = 0o600) -> None: temporary = target.with_name(target.name + '.' + uuid.uuid4().hex + '.tmp') fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, mode) try: with source.open('rb') as input_stream, os.fdopen(fd, 'wb') as output_stream: shutil.copyfileobj(input_stream, output_stream, 1024 * 1024) output_stream.flush(); os.fsync(output_stream.fileno()) os.replace(temporary, target) directory = os.open(target.parent, os.O_RDONLY | os.O_DIRECTORY) try: os.fsync(directory) finally: os.close(directory) finally: temporary.unlink(missing_ok=True) def apply_geoip_update(data: Path, staging: Path | None, names: list[str], uid: int, gid: int) -> None: if not names: return directory = data / 'geoip' if directory.exists() and directory.is_symlink(): raise ValueError('GeoIP directory cannot be a symlink.') directory.mkdir(mode=0o700, exist_ok=True) os.chmod(directory, 0o700) os.chown(directory, uid, gid) if staging is None: raise ValueError('GeoIP staging directory is missing.') for name in names: source = staging / name target = directory / name copy_private(source, target, 0o600) os.chown(target, uid, gid) print('GeoIP database updated: ' + ', '.join(names)) def download_geoip(data: Path, uid: int, gid: int) -> None: # First install has no code-prefix staging transaction, so download and verify # each asset directly before publishing it atomically into the private data dir. expected = publisher_geoip_checksums() directory = data / 'geoip' if directory.exists() and directory.is_symlink(): raise ValueError('GeoIP directory cannot be a symlink.') directory.mkdir(mode=0o700, exist_ok=True) os.chmod(directory, 0o700) os.chown(directory, uid, gid) for name in GEOIP_ASSETS: payload = fetch_https(GEOIP_RELEASE + name, GEOIP_MAX_BYTES) actual = hashlib.sha256(payload).hexdigest() if actual != expected[name]: raise ValueError(f'GeoIP SHA-256 mismatch for {name}.') target = directory / name write_private(target, payload, 0o600) os.chown(target, uid, gid) def write_private(path: Path, value: bytes, mode: int = 0o600) -> None: temporary = path.with_name(path.name + '.' + uuid.uuid4().hex + '.tmp') fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, mode) try: with os.fdopen(fd, 'wb') as stream: stream.write(value); stream.flush(); os.fsync(stream.fileno()) os.replace(temporary, path) directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) try: os.fsync(directory) finally: os.close(directory) finally: temporary.unlink(missing_ok=True) def shell_path_profile(prefix: Path) -> bytes: binary_dir = prefix / 'current' return ( '# Managed by the RustProxy installer. New login shells can invoke rust-proxy directly.\n' 'case ":${PATH:-}:" in\n' f' *":{binary_dir}:"*) ;;\n' f' *) export PATH="{binary_dir}${{PATH:+:$PATH}}" ;;\n' 'esac\n' ).encode() def install_shell_path(prefix: Path) -> None: write_private(SHELL_PATH_PROFILE, shell_path_profile(prefix), 0o644) def check_shell_path(prefix: Path) -> bool: expected = shell_path_profile(prefix) try: st = SHELL_PATH_PROFILE.lstat() valid = stat.S_ISREG(st.st_mode) and st.st_uid == 0 and not (st.st_mode & 0o022) and SHELL_PATH_PROFILE.read_bytes() == expected except OSError: valid = False if not valid: print( f'WARNING: shell PATH setup is missing or outdated: {SHELL_PATH_PROFILE}. ' f'Expected {prefix}/current in PATH; update mode only checks this file and does not modify it.', file=sys.stderr, ) return valid def read_record(prefix: Path) -> dict: path = prefix / 'install.json' fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW) with os.fdopen(fd, 'rb') as stream: st = os.fstat(stream.fileno()) if not stat.S_ISREG(st.st_mode) or st.st_uid != 0 or st.st_mode & 0o077 or st.st_size > 16384: raise ValueError('Unsafe or invalid root-owned installation record.') value = json.load(stream) if value.get('schema') != 1 or value.get('prefix') != str(prefix): raise ValueError('Installation record mismatch.') safe_path(value['data_dir']); validate_user(value['user']) remote_admin = bool(value.get('remote_admin', False)) admin, checked_remote = admin_address(value['admin'], remote_admin) if checked_remote != remote_admin: raise ValueError('Installation record remote administration flag does not match its address.') value['admin'] = admin value['remote_admin'] = remote_admin return value def validate_user(name: str) -> None: if not re.fullmatch(r'[a-z_][a-z0-9_-]{0,30}', name) or name in ('root', 'nobody'): raise ValueError('Use a dedicated non-root service account.') def service_unit(record: dict) -> str: prefix, data, user = record['prefix'], record['data_dir'], record['user'] args = f'{prefix}/current/rust-proxy --data-dir {data} --admin-listen {record["admin"]} serve' args += f' --proxy-listen {record["http"]}' if record.get('https'): args += f' --tls-listen {record["https"]}' if record.get('remote_admin'): args += ' --allow-remote-admin' return f'''[Unit] Description=RustProxy reverse proxy Wants=network-online.target After=network-online.target [Service] Type=simple User={user} Group={record['group']} WorkingDirectory={data} UMask=0077 Environment=RUST_LOG=rust_proxy=info,warn Environment=MALLOC_ARENA_MAX=2 Environment=PATH=/opt/rustproxy-certbot/bin:/usr/local/bin:/usr/bin:/bin EnvironmentFile=-/etc/rustproxy/environment ExecStart={args} Restart=on-failure RestartSec=3 KillSignal=SIGINT KillMode=control-group TimeoutStopSec=120 AmbientCapabilities=CAP_NET_BIND_SERVICE CapabilityBoundingSet=CAP_NET_BIND_SERVICE NoNewPrivileges=true PrivateTmp=true PrivateDevices=true ProtectSystem=strict LogsDirectory=rustproxy LogsDirectoryMode=0770 ReadWritePaths={data} /var/log/rustproxy ProtectHome=true ProtectKernelTunables=true ProtectKernelModules=true ProtectKernelLogs=true ProtectControlGroups=true RestrictSUIDSGID=true RestrictNamespaces=true RestrictRealtime=true RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 SystemCallArchitectures=native LockPersonality=true [Install] WantedBy=multi-user.target '''