v0.27.3
This commit is contained in:
1 parent
e84df445c6
commit
e87a382a7e
109 files changed
+1092
-2925
No files matched your search
Executable
+352
@@ -0,0 +1,352 @@
|
||||
#!/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[2]
|
||||
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.27.3'})
|
||||
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=/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
|
||||
'''
|
||||
|
||||
|
||||
Reference in new issue
Block a user