v0.24.5
This commit is contained in:
commit
5e23e9af02
240 files changed
+99957
No files matched your search
Executable
+770
@@ -0,0 +1,770 @@
|
||||
#!/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.5'})
|
||||
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()
|
||||
Reference in new issue
Block a user