771 lines
36 KiB
Python
Executable File
771 lines
36 KiB
Python
Executable File
#!/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.24.9'})
|
|
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
|
|
ProtectSystem=strict
|
|
LogsDirectory=rustproxy
|
|
LogsDirectoryMode=0770
|
|
ReadWritePaths={data} /var/log/rustproxy
|
|
ProtectHome=true
|
|
ProtectKernelTunables=true
|
|
ProtectKernelModules=true
|
|
ProtectControlGroups=true
|
|
RestrictSUIDSGID=true
|
|
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
|
LockPersonality=true
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
'''
|
|
|
|
|
|
def checked_tree(source: Path) -> None:
|
|
"""Accept only regular files/directories and relative Certbot-internal symlinks."""
|
|
boundary = source.resolve(strict=True)
|
|
if source.is_symlink():
|
|
raise ValueError('Data root cannot be a symlink.')
|
|
for p in source.rglob('*'):
|
|
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)
|
|
shutil.copytree(source, target, symlinks=True)
|
|
os.chmod(target, 0o700)
|
|
|
|
|
|
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'])
|
|
# Updates deliberately do not rewrite shell environment. Check the installer-
|
|
# managed PATH profile early and report the exact remediation target instead.
|
|
check_shell_path(prefix)
|
|
# Do this before staging/network work and, critically, before stopping the
|
|
# currently healthy service. It also repairs stale root:root/0700 metadata
|
|
# left by manual maintenance while preserving private 0700 semantics.
|
|
ensure_service_data_access(data, account)
|
|
release, version = stage(binary, prefix, record['user'], sha)
|
|
geoip_stage: Path | None = None
|
|
geoip_names: list[str] = []
|
|
if refresh_geoip:
|
|
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])
|
|
if geoip_stage is not None:
|
|
shutil.rmtree(geoip_stage, ignore_errors=True)
|
|
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)
|
|
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); 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
|
|
finally:
|
|
if geoip_stage is not None:
|
|
shutil.rmtree(geoip_stage, ignore_errors=True)
|
|
print(f'Backup (contains private keys): {backup}')
|
|
return replacement
|
|
|
|
|
|
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)
|
|
# 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
|
|
|
|
|
|
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()
|