This commit is contained in:
Mateusz Gruszczyński committed 2026-10-08 09:42:10 +02:00
1 parent c24bc4279b
commit 3b3f0db377
84 files changed
+2686 -392

No files matched your search

+1 -1
View File
@@ -85,7 +85,7 @@ def download(destination: Path) -> None:
"""TLS verification uses Python's default trust store; never executes a shell."""
started = time.monotonic()
opener = build_opener(HttpsRedirects())
request = Request(DOWNLOAD_URL, headers={"User-Agent": "RustProxy-build/0.27.3"})
request = Request(DOWNLOAD_URL, headers={"User-Agent": "RustProxy-build/0.28.0"})
with opener.open(request, timeout=30) as response, destination.open("wb") as output:
length = response.headers.get("Content-Length")
if length and int(length) > MAX_ARCHIVE:
+87 -40
View File
@@ -1,10 +1,10 @@
#!/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
First install downloads the PDDL user-country GeoIP CSV files directly from the
sapics/ip-location-db GitHub release and validates their structure before publish.
Updates download/validate current files before downtime, compare content SHA-256
locally and stage only changed assets. 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.
@@ -35,12 +35,10 @@ 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_SOURCES = {
'user-country-ipv4.csv': 'https://github.com/sapics/ip-location-db/releases/download/latest/user-country-ipv4.csv',
'user-country-ipv6.csv': 'https://github.com/sapics/ip-location-db/releases/download/latest/user-country-ipv6.csv',
}
GEOIP_MAX_BYTES = 256 * 1024 * 1024
@@ -97,7 +95,7 @@ def digest(path: Path) -> str:
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'})
request = urllib.request.Request(url, headers={'User-Agent': 'RustProxy-installer/0.28.0'})
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.')
@@ -115,15 +113,65 @@ def fetch_https(url: str, max_bytes: int) -> bytes:
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 acquire_geoip_update_lock(data: Path, uid: int | None = None, gid: int | None = None) -> int:
"""Serialize installer/manual GeoIP refresh with the in-process worker."""
path = data / '.geoip-update.lock'
flags = os.O_RDWR | os.O_CREAT
if hasattr(os, 'O_NOFOLLOW'):
flags |= os.O_NOFOLLOW
fd = os.open(path, flags, 0o600)
try:
os.fchmod(fd, 0o600)
if uid is not None and gid is not None:
os.fchown(fd, uid, gid)
fcntl.flock(fd, fcntl.LOCK_EX)
return fd
except BaseException:
os.close(fd)
raise
def release_geoip_update_lock(fd: int | None) -> None:
if fd is None:
return
try:
fcntl.flock(fd, fcntl.LOCK_UN)
finally:
os.close(fd)
def validate_geoip_csv(name: str, payload: bytes) -> None:
"""Reject truncated/error payloads before a GeoIP CSV is published."""
family = 4 if name == 'user-country-ipv4.csv' else 6
try:
text = payload.decode('utf-8', 'strict')
except UnicodeDecodeError as error:
raise ValueError(f'GeoIP {name} is not valid UTF-8.') from error
previous_end: int | None = None
rows = 0
for number, raw in enumerate(text.splitlines(), 1):
line = raw.strip()
if not line or line.startswith('#'):
continue
fields = [value.strip().strip('"') for value in line.split(',')]
if len(fields) != 3:
raise ValueError(f'GeoIP {name} row {number} must contain exactly 3 columns.')
try:
start = ipaddress.ip_address(fields[0])
end = ipaddress.ip_address(fields[1])
except ValueError as error:
raise ValueError(f'GeoIP {name} row {number} contains an invalid IP address.') from error
if start.version != family or end.version != family or int(start) > int(end):
raise ValueError(f'GeoIP {name} row {number} contains an invalid address range.')
if previous_end is not None and int(start) <= previous_end:
raise ValueError(f'GeoIP {name} ranges overlap or are unsorted at row {number}.')
country = fields[2]
if country and not re.fullmatch(r'[A-Za-z]{2}', country):
raise ValueError(f'GeoIP {name} row {number} contains an invalid country code.')
previous_end = int(end)
rows += 1
if rows == 0:
raise ValueError(f'GeoIP {name} does not contain any address ranges.')
def geoip_file_digest(path: Path) -> str | None:
@@ -137,29 +185,31 @@ def geoip_file_digest(path: Path) -> str | None:
def prepare_geoip_update(data: Path, prefix: Path) -> tuple[Path | None, list[str]]:
"""Verify local GeoIP files against current publisher checksums.
"""Download and validate current GeoIP CSVs before service downtime.
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.
Publisher checksums are intentionally not required here: the upstream checksum
release can move independently from the `latest` data release. Local SHA-256 is
used only to decide whether the validated payload changes an installed file.
"""
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]]
payloads: dict[str, bytes] = {}
stale: list[str] = []
for name, url in GEOIP_SOURCES.items():
payload = fetch_https(url, GEOIP_MAX_BYTES)
validate_geoip_csv(name, payload)
payloads[name] = payload
if geoip_file_digest(directory / name) != hashlib.sha256(payload).hexdigest():
stale.append(name)
if not stale:
print('GeoIP database is current (publisher SHA-256 matches).')
print('GeoIP database is current (validated content is unchanged).')
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)
write_private(staging / name, payloads[name], 0o600)
print('GeoIP update prepared: ' + ', '.join(stale))
return staging, stale
except BaseException:
@@ -204,20 +254,17 @@ def apply_geoip_update(data: Path, staging: Path | None, names: list[str], uid:
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()
# First install has no code-prefix staging transaction. Validate each direct
# payload before publishing it atomically into the private data directory.
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}.')
for name, url in GEOIP_SOURCES.items():
payload = fetch_https(url, GEOIP_MAX_BYTES)
validate_geoip_csv(name, payload)
target = directory / name
write_private(target, payload, 0o600)
os.chown(target, uid, gid)
+60 -53
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from deploy_core import * # re-exported internal deployment primitives
ACME_RUNTIME_NAMES = frozenset({'acme-python', 'acme-python.installing', 'acme-python.virtualenv-bootstrap'})
RUNTIME_STATE_NAMES = frozenset({'.geoip-update.lock'})
CERTBOT_PACKAGES = ('certbot==5.8.0', 'certbot-dns-cloudflare==5.8.0', 'certbot-dns-ovh==5.8.0', 'certbot-dns-rfc2136==5.8.0')
def _under_acme_runtime(source: Path, path: Path) -> bool:
@@ -30,7 +31,9 @@ def copy_data(source: Path, target: Path) -> None:
checked_tree(source)
root = source.resolve(strict=True)
def ignore(directory: str, names: list[str]) -> set[str]:
return set(names) & ACME_RUNTIME_NAMES if Path(directory).resolve(strict=True) == root else set()
if Path(directory).resolve(strict=True) != root:
return set()
return set(names) & (ACME_RUNTIME_NAMES | RUNTIME_STATE_NAMES)
shutil.copytree(source, target, symlinks=True, ignore=ignore)
os.chmod(target, 0o700)
@@ -353,64 +356,68 @@ def stop_for_managed_update(data: Path) -> None:
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_lock_fd: int | None = None
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)
prepare_managed_acme_runtime(data, account)
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)
# 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)
# Repair stale root:root/0700 metadata before staging/network work and before
# stopping the currently healthy service.
ensure_service_data_access(data, account)
release, version = stage(binary, prefix, record['user'], sha)
geoip_names: list[str] = []
if refresh_geoip:
# Keep the same lock for checksum/download staging and publication. The
# runtime worker uses this file too, so manual upgrades cannot race it.
geoip_lock_fd = acquire_geoip_update_lock(data, account.pw_uid, account.pw_gid)
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)
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)
restore_managed_acme_runtime(failed, 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
try:
backup = backup_stopped(record, prefix)
except BaseException:
if active:
run(['systemctl', 'start', SERVICE])
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)
prepare_managed_acme_runtime(data, account)
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)
restore_managed_acme_runtime(failed, 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
print(f'Backup (contains private keys): {backup}')
return replacement
finally:
if geoip_stage is not None:
shutil.rmtree(geoip_stage, ignore_errors=True)
print(f'Backup (contains private keys): {backup}')
return replacement
release_geoip_update_lock(geoip_lock_fd)
def install(record: dict, binary: Path, sha: str, start: bool, install_geoip: bool = False) -> dict:
+3 -3
View File
@@ -46,7 +46,7 @@ def upgrade(source: Path, target: Path, *, stopped: bool, data_dir: Path | None
for name in MANAGED:
if (target / name).is_symlink():
raise ValueError(f"Managed target {name} is a symlink; refusing to replace it.")
backup = target / ".upgrade-backup" / ("before-0.27.3-" + uuid.uuid4().hex[:12])
backup = target / ".upgrade-backup" / ("before-0.28.0-" + uuid.uuid4().hex[:12])
backup.mkdir(parents=True, mode=0o700)
installed: list[str] = []
moved: list[str] = []
@@ -84,7 +84,7 @@ def upgrade(source: Path, target: Path, *, stopped: bool, data_dir: Path | None
remove_legacy = legacy.exists()
if remove_legacy and (legacy.is_symlink() or not legacy.is_file()):
raise ValueError("Obsolete renewal observation sidecar must be a regular file.")
(backup / "upgrade.json").write_text(json.dumps({"from_directory":str(target), "to_version":"0.27.3", "managed_paths":MANAGED, "removed_obsolete_runtime_state": remove_legacy}, indent=2)+"\n", encoding="utf-8")
(backup / "upgrade.json").write_text(json.dumps({"from_directory":str(target), "to_version":"0.28.0", "managed_paths":MANAGED, "removed_obsolete_runtime_state": remove_legacy}, indent=2)+"\n", encoding="utf-8")
if remove_legacy:
legacy.unlink()
except BaseException:
@@ -115,7 +115,7 @@ def main() -> None:
backup = upgrade(Path(__file__).resolve().parents[2], args.target, stopped=args.stopped, data_dir=data_dir)
except (OSError, ValueError) as error:
parser.exit(2, f"Upgrade refused or rolled back: {error}\n")
print(f"Installed RustProxy 0.27.3. Previous managed source files: {backup}")
print(f"Installed RustProxy 0.28.0. Previous managed source files: {backup}")
print("Preserved data, .env, .git, target and other unmanaged files. No database migration was run.")
print(f"Next: cd {args.target}; ./dev.sh test; ./dev.sh")