v0.24.5
This commit is contained in:
commit
5e23e9af02
240 files changed
+99957
No files matched your search
Executable
+449
@@ -0,0 +1,449 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Private Certbot HTTP-01 / DNS-01 multi-provider adapter. JSON input on stdin, no shell.
|
||||
|
||||
This file is also embedded in the Rust binary. Never pass a TSIG secret on the
|
||||
command line. No network operation happens at import or in --doctor.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import base64
|
||||
import datetime
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
DIRECTORIES = {
|
||||
"staging": "https://acme-staging-v02.api.letsencrypt.org/directory",
|
||||
"production": "https://acme-v02.api.letsencrypt.org/directory",
|
||||
}
|
||||
ALGORITHMS = {"HMAC-SHA256", "HMAC-SHA384", "HMAC-SHA512"}
|
||||
MAX_INPUT = 16384
|
||||
MAX_PEM = 131072
|
||||
PROVIDERS = {"http01", "cloudflare", "ovh", "rfc2136"}
|
||||
SECRET_FIELDS = {"key_secret", "api_token", "application_key", "application_secret", "consumer_key"}
|
||||
COMMON_FIELDS = {"provider", "certificate_id", "email", "domains", "environment", "agree_tos", "confirm_production", "propagation_seconds"}
|
||||
PROVIDER_FIELDS = {
|
||||
"http01": set(),
|
||||
"cloudflare": {"api_token"},
|
||||
"ovh": {"endpoint", "application_key", "application_secret", "consumer_key"},
|
||||
"rfc2136": {"dns_server", "dns_port", "key_name", "key_secret", "key_algorithm", "sign_query"},
|
||||
}
|
||||
|
||||
|
||||
def credential(value: object) -> str:
|
||||
# All supported API keys are ASCII tokens. Reject whitespace/newlines and
|
||||
# INI metacharacters; provider input must never add options or sections.
|
||||
if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9_./+\-=]{8,4096}", value):
|
||||
raise AcmeError("acme.invalid_api_credential")
|
||||
return value
|
||||
|
||||
|
||||
class AcmeError(Exception):
|
||||
"""Messages contain fixed diagnostics, never provider output or input secrets."""
|
||||
|
||||
|
||||
def domain(value: object, wildcard: bool = True, single_label: bool = False) -> str:
|
||||
if not isinstance(value, str) or not value.isascii() or len(value) > 253:
|
||||
raise AcmeError("acme.invalid_domain")
|
||||
value = value.lower().rstrip(".")
|
||||
check = value[2:] if wildcard and value.startswith("*.") else value
|
||||
if (not single_label and "." not in check) or any(not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", p) for p in check.split(".")):
|
||||
raise AcmeError("acme.invalid_domain")
|
||||
try:
|
||||
ipaddress.ip_address(check)
|
||||
except ValueError:
|
||||
return value
|
||||
raise AcmeError("acme.dns_names_only")
|
||||
|
||||
|
||||
def validate(raw: object) -> dict:
|
||||
if not isinstance(raw, dict):
|
||||
raise AcmeError("acme.invalid_request")
|
||||
provider = raw.get("provider", "rfc2136")
|
||||
if not isinstance(provider, str) or provider not in PROVIDERS:
|
||||
raise AcmeError("acme.invalid_provider")
|
||||
if set(raw) - (COMMON_FIELDS | PROVIDER_FIELDS[provider]):
|
||||
raise AcmeError("acme.unknown_field")
|
||||
value = dict(raw, provider=provider)
|
||||
if not isinstance(value.get("certificate_id"), str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", value["certificate_id"]):
|
||||
raise AcmeError("acme.invalid_id")
|
||||
email = value.get("email", "")
|
||||
if not isinstance(email, str) or not email.isascii() or len(email) > 254 or not re.fullmatch(r"[^\s@]+@[^\s@]+\.[^\s@]+", email):
|
||||
raise AcmeError("acme.invalid_email")
|
||||
names = value.get("domains")
|
||||
if not isinstance(names, list) or not 1 <= len(names) <= 16:
|
||||
raise AcmeError("acme.invalid_domains")
|
||||
if provider == "http01" and any(isinstance(d, str) and d.startswith("*.") for d in names):
|
||||
raise AcmeError("acme.http01_no_wildcard")
|
||||
value["domains"] = list(dict.fromkeys(domain(d) for d in names))
|
||||
value.setdefault("environment", "staging")
|
||||
if not isinstance(value["environment"], str) or value["environment"] not in DIRECTORIES:
|
||||
raise AcmeError("acme.invalid_environment")
|
||||
if value.get("agree_tos") is not True:
|
||||
raise AcmeError("acme.tos_required")
|
||||
if value["environment"] == "production" and value.get("confirm_production") is not True:
|
||||
raise AcmeError("acme.production_confirmation")
|
||||
propagation = value.get("propagation_seconds", 60)
|
||||
if type(propagation) is not int or not 10 <= propagation <= 900:
|
||||
raise AcmeError("acme.invalid_limits")
|
||||
value["propagation_seconds"] = propagation
|
||||
if provider == "http01":
|
||||
return value
|
||||
if provider == "cloudflare":
|
||||
value["api_token"] = credential(value.get("api_token"))
|
||||
return value
|
||||
if provider == "ovh":
|
||||
if value.get("endpoint", "ovh-eu") not in ("ovh-eu", "ovh-ca"):
|
||||
raise AcmeError("acme.invalid_ovh_endpoint")
|
||||
value.setdefault("endpoint", "ovh-eu")
|
||||
for name in ("application_key", "application_secret", "consumer_key"):
|
||||
value[name] = credential(value.get(name))
|
||||
return value
|
||||
if not isinstance(value.get("dns_server"), str):
|
||||
raise AcmeError("acme.invalid_dns_server")
|
||||
try:
|
||||
value["dns_server"] = str(ipaddress.ip_address(value.get("dns_server", "")))
|
||||
except ValueError:
|
||||
raise AcmeError("acme.invalid_dns_server") from None
|
||||
for name, default, low, high in [("dns_port", 53, 1, 65535), ("propagation_seconds", 60, 10, 900)]:
|
||||
v = value.get(name, default)
|
||||
if type(v) is not int or not low <= v <= high:
|
||||
raise AcmeError("acme.invalid_limits")
|
||||
value[name] = v
|
||||
value["key_name"] = domain(value.get("key_name", ""), wildcard=False, single_label=True) + "."
|
||||
value.setdefault("key_algorithm", "HMAC-SHA256")
|
||||
if not isinstance(value["key_algorithm"], str) or value["key_algorithm"] not in ALGORITHMS:
|
||||
raise AcmeError("acme.invalid_key_algorithm")
|
||||
secret = value.get("key_secret", "")
|
||||
try:
|
||||
if not isinstance(secret, str) or len(secret) > 1024 or not 16 <= len(base64.b64decode(secret, validate=True)) <= 512:
|
||||
raise ValueError()
|
||||
except (ValueError, TypeError):
|
||||
raise AcmeError("acme.invalid_key_secret") from None
|
||||
value.setdefault("sign_query", False)
|
||||
if type(value["sign_query"]) is not bool:
|
||||
raise AcmeError("acme.invalid_sign_query")
|
||||
return value
|
||||
|
||||
|
||||
def private_directory(path: Path) -> None:
|
||||
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
info = path.lstat()
|
||||
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o077:
|
||||
raise AcmeError("acme.unsafe_directory")
|
||||
|
||||
|
||||
def write_new(path: Path, data: bytes) -> None:
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
||||
with os.fdopen(fd, "wb") as stream:
|
||||
stream.write(data)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
|
||||
|
||||
def credentials(value: dict) -> bytes:
|
||||
if value["provider"] == "http01":
|
||||
return b""
|
||||
if value["provider"] == "cloudflare":
|
||||
return f"dns_cloudflare_api_token = {value['api_token']}\n".encode("ascii")
|
||||
if value["provider"] == "ovh":
|
||||
return "".join(f"dns_ovh_{k} = {value[k]}\n" for k in
|
||||
("endpoint", "application_key", "application_secret", "consumer_key")).encode("ascii")
|
||||
lines = {
|
||||
"server": value["dns_server"], "port": value["dns_port"], "name": value["key_name"],
|
||||
"secret": value["key_secret"], "algorithm": value["key_algorithm"],
|
||||
"sign_query": str(value["sign_query"]).lower(),
|
||||
}
|
||||
return "".join(f"dns_rfc2136_{k} = {v}\n" for k, v in lines.items()).encode("ascii")
|
||||
|
||||
|
||||
def certbot_command(value: dict, work: Path, credential_file: Path, executable: str) -> list[str]:
|
||||
args = [executable, "certonly", "--non-interactive", "--agree-tos", "--no-eff-email",
|
||||
"--email", value["email"], "--server", DIRECTORIES[value["environment"]],
|
||||
"--cert-name", "rustproxy", "--keep-until-expiring", "--key-type", "ecdsa",
|
||||
"--config-dir", str(work / "config"), "--work-dir", str(work / "work"),
|
||||
"--logs-dir", str(work / "logs")]
|
||||
if value["provider"] == "http01":
|
||||
args += ["--authenticator", "webroot", "--webroot-path", str(work.parent.parent / "acme-http01"),
|
||||
"--preferred-challenges", "http-01"]
|
||||
else:
|
||||
plugin = "dns-" + value["provider"]
|
||||
args += ["--authenticator", plugin, "--" + plugin + "-credentials", str(credential_file),
|
||||
"--" + plugin + "-propagation-seconds", str(value["propagation_seconds"])]
|
||||
for name in value["domains"]:
|
||||
args += ["-d", name]
|
||||
return args
|
||||
|
||||
|
||||
def read_generated(work: Path, name: str) -> bytes:
|
||||
# Certbot live entries are symlinks into its private archive. Resolve only
|
||||
# within that archive, then open without following a second/final symlink.
|
||||
archive = (work / "config" / "archive").resolve(strict=True)
|
||||
path = (work / "config" / "live" / "rustproxy" / name).resolve(strict=True)
|
||||
if not path.is_relative_to(archive):
|
||||
raise AcmeError("acme.unsafe_output_path")
|
||||
fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
||||
with os.fdopen(fd, "rb") as stream:
|
||||
info = os.fstat(stream.fileno())
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_size > MAX_PEM:
|
||||
raise AcmeError("acme.invalid_pem_size")
|
||||
data = stream.read(MAX_PEM + 1)
|
||||
if len(data) > MAX_PEM or not data.startswith(b"-----BEGIN "):
|
||||
raise AcmeError("acme.invalid_pem")
|
||||
return data
|
||||
|
||||
|
||||
def issue(raw: dict, certificate_directory: Path, executable: str | None = None) -> dict:
|
||||
value = validate(raw)
|
||||
executable = executable or shutil.which("certbot")
|
||||
if not executable:
|
||||
raise AcmeError("acme.certbot_not_installed")
|
||||
certificate_directory = certificate_directory.absolute()
|
||||
private_directory(certificate_directory)
|
||||
root = certificate_directory / "acme"
|
||||
private_directory(root)
|
||||
# Each identity/environment has isolated CA accounts, DNS credentials and
|
||||
# Certbot lineages. Both profile IDs and directory paths are operator-owned.
|
||||
suffix = "" if value["provider"] == "rfc2136" else "-" + value["provider"]
|
||||
work = root / (value["environment"] + "-" + value["certificate_id"] + suffix)
|
||||
private_directory(work)
|
||||
if len(list(root.iterdir())) > 128:
|
||||
raise AcmeError("acme.profile_limit")
|
||||
import fcntl
|
||||
lock = os.open(work / ".lock", os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
||||
with os.fdopen(lock, "w") as lock_file:
|
||||
try:
|
||||
fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
raise AcmeError("acme.profile_busy") from None
|
||||
for folder in ["config", "work", "logs"]:
|
||||
private_directory(work / folder)
|
||||
# Keep credentials for this lineage's renewal. Replace atomically; never
|
||||
# follow an existing credential-file symlink or put secrets in SQLite.
|
||||
credential_file = work / (value["provider"] + ".ini")
|
||||
if value["provider"] == "http01":
|
||||
webroot = certificate_directory / "acme-http01"
|
||||
for path in (webroot, webroot / ".well-known", webroot / ".well-known" / "acme-challenge"):
|
||||
private_directory(path)
|
||||
else:
|
||||
temporary = work / ("credentials-" + uuid.uuid4().hex)
|
||||
write_new(temporary, credentials(value))
|
||||
os.replace(temporary, credential_file)
|
||||
profile = {k: v for k, v in value.items() if k not in SECRET_FIELDS}
|
||||
temporary = work / ("profile-" + uuid.uuid4().hex)
|
||||
write_new(temporary, json.dumps(profile).encode())
|
||||
os.replace(temporary, work / "profile.json")
|
||||
args = certbot_command(value, work, credential_file, executable)
|
||||
try:
|
||||
result = subprocess.run(args, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, timeout=1200 + value["propagation_seconds"], check=False)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise AcmeError("acme.provider_timeout") from None
|
||||
if result.returncode:
|
||||
raise AcmeError("acme.certbot_failed_check_private_log")
|
||||
cert = read_generated(work, "fullchain.pem")
|
||||
key = read_generated(work, "privkey.pem")
|
||||
# Immutable copies allow validation/publication without changing the
|
||||
# active certificate during CA work or a concurrent configuration edit.
|
||||
stem = "acme-" + uuid.uuid4().hex
|
||||
cert_path = certificate_directory / (stem + ".pem")
|
||||
key_path = certificate_directory / (stem + ".key")
|
||||
if len(list(certificate_directory.glob("acme-*.key"))) >= 128:
|
||||
raise AcmeError("acme.staging_limit")
|
||||
try:
|
||||
write_new(key_path, key)
|
||||
write_new(cert_path, cert)
|
||||
except BaseException:
|
||||
cert_path.unlink(missing_ok=True)
|
||||
key_path.unlink(missing_ok=True)
|
||||
raise
|
||||
return {"provider": value["provider"], "certificate_id": value["certificate_id"], "environment": value["environment"],
|
||||
"domains": value["domains"], "cert_file": cert_path.name, "key_file": key_path.name,
|
||||
"staged": True}
|
||||
|
||||
|
||||
|
||||
def profile_directory(certificate_directory: Path, provider: str, environment: str, certificate_id: str) -> Path:
|
||||
suffix = "" if provider == "rfc2136" else "-" + provider
|
||||
return certificate_directory / "acme" / (environment + "-" + certificate_id + suffix)
|
||||
|
||||
|
||||
def read_private(path: Path, limit: int = MAX_INPUT) -> bytes:
|
||||
fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
||||
with os.fdopen(fd, "rb") as stream:
|
||||
info = os.fstat(stream.fileno())
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o077 or info.st_size > limit:
|
||||
raise AcmeError("acme.unsafe_profile")
|
||||
data = stream.read(limit + 1)
|
||||
if len(data) > limit:
|
||||
raise AcmeError("acme.unsafe_profile")
|
||||
return data
|
||||
|
||||
|
||||
def certificate_dates(pem: bytes, executable: str | None = None) -> dict:
|
||||
"""Read validity without exposing subject/PEM data or requiring a new Python package."""
|
||||
executable = executable or shutil.which("openssl")
|
||||
if not executable:
|
||||
raise AcmeError("acme.openssl_not_installed")
|
||||
try:
|
||||
result = subprocess.run([executable, "x509", "-noout", "-startdate", "-enddate"],
|
||||
input=pem, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
||||
timeout=10, check=False, env={**os.environ, "LC_ALL": "C"})
|
||||
if result.returncode or len(result.stdout) > 4096:
|
||||
raise ValueError()
|
||||
dates = {}
|
||||
for line in result.stdout.decode("ascii").splitlines():
|
||||
name, text = line.split("=", 1)
|
||||
# OpenSSL's English ASN.1 date print format; LC_ALL is fixed above.
|
||||
value = datetime.datetime.strptime(text.strip(), "%b %d %H:%M:%S %Y GMT").replace(tzinfo=datetime.timezone.utc)
|
||||
dates[name] = int(value.timestamp())
|
||||
if dates["notAfter"] <= dates["notBefore"]:
|
||||
raise ValueError()
|
||||
return {"not_before_unix": dates["notBefore"], "not_after_unix": dates["notAfter"]}
|
||||
except (ValueError, KeyError, UnicodeError, subprocess.TimeoutExpired):
|
||||
raise AcmeError("acme.certificate_dates_invalid") from None
|
||||
|
||||
|
||||
def renewal_request(raw: object) -> dict:
|
||||
if not isinstance(raw, dict) or set(raw) - {"action", "certificate_id", "provider", "environment", "cert_file", "key_file", "dry_run"}:
|
||||
raise AcmeError("acme.invalid_renewal_request")
|
||||
if raw.get("action") != "renew" or raw.get("provider") not in PROVIDERS or raw.get("environment") not in DIRECTORIES:
|
||||
raise AcmeError("acme.invalid_renewal_request")
|
||||
if not isinstance(raw.get("certificate_id"), str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", raw["certificate_id"]):
|
||||
raise AcmeError("acme.invalid_id")
|
||||
for name, suffix in [("cert_file", ".pem"), ("key_file", ".key")]:
|
||||
value = raw.get(name)
|
||||
if not isinstance(value, str) or not re.fullmatch(r"acme-[A-Za-z0-9_-]{1,80}" + re.escape(suffix), value):
|
||||
raise AcmeError("acme.invalid_renewal_request")
|
||||
if type(raw.get("dry_run", False)) is not bool:
|
||||
raise AcmeError("acme.invalid_renewal_request")
|
||||
return dict(raw, dry_run=raw.get("dry_run", False))
|
||||
|
||||
|
||||
def renew(raw: dict, certificate_directory: Path, executable: str | None = None) -> dict:
|
||||
"""Renew ONE existing, consented lineage; never force issuance or create a profile.
|
||||
|
||||
Certbot decides when a certificate is due (including its ARI support). A zero
|
||||
exit status alone does not prove a renewal: compare actual certificate/key
|
||||
bytes with the installed generation, so crash-after-renewal is recoverable.
|
||||
Dry-run validates challenge access but never stages/deploys its test output.
|
||||
"""
|
||||
value = renewal_request(raw)
|
||||
executable = executable or shutil.which("certbot")
|
||||
if not executable:
|
||||
raise AcmeError("acme.certbot_not_installed")
|
||||
certificate_directory = certificate_directory.absolute()
|
||||
private_directory(certificate_directory)
|
||||
work = profile_directory(certificate_directory, value["provider"], value["environment"], value["certificate_id"])
|
||||
# Do not create missing profile paths during renewal.
|
||||
for path in [certificate_directory / "acme", work, work / "config", work / "work", work / "logs"]:
|
||||
if not path.exists():
|
||||
raise AcmeError("acme.renewal_profile_missing")
|
||||
private_directory(path)
|
||||
import fcntl
|
||||
lock = os.open(work / ".lock", os.O_WRONLY | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
||||
with os.fdopen(lock, "w") as lock_file:
|
||||
try:
|
||||
fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
raise AcmeError("acme.profile_busy") from None
|
||||
try:
|
||||
profile = json.loads(read_private(work / "profile.json"))
|
||||
except (OSError, ValueError):
|
||||
raise AcmeError("acme.renewal_profile_missing") from None
|
||||
if not isinstance(profile, dict) or any(profile.get(k) != value[k] for k in ("provider", "certificate_id", "environment")):
|
||||
raise AcmeError("acme.renewal_profile_mismatch")
|
||||
if profile.get("agree_tos") is not True or (value["environment"] == "production" and profile.get("confirm_production") is not True):
|
||||
raise AcmeError("acme.consent_required")
|
||||
names = profile.get("domains")
|
||||
if not isinstance(names, list) or not 1 <= len(names) <= 16:
|
||||
raise AcmeError("acme.invalid_domains")
|
||||
names = [domain(n, wildcard=value["provider"] != "http01") for n in names]
|
||||
# This exact renewal file must have been created by the original issuance.
|
||||
read_private(work / "config" / "renewal" / "rustproxy.conf", 65536)
|
||||
if value["provider"] != "http01":
|
||||
read_private(work / (value["provider"] + ".ini"))
|
||||
else:
|
||||
for path in [certificate_directory / "acme-http01", certificate_directory / "acme-http01" / ".well-known",
|
||||
certificate_directory / "acme-http01" / ".well-known" / "acme-challenge"]:
|
||||
private_directory(path)
|
||||
installed_cert = read_private(certificate_directory / value["cert_file"], MAX_PEM)
|
||||
installed_key = read_private(certificate_directory / value["key_file"], MAX_PEM)
|
||||
# Check dependencies/installed PEM before invoking a CA operation.
|
||||
old_dates = certificate_dates(installed_cert)
|
||||
args = [executable, "renew", "--cert-name", "rustproxy", "--non-interactive",
|
||||
"--no-random-sleep-on-renew", "--no-directory-hooks",
|
||||
"--config-dir", str(work / "config"), "--work-dir", str(work / "work"), "--logs-dir", str(work / "logs")]
|
||||
if value["dry_run"]:
|
||||
args.append("--dry-run")
|
||||
try:
|
||||
result = subprocess.run(args, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, timeout=2100, check=False)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise AcmeError("acme.provider_timeout") from None
|
||||
if result.returncode:
|
||||
raise AcmeError("acme.certbot_failed_check_private_log")
|
||||
output = {k: value[k] for k in ("provider", "certificate_id", "environment")}
|
||||
output.update(domains=names, changed=False, staged=False, dry_run=value["dry_run"], **old_dates)
|
||||
if value["dry_run"]:
|
||||
return output
|
||||
cert, key = read_generated(work, "fullchain.pem"), read_generated(work, "privkey.pem")
|
||||
dates = certificate_dates(cert)
|
||||
now = int(datetime.datetime.now(datetime.timezone.utc).timestamp())
|
||||
if dates["not_before_unix"] > now + 300 or dates["not_after_unix"] <= now + 300:
|
||||
raise AcmeError("acme.certificate_not_valid")
|
||||
if cert == installed_cert and key == installed_key:
|
||||
return output
|
||||
if dates["not_after_unix"] < old_dates["not_after_unix"]:
|
||||
raise AcmeError("acme.renewal_older_certificate")
|
||||
# Content-addressed immutable staging makes a retry after a publication
|
||||
# failure idempotent (no additional orphan PEMs each time).
|
||||
stem = "acme-" + hashlib.sha256(cert + b"\0" + key).hexdigest()[:48]
|
||||
paths = [(certificate_directory / (stem + ".pem"), cert), (certificate_directory / (stem + ".key"), key)]
|
||||
if len(list(certificate_directory.glob("acme-*.key"))) >= 1024 and not paths[1][0].exists():
|
||||
raise AcmeError("acme.staging_limit")
|
||||
for path, content in paths:
|
||||
try:
|
||||
write_new(path, content)
|
||||
except FileExistsError:
|
||||
if read_private(path, MAX_PEM) != content:
|
||||
raise AcmeError("acme.immutable_output_conflict") from None
|
||||
output.update(changed=True, staged=True, cert_file=paths[0][0].name, key_file=paths[1][0].name, **dates)
|
||||
return output
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--cert-dir", type=Path)
|
||||
parser.add_argument("--doctor", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.doctor:
|
||||
print(json.dumps({"python": sys.version.split()[0], "certbot": shutil.which("certbot"),
|
||||
"providers": sorted(PROVIDERS), "required_plugins": ["certbot-dns-cloudflare", "certbot-dns-ovh", "certbot-dns-rfc2136"], "openssl": shutil.which("openssl"), "automatic_renewal": True, "network_checked": False}))
|
||||
return
|
||||
if not args.cert_dir:
|
||||
parser.error("--cert-dir is required")
|
||||
os.umask(0o077)
|
||||
try:
|
||||
raw = sys.stdin.buffer.read(MAX_INPUT + 1)
|
||||
if len(raw) > MAX_INPUT:
|
||||
raise AcmeError("acme.request_too_large")
|
||||
value = json.loads(raw)
|
||||
result = renew(value, args.cert_dir) if isinstance(value, dict) and value.get("action") == "renew" else issue(value, args.cert_dir)
|
||||
print(json.dumps(result))
|
||||
except (AcmeError, OSError, ValueError) as error:
|
||||
# OSError/JSON errors can contain paths/input. Do not serialize them.
|
||||
message = str(error) if isinstance(error, AcmeError) else "acme.local_io_or_json_error"
|
||||
print(json.dumps({"error": message}))
|
||||
raise SystemExit(1) from None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compatibility entry point; all issuance uses acme_provider."""
|
||||
from acme_provider import * # noqa: F403
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only anonymous admin access probes against a running RustProxy.
|
||||
No cookie, password or token is sent; response bodies are never printed.
|
||||
Only HTTP loopback URLs are accepted. Exit 1 on an unexpected disclosure/status.
|
||||
This is a focused regression probe, NOT a penetration test or full security audit.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import ipaddress
|
||||
import json
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
from urllib.request import Request, build_opener, ProxyHandler, HTTPRedirectHandler
|
||||
from urllib.error import HTTPError, URLError
|
||||
|
||||
PRIVATE = ('/api/status','/api/config','/api/backends','/api/cache','/api/transform','/api/logs','/api/audit',
|
||||
'/api/events','/api/certificates','/api/acme','/api/auth/session','/metrics','/api/metrics',
|
||||
'/docs/openapi.json','/docs/init.js','/docs/style.css','/docs/swagger-ui-bundle.js',
|
||||
'/docs/swagger-ui.css','/docs/licenses.txt','/assets/app.js','/assets/langs.json','/assets/vhosts.js',
|
||||
'/assets/management.js','/assets/integrations.js','/assets/settings.js','/assets/acme.js',
|
||||
'/assets/cache-panel.js','/assets/transform-panel.js','/api/does-not-exist')
|
||||
|
||||
class NoRedirect(HTTPRedirectHandler):
|
||||
def redirect_request(self,*args,**kwargs):
|
||||
return None
|
||||
|
||||
def base_url(value: str) -> str:
|
||||
url=urlsplit(value)
|
||||
if url.scheme!='http' or url.username or url.password or url.path not in ('','/') or url.query or url.fragment:
|
||||
raise argparse.ArgumentTypeError('Use the HTTP loopback administrator origin, e.g. http://127.0.0.1:9090')
|
||||
try:
|
||||
loopback=url.hostname=='localhost' or ipaddress.ip_address(url.hostname).is_loopback
|
||||
if not loopback or not url.port:raise ValueError()
|
||||
except (ValueError,TypeError):raise argparse.ArgumentTypeError('Explicit loopback address and port required')
|
||||
return value.rstrip('/')
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--url',type=base_url,default='http://127.0.0.1:9090');args=parser.parse_args()
|
||||
opener=build_opener(ProxyHandler({}),NoRedirect());failed=0;count=0
|
||||
def check(path,expected,validate):
|
||||
nonlocal failed,count
|
||||
count+=1
|
||||
try:
|
||||
request=Request(args.url+path,headers={'Accept':'application/json'})
|
||||
try:response=opener.open(request,timeout=5)
|
||||
except HTTPError as error:response=error
|
||||
with response:
|
||||
status=response.code;headers=response.headers;body=response.read(8388609 if path=='/assets/login-langs.json' else 131073)
|
||||
ok=status==expected and headers.get('Cache-Control')=='no-store' and validate(body,headers)
|
||||
except (OSError,ValueError,KeyError,TypeError,URLError):status='unavailable';ok=False
|
||||
if not ok:failed+=1
|
||||
print(f'{"PASS" if ok else "FAIL"} {path}: HTTP {status}; expected {expected}')
|
||||
def denied(body,headers):return json.loads(body)=={'error':'auth.required'}
|
||||
for path in PRIVATE:check(path,401,denied)
|
||||
check('/docs',303,lambda b,h:h.get('Location')=='/?next=docs')
|
||||
check('/docs/',303,lambda b,h:h.get('Location')=='/?next=docs')
|
||||
check('/healthz',200,lambda b,h:json.loads(b)=={'status':'ok'})
|
||||
check('/api/auth/status',200,lambda b,h:isinstance((v:=json.loads(b)).get('setup_required'),bool) and set(v)=={'setup_required'})
|
||||
check('/',200,lambda b,h:b'/assets/login.js' in b and all(x not in b for x in [b'/assets/app.js',b'config-editor',b'loginLead']))
|
||||
check('/assets/login-langs.json',200,lambda b,h:all(set(p['translations'])<=set(json.loads((Path(__file__).resolve().parents[1]/'src/admin/web/login-keys.json').read_text())) for p in json.loads(b)['languages']))
|
||||
print(f'{count-failed}/{count} anonymous probes passed. No authenticated/mutation/TLS checks performed.')
|
||||
raise SystemExit(bool(failed))
|
||||
if __name__=='__main__':main()
|
||||
Executable
+99
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create a private, consistent SQLite backup, including a live database in WAL mode.
|
||||
This does not copy source code or accept arbitrary SQL. Restore instructions are in docs/MIGRATION.md.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import stat
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
def backup(data_dir: Path, destination: Path) -> Path:
|
||||
os.umask(0o077)
|
||||
if data_dir.is_symlink() or not data_dir.is_dir():
|
||||
raise ValueError("Data directory must be a real directory, not a symlink.")
|
||||
if os.name == "posix" and stat.S_IMODE(data_dir.stat().st_mode) & 0o077:
|
||||
raise ValueError("Data directory must have mode 700.")
|
||||
source = data_dir / "config.sqlite3"
|
||||
if source.is_symlink() or not source.is_file():
|
||||
raise ValueError("config.sqlite3 must be a regular, existing file.")
|
||||
destination.mkdir(mode=0o700, parents=False, exist_ok=False)
|
||||
target = destination / "config.sqlite3"
|
||||
# The online backup API incorporates committed WAL contents; a plain file copy does not.
|
||||
reader = sqlite3.connect(f"file:{quote(str(source.resolve()), safe='/')}?mode=ro", uri=True, timeout=10)
|
||||
writer = sqlite3.connect(target)
|
||||
try:
|
||||
reader.backup(writer, pages=256, sleep=0.05)
|
||||
check = writer.execute("PRAGMA quick_check").fetchone()[0]
|
||||
if check != "ok":
|
||||
raise RuntimeError(f"SQLite integrity check failed: {check}")
|
||||
schema = writer.execute("PRAGMA user_version").fetchone()[0]
|
||||
revision, document = writer.execute("SELECT revision, document FROM configuration WHERE id = 1").fetchone()
|
||||
config = json.loads(document)
|
||||
finally:
|
||||
writer.close()
|
||||
reader.close()
|
||||
os.chmod(target, 0o600)
|
||||
digest = hashlib.sha256()
|
||||
with target.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
checksum = digest.hexdigest()
|
||||
certificate_hashes = {}
|
||||
names = set()
|
||||
settings = config.get("tls", {})
|
||||
for binding in settings.get("certificates", []):
|
||||
names.update([binding["cert_file"], binding["key_file"]])
|
||||
cert_dir = data_dir / "certs"
|
||||
if cert_dir.exists():
|
||||
if cert_dir.is_symlink() or not cert_dir.is_dir():
|
||||
raise ValueError("Certificate directory must not be a symlink.")
|
||||
for name in ["default.pem", "default.key"]:
|
||||
if (cert_dir / name).exists():
|
||||
names.add(name)
|
||||
if len(names) > 130:
|
||||
raise ValueError("Too many configured certificate files.")
|
||||
if names:
|
||||
(destination / "certs").mkdir(mode=0o700)
|
||||
for name in sorted(names):
|
||||
if not isinstance(name, str) or not re.fullmatch(r"[A-Za-z0-9_.-]{1,96}", name) or ".." in name:
|
||||
raise ValueError("Unsafe certificate filename in the saved configuration.")
|
||||
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
||||
with os.fdopen(os.open(cert_dir / name, flags), "rb") as stream:
|
||||
info = os.fstat(stream.fileno())
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_size > 131072:
|
||||
raise ValueError("Certificate input must be a regular file up to 128 KiB.")
|
||||
content = stream.read(131073)
|
||||
if len(content) > 131072:
|
||||
raise ValueError("Certificate input grew beyond 128 KiB.")
|
||||
with (destination / "certs" / name).open("xb") as stream:
|
||||
stream.write(content)
|
||||
os.chmod(destination / "certs" / name, 0o600)
|
||||
certificate_hashes[name] = hashlib.sha256(content).hexdigest()
|
||||
metadata = {"project":"RustProxy", "created_at_unix":int(time.time()), "schema":schema,
|
||||
"configuration_revision":revision, "sha256":checksum, "certificate_sha256":certificate_hashes,
|
||||
"warning":"Contains password hashes, session state and configured TLS private keys. Keep private. Revoke sessions after restoring."}
|
||||
(destination / "backup.json").write_text(json.dumps(metadata, indent=2) + "\n", encoding="utf-8")
|
||||
return target
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--data-dir", type=Path, default=Path("data"))
|
||||
parser.add_argument("destination", type=Path, help="new directory; existing destinations are never overwritten")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
print(f"Private SQLite and certificate backup created: {backup(args.data_dir, args.destination)}")
|
||||
except (OSError, ValueError, sqlite3.Error, RuntimeError) as error:
|
||||
parser.exit(1, f"Backup failed: {error}\nA partially-created destination must be inspected before reuse.\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+1105
File diff suppressed because it is too large.
Load diff
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()
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate language-pack content and emit the same-shaped bundle used by UI QA.
|
||||
Rust build.rs performs the actual compile-time discovery and embedding.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
def bundle(root=ROOT):
|
||||
languages=[];locales=set()
|
||||
for path in sorted((root/'langs').glob('*.json')):
|
||||
assert re.fullmatch(r'[A-Za-z0-9_-]{1,64}',path.stem),path
|
||||
assert path.stat().st_size<=262144,path
|
||||
pack=json.loads(path.read_text(encoding='utf-8'));assert set(pack)=={'meta','translations'},f'unexpected top-level language-pack keys: {set(pack)-{"meta","translations"}} in {path}';meta=pack['meta'];translations=pack['translations']
|
||||
assert all(isinstance(meta.get(key),str) and 0<len(meta[key].encode())<=128 for key in ['locale','name','native_name','direction']),path
|
||||
assert meta['direction'] in ('ltr','rtl'),path
|
||||
assert re.fullmatch(r'[A-Za-z0-9]{1,8}(?:-[A-Za-z0-9]{1,8})*',meta['locale']),path
|
||||
assert meta['locale'].lower() not in locales, 'duplicate locale'
|
||||
locales.add(meta['locale'].lower())
|
||||
assert isinstance(translations,dict) and len(translations)<=4096,path
|
||||
assert all(isinstance(v,str) and 0<len(k.encode())<=128 and len(v.encode())<=16384 for k,v in translations.items()),path
|
||||
languages.append({'id':path.stem,**pack})
|
||||
assert 1<=len(languages)<=128
|
||||
fallback=next(pack for pack in languages if pack['id']=='en_US')['translations']
|
||||
html=(root/'src/admin/web/index.html').read_text()
|
||||
keys=set(re.findall(r'data-i18n(?:-placeholder|-aria)?="([^"]+)"',html))
|
||||
js='\n'.join(p.read_text() for p in (root/'src/admin/web').glob('*.js'))
|
||||
keys.update(re.findall(r'\bt\("([^"\n]+)"\)',js))
|
||||
keys.update(re.findall(r'"(vh[A-Z][A-Za-z]+)"',js))
|
||||
assert keys<=fallback.keys(),f'Missing fallback keys: {keys-fallback.keys()}'
|
||||
result={'default':'en_US','languages':languages}
|
||||
assert len(json.dumps(result,ensure_ascii=False).encode())<=8388608
|
||||
return result
|
||||
if __name__=='__main__':
|
||||
parser=argparse.ArgumentParser();parser.add_argument('--output',type=Path);args=parser.parse_args()
|
||||
data=bundle()
|
||||
if args.output:args.output.write_text(json.dumps(data,ensure_ascii=False)+'\n')
|
||||
print(f"PASS: {len(data['languages'])} language packs, metadata, fallback UI keys and bounds.")
|
||||
Executable
+589
@@ -0,0 +1,589 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Migrate Nginx Proxy Manager proxy hosts from SQLite into RustProxy.
|
||||
|
||||
The migrator intentionally focuses on proxy-host identity and upstream routing. It
|
||||
never imports certificate/key material from NPM. NPM hosts that used TLS are attached
|
||||
to RustProxy's TLS listener and use the RustProxy default certificate unless a named
|
||||
certificate is supplied explicitly.
|
||||
|
||||
Run without --apply first. The script logs in to the RustProxy administration API,
|
||||
reads the current revision, builds a merged configuration and prints a review report.
|
||||
Only --apply performs the revisioned PUT /api/config.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import dataclass, asdict
|
||||
import getpass
|
||||
import http.cookiejar
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sqlite3
|
||||
import ssl
|
||||
import sys
|
||||
from typing import Any
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
MAX_CONFIG_BYTES = 262_144
|
||||
|
||||
|
||||
class MigrationError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NpmProxyHost:
|
||||
id: int
|
||||
domain_names: list[str]
|
||||
forward_scheme: str
|
||||
forward_host: str
|
||||
forward_port: int
|
||||
allow_websocket_upgrade: bool
|
||||
http2_support: bool
|
||||
enabled: bool
|
||||
certificate_id: int
|
||||
ssl_forced: bool
|
||||
access_list_id: int
|
||||
advanced_config: str
|
||||
locations: list[Any]
|
||||
caching_enabled: bool
|
||||
block_exploits: bool
|
||||
meta: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Decision:
|
||||
npm_id: int
|
||||
action: str
|
||||
hosts: list[str]
|
||||
backend: str | None = None
|
||||
rustproxy_ids: list[str] | None = None
|
||||
warnings: list[str] | None = None
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
def json_value(raw: Any, fallback: Any) -> Any:
|
||||
if raw is None or raw == "":
|
||||
return fallback
|
||||
if isinstance(raw, (list, dict)):
|
||||
return raw
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return fallback
|
||||
|
||||
|
||||
def bool_value(raw: Any) -> bool:
|
||||
if isinstance(raw, str):
|
||||
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
||||
return bool(raw)
|
||||
|
||||
|
||||
def normalize_host_pattern(value: str) -> str:
|
||||
value = value.strip().lower().rstrip(".")
|
||||
wildcard = value.startswith("*.")
|
||||
base = value[2:] if wildcard else value
|
||||
if not base or len(base) > 253 or "*" in base or ":" in base or "/" in base:
|
||||
raise MigrationError(f"invalid hostname {value!r}")
|
||||
labels = base.split(".")
|
||||
if wildcard and len(labels) < 2:
|
||||
raise MigrationError(f"invalid wildcard hostname {value!r}")
|
||||
for label in labels:
|
||||
if not label or len(label) > 63 or label.startswith("-") or label.endswith("-"):
|
||||
raise MigrationError(f"invalid hostname {value!r}")
|
||||
if not re.fullmatch(r"[a-z0-9-]+", label):
|
||||
raise MigrationError(f"hostname must be ASCII/punycode: {value!r}")
|
||||
return ("*." if wildcard else "") + base
|
||||
|
||||
|
||||
def sql_columns(connection: sqlite3.Connection, table: str) -> set[str]:
|
||||
return {str(row[1]) for row in connection.execute(f"PRAGMA table_info({table})")}
|
||||
|
||||
|
||||
def load_npm_hosts(database: Path) -> list[NpmProxyHost]:
|
||||
if database.is_symlink() or not database.is_file():
|
||||
raise MigrationError(f"NPM SQLite database not found or unsafe: {database}")
|
||||
uri = f"file:{urllib.parse.quote(str(database.resolve()))}?mode=ro"
|
||||
connection = sqlite3.connect(uri, uri=True)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
tables = {row[0] for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
if "proxy_host" not in tables:
|
||||
raise MigrationError("NPM database does not contain the proxy_host table")
|
||||
columns = sql_columns(connection, "proxy_host")
|
||||
required = {"id", "domain_names", "forward_scheme", "forward_host", "forward_port"}
|
||||
missing = sorted(required - columns)
|
||||
if missing:
|
||||
raise MigrationError("NPM proxy_host table is missing required columns: " + ", ".join(missing))
|
||||
wanted = [
|
||||
"id", "domain_names", "forward_scheme", "forward_host", "forward_port",
|
||||
"allow_websocket_upgrade", "http2_support", "enabled", "certificate_id",
|
||||
"ssl_forced", "access_list_id", "advanced_config", "locations",
|
||||
"caching_enabled", "block_exploits", "meta", "is_deleted",
|
||||
]
|
||||
select = ", ".join(name for name in wanted if name in columns)
|
||||
where = " WHERE is_deleted = 0" if "is_deleted" in columns else ""
|
||||
rows = connection.execute(f"SELECT {select} FROM proxy_host{where} ORDER BY id").fetchall()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
result: list[NpmProxyHost] = []
|
||||
for row in rows:
|
||||
data = dict(row)
|
||||
domains_raw = json_value(data.get("domain_names"), [])
|
||||
if not isinstance(domains_raw, list):
|
||||
domains_raw = []
|
||||
domains: list[str] = []
|
||||
for item in domains_raw:
|
||||
if isinstance(item, str) and item.strip():
|
||||
domains.append(item.strip())
|
||||
locations = json_value(data.get("locations"), [])
|
||||
if not isinstance(locations, list):
|
||||
locations = []
|
||||
meta = json_value(data.get("meta"), {})
|
||||
if not isinstance(meta, dict):
|
||||
meta = {}
|
||||
try:
|
||||
port = int(data.get("forward_port"))
|
||||
except (TypeError, ValueError):
|
||||
port = 0
|
||||
result.append(NpmProxyHost(
|
||||
id=int(data["id"]),
|
||||
domain_names=domains,
|
||||
forward_scheme=str(data.get("forward_scheme") or "").strip().lower(),
|
||||
forward_host=str(data.get("forward_host") or "").strip(),
|
||||
forward_port=port,
|
||||
allow_websocket_upgrade=bool_value(data.get("allow_websocket_upgrade", False)),
|
||||
http2_support=bool_value(data.get("http2_support", True)),
|
||||
enabled=bool_value(data.get("enabled", True)),
|
||||
certificate_id=int(data.get("certificate_id") or 0),
|
||||
ssl_forced=bool_value(data.get("ssl_forced", False)),
|
||||
access_list_id=int(data.get("access_list_id") or 0),
|
||||
advanced_config=str(data.get("advanced_config") or ""),
|
||||
locations=locations,
|
||||
caching_enabled=bool_value(data.get("caching_enabled", False)),
|
||||
block_exploits=bool_value(data.get("block_exploits", False)),
|
||||
meta=meta,
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
def backend_url(item: NpmProxyHost) -> str:
|
||||
if item.forward_scheme not in {"http", "https"}:
|
||||
raise MigrationError(f"unsupported upstream scheme {item.forward_scheme!r}")
|
||||
if not 1 <= item.forward_port <= 65535:
|
||||
raise MigrationError(f"invalid upstream port {item.forward_port}")
|
||||
host = item.forward_host.strip()
|
||||
if not host or any(ch.isspace() for ch in host) or "/" in host or "@" in host:
|
||||
raise MigrationError(f"invalid upstream host {host!r}")
|
||||
if host.startswith("[") and host.endswith("]"):
|
||||
rendered = host
|
||||
else:
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
rendered = f"[{ip}]" if ip.version == 6 else str(ip)
|
||||
except ValueError:
|
||||
if ":" in host:
|
||||
raise MigrationError(f"invalid upstream host {host!r}")
|
||||
rendered = host
|
||||
return f"{item.forward_scheme}://{rendered}:{item.forward_port}"
|
||||
|
||||
|
||||
def slug(value: str) -> str:
|
||||
value = value.lower().replace("*.", "wildcard-")
|
||||
value = re.sub(r"[^a-z0-9_-]+", "-", value).strip("-_")
|
||||
return value[:40] or "host"
|
||||
|
||||
|
||||
def unique_id(base: str, used: set[str]) -> str:
|
||||
base = re.sub(r"[^A-Za-z0-9_-]+", "-", base)[:64].strip("-_") or "npm"
|
||||
candidate = base
|
||||
index = 2
|
||||
while candidate in used:
|
||||
suffix = f"-{index}"
|
||||
candidate = base[:64 - len(suffix)] + suffix
|
||||
index += 1
|
||||
used.add(candidate)
|
||||
return candidate
|
||||
|
||||
|
||||
def listener_inventory(config: dict[str, Any]) -> tuple[list[str], list[str]]:
|
||||
http_ids: list[str] = []
|
||||
https_ids: list[str] = []
|
||||
for entry in ((config.get("listeners") or {}).get("endpoints") or []):
|
||||
if not isinstance(entry, dict) or not entry.get("enabled", True):
|
||||
continue
|
||||
listener_id = str(entry.get("id") or "").strip()
|
||||
binding = entry.get("binding") or {}
|
||||
if not listener_id or not isinstance(binding, dict):
|
||||
continue
|
||||
(https_ids if binding.get("tls") else http_ids).append(listener_id)
|
||||
return http_ids, https_ids
|
||||
|
||||
|
||||
def selected_tls_ports(config: dict[str, Any], listener_ids: list[str]) -> set[int]:
|
||||
selected = set(listener_ids)
|
||||
ports: set[int] = set()
|
||||
for entry in ((config.get("listeners") or {}).get("endpoints") or []):
|
||||
if not isinstance(entry, dict) or str(entry.get("id") or "") not in selected:
|
||||
continue
|
||||
binding = entry.get("binding") or {}
|
||||
if not isinstance(binding, dict) or not binding.get("tls"):
|
||||
continue
|
||||
for address in [binding.get("address"), *(entry.get("additional_addresses") or [])]:
|
||||
if not isinstance(address, str) or ":" not in address:
|
||||
raise MigrationError(f"invalid RustProxy listener address for Force SSL: {address!r}")
|
||||
try:
|
||||
port = int(address.rsplit(":", 1)[1])
|
||||
except (TypeError, ValueError) as error:
|
||||
raise MigrationError(f"invalid RustProxy listener address for Force SSL: {address!r}") from error
|
||||
if not 1 <= port <= 65535:
|
||||
raise MigrationError(f"invalid RustProxy listener port for Force SSL: {address!r}")
|
||||
ports.add(port)
|
||||
return ports
|
||||
|
||||
|
||||
def existing_host_owners(config: dict[str, Any]) -> dict[str, str]:
|
||||
result: dict[str, str] = {}
|
||||
for vhost in config.get("vhosts") or []:
|
||||
if not isinstance(vhost, dict):
|
||||
continue
|
||||
vid = str(vhost.get("id") or "?")
|
||||
frontend = vhost.get("frontend") or {}
|
||||
for raw in frontend.get("hosts") or []:
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
result[normalize_host_pattern(raw)] = vid
|
||||
except MigrationError:
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
def warnings_for(item: NpmProxyHost, tls_verify: bool) -> list[str]:
|
||||
warnings: list[str] = []
|
||||
if item.access_list_id:
|
||||
warnings.append(f"NPM access_list_id={item.access_list_id} was not imported; attach an ACL Profile manually")
|
||||
if item.advanced_config.strip():
|
||||
warnings.append("NPM advanced_config was not imported")
|
||||
if item.locations:
|
||||
warnings.append(f"{len(item.locations)} NPM custom location(s) were not imported")
|
||||
if item.caching_enabled:
|
||||
warnings.append("NPM caching_enabled was not mapped; configure RustProxy cache policy separately")
|
||||
if item.block_exploits:
|
||||
warnings.append("NPM block_exploits was not mapped")
|
||||
if item.ssl_forced:
|
||||
warnings.append("NPM ssl_forced is mapped to RustProxy Force SSL (HTTP -> HTTPS 308 redirect)")
|
||||
if item.forward_scheme == "https" and tls_verify:
|
||||
warnings.append("HTTPS upstream verification was explicitly enabled; provide a matching DNS name/private CA when needed")
|
||||
if item.meta.get("nginx_err"):
|
||||
warnings.append("NPM meta.nginx_err is set; review the source host before cutover")
|
||||
return warnings
|
||||
|
||||
|
||||
def build_migration(
|
||||
npm_hosts: list[NpmProxyHost],
|
||||
config: dict[str, Any],
|
||||
*,
|
||||
http_listener_ids: list[str] | None = None,
|
||||
https_listener_ids: list[str] | None = None,
|
||||
certificate_id: str | None = None,
|
||||
force_https: bool = False,
|
||||
npm_compatible_backend_tls: bool = False,
|
||||
) -> tuple[dict[str, Any], list[Decision]]:
|
||||
new_config = json.loads(json.dumps(config))
|
||||
new_config.setdefault("vhosts", [])
|
||||
discovered_http, discovered_https = listener_inventory(new_config)
|
||||
http_ids = http_listener_ids if http_listener_ids is not None else discovered_http[:1]
|
||||
https_ids = https_listener_ids if https_listener_ids is not None else discovered_https[:1]
|
||||
unknown_http = [value for value in http_ids if value not in discovered_http]
|
||||
unknown_https = [value for value in https_ids if value not in discovered_https]
|
||||
if unknown_http:
|
||||
raise MigrationError("HTTP listener ID is missing, disabled or TLS-enabled: " + ", ".join(unknown_http))
|
||||
if unknown_https:
|
||||
raise MigrationError("HTTPS listener ID is missing, disabled or not TLS-enabled: " + ", ".join(unknown_https))
|
||||
if not http_ids and not https_ids:
|
||||
raise MigrationError("RustProxy config has no enabled HTTP/TLS listener to attach imported vhosts")
|
||||
https_ports = selected_tls_ports(new_config, https_ids)
|
||||
if certificate_id:
|
||||
known_certificates = {str(item.get("id")) for item in ((new_config.get("tls") or {}).get("certificates") or []) if isinstance(item, dict)}
|
||||
if certificate_id not in known_certificates:
|
||||
raise MigrationError(f"RustProxy certificate ID not found: {certificate_id}")
|
||||
|
||||
owners = existing_host_owners(new_config)
|
||||
used_ids = {str(v.get("id")) for v in new_config.get("vhosts") or [] if isinstance(v, dict)}
|
||||
decisions: list[Decision] = []
|
||||
|
||||
for item in npm_hosts:
|
||||
warnings = warnings_for(item, False)
|
||||
if not item.domain_names:
|
||||
decisions.append(Decision(item.id, "skip", [], warnings=warnings, reason="no domain_names"))
|
||||
continue
|
||||
try:
|
||||
normalized = []
|
||||
seen = set()
|
||||
for raw in item.domain_names:
|
||||
host = normalize_host_pattern(raw)
|
||||
if host not in seen:
|
||||
normalized.append(host)
|
||||
seen.add(host)
|
||||
origin = backend_url(item)
|
||||
except MigrationError as error:
|
||||
decisions.append(Decision(item.id, "skip", item.domain_names, warnings=warnings, reason=str(error)))
|
||||
continue
|
||||
|
||||
conflicts = [(host, owners[host]) for host in normalized if host in owners]
|
||||
if conflicts:
|
||||
reason = "hostname already owned in RustProxy: " + ", ".join(f"{host} ({owner})" for host, owner in conflicts)
|
||||
decisions.append(Decision(item.id, "skip", normalized, backend=origin, warnings=warnings, reason=reason))
|
||||
continue
|
||||
|
||||
force_ssl = item.ssl_forced or force_https
|
||||
source_tls = item.certificate_id > 0 or force_ssl
|
||||
if force_ssl and not https_ids:
|
||||
decisions.append(Decision(
|
||||
item.id, "skip", normalized, backend=origin, warnings=warnings,
|
||||
reason="NPM Force SSL requires an enabled RustProxy TLS listener",
|
||||
))
|
||||
continue
|
||||
if force_ssl and len(https_ports) != 1:
|
||||
decisions.append(Decision(
|
||||
item.id, "skip", normalized, backend=origin, warnings=warnings,
|
||||
reason="RustProxy Force SSL requires the selected TLS listeners to use one HTTPS port",
|
||||
))
|
||||
continue
|
||||
if source_tls:
|
||||
listeners = list(dict.fromkeys([*http_ids, *https_ids]))
|
||||
else:
|
||||
listeners = list(http_ids or https_ids)
|
||||
if source_tls and not https_ids:
|
||||
warnings.append("source host used TLS but RustProxy has no enabled TLS listener; imported on HTTP only")
|
||||
listeners = list(http_ids)
|
||||
if force_ssl and not http_ids:
|
||||
warnings.append("Force SSL is preserved, but no HTTP listener is selected so there is currently no cleartext request to redirect")
|
||||
if not listeners:
|
||||
decisions.append(Decision(item.id, "skip", normalized, backend=origin, warnings=warnings, reason="no compatible RustProxy listener"))
|
||||
continue
|
||||
|
||||
created_ids: list[str] = []
|
||||
base = f"npm-{item.id}-{slug(normalized[0])}"
|
||||
vid = unique_id(base, used_ids)
|
||||
vhost: dict[str, Any] = {
|
||||
"id": vid,
|
||||
"enabled": item.enabled,
|
||||
"force_ssl": force_ssl,
|
||||
"forward_client_ip": True,
|
||||
"websockets": item.allow_websocket_upgrade,
|
||||
"http2_enabled": item.http2_support,
|
||||
"http3_enabled": False,
|
||||
"frontend": {"hosts": normalized, "listener_ids": listeners},
|
||||
"backends": [{
|
||||
"id": "origin",
|
||||
"url": origin,
|
||||
"allow_private": True,
|
||||
"preserve_host": True,
|
||||
"tls_verify": False,
|
||||
}],
|
||||
"load_balancing": {"health_check": {"mode": "off"}},
|
||||
"routes": [{"id": "root", "path_prefix": "/"}],
|
||||
}
|
||||
if source_tls and certificate_id:
|
||||
vhost["certificate_id"] = certificate_id
|
||||
new_config["vhosts"].append(vhost)
|
||||
created_ids.append(vid)
|
||||
for host in normalized:
|
||||
owners[host] = vid
|
||||
decisions.append(Decision(item.id, "import", normalized, backend=origin, rustproxy_ids=created_ids, warnings=warnings))
|
||||
return new_config, decisions
|
||||
|
||||
|
||||
class RustProxyClient:
|
||||
def __init__(self, base_url: str, *, ca_file: Path | None = None, insecure_tls: bool = False):
|
||||
parsed = urllib.parse.urlsplit(base_url.rstrip("/"))
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.path not in {"", "/"}:
|
||||
raise MigrationError("--rustproxy must be an http(s) origin, e.g. https://rustproxy.example:9090")
|
||||
if parsed.query or parsed.fragment or parsed.username or parsed.password:
|
||||
raise MigrationError("--rustproxy must not contain credentials, query or fragment")
|
||||
self.base_url = urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, "", "", ""))
|
||||
jar = http.cookiejar.CookieJar()
|
||||
handlers: list[Any] = [urllib.request.HTTPCookieProcessor(jar)]
|
||||
if parsed.scheme == "https":
|
||||
if insecure_tls:
|
||||
context = ssl._create_unverified_context() # Explicit operator opt-in for local/self-signed admin TLS.
|
||||
else:
|
||||
context = ssl.create_default_context(cafile=str(ca_file) if ca_file else None)
|
||||
handlers.append(urllib.request.HTTPSHandler(context=context))
|
||||
self.opener = urllib.request.build_opener(*handlers)
|
||||
self.csrf = ""
|
||||
|
||||
def request(self, method: str, path: str, body: dict[str, Any] | None = None, *, authenticated_write: bool = False) -> Any:
|
||||
data = None if body is None else json.dumps(body, separators=(",", ":")).encode()
|
||||
headers = {"Accept": "application/json", "X-RustProxy-Request": "1"}
|
||||
if data is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
if method != "GET":
|
||||
headers["Origin"] = self.base_url
|
||||
if authenticated_write:
|
||||
headers["X-CSRF-Token"] = self.csrf
|
||||
request = urllib.request.Request(self.base_url + path, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with self.opener.open(request, timeout=10) as response:
|
||||
payload = response.read(1024 * 1024)
|
||||
except urllib.error.HTTPError as error:
|
||||
payload = error.read(65536)
|
||||
try:
|
||||
detail = json.loads(payload).get("error", payload.decode("utf-8", "replace"))
|
||||
except Exception:
|
||||
detail = payload.decode("utf-8", "replace")
|
||||
raise MigrationError(f"RustProxy API {method} {path} failed with HTTP {error.code}: {detail}") from error
|
||||
except (OSError, urllib.error.URLError) as error:
|
||||
raise MigrationError(f"RustProxy API connection failed: {error}") from error
|
||||
try:
|
||||
return json.loads(payload)
|
||||
except json.JSONDecodeError as error:
|
||||
raise MigrationError(f"RustProxy API returned invalid JSON for {path}") from error
|
||||
|
||||
def login(self, username: str, password: str) -> None:
|
||||
info = self.request("POST", "/api/auth/login", {"username": username, "password": password})
|
||||
token = info.get("csrf_token") if isinstance(info, dict) else None
|
||||
if not isinstance(token, str) or not token:
|
||||
session = self.request("GET", "/api/auth/session")
|
||||
token = session.get("csrf_token") if isinstance(session, dict) else None
|
||||
if not isinstance(token, str) or not token:
|
||||
raise MigrationError("RustProxy login succeeded but no CSRF token was returned")
|
||||
self.csrf = token
|
||||
|
||||
def config(self) -> dict[str, Any]:
|
||||
value = self.request("GET", "/api/config")
|
||||
if not isinstance(value, dict) or not isinstance(value.get("revision"), int) or not isinstance(value.get("config"), dict):
|
||||
raise MigrationError("unexpected RustProxy /api/config response")
|
||||
return value
|
||||
|
||||
def apply(self, revision: int, config: dict[str, Any]) -> dict[str, Any]:
|
||||
value = self.request("PUT", "/api/config", {"expected_revision": revision, "config": config}, authenticated_write=True)
|
||||
if not isinstance(value, dict):
|
||||
raise MigrationError("unexpected RustProxy PUT /api/config response")
|
||||
return value
|
||||
|
||||
|
||||
|
||||
def write_private_json(path: Path, value: dict[str, Any]) -> None:
|
||||
path = path.expanduser()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if path.is_symlink():
|
||||
raise MigrationError(f"refusing to write migration plan through symlink: {path}")
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
fd = os.open(path, flags, 0o600)
|
||||
try:
|
||||
os.fchmod(fd, 0o600)
|
||||
payload = (json.dumps(value, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
|
||||
with os.fdopen(fd, "wb", closefd=False) as stream:
|
||||
stream.write(payload)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
def split_ids(values: list[str] | None) -> list[str] | None:
|
||||
if values is None:
|
||||
return None
|
||||
out: list[str] = []
|
||||
for value in values:
|
||||
for item in value.split(","):
|
||||
item = item.strip()
|
||||
if item and item not in out:
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
def print_report(decisions: list[Decision]) -> None:
|
||||
imported = sum(1 for d in decisions if d.action == "import")
|
||||
skipped = sum(1 for d in decisions if d.action == "skip")
|
||||
print(f"NPM proxy hosts: {len(decisions)}; import: {imported}; skip: {skipped}")
|
||||
for decision in decisions:
|
||||
print(f"\n[{decision.action.upper()}] NPM #{decision.npm_id}")
|
||||
if decision.hosts:
|
||||
print(" hosts: " + ", ".join(decision.hosts))
|
||||
if decision.backend:
|
||||
print(" backend: " + decision.backend)
|
||||
if decision.rustproxy_ids:
|
||||
print(" ids: " + ", ".join(decision.rustproxy_ids))
|
||||
if decision.reason:
|
||||
print(" reason: " + decision.reason)
|
||||
for warning in decision.warnings or []:
|
||||
print(" warning: " + warning)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--database", type=Path, default=Path("/data/database.sqlite"), help="NPM SQLite database (default: /data/database.sqlite)")
|
||||
parser.add_argument("--rustproxy", required=True, help="RustProxy admin origin, e.g. https://rustproxy.example:9090")
|
||||
parser.add_argument("--username", default=os.environ.get("RUSTPROXY_USERNAME"), help="RustProxy administrator username (or RUSTPROXY_USERNAME)")
|
||||
parser.add_argument("--password-env", default="RUSTPROXY_PASSWORD", help="environment variable containing the RustProxy password")
|
||||
parser.add_argument("--ca-file", type=Path, help="CA file for RustProxy administration HTTPS")
|
||||
parser.add_argument("--insecure-admin-tls", action="store_true", help="disable certificate verification for the RustProxy admin HTTPS connection")
|
||||
parser.add_argument("--http-listener", action="append", help="RustProxy HTTP listener ID; repeat or comma-separate. Default: first enabled HTTP listener")
|
||||
parser.add_argument("--https-listener", action="append", help="RustProxy TLS listener ID; repeat or comma-separate. Default: first enabled TLS listener")
|
||||
parser.add_argument("--certificate-id", help="named RustProxy certificate for imported TLS hosts; omit to use the application default certificate")
|
||||
parser.add_argument("--force-https", action="store_true", help="enable RustProxy Force SSL for every imported host and attach it to HTTP+TLS listeners")
|
||||
parser.add_argument("--npm-compatible-backend-tls", action="store_true", help="legacy no-op: backend tls_verify already defaults to false in RustProxy 0.22.1")
|
||||
parser.add_argument("--plan-out", type=Path, default=Path("npm-migration-plan.json"), help="write the generated plan/config JSON here")
|
||||
parser.add_argument("--apply", action="store_true", help="perform the revisioned PUT /api/config; without this flag the run is read-only")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.ca_file and args.insecure_admin_tls:
|
||||
parser.error("choose either --ca-file or --insecure-admin-tls")
|
||||
username = args.username or input("RustProxy username: ").strip()
|
||||
if not username:
|
||||
parser.error("RustProxy username is required")
|
||||
password = os.environ.get(args.password_env)
|
||||
if password is None:
|
||||
password = getpass.getpass("RustProxy password: ")
|
||||
|
||||
try:
|
||||
npm_hosts = load_npm_hosts(args.database)
|
||||
client = RustProxyClient(args.rustproxy, ca_file=args.ca_file, insecure_tls=args.insecure_admin_tls)
|
||||
client.login(username, password)
|
||||
envelope = client.config()
|
||||
merged, decisions = build_migration(
|
||||
npm_hosts,
|
||||
envelope["config"],
|
||||
http_listener_ids=split_ids(args.http_listener),
|
||||
https_listener_ids=split_ids(args.https_listener),
|
||||
certificate_id=args.certificate_id,
|
||||
force_https=args.force_https,
|
||||
npm_compatible_backend_tls=args.npm_compatible_backend_tls,
|
||||
)
|
||||
config_bytes = len(json.dumps(merged, separators=(",", ":"), ensure_ascii=False).encode("utf-8"))
|
||||
if config_bytes > MAX_CONFIG_BYTES:
|
||||
raise MigrationError(f"merged RustProxy config is {config_bytes} bytes; API limit is {MAX_CONFIG_BYTES} bytes")
|
||||
plan = {
|
||||
"source": {"type": "nginx-proxy-manager-sqlite", "database": str(args.database)},
|
||||
"rustproxy": {"origin": args.rustproxy, "expected_revision": envelope["revision"], "config_bytes": config_bytes},
|
||||
"apply_requested": bool(args.apply),
|
||||
"decisions": [asdict(item) for item in decisions],
|
||||
"config": merged,
|
||||
}
|
||||
write_private_json(args.plan_out, plan)
|
||||
print_report(decisions)
|
||||
print(f"\nPlan written: {args.plan_out}")
|
||||
if args.apply:
|
||||
imported = sum(1 for item in decisions if item.action == "import")
|
||||
if imported == 0:
|
||||
raise MigrationError("nothing to import; configuration was not written")
|
||||
applied = client.apply(envelope["revision"], merged)
|
||||
print(f"Applied RustProxy configuration revision {applied.get('revision', '?')} ({imported} NPM proxy host records imported).")
|
||||
else:
|
||||
print("Dry run only. Review the plan and rerun with --apply to write RustProxy configuration.")
|
||||
except (MigrationError, OSError, sqlite3.Error) as error:
|
||||
parser.exit(1, f"Migration failed: {error}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+147
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
"""LOOPBACK-ONLY fake Tinyauth protocol peer. NEVER use this as real authentication.
|
||||
A cookie named mock_auth with value allow approves a request. Any client can mint it.
|
||||
This exists to test RustProxy auth_request, identity forwarding and fail-closed paths.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
from collections import deque
|
||||
from http.cookies import SimpleCookie
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import ipaddress
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
class State:
|
||||
def __init__(self):
|
||||
self.lock = threading.Lock()
|
||||
self.status = None
|
||||
self.delay_ms = 0
|
||||
self.location = None
|
||||
self.calls = 0
|
||||
self.recent = deque(maxlen=16)
|
||||
|
||||
class Server(ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
def __init__(self, address, state=None):
|
||||
if not ipaddress.ip_address(address[0]).is_loopback:
|
||||
raise ValueError("The fake auth server may bind only a numeric loopback address")
|
||||
self.state = state or State()
|
||||
super().__init__(address, Handler)
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
server_version = "RustProxy-Mock-Auth/0.4.0"
|
||||
sys_version = ""
|
||||
def log_message(self, *_):
|
||||
pass
|
||||
def setup(self):
|
||||
super().setup()
|
||||
self.connection.settimeout(5)
|
||||
def reply(self, status, value=None, headers=()):
|
||||
data = b"" if value is None else json.dumps(value, ensure_ascii=True).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Content-Type", "application/json")
|
||||
for key, value in headers:
|
||||
self.send_header(key, value)
|
||||
self.end_headers()
|
||||
if self.command != "HEAD":
|
||||
try:
|
||||
self.wfile.write(data)
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
def do_GET(self):
|
||||
path = urlsplit(self.path).path
|
||||
if self.headers.get("Transfer-Encoding") or self.headers.get("Content-Length", "0") != "0":
|
||||
self.close_connection = True
|
||||
return self.reply(400, {"error":"GET verification requests must have no body"})
|
||||
state = self.server.state
|
||||
if path == "/api/auth/nginx":
|
||||
cookie = SimpleCookie()
|
||||
try:
|
||||
cookie.load(self.headers.get("Cookie", ""))
|
||||
except Exception:
|
||||
pass
|
||||
allowed = "mock_auth" in cookie and cookie["mock_auth"].value == "allow"
|
||||
with state.lock:
|
||||
state.calls += 1
|
||||
# Do not store Cookie, Authorization, API keys, or query strings.
|
||||
state.recent.append({"method":self.headers.get("X-Original-Method"),
|
||||
"client":self.headers.get("X-Real-IP"), "host":self.headers.get("X-Forwarded-Host"),
|
||||
"proto":self.headers.get("X-Forwarded-Proto"),
|
||||
"forwarded":self.headers.get("Forwarded"),"xff":self.headers.get("X-Forwarded-For")})
|
||||
status = state.status if state.status is not None else (204 if allowed else 401)
|
||||
delay_ms, location = state.delay_ms, state.location
|
||||
time.sleep(delay_ms / 1000)
|
||||
headers = []
|
||||
if 200 <= status < 300:
|
||||
headers = [("Remote-User", "mock-user"), ("Remote-Email", "mock@example.test"), ("Remote-Groups", "testers")]
|
||||
if location:
|
||||
headers.append(("X-Tinyauth-Location", location))
|
||||
return self.reply(status, headers=headers)
|
||||
if path == "/state":
|
||||
with state.lock:
|
||||
return self.reply(200, {"calls":state.calls,"status":state.status,"delay_ms":state.delay_ms,"recent":list(state.recent)})
|
||||
if path == "/login":
|
||||
return self.reply(200, {"warning":"Fake login fixture, not authentication", "test_cookie":"mock_auth=allow", "instructions":"Send this cookie to the protected local test vhost."})
|
||||
self.reply(404, {"error":"not found"})
|
||||
def do_POST(self):
|
||||
if urlsplit(self.path).path != "/control":
|
||||
self.close_connection = True
|
||||
return self.reply(405, {"error":"only POST /control is supported"})
|
||||
try:
|
||||
if self.headers.get("Transfer-Encoding") or len(self.headers.get_all("Content-Length", [])) != 1:
|
||||
raise ValueError("one Content-Length is required")
|
||||
size = int(self.headers["Content-Length"])
|
||||
if not 0 <= size <= 4096:
|
||||
raise ValueError("body limit is 4096 bytes")
|
||||
data = self.rfile.read(size)
|
||||
if len(data) != size:
|
||||
raise ValueError("incomplete request")
|
||||
payload = json.loads(data)
|
||||
if not isinstance(payload, dict) or set(payload) - {"status", "delay_ms", "location", "reset"}:
|
||||
raise ValueError("invalid fields")
|
||||
status = payload.get("status")
|
||||
if status is not None and (type(status) is not int or status not in (200, 204, 401, 403, 500, 503)):
|
||||
raise ValueError("unsupported verification status")
|
||||
delay = payload.get("delay_ms", 0)
|
||||
if type(delay) is not int or not 0 <= delay <= 15000:
|
||||
raise ValueError("delay must be 0..15000 ms")
|
||||
location = payload.get("location")
|
||||
if location is not None and (not isinstance(location, str) or len(location) > 2048
|
||||
or any(ord(c) < 32 or ord(c) > 126 for c in location)):
|
||||
raise ValueError("invalid redirect header")
|
||||
with self.server.state.lock:
|
||||
state = self.server.state
|
||||
if payload.get("reset"):
|
||||
state.calls = 0
|
||||
state.recent.clear()
|
||||
state.status, state.delay_ms, state.location = status, delay, location
|
||||
except (ValueError, KeyError, TimeoutError, OSError) as error:
|
||||
self.close_connection = True
|
||||
return self.reply(400, {"error":str(error)})
|
||||
self.reply(200, {"updated":True})
|
||||
def do_HEAD(self):
|
||||
self.do_GET()
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--port", type=int, default=9300)
|
||||
args = parser.parse_args()
|
||||
if not 1 <= args.port <= 65535:
|
||||
parser.error("port must be 1..65535")
|
||||
server = Server(("127.0.0.1", args.port))
|
||||
print(f"FAKE AUTH - LOCAL TESTING ONLY: http://127.0.0.1:{args.port}/api/auth/nginx", flush=True)
|
||||
print("Approve with Cookie: mock_auth=allow. /control configures failures/delays. Ctrl+C stops.", flush=True)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
server.server_close()
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+191
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Local HTTP/1.1 origin for manual cache, health, compression and pool tests.
|
||||
Only Python's standard library is used. This is not a production application.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import ipaddress
|
||||
import json
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
MAX_BODY = 2 * 1024 * 1024
|
||||
MAX_WORKERS = 32
|
||||
|
||||
class State:
|
||||
def __init__(self, name="origin-a", ttl=30):
|
||||
self.name, self.ttl = name, ttl
|
||||
self.healthy, self.delay_ms, self.sequence = True, 0, 0
|
||||
self.lock = threading.Lock()
|
||||
|
||||
class Server(ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
allow_reuse_address = True
|
||||
def __init__(self, address, state):
|
||||
self.state = state
|
||||
self.slots = threading.BoundedSemaphore(MAX_WORKERS)
|
||||
super().__init__(address, Handler)
|
||||
def process_request(self, request, client_address):
|
||||
if not self.slots.acquire(blocking=False):
|
||||
self.shutdown_request(request)
|
||||
return
|
||||
try:
|
||||
super().process_request(request, client_address)
|
||||
except BaseException:
|
||||
self.slots.release()
|
||||
raise
|
||||
def process_request_thread(self, request, client_address):
|
||||
try:
|
||||
super().process_request_thread(request, client_address)
|
||||
finally:
|
||||
self.slots.release()
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
server_version = "RustProxy-Mock/0.3.0"
|
||||
def setup(self):
|
||||
super().setup()
|
||||
self.connection.settimeout(15)
|
||||
def log_message(self, *_):
|
||||
pass
|
||||
def send(self, status, body=b"", content_type="application/json; charset=utf-8", control="no-store", extra=None):
|
||||
if isinstance(body, dict):
|
||||
body = (json.dumps(body, ensure_ascii=False) + "\n").encode()
|
||||
if isinstance(body, str):
|
||||
body = body.encode()
|
||||
if status in (204, 304):
|
||||
body = b""
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", content_type)
|
||||
if status != 204:
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", control)
|
||||
self.send_header("X-Mock-Origin", self.server.state.name)
|
||||
for name, value in (extra or {}).items():
|
||||
self.send_header(name, value)
|
||||
self.end_headers()
|
||||
if self.command != "HEAD":
|
||||
try:
|
||||
self.wfile.write(body)
|
||||
except (BrokenPipeError, ConnectionResetError, TimeoutError):
|
||||
self.close_connection = True
|
||||
def integer(self, query, key, default, maximum):
|
||||
value = int(query.get(key, [str(default)])[0])
|
||||
if not 0 <= value <= maximum:
|
||||
raise ValueError(key)
|
||||
return value
|
||||
def do_HEAD(self):
|
||||
self.do_GET()
|
||||
def do_GET(self):
|
||||
target = urlsplit(self.path)
|
||||
q = parse_qs(target.query)
|
||||
state = self.server.state
|
||||
try:
|
||||
delay = self.integer(q, "ms", 800 if target.path == "/slow" else 0, 10000)
|
||||
size = self.integer(q, "size", 65536, MAX_BODY)
|
||||
ttl = self.integer(q, "ttl", state.ttl, 604800)
|
||||
except ValueError:
|
||||
return self.send(400, {"error":"invalid query parameter"})
|
||||
with state.lock:
|
||||
state.sequence += 1
|
||||
sequence, healthy = state.sequence, state.healthy
|
||||
delay += state.delay_ms
|
||||
if delay:
|
||||
time.sleep(min(delay, 10000) / 1000)
|
||||
payload = {"origin":state.name, "sequence":sequence, "generated_at_ns":time.time_ns(),
|
||||
"nonce":secrets.token_hex(16), "path":target.path,
|
||||
"accept_language":self.headers.get("Accept-Language", ""), "healthy":healthy}
|
||||
if target.path == "/health":
|
||||
return self.send(200 if healthy else 503, payload)
|
||||
if target.path == "/state":
|
||||
return self.send(200, {**payload, "delay_ms":state.delay_ms, "ttl":state.ttl})
|
||||
if target.path == "/":
|
||||
return self.send(200, """<!doctype html><meta charset="utf-8"><title>RustProxy mock origin</title>
|
||||
<h1>RustProxy test origin</h1><p>Each uncached request generates a new sequence and nonce.</p>
|
||||
<ul><li><a href="/cache">/cache</a> - public JSON, 30-second freshness by default</li>
|
||||
<li><a href="/nocache">/nocache</a> - no-store JSON</li><li><a href="/private">/private</a> - private JSON</li>
|
||||
<li><a href="/cookie">/cookie</a> - Set-Cookie, bypass shared cache</li><li><a href="/large">/large?size=65536</a> - compressible bytes</li>
|
||||
<li><a href="/slow">/slow?ms=800</a> - controlled delay</li><li><a href="/health">/health</a> - active probe target</li>
|
||||
<li><a href="/status/503">/status/503</a> - custom error page test</li></ul>
|
||||
<p>POST /control with JSON {"healthy":false,"delay_ms":0} to test failover/recovery.</p>""", "text/html; charset=utf-8")
|
||||
if target.path == "/large":
|
||||
prefix = (json.dumps(payload) + "\n").encode()
|
||||
body = (prefix + b"RustProxy compression and cache fixture.\n" * (size // 30 + 1))[:size]
|
||||
return self.send(200, body, "text/plain; charset=utf-8", f"public, max-age={ttl}")
|
||||
if target.path.startswith("/status/"):
|
||||
try:
|
||||
status = int(target.path.rsplit("/", 1)[1])
|
||||
if status not in [200, 204, 400, 401, 403, 404, 408, 413, 429, 500, 502, 503, 504]:
|
||||
raise ValueError()
|
||||
except ValueError:
|
||||
return self.send(400, {"error":"unsupported status"})
|
||||
return self.send(status, payload, extra={"WWW-Authenticate":'Basic realm="mock"'} if status == 401 else None)
|
||||
if target.path == "/cache":
|
||||
return self.send(200, payload, control=f"public, max-age={ttl}", extra={"Vary":"Accept-Language"})
|
||||
if target.path == "/private":
|
||||
return self.send(200, payload, control="private, max-age=60")
|
||||
if target.path == "/cookie":
|
||||
return self.send(200, payload, control=f"public, max-age={ttl}", extra={"Set-Cookie":"mock-session=demo; HttpOnly; SameSite=Lax; Path=/"})
|
||||
self.send(200 if target.path in ("/nocache", "/slow") else 404, payload)
|
||||
def do_POST(self):
|
||||
# Always consume or close a body before reusing an HTTP/1.1 connection.
|
||||
if self.headers.get("Transfer-Encoding") or len(self.headers.get_all("Content-Length", [])) != 1:
|
||||
self.close_connection = True
|
||||
return self.send(411, {"error":"one Content-Length is required"})
|
||||
try:
|
||||
length = int(self.headers["Content-Length"])
|
||||
if not 0 <= length <= 4096:
|
||||
raise ValueError()
|
||||
raw = self.rfile.read(length)
|
||||
if len(raw) != length:
|
||||
raise ValueError()
|
||||
data = json.loads(raw)
|
||||
if self.path != "/control" or not isinstance(data, dict) or set(data) - {"healthy", "delay_ms"}:
|
||||
raise ValueError()
|
||||
if "healthy" in data and not isinstance(data["healthy"], bool):
|
||||
raise ValueError()
|
||||
if "delay_ms" in data and (type(data["delay_ms"]) is not int or not 0 <= data["delay_ms"] <= 10000):
|
||||
raise ValueError()
|
||||
except (ValueError, TimeoutError):
|
||||
self.close_connection = True
|
||||
return self.send(400, {"error":"expected healthy: boolean and/or delay_ms: 0..10000"})
|
||||
with self.server.state.lock:
|
||||
for key, value in data.items():
|
||||
setattr(self.server.state, key, value)
|
||||
self.send(200, {"updated":True})
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=9001)
|
||||
parser.add_argument("--name", default="origin-a")
|
||||
parser.add_argument("--ttl", type=int, default=30)
|
||||
parser.add_argument("--allow-remote", action="store_true", help="Explicitly expose this development-only server on a non-loopback IPv4 address")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
ip = ipaddress.ip_address(args.host)
|
||||
except ValueError:
|
||||
parser.error("--host must be an IPv4 address")
|
||||
if ip.version != 4:
|
||||
parser.error("this development helper accepts IPv4 bindings only")
|
||||
if not ip.is_loopback and not args.allow_remote:
|
||||
parser.error("non-loopback binding requires --allow-remote; /control is unauthenticated")
|
||||
if not 0 <= args.port <= 65535 or not 0 <= args.ttl <= 604800:
|
||||
parser.error("invalid port or TTL")
|
||||
if not args.name or len(args.name) > 64 or any(ord(c) < 32 or ord(c) > 126 for c in args.name):
|
||||
parser.error("--name must contain 1..64 printable ASCII characters")
|
||||
server = Server((args.host, args.port), State(args.name, args.ttl))
|
||||
print(f"Mock origin {args.name}: http://{args.host}:{server.server_port} (development only)", flush=True)
|
||||
print("Endpoints: /cache /nocache /private /cookie /large /slow /health /status/503; POST /control", flush=True)
|
||||
try:
|
||||
server.serve_forever(poll_interval=0.2)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
server.server_close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,107 @@
|
||||
# OPNsense os-acme-client -> RustProxy
|
||||
|
||||
A simple mechanism for automatically sending a certificate generated by `os-acme-client` on OPNsense to RustProxy via API.
|
||||
|
||||
## Files
|
||||
|
||||
- `opnsense-rustproxy-cert-deploy.sh` - sends the certificate and private key to RustProxy.
|
||||
- `rustproxy-acme.conf` - connection settings, API token, and certificate file paths.
|
||||
- `actions_rustproxy_cert.conf` - optional `configd` action that allows the script to be executed from Cron in OPNsense.
|
||||
|
||||
## 1. Installation
|
||||
|
||||
```sh
|
||||
cp opnsense-rustproxy-cert-deploy.sh /usr/local/sbin/
|
||||
chmod 750 /usr/local/sbin/opnsense-rustproxy-cert-deploy.sh
|
||||
|
||||
cp rustproxy-acme.conf.example /usr/local/etc/rustproxy-acme.conf
|
||||
chmod 600 /usr/local/etc/rustproxy-acme.conf
|
||||
```
|
||||
|
||||
## 2. Configuration
|
||||
|
||||
Edit:
|
||||
|
||||
```sh
|
||||
vi /usr/local/etc/rustproxy-acme.conf
|
||||
```
|
||||
|
||||
Important parameters:
|
||||
|
||||
```sh
|
||||
RUSTPROXY_URL='https://127.0.0.1:9090'
|
||||
RUSTPROXY_KEY_ID='opnsense-acme'
|
||||
RUSTPROXY_TOKEN='RUSTPROXY_TOKEN'
|
||||
|
||||
CERTIFICATE_ID='shared-cert'
|
||||
|
||||
CERT_FILE='/var/etc/acme-client/certs/CERT_UUID/fullchain.pem'
|
||||
KEY_FILE='/var/etc/acme-client/keys/CERT_UUID/private.key'
|
||||
```
|
||||
|
||||
If `os-acme-client` exports the certificate to a custom directory, simply set the correct paths:
|
||||
|
||||
```sh
|
||||
CERT_FILE='/path/to/fullchain.pem'
|
||||
KEY_FILE='/path/to/private.key'
|
||||
```
|
||||
|
||||
`CERTIFICATE_ID` is the stable external certificate id. If it does not exist, RustProxy creates a manual certificate entry. If it already exists as a manual certificate, RustProxy rotates it in place. RustProxy-managed ACME ids are rejected to prevent two renewal owners for one lineage.
|
||||
|
||||
## 3. RustProxy API
|
||||
|
||||
Create a certificate API key in RustProxy and enter its values in the configuration:
|
||||
|
||||
```text
|
||||
RUSTPROXY_KEY_ID
|
||||
RUSTPROXY_TOKEN
|
||||
```
|
||||
|
||||
The script sends the certificate to:
|
||||
|
||||
```text
|
||||
POST /api/certificates/deploy
|
||||
```
|
||||
|
||||
The connection from OPNsense to RustProxy should use HTTPS.
|
||||
|
||||
## 4. Test
|
||||
|
||||
Run the certificate reconciliation:
|
||||
|
||||
```sh
|
||||
/usr/local/sbin/opnsense-rustproxy-cert-deploy.sh
|
||||
```
|
||||
|
||||
On success, you should see a message similar to:
|
||||
|
||||
```text
|
||||
RustProxy certificate reconcile succeeded (HTTP 200, id=shared-cert)
|
||||
```
|
||||
|
||||
## 5. OPNsense Automation
|
||||
|
||||
Optionally install the `configd` action:
|
||||
|
||||
```sh
|
||||
cp actions_rustproxy_cert.conf /usr/local/opnsense/service/conf/actions.d/
|
||||
service configd restart
|
||||
```
|
||||
|
||||
Then go to **System -> Settings -> Cron** and add a scheduled execution of the `Deploy ACME certificate to RustProxy` action.
|
||||
|
||||
The script deliberately reconciles on every run instead of trusting a local cache. The server is the source of truth: an identical RustProxy-owned certificate generation is a no-op, a missing id is created, and changed material is rotated atomically. This also repairs drift when a certificate entry or generation was removed outside OPNsense.
|
||||
|
||||
## Additional Options
|
||||
|
||||
If RustProxy uses a private CA:
|
||||
|
||||
```sh
|
||||
RUSTPROXY_CACERT='/usr/local/etc/ssl/rustproxy-ca.pem'
|
||||
```
|
||||
|
||||
If you want to connect to a specific LAN address while keeping proper TLS hostname verification:
|
||||
|
||||
```sh
|
||||
RUSTPROXY_RESOLVE='rustproxy.lan:8443:192.168.1.20'
|
||||
```
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# Deploy an externally issued OPNsense/os-acme-client certificate to RustProxy 0.23.x.
|
||||
# Configuration is kept separately so the API token does not need to live in this script.
|
||||
|
||||
CONFIG_FILE="${RUSTPROXY_ACME_CONFIG:-/usr/local/etc/rustproxy-acme.conf}"
|
||||
|
||||
log() {
|
||||
logger -t rustproxy-acme -- "$*" 2>/dev/null || true
|
||||
printf '%s\n' "$*"
|
||||
}
|
||||
|
||||
fail() {
|
||||
log "ERROR: $*"
|
||||
exit 1
|
||||
}
|
||||
|
||||
[ -r "$CONFIG_FILE" ] || fail "Cannot read config: $CONFIG_FILE"
|
||||
# shellcheck disable=SC1090
|
||||
. "$CONFIG_FILE"
|
||||
|
||||
: "${RUSTPROXY_URL:?Set RUSTPROXY_URL in $CONFIG_FILE}"
|
||||
: "${RUSTPROXY_KEY_ID:?Set RUSTPROXY_KEY_ID in $CONFIG_FILE}"
|
||||
: "${RUSTPROXY_TOKEN:?Set RUSTPROXY_TOKEN in $CONFIG_FILE}"
|
||||
: "${CERTIFICATE_ID:?Set CERTIFICATE_ID in $CONFIG_FILE}"
|
||||
: "${CERT_FILE:?Set CERT_FILE in $CONFIG_FILE}"
|
||||
: "${KEY_FILE:?Set KEY_FILE in $CONFIG_FILE}"
|
||||
|
||||
CURL="${CURL:-$(command -v curl || true)}"
|
||||
OPENSSL="${OPENSSL:-$(command -v openssl || true)}"
|
||||
[ -n "$CURL" ] || fail "curl not found"
|
||||
[ -n "$OPENSSL" ] || fail "openssl not found"
|
||||
|
||||
case "$CERTIFICATE_ID" in
|
||||
''|*[!A-Za-z0-9_-]*) fail "CERTIFICATE_ID may contain only A-Z, a-z, 0-9, _ and -" ;;
|
||||
esac
|
||||
[ "${#CERTIFICATE_ID}" -le 64 ] || fail "CERTIFICATE_ID is longer than 64 characters"
|
||||
|
||||
[ -r "$CERT_FILE" ] || fail "Certificate not readable: $CERT_FILE"
|
||||
[ -r "$KEY_FILE" ] || fail "Private key not readable: $KEY_FILE"
|
||||
|
||||
# Validate certificate syntax and make sure the certificate and private key belong together.
|
||||
"$OPENSSL" x509 -in "$CERT_FILE" -noout >/dev/null 2>&1 \
|
||||
|| fail "Invalid certificate PEM: $CERT_FILE"
|
||||
"$OPENSSL" pkey -in "$KEY_FILE" -noout >/dev/null 2>&1 \
|
||||
|| fail "Invalid private key PEM: $KEY_FILE"
|
||||
|
||||
CERT_PUB=$(
|
||||
"$OPENSSL" x509 -in "$CERT_FILE" -pubkey -noout 2>/dev/null \
|
||||
| "$OPENSSL" pkey -pubin -outform DER 2>/dev/null \
|
||||
| "$OPENSSL" dgst -sha256 2>/dev/null
|
||||
)
|
||||
KEY_PUB=$(
|
||||
"$OPENSSL" pkey -in "$KEY_FILE" -pubout -outform DER 2>/dev/null \
|
||||
| "$OPENSSL" dgst -sha256 2>/dev/null
|
||||
)
|
||||
[ -n "$CERT_PUB" ] && [ "$CERT_PUB" = "$KEY_PUB" ] \
|
||||
|| fail "Certificate and private key do not match"
|
||||
|
||||
# Do not publish an already-expired certificate.
|
||||
"$OPENSSL" x509 -in "$CERT_FILE" -checkend 0 -noout >/dev/null 2>&1 \
|
||||
|| fail "Certificate is already expired"
|
||||
|
||||
# Always reconcile with RustProxy. The deploy endpoint is idempotent: an identical
|
||||
# RustProxy-owned generation is a server-side no-op, while a missing certificate id
|
||||
# is created and a changed manual certificate is rotated atomically. This avoids
|
||||
# trusting stale local state when the RustProxy configuration was changed separately.
|
||||
|
||||
umask 077
|
||||
TMP_JSON=$(mktemp "${TMPDIR:-/tmp}/rustproxy-cert.XXXXXX") || fail "mktemp failed"
|
||||
TMP_CURL=$(mktemp "${TMPDIR:-/tmp}/rustproxy-curl.XXXXXX") || { rm -f "$TMP_JSON"; fail "mktemp failed"; }
|
||||
TMP_RESPONSE=$(mktemp "${TMPDIR:-/tmp}/rustproxy-response.XXXXXX") || { rm -f "$TMP_JSON" "$TMP_CURL"; fail "mktemp failed"; }
|
||||
trap 'rm -f "$TMP_JSON" "$TMP_CURL" "$TMP_RESPONSE"' EXIT HUP INT TERM
|
||||
|
||||
# PEM is line-oriented Base64 plus BEGIN/END markers. Escape it as a JSON string
|
||||
# without needing jq/python on OPNsense.
|
||||
pem_json() {
|
||||
awk '{
|
||||
gsub(/\\/, "\\\\");
|
||||
gsub(/\"/, "\\\"");
|
||||
sub(/\r$/, "");
|
||||
printf "%s\\n", $0;
|
||||
}' "$1"
|
||||
}
|
||||
|
||||
{
|
||||
printf '{"certificate_id":"%s","cert_pem":"' "$CERTIFICATE_ID"
|
||||
pem_json "$CERT_FILE"
|
||||
printf '","key_pem":"'
|
||||
pem_json "$KEY_FILE"
|
||||
printf '"}\n'
|
||||
} > "$TMP_JSON"
|
||||
|
||||
# Keep the Bearer token out of curl's process arguments by using a mode-0600 config file.
|
||||
cat > "$TMP_CURL" <<EOF_CURL
|
||||
silent
|
||||
show-error
|
||||
connect-timeout = 10
|
||||
max-time = 45
|
||||
request = "POST"
|
||||
url = "${RUSTPROXY_URL%/}/api/certificates/deploy"
|
||||
header = "Authorization: Bearer ${RUSTPROXY_TOKEN}"
|
||||
header = "X-RustProxy-Certificate-Key: ${RUSTPROXY_KEY_ID}"
|
||||
header = "Content-Type: application/json"
|
||||
data-binary = "@${TMP_JSON}"
|
||||
output = "${TMP_RESPONSE}"
|
||||
write-out = "%{http_code}"
|
||||
EOF_CURL
|
||||
|
||||
# Optional trust anchor for an internal/self-signed administration certificate.
|
||||
if [ -n "${RUSTPROXY_CACERT:-}" ]; then
|
||||
[ -r "$RUSTPROXY_CACERT" ] || fail "CA file not readable: $RUSTPROXY_CACERT"
|
||||
printf 'cacert = "%s"\n' "$RUSTPROXY_CACERT" >> "$TMP_CURL"
|
||||
fi
|
||||
|
||||
# Optional curl --resolve equivalent, e.g. rustproxy.lan:8443:10.10.10.20.
|
||||
# This lets the URL use the certificate DNS name while traffic goes to a fixed LAN IP.
|
||||
if [ -n "${RUSTPROXY_RESOLVE:-}" ]; then
|
||||
printf 'resolve = "%s"\n' "$RUSTPROXY_RESOLVE" >> "$TMP_CURL"
|
||||
fi
|
||||
|
||||
HTTP_CODE=$("$CURL" --config "$TMP_CURL") || fail "HTTPS request to RustProxy failed"
|
||||
|
||||
case "$HTTP_CODE" in
|
||||
2??)
|
||||
log "RustProxy certificate reconcile succeeded (HTTP $HTTP_CODE, id=$CERTIFICATE_ID)"
|
||||
cat "$TMP_RESPONSE"
|
||||
printf '\n'
|
||||
;;
|
||||
*)
|
||||
log "RustProxy certificate deploy failed (HTTP $HTTP_CODE)"
|
||||
cat "$TMP_RESPONSE" >&2
|
||||
printf '\n' >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Detect mixed source trees and preserve the known obsolete 0.0.1 auth module."""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
LEGACY_AUTH = "4df9d473c9214365d91c83072f97aafab6ede4edcf4e1369b5c3679ded5cff85"
|
||||
|
||||
|
||||
def check(root: Path, repair_known: bool = False) -> list[str]:
|
||||
messages: list[str] = []
|
||||
legacy = root / "src/auth.rs"
|
||||
if legacy.exists() and (root / "src/auth/mod.rs").is_file():
|
||||
if legacy.is_symlink() or hashlib.sha256(legacy.read_bytes()).hexdigest() != LEGACY_AUTH:
|
||||
raise ValueError("Both src/auth.rs and src/auth/mod.rs exist. The single-file module is not the known 0.0.1 file. Preserve your edits and move it outside src before rebuilding. See docs/MIGRATION.md.")
|
||||
if not repair_known:
|
||||
raise ValueError("Known obsolete src/auth.rs from 0.0.1 found. Run ./dev.sh repair or start ./dev.sh to preserve it in .upgrade-backup/legacy-auth/.")
|
||||
directory = root / ".upgrade-backup/legacy-auth"
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
target = directory / "auth.rs.0.0.1"
|
||||
if target.exists():
|
||||
if hashlib.sha256(target.read_bytes()).hexdigest() != LEGACY_AUTH:
|
||||
raise ValueError("Legacy backup path contains a different file; refusing to overwrite it.")
|
||||
legacy.unlink() # Byte-identical content was already preserved.
|
||||
else:
|
||||
legacy.rename(target)
|
||||
messages.append("Preserved obsolete src/auth.rs as .upgrade-backup/legacy-auth/auth.rs.0.0.1")
|
||||
for module in (root / "src").rglob("mod.rs"):
|
||||
flat = module.parent.with_suffix(".rs")
|
||||
if flat.is_file():
|
||||
raise ValueError(f"Conflicting Rust modules: {flat.relative_to(root)} and {module.relative_to(root)}")
|
||||
for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/upstream.rs", "src/tls.rs", "src/proxy/websocket.rs", "src/cache/mod.rs", "src/cache/model.rs", "src/cache/lifecycle.rs", "src/cache/cold.rs", "src/cache/dashboard.rs", "src/cache/tickets.rs", "src/cache/lookup.rs", "src/cache/policy.rs", "src/cache/fill.rs", "src/cache/tests.rs", "src/settings.rs", "src/compression.rs", "src/diagnostics.rs", "src/system_stats/mod.rs", "src/system_stats/linux.rs", "src/vhost.rs", "src/balance.rs", "src/security.rs", "src/admin/web/vhosts.js", "src/admin/web/streams.js", "src/admin/web/host404.js", "src/admin/web/management.js", "src/pages.rs", "scripts/mock_server.py", "scripts/mock_auth.py", "src/forwarding.rs", "src/tinyauth.rs", "src/http3.rs", "src/stream.rs", "src/admin/web/integrations.js", "src/prometheus.rs", "src/admin/web/settings.js", "src/admin/web/docker.js", "src/docker.rs", "docker-agent/main.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/install.sh", "docs/DOCKER_DISCOVERY.md", "scripts/acme_provider.py", "src/basic_auth.rs", "scripts/deploy.py", "scripts/migrate_npm.py", "docs/NPM_MIGRATION.md", "docs/STREAMS.md", "install.sh", "install-debian13.sh", "update.sh", "build.rs", "scripts/swagger_assets.py", "src/admin/documentation.rs", "src/admin/openapi.json", "langs/en_US.json", "langs/pl_pl.json", "Dockerfile", ".dockerignore", "docs/CONTAINER.md", "docs/PROMETHEUS.md", "docs/NOT_FOUND_HOSTS.md", "debian/control", "debian/rules", "debian/rustproxy.service", "debian/rustproxy.logrotate", "grafana/rustproxy-overview.json"]:
|
||||
if not (root / path).is_file():
|
||||
raise ValueError(f"Incomplete RustProxy 0.24.5 source tree: missing {path}")
|
||||
text = (root / "src/lib.rs").read_text(encoding="utf-8")
|
||||
if '#[path = "auth/mod.rs"]' not in text:
|
||||
raise ValueError("src/lib.rs is not the 0.24.5 entry point; extract the complete package, not individual files.")
|
||||
return messages
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1])
|
||||
parser.add_argument("--repair-known", action="store_true")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
for line in check(args.root.resolve(), args.repair_known):
|
||||
print(line, file=sys.stderr)
|
||||
except (OSError, ValueError) as error:
|
||||
parser.exit(2, f"Source preflight failed: {error}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+1067
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,39 @@
|
||||
# Zot / OCI registry. Do not include a path in REGISTRY.
|
||||
REGISTRY=zot.linuxiarz.pl
|
||||
IMAGE_NAME=rustproxy
|
||||
|
||||
# Alternative shorthand instead of REGISTRY + IMAGE_NAME:
|
||||
# OCI_IMAGE=zot.example.net/network/rustproxy
|
||||
|
||||
# Zot Basic Auth / htpasswd or token credentials.
|
||||
OCI_USERNAME=admin
|
||||
OCI_PASSWORD=
|
||||
# OCI_TOKEN=
|
||||
|
||||
# RouterOS targets used by this helper.
|
||||
BUILD_PLATFORMS=linux/amd64,linux/arm64
|
||||
#BUILD_PLATFORMS=linux/amd64
|
||||
BUILDER=rustproxy
|
||||
|
||||
# RustProxy currently requires Rust 1.88.
|
||||
RUST_VERSION=1.88
|
||||
DEBIAN_SUITE=trixie
|
||||
|
||||
# Empty OCI_TAG means the script uses Cargo.toml package version.
|
||||
OCI_TAG=
|
||||
OCI_LATEST=1
|
||||
OCI_EXTRA_TAGS=
|
||||
|
||||
# Build behavior.
|
||||
OCI_PULL=1
|
||||
OCI_NO_CACHE=0
|
||||
OCI_VERIFY=1
|
||||
OCI_PROGRESS=plain
|
||||
|
||||
# 1 stores BuildKit cache in Zot as IMAGE_NAME:buildcache.
|
||||
# Recommended for CI/ephemeral buildx builders.
|
||||
OCI_REGISTRY_CACHE=0
|
||||
|
||||
# Optional BuildKit config, useful for an internal Zot with a custom CA.
|
||||
# BUILDER_CONFIG=/etc/buildkit/buildkitd.toml
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Zot / OCI registry. Do not include a path in REGISTRY.
|
||||
REGISTRY=zot.example.net
|
||||
IMAGE_NAME=network/rustproxy
|
||||
|
||||
# Alternative shorthand instead of REGISTRY + IMAGE_NAME:
|
||||
# OCI_IMAGE=zot.example.net/network/rustproxy
|
||||
|
||||
# Zot Basic Auth / htpasswd or token credentials.
|
||||
OCI_USERNAME=
|
||||
OCI_PASSWORD=
|
||||
# OCI_TOKEN=
|
||||
|
||||
# RouterOS targets used by this helper.
|
||||
BUILD_PLATFORMS=linux/amd64,linux/arm64
|
||||
BUILDER=rustproxy-routeros
|
||||
|
||||
# RustProxy currently requires Rust 1.88.
|
||||
RUST_VERSION=1.88
|
||||
DEBIAN_SUITE=trixie
|
||||
|
||||
# Empty OCI_TAG means the script uses Cargo.toml package version.
|
||||
OCI_TAG=
|
||||
OCI_LATEST=1
|
||||
OCI_EXTRA_TAGS=routeros
|
||||
|
||||
# Build behavior.
|
||||
OCI_PULL=1
|
||||
OCI_NO_CACHE=0
|
||||
OCI_VERIFY=1
|
||||
OCI_PROGRESS=plain
|
||||
|
||||
# 1 stores BuildKit cache in Zot as IMAGE_NAME:buildcache.
|
||||
# Recommended for CI/ephemeral buildx builders.
|
||||
OCI_REGISTRY_CACHE=0
|
||||
|
||||
# Optional BuildKit config, useful for an internal Zot with a custom CA.
|
||||
# BUILDER_CONFIG=/etc/buildkit/buildkitd.toml
|
||||
|
||||
# Optional manual repository root override.
|
||||
# RUSTPROXY_PROJECT_ROOT=/home/user/RustProxy
|
||||
@@ -0,0 +1,51 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM rust:1.88-trixie AS build
|
||||
|
||||
WORKDIR /build
|
||||
COPY . .
|
||||
|
||||
RUN if [ ! -f Cargo.lock ]; then cargo generate-lockfile; fi \
|
||||
&& cargo build --locked --release
|
||||
|
||||
|
||||
FROM debian:trixie-slim AS runtime-trixie
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
python3 \
|
||||
certbot \
|
||||
openssl \
|
||||
gosu \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& groupadd --system --gid 10001 rustproxy \
|
||||
&& useradd --system \
|
||||
--uid 10001 \
|
||||
--gid rustproxy \
|
||||
--home /var/lib/rustproxy \
|
||||
--shell /usr/sbin/nologin \
|
||||
rustproxy \
|
||||
&& install -d -o rustproxy -g rustproxy -m 0700 /var/lib/rustproxy \
|
||||
&& install -d -o rustproxy -g rustproxy -m 0750 /var/log/rustproxy
|
||||
|
||||
COPY --from=build /build/target/release/rust-proxy /usr/local/bin/rust-proxy
|
||||
COPY scripts/routeros-oci-build/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
RUN chmod 0755 \
|
||||
/usr/local/bin/rust-proxy \
|
||||
/usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
ENV RUST_LOG=rust_proxy=info,warn \
|
||||
RUST_PROXY_DATA_DIR=/var/lib/rustproxy \
|
||||
RUST_PROXY_ADMIN_LISTEN=0.0.0.0:9090 \
|
||||
RUST_PROXY_PROXY_LISTEN=0.0.0.0:8080 \
|
||||
RUST_PROXY_TLS_LISTEN=0.0.0.0:8443
|
||||
|
||||
VOLUME ["/var/lib/rustproxy"]
|
||||
|
||||
EXPOSE 8080/tcp
|
||||
EXPOSE 8443/tcp
|
||||
EXPOSE 9090/tcp
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
CMD ["serve", "--allow-remote-admin"]
|
||||
@@ -0,0 +1,25 @@
|
||||
.git
|
||||
.gitignore
|
||||
.github
|
||||
.idea
|
||||
.vscode
|
||||
target
|
||||
**/target
|
||||
node_modules
|
||||
**/node_modules
|
||||
*.zip
|
||||
*.tar
|
||||
*.tar.gz
|
||||
*.tgz
|
||||
*.pkg
|
||||
*.deb
|
||||
*.rpm
|
||||
.env
|
||||
**/.env
|
||||
*.pem
|
||||
*.key
|
||||
*.p12
|
||||
*.pfx
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.db
|
||||
@@ -0,0 +1,137 @@
|
||||
# RustProxy RouterOS OCI builder - Debian Trixie
|
||||
|
||||
Multi-arch build/push helper for RustProxy images intended for RouterOS Container.
|
||||
It publishes one OCI manifest for `linux/amd64` and `linux/arm64`.
|
||||
|
||||
## Why ARM64 builds are faster
|
||||
|
||||
The Rust build stage is pinned to Docker BuildKit's `BUILDPLATFORM`:
|
||||
|
||||
```dockerfile
|
||||
FROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-trixie AS build
|
||||
```
|
||||
|
||||
On an amd64 build host:
|
||||
|
||||
- amd64 uses native `rustc` + `gcc`,
|
||||
- arm64 uses the same native amd64 `rustc` + `aarch64-linux-gnu-gcc`.
|
||||
|
||||
The expensive Cargo/Rust/C compilation is therefore not executed by an ARM CPU emulator.
|
||||
The final Debian arm64 runtime stage can still use binfmt/QEMU briefly for `apt/dpkg` if the build host is amd64; this is normally a small part of the total build and is cacheable.
|
||||
|
||||
## Repository layout
|
||||
|
||||
Copy this directory as:
|
||||
|
||||
```text
|
||||
RustProxy/
|
||||
Cargo.toml
|
||||
Cargo.lock
|
||||
scripts/
|
||||
routeros-oci-build/
|
||||
build-routeros-oci.sh
|
||||
Dockerfile.routeros
|
||||
Dockerfile.routeros.dockerignore
|
||||
.env
|
||||
```
|
||||
|
||||
Create `.env`:
|
||||
|
||||
```bash
|
||||
cp scripts/routeros-oci-build/.env.example \
|
||||
scripts/routeros-oci-build/.env
|
||||
```
|
||||
|
||||
Then edit the Zot address and credentials.
|
||||
|
||||
## Zot example
|
||||
|
||||
```env
|
||||
REGISTRY=zot.example.net
|
||||
IMAGE_NAME=network/rustproxy
|
||||
OCI_USERNAME=builder
|
||||
OCI_PASSWORD=secret
|
||||
|
||||
BUILD_PLATFORMS=linux/amd64,linux/arm64
|
||||
RUST_VERSION=1.88
|
||||
DEBIAN_SUITE=trixie
|
||||
```
|
||||
|
||||
`OCI_TOKEN` can be used instead of `OCI_PASSWORD`.
|
||||
Credentials are sent to `docker login` using `--password-stdin`.
|
||||
|
||||
## Check configuration
|
||||
|
||||
```bash
|
||||
ENV_FILE=scripts/routeros-oci-build/.env \
|
||||
bash scripts/routeros-oci-build/build-routeros-oci.sh config
|
||||
```
|
||||
|
||||
The repository root is detected using Git. If that is unavailable, the script walks upward until it finds `Cargo.toml`.
|
||||
|
||||
## Build and push multi-arch image
|
||||
|
||||
```bash
|
||||
ENV_FILE=scripts/routeros-oci-build/.env \
|
||||
bash scripts/routeros-oci-build/build-routeros-oci.sh push
|
||||
```
|
||||
|
||||
With RustProxy version `0.24.5` and the defaults this publishes:
|
||||
|
||||
```text
|
||||
zot.example.net/network/rustproxy:0.24.5
|
||||
zot.example.net/network/rustproxy:latest
|
||||
zot.example.net/network/rustproxy:routeros
|
||||
```
|
||||
|
||||
Each tag points to the same amd64 + arm64 OCI manifest.
|
||||
|
||||
## Local single-architecture build
|
||||
|
||||
```bash
|
||||
ENV_FILE=scripts/routeros-oci-build/.env \
|
||||
bash scripts/routeros-oci-build/build-routeros-oci.sh load amd64
|
||||
```
|
||||
|
||||
or:
|
||||
|
||||
```bash
|
||||
ENV_FILE=scripts/routeros-oci-build/.env \
|
||||
bash scripts/routeros-oci-build/build-routeros-oci.sh load arm64
|
||||
```
|
||||
|
||||
`load arm64` loads an ARM64 image into Docker; it does not attempt to start the container.
|
||||
|
||||
## Cache
|
||||
|
||||
BuildKit cache mounts are used automatically for Cargo registry/git/target data.
|
||||
For short-lived CI builders, enable registry cache:
|
||||
|
||||
```env
|
||||
OCI_REGISTRY_CACHE=1
|
||||
```
|
||||
|
||||
It is stored in Zot as:
|
||||
|
||||
```text
|
||||
zot.example.net/network/rustproxy:buildcache
|
||||
```
|
||||
|
||||
## Custom/self-signed Zot CA
|
||||
|
||||
Point `BUILDER_CONFIG` to a BuildKit configuration that trusts the registry CA, for example:
|
||||
|
||||
```env
|
||||
BUILDER_CONFIG=/etc/buildkit/buildkitd.toml
|
||||
```
|
||||
|
||||
Docker itself must also trust the registry CA for `docker login` and manifest inspection.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Docker Engine
|
||||
- Docker Buildx
|
||||
- access to the Zot/OCI registry
|
||||
- an existing RustProxy `Cargo.lock`
|
||||
|
||||
Do not add an ARM-native Rust builder stage. Doing that would put `rustc` and Cargo back under QEMU on an amd64 host.
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
# RustProxy multi-arch OCI builder for RouterOS.
|
||||
# Rust compilation runs on BUILDPLATFORM. Target binaries are cross-compiled,
|
||||
# so rustc/cargo do not run through QEMU for arm64 builds.
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
resolve_project_root() {
|
||||
if [[ -n "${RUSTPROXY_PROJECT_ROOT:-}" ]]; then
|
||||
(cd "$RUSTPROXY_PROJECT_ROOT" && pwd)
|
||||
return
|
||||
fi
|
||||
|
||||
if command -v git >/dev/null 2>&1 && git -C "$SCRIPT_DIR" rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||
git -C "$SCRIPT_DIR" rev-parse --show-toplevel
|
||||
return
|
||||
fi
|
||||
|
||||
local dir="$SCRIPT_DIR"
|
||||
while [[ "$dir" != "/" ]]; do
|
||||
if [[ -f "$dir/Cargo.toml" ]]; then
|
||||
printf '%s\n' "$dir"
|
||||
return
|
||||
fi
|
||||
dir="$(dirname "$dir")"
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
PROJECT_ROOT="$(resolve_project_root)" || {
|
||||
echo "Cannot find repository root (Cargo.toml)." >&2
|
||||
echo "Set RUSTPROXY_PROJECT_ROOT=/path/to/RustProxy." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ -f "$PROJECT_ROOT/Cargo.toml" ]] || {
|
||||
echo "Missing $PROJECT_ROOT/Cargo.toml" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Supports the invocation:
|
||||
# ENV_FILE=scripts/routeros-oci-build/.env bash scripts/routeros-oci-build/build-routeros-oci.sh push
|
||||
ENV_FILE="${ROUTEROS_OCI_ENV_FILE:-${ENV_FILE:-$SCRIPT_DIR/.env}}"
|
||||
if [[ "$ENV_FILE" != /* ]]; then
|
||||
ENV_FILE="$PROJECT_ROOT/$ENV_FILE"
|
||||
fi
|
||||
|
||||
[[ -f "$ENV_FILE" ]] || {
|
||||
echo "Missing environment file: $ENV_FILE" >&2
|
||||
echo "Copy $SCRIPT_DIR/.env.example to $SCRIPT_DIR/.env and edit it." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
|
||||
ACTION="${1:-push}"
|
||||
|
||||
VERSION="$(awk '
|
||||
/^\[package\][[:space:]]*$/ { in_package=1; next }
|
||||
/^\[/ && in_package { exit }
|
||||
in_package && /^[[:space:]]*version[[:space:]]*=/ {
|
||||
line=$0
|
||||
sub(/^[^=]*=[[:space:]]*"/, "", line)
|
||||
sub(/".*/, "", line)
|
||||
print line
|
||||
exit
|
||||
}
|
||||
' "$PROJECT_ROOT/Cargo.toml")"
|
||||
[[ -n "$VERSION" ]] || { echo "Cannot determine package version from Cargo.toml" >&2; exit 1; }
|
||||
|
||||
# Registry/image. Preferred format: REGISTRY + IMAGE_NAME.
|
||||
# OCI_IMAGE is kept as a compatible shorthand.
|
||||
if [[ -n "${OCI_IMAGE:-}" ]]; then
|
||||
IMAGE_REPO="${OCI_IMAGE%/}"
|
||||
REGISTRY="${OCI_REGISTRY:-${IMAGE_REPO%%/*}}"
|
||||
else
|
||||
: "${REGISTRY:?Set REGISTRY in $ENV_FILE}"
|
||||
: "${IMAGE_NAME:?Set IMAGE_NAME in $ENV_FILE}"
|
||||
REGISTRY="${REGISTRY#https://}"
|
||||
REGISTRY="${REGISTRY#http://}"
|
||||
REGISTRY="${REGISTRY%/}"
|
||||
IMAGE_REPO="${REGISTRY}/${IMAGE_NAME#/}"
|
||||
fi
|
||||
|
||||
REGISTRY="${REGISTRY#https://}"
|
||||
REGISTRY="${REGISTRY#http://}"
|
||||
REGISTRY="${REGISTRY%/}"
|
||||
IMAGE_REPO="${IMAGE_REPO#https://}"
|
||||
IMAGE_REPO="${IMAGE_REPO#http://}"
|
||||
IMAGE_REPO="${IMAGE_REPO%/}"
|
||||
|
||||
BUILD_PLATFORMS="${BUILD_PLATFORMS:-linux/amd64,linux/arm64}"
|
||||
BUILDER="${BUILDER:-rustproxy-routeros}"
|
||||
RUST_VERSION="${RUST_VERSION:-1.88}"
|
||||
DEBIAN_SUITE="${DEBIAN_SUITE:-trixie}"
|
||||
DOCKERFILE="${DOCKERFILE:-$SCRIPT_DIR/Dockerfile.routeros}"
|
||||
OCI_TAG="${OCI_TAG:-$VERSION}"
|
||||
OCI_LATEST="${OCI_LATEST:-1}"
|
||||
OCI_EXTRA_TAGS="${OCI_EXTRA_TAGS:-routeros}"
|
||||
OCI_PULL="${OCI_PULL:-1}"
|
||||
OCI_NO_CACHE="${OCI_NO_CACHE:-0}"
|
||||
OCI_VERIFY="${OCI_VERIFY:-1}"
|
||||
OCI_REGISTRY_CACHE="${OCI_REGISTRY_CACHE:-0}"
|
||||
OCI_PROGRESS="${OCI_PROGRESS:-plain}"
|
||||
|
||||
IMAGE_TAG="${IMAGE_REPO}:${OCI_TAG}"
|
||||
|
||||
[[ -f "$DOCKERFILE" ]] || { echo "Missing Dockerfile: $DOCKERFILE" >&2; exit 1; }
|
||||
|
||||
REGISTRY_USERNAME="${OCI_USERNAME:-${REGISTRY_USERNAME:-}}"
|
||||
REGISTRY_SECRET="${OCI_TOKEN:-${OCI_PASSWORD:-${REGISTRY_PASSWORD:-}}}"
|
||||
|
||||
registry_login() {
|
||||
if [[ -z "$REGISTRY_USERNAME" && -z "$REGISTRY_SECRET" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if [[ -z "$REGISTRY_USERNAME" || -z "$REGISTRY_SECRET" ]]; then
|
||||
echo "Set both OCI_USERNAME and OCI_PASSWORD/OCI_TOKEN, or neither." >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s' "$REGISTRY_SECRET" | docker login "$REGISTRY" \
|
||||
--username "$REGISTRY_USERNAME" \
|
||||
--password-stdin
|
||||
}
|
||||
|
||||
ensure_builder() {
|
||||
command -v docker >/dev/null 2>&1 || { echo "docker is required" >&2; exit 127; }
|
||||
docker buildx version >/dev/null 2>&1 || { echo "docker buildx is required" >&2; exit 127; }
|
||||
if docker buildx inspect "$BUILDER" >/dev/null 2>&1; then
|
||||
docker buildx use "$BUILDER"
|
||||
else
|
||||
local args=(create --name "$BUILDER" --driver docker-container --use)
|
||||
[[ -z "${BUILDER_CONFIG:-}" ]] || args+=(--config "$BUILDER_CONFIG")
|
||||
docker buildx "${args[@]}" >/dev/null
|
||||
fi
|
||||
docker buildx inspect --bootstrap >/dev/null
|
||||
}
|
||||
|
||||
revision="unknown"
|
||||
if command -v git >/dev/null 2>&1 && git -C "$PROJECT_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
revision="$(git -C "$PROJECT_ROOT" rev-parse --short=12 HEAD 2>/dev/null || printf 'unknown')"
|
||||
fi
|
||||
created="$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
|
||||
|
||||
common_build_args() {
|
||||
BUILD_ARGS=(
|
||||
build
|
||||
--builder "$BUILDER"
|
||||
--file "$DOCKERFILE"
|
||||
--target runtime-trixie
|
||||
--build-arg "BUILD_VERSION=$VERSION"
|
||||
--build-arg "RUST_VERSION=$RUST_VERSION"
|
||||
--build-arg "DEBIAN_SUITE=$DEBIAN_SUITE"
|
||||
--provenance=false
|
||||
--sbom=false
|
||||
--progress "$OCI_PROGRESS"
|
||||
--label "org.opencontainers.image.title=RustProxy"
|
||||
--label "org.opencontainers.image.version=$VERSION"
|
||||
--label "org.opencontainers.image.revision=$revision"
|
||||
--label "org.opencontainers.image.created=$created"
|
||||
)
|
||||
|
||||
[[ "$OCI_PULL" == "1" ]] && BUILD_ARGS+=(--pull)
|
||||
[[ "$OCI_NO_CACHE" == "1" ]] && BUILD_ARGS+=(--no-cache)
|
||||
|
||||
if [[ "$OCI_REGISTRY_CACHE" == "1" ]]; then
|
||||
BUILD_ARGS+=(
|
||||
--cache-from "type=registry,ref=${IMAGE_REPO}:buildcache"
|
||||
--cache-to "type=registry,ref=${IMAGE_REPO}:buildcache,mode=max,image-manifest=true,oci-mediatypes=true"
|
||||
)
|
||||
fi
|
||||
}
|
||||
|
||||
build_push() {
|
||||
registry_login
|
||||
ensure_builder
|
||||
common_build_args
|
||||
|
||||
BUILD_ARGS+=(
|
||||
--platform "$BUILD_PLATFORMS"
|
||||
--tag "$IMAGE_TAG"
|
||||
)
|
||||
|
||||
if [[ "$OCI_LATEST" == "1" && "$OCI_TAG" != "latest" ]]; then
|
||||
BUILD_ARGS+=(--tag "${IMAGE_REPO}:latest")
|
||||
fi
|
||||
|
||||
if [[ -n "$OCI_EXTRA_TAGS" ]]; then
|
||||
local tag
|
||||
IFS=',' read -r -a extra_tags <<< "$OCI_EXTRA_TAGS"
|
||||
for tag in "${extra_tags[@]}"; do
|
||||
tag="${tag//[[:space:]]/}"
|
||||
[[ -n "$tag" ]] && BUILD_ARGS+=(--tag "${IMAGE_REPO}:${tag}")
|
||||
done
|
||||
fi
|
||||
|
||||
BUILD_ARGS+=(--push "$PROJECT_ROOT")
|
||||
|
||||
cat <<INFO
|
||||
==> RustProxy RouterOS OCI build
|
||||
image: $IMAGE_TAG
|
||||
platforms: $BUILD_PLATFORMS
|
||||
Rust: $RUST_VERSION
|
||||
runtime: debian:$DEBIAN_SUITE-slim
|
||||
builder: $BUILDER
|
||||
project: $PROJECT_ROOT
|
||||
Dockerfile: $DOCKERFILE
|
||||
|
||||
Rust compilation runs on BUILDPLATFORM; arm64 is cross-compiled.
|
||||
INFO
|
||||
|
||||
docker buildx "${BUILD_ARGS[@]}"
|
||||
|
||||
if [[ "$OCI_VERIFY" == "1" ]]; then
|
||||
echo
|
||||
echo "==> Published manifest"
|
||||
docker buildx imagetools inspect "$IMAGE_TAG"
|
||||
fi
|
||||
}
|
||||
|
||||
build_load() {
|
||||
local arch="${2:-amd64}" platform
|
||||
case "$arch" in
|
||||
amd64|x86_64) platform="linux/amd64"; arch="amd64" ;;
|
||||
arm64|aarch64) platform="linux/arm64"; arch="arm64" ;;
|
||||
*) echo "Usage: $0 load [amd64|arm64]" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
ensure_builder
|
||||
common_build_args
|
||||
BUILD_ARGS+=(
|
||||
--platform "$platform"
|
||||
--tag "${IMAGE_REPO}:local-${arch}"
|
||||
--load
|
||||
"$PROJECT_ROOT"
|
||||
)
|
||||
|
||||
docker buildx "${BUILD_ARGS[@]}"
|
||||
printf 'Loaded %s:local-%s (%s)\n' "$IMAGE_REPO" "$arch" "$platform"
|
||||
}
|
||||
|
||||
show_config() {
|
||||
cat <<CFG
|
||||
project_root=$PROJECT_ROOT
|
||||
env_file=$ENV_FILE
|
||||
version=$VERSION
|
||||
image=$IMAGE_TAG
|
||||
registry=$REGISTRY
|
||||
platforms=$BUILD_PLATFORMS
|
||||
builder=$BUILDER
|
||||
rust=$RUST_VERSION
|
||||
debian_suite=$DEBIAN_SUITE
|
||||
dockerfile=$DOCKERFILE
|
||||
registry_cache=$OCI_REGISTRY_CACHE
|
||||
CFG
|
||||
}
|
||||
|
||||
case "$ACTION" in
|
||||
push) build_push ;;
|
||||
load) build_load "${2:-amd64}" ;;
|
||||
config) show_config ;;
|
||||
*)
|
||||
echo "Usage: $0 [push|load [amd64|arm64]|config]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
DATA_DIR="${RUST_PROXY_DATA_DIR:-/var/lib/rustproxy}"
|
||||
RUSTPROXY_UID="${RUSTPROXY_UID:-10001}"
|
||||
RUSTPROXY_GID="${RUSTPROXY_GID:-10001}"
|
||||
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
chown "$RUSTPROXY_UID:$RUSTPROXY_GID" "$DATA_DIR"
|
||||
chmod 0700 "$DATA_DIR"
|
||||
|
||||
exec gosu "$RUSTPROXY_UID:$RUSTPROXY_GID" \
|
||||
/usr/local/bin/rust-proxy "$@"
|
||||
fi
|
||||
|
||||
exec /usr/local/bin/rust-proxy "$@"
|
||||
Executable
+232
@@ -0,0 +1,232 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prepare pinned Swagger assets for build.rs. No Cargo config or runtime CDN needed.
|
||||
|
||||
Only the upstream tag archive (or an operator-supplied local copy of that tag) is
|
||||
accepted. ZIP paths are never extracted: bounded, exact members are read and
|
||||
written to fixed output names. No application data or credentials are accessed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from urllib.parse import urlsplit
|
||||
from urllib.request import HTTPRedirectHandler, Request, build_opener, url2pathname
|
||||
import zipfile
|
||||
import zlib
|
||||
|
||||
VERSION = "5.32.11"
|
||||
DOWNLOAD_URL = f"https://github.com/swagger-api/swagger-ui/archive/refs/tags/v{VERSION}.zip"
|
||||
PREFIX = f"swagger-ui-{VERSION}/"
|
||||
MAX_ARCHIVE = 64 * 1024 * 1024
|
||||
ASSETS = {
|
||||
"swagger-ui-bundle.js": ("dist/swagger-ui-bundle.js", 16 * 1024 * 1024),
|
||||
"swagger-ui.css": ("dist/swagger-ui.css", 2 * 1024 * 1024),
|
||||
}
|
||||
LICENSE_MEMBERS = ("LICENSE", "NOTICE", "dist/swagger-ui-bundle.js.LICENSE.txt")
|
||||
HELP = ("Install Python 3.10+ and allow HTTPS access to github.com/codeload.github.com, "
|
||||
"or set SWAGGER_UI_DOWNLOAD_URL=file:///absolute/path/swagger-ui-5.32.11.zip "
|
||||
"to a reviewed copy of the same tag archive. See docs/API_ACCESS.md.")
|
||||
|
||||
|
||||
class AssetError(ValueError):
|
||||
"""A controlled, actionable source/archive error (never an implicit fallback)."""
|
||||
|
||||
|
||||
def source_value(value: str | None) -> str | Path:
|
||||
"""Unset/empty means the pin, never the adapter's historical default."""
|
||||
if value is None or not value.strip():
|
||||
return DOWNLOAD_URL
|
||||
if any(c in value for c in "\r\n\0"):
|
||||
raise AssetError("Swagger source contains invalid control characters.")
|
||||
value = value.strip()
|
||||
if value == DOWNLOAD_URL:
|
||||
return value
|
||||
try:
|
||||
parts = urlsplit(value)
|
||||
if (parts.scheme != "file" or parts.netloc not in ("", "localhost")
|
||||
or parts.query or parts.fragment):
|
||||
raise ValueError()
|
||||
# url2pathname decodes percent escapes (including spaces) on this platform.
|
||||
path = Path(url2pathname(parts.path))
|
||||
if not path.is_absolute() or any(c in str(path) for c in "\r\n\0"):
|
||||
raise ValueError()
|
||||
return path
|
||||
except (ValueError, OSError):
|
||||
raise AssetError("Swagger source must be the pinned HTTPS URL or an absolute file:// URL.") from None
|
||||
|
||||
|
||||
def expected_digest(value: str | None) -> str | None:
|
||||
if value is None or not value.strip():
|
||||
return None
|
||||
value = value.strip().lower()
|
||||
if not re.fullmatch(r"[0-9a-f]{64}", value):
|
||||
raise AssetError("SWAGGER_UI_ARCHIVE_SHA256 must contain exactly 64 hexadecimal digits.")
|
||||
return value
|
||||
|
||||
|
||||
class HttpsRedirects(HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
target = urlsplit(newurl)
|
||||
if (target.scheme != "https" or target.hostname not in ("github.com", "codeload.github.com")
|
||||
or target.username or target.password or target.port not in (None, 443)):
|
||||
raise AssetError("Swagger download refused an unexpected redirect.")
|
||||
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
|
||||
|
||||
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.24.5"})
|
||||
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:
|
||||
raise AssetError("Swagger archive exceeds the 64 MiB limit.")
|
||||
received = 0
|
||||
while True:
|
||||
block = response.read(128 * 1024)
|
||||
if not block:
|
||||
break
|
||||
received += len(block)
|
||||
if received > MAX_ARCHIVE or time.monotonic() - started > 180:
|
||||
raise AssetError("Swagger download exceeded the size or time limit.")
|
||||
output.write(block)
|
||||
|
||||
|
||||
def read_assets(path: Path, checksum: str | None) -> tuple[dict[str, bytes], str]:
|
||||
if not path.is_file() or not 0 < path.stat().st_size <= MAX_ARCHIVE:
|
||||
raise AssetError("Swagger archive must be a nonempty regular file of at most 64 MiB.")
|
||||
with path.open("rb") as stream:
|
||||
snapshot = stream.read(MAX_ARCHIVE + 1)
|
||||
if not 0 < len(snapshot) <= MAX_ARCHIVE:
|
||||
raise AssetError("Swagger archive exceeds the 64 MiB limit.")
|
||||
# Parse exactly the same bounded bytes that were hashed, not a reopened file
|
||||
# that could have changed between verification and ZIP member reads.
|
||||
digest = hashlib.sha256(snapshot).hexdigest()
|
||||
if checksum and digest != checksum:
|
||||
raise AssetError("Swagger archive SHA-256 mismatch; no assets were published.")
|
||||
with zipfile.ZipFile(io.BytesIO(snapshot)) as archive:
|
||||
names = archive.namelist()
|
||||
if len(names) > 20000:
|
||||
raise AssetError("Swagger archive contains too many entries.")
|
||||
|
||||
def member(relative: str, limit: int, *, required: bool = True) -> bytes:
|
||||
name = PREFIX + relative
|
||||
count = names.count(name)
|
||||
if count == 0 and not required:
|
||||
return b""
|
||||
if count != 1:
|
||||
raise AssetError(f"Swagger archive must contain exactly one {name}.")
|
||||
info = archive.getinfo(name)
|
||||
mode = (info.external_attr >> 16) & 0o170000
|
||||
if info.is_dir() or mode not in (0, 0o100000) or not 0 < info.file_size <= limit:
|
||||
raise AssetError(f"Swagger archive member is not a bounded regular file: {name}.")
|
||||
# Bounded read as well as metadata validation; CRC checked by ZipFile.
|
||||
with archive.open(info) as stream:
|
||||
data = stream.read(limit + 1)
|
||||
if not 0 < len(data) <= limit or len(data) != info.file_size:
|
||||
raise AssetError(f"Swagger archive member has an invalid size: {name}.")
|
||||
data.decode("utf-8")
|
||||
return data
|
||||
|
||||
package = json.loads(member("package.json", 256 * 1024))
|
||||
if not isinstance(package, dict) or package.get("name") != "swagger-ui" or package.get("version") != VERSION:
|
||||
raise AssetError(f"Swagger archive must be swagger-ui {VERSION}; older versions are not a fallback.")
|
||||
assets = {name: member(source, limit) for name, (source, limit) in ASSETS.items()}
|
||||
licenses = []
|
||||
for name in LICENSE_MEMBERS:
|
||||
text = member(name, 512 * 1024, required=name == "LICENSE")
|
||||
if text:
|
||||
licenses.append(name.encode() + b"\n" + b"-" * 40 + b"\n" + text)
|
||||
assets["licenses.txt"] = b"Swagger UI " + VERSION.encode() + b"\n\n" + b"\n\n".join(licenses)
|
||||
return assets, digest
|
||||
|
||||
|
||||
def atomic_write(path: Path, data: bytes) -> None:
|
||||
if path.is_file() and path.read_bytes() == data:
|
||||
return # Preserve mtime for Cargo's rerun-if-changed tracking.
|
||||
fd, name = tempfile.mkstemp(prefix=".swagger-", dir=path.parent)
|
||||
temporary = Path(name)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as stream:
|
||||
stream.write(data)
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def prepare(out_dir: Path, source: str | Path, checksum: str | None = None,
|
||||
*, offline: bool = False) -> list[Path]:
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
cache = out_dir / f"swagger-ui-{VERSION}.zip"
|
||||
if isinstance(source, Path):
|
||||
archive = source
|
||||
assets, digest = read_assets(archive, checksum)
|
||||
elif source == DOWNLOAD_URL:
|
||||
archive = cache
|
||||
if cache.exists():
|
||||
assets, digest = read_assets(cache, checksum)
|
||||
else:
|
||||
if offline:
|
||||
raise AssetError("Swagger assets are not cached and CARGO_NET_OFFLINE is set. " + HELP)
|
||||
fd, name = tempfile.mkstemp(prefix=".swagger-download-", suffix=".zip", dir=out_dir)
|
||||
os.close(fd)
|
||||
temporary = Path(name)
|
||||
try:
|
||||
download(temporary)
|
||||
assets, digest = read_assets(temporary, checksum)
|
||||
os.replace(temporary, cache) # Cache only an already-validated archive.
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
else:
|
||||
raise AssetError("Unrecognized Swagger source.")
|
||||
destination = out_dir / "swagger-ui"
|
||||
destination.mkdir(exist_ok=True)
|
||||
outputs = []
|
||||
for name, data in assets.items():
|
||||
target = destination / name
|
||||
atomic_write(target, data)
|
||||
outputs.append(target)
|
||||
receipt = {
|
||||
"version": VERSION, "source": str(source), "archive_sha256": digest,
|
||||
"assets_sha256": {key: hashlib.sha256(data).hexdigest() for key, data in assets.items()},
|
||||
}
|
||||
receipt_path = destination / "source.json"
|
||||
atomic_write(receipt_path, (json.dumps(receipt, indent=2, sort_keys=True) + "\n").encode())
|
||||
# Watch both a supplied symlink and its resolved target, plus cached/generated files.
|
||||
inputs = [archive.absolute(), archive.resolve(), *outputs, receipt_path]
|
||||
return list(dict.fromkeys(inputs))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--out-dir", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
if sys.version_info < (3, 10):
|
||||
parser.error("Python 3.10+ is required to prepare the embedded Swagger UI.")
|
||||
try:
|
||||
source = source_value(os.environ.get("SWAGGER_UI_DOWNLOAD_URL"))
|
||||
checksum = expected_digest(os.environ.get("SWAGGER_UI_ARCHIVE_SHA256"))
|
||||
offline = os.environ.get("CARGO_NET_OFFLINE", "").lower() in ("1", "true")
|
||||
paths = prepare(args.out_dir.resolve(), source, checksum, offline=offline)
|
||||
for path in paths:
|
||||
# These values are Cargo directives, so never allow extra output lines.
|
||||
if any(c in str(path) for c in "\r\n\0"):
|
||||
raise AssetError("Build input/output path contains an invalid control character.")
|
||||
print(f"cargo:rerun-if-changed={path}")
|
||||
except (OSError, ValueError, RuntimeError, zipfile.BadZipFile, zlib.error) as error:
|
||||
print(f"Swagger UI asset preparation failed: {error}\n{HELP}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+249
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import hashlib
|
||||
import io
|
||||
import time
|
||||
import zlib
|
||||
|
||||
import brotli
|
||||
import requests
|
||||
import zstandard
|
||||
|
||||
|
||||
TESTS = {
|
||||
"identity": "identity",
|
||||
"gzip": "gzip",
|
||||
"deflate": "deflate",
|
||||
"brotli": "br",
|
||||
"zstd": "zstd",
|
||||
"all": "zstd, br, gzip, deflate",
|
||||
}
|
||||
|
||||
|
||||
def decompress(data, content_encoding):
|
||||
encodings = [
|
||||
e.strip().lower()
|
||||
for e in content_encoding.split(",")
|
||||
if e.strip() and e.strip().lower() != "identity"
|
||||
]
|
||||
|
||||
# Content-Encoding usuwa się w odwrotnej kolejności.
|
||||
for encoding in reversed(encodings):
|
||||
if encoding == "gzip":
|
||||
data = gzip.decompress(data)
|
||||
|
||||
elif encoding == "deflate":
|
||||
try:
|
||||
data = zlib.decompress(data)
|
||||
except zlib.error:
|
||||
# Niektóre serwery wysyłają raw DEFLATE.
|
||||
data = zlib.decompress(data, -zlib.MAX_WBITS)
|
||||
|
||||
elif encoding == "br":
|
||||
data = brotli.decompress(data)
|
||||
|
||||
elif encoding == "zstd":
|
||||
dctx = zstandard.ZstdDecompressor()
|
||||
with dctx.stream_reader(io.BytesIO(data)) as reader:
|
||||
data = reader.read()
|
||||
|
||||
else:
|
||||
raise RuntimeError(f"Nieznany Content-Encoding: {encoding}")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def human_size(size):
|
||||
if size < 1024:
|
||||
return f"{size} B"
|
||||
if size < 1024 * 1024:
|
||||
return f"{size / 1024:.1f} KB"
|
||||
return f"{size / 1024 / 1024:.2f} MB"
|
||||
|
||||
|
||||
def request_test(url, accept_encoding, verify_ssl=True, timeout=15):
|
||||
headers = {
|
||||
"Accept-Encoding": accept_encoding,
|
||||
"User-Agent": "compression-tester/1.0",
|
||||
"Cache-Control": "no-cache",
|
||||
}
|
||||
|
||||
start = time.perf_counter()
|
||||
|
||||
r = requests.get(
|
||||
url,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
verify=verify_ssl,
|
||||
stream=True,
|
||||
allow_redirects=True,
|
||||
)
|
||||
|
||||
r.raw.decode_content = False
|
||||
raw = r.raw.read()
|
||||
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
content_encoding = r.headers.get("Content-Encoding", "identity")
|
||||
|
||||
try:
|
||||
decoded = decompress(raw, content_encoding)
|
||||
error = None
|
||||
except Exception as exc:
|
||||
decoded = b""
|
||||
error = str(exc)
|
||||
|
||||
return {
|
||||
"status": r.status_code,
|
||||
"requested": accept_encoding,
|
||||
"received": content_encoding,
|
||||
"raw_size": len(raw),
|
||||
"decoded_size": len(decoded),
|
||||
"time": elapsed,
|
||||
"decoded": decoded,
|
||||
"error": error,
|
||||
"headers": r.headers,
|
||||
"final_url": r.url,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Tester HTTP Content-Encoding: zstd/br/gzip/deflate"
|
||||
)
|
||||
|
||||
parser.add_argument("url", help="URL do przetestowania")
|
||||
parser.add_argument(
|
||||
"--insecure",
|
||||
action="store_true",
|
||||
help="Wyłącz weryfikację certyfikatu SSL",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout",
|
||||
type=int,
|
||||
default=15,
|
||||
help="Timeout HTTP w sekundach",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.insecure:
|
||||
requests.packages.urllib3.disable_warnings()
|
||||
|
||||
print()
|
||||
print(f"URL: {args.url}")
|
||||
print("=" * 100)
|
||||
|
||||
results = {}
|
||||
|
||||
for name, accept_encoding in TESTS.items():
|
||||
try:
|
||||
result = request_test(
|
||||
args.url,
|
||||
accept_encoding,
|
||||
verify_ssl=not args.insecure,
|
||||
timeout=args.timeout,
|
||||
)
|
||||
results[name] = result
|
||||
except Exception as exc:
|
||||
results[name] = {
|
||||
"error": f"REQUEST ERROR: {exc}"
|
||||
}
|
||||
|
||||
identity_body = None
|
||||
|
||||
if "identity" in results:
|
||||
identity_body = results["identity"].get("decoded")
|
||||
|
||||
print(
|
||||
f"{'TEST':<10}"
|
||||
f"{'HTTP':<7}"
|
||||
f"{'REQUEST':<27}"
|
||||
f"{'RESPONSE':<12}"
|
||||
f"{'RAW':<12}"
|
||||
f"{'DECODED':<12}"
|
||||
f"{'RATIO':<10}"
|
||||
f"{'TIME':<10}"
|
||||
f"{'BODY'}"
|
||||
)
|
||||
|
||||
print("-" * 120)
|
||||
|
||||
failed = False
|
||||
|
||||
for name, result in results.items():
|
||||
if result.get("error"):
|
||||
print(f"{name:<10} ERROR: {result['error']}")
|
||||
failed = True
|
||||
continue
|
||||
|
||||
raw_size = result["raw_size"]
|
||||
decoded_size = result["decoded_size"]
|
||||
|
||||
if decoded_size:
|
||||
ratio = raw_size / decoded_size
|
||||
ratio_text = f"{ratio * 100:.1f}%"
|
||||
else:
|
||||
ratio_text = "-"
|
||||
|
||||
if identity_body is not None and result["decoded"]:
|
||||
if result["decoded"] == identity_body:
|
||||
body_status = "OK"
|
||||
else:
|
||||
body_status = "DIFF"
|
||||
else:
|
||||
body_status = "-"
|
||||
|
||||
print(
|
||||
f"{name:<10}"
|
||||
f"{result['status']:<7}"
|
||||
f"{result['requested']:<27}"
|
||||
f"{result['received']:<12}"
|
||||
f"{human_size(raw_size):<12}"
|
||||
f"{human_size(decoded_size):<12}"
|
||||
f"{ratio_text:<10}"
|
||||
f"{result['time']:.3f}s "
|
||||
f"{body_status}"
|
||||
)
|
||||
|
||||
print()
|
||||
print("=" * 100)
|
||||
print("Szczegóły nagłówków:")
|
||||
print()
|
||||
|
||||
for name, result in results.items():
|
||||
if result.get("error"):
|
||||
continue
|
||||
|
||||
headers = result["headers"]
|
||||
|
||||
print(f"[{name}]")
|
||||
print(f" URL finalny: {result['final_url']}")
|
||||
print(f" HTTP status: {result['status']}")
|
||||
print(f" Content-Encoding: {headers.get('Content-Encoding', '-')}")
|
||||
print(f" Content-Type: {headers.get('Content-Type', '-')}")
|
||||
print(f" Content-Length: {headers.get('Content-Length', '-')}")
|
||||
print(f" Vary: {headers.get('Vary', '-')}")
|
||||
print(f" Cache-Control: {headers.get('Cache-Control', '-')}")
|
||||
print(f" ETag: {headers.get('ETag', '-')}")
|
||||
print(f" Server: {headers.get('Server', '-')}")
|
||||
print()
|
||||
|
||||
print("SHA256 po dekompresji:")
|
||||
print()
|
||||
|
||||
for name, result in results.items():
|
||||
body = result.get("decoded")
|
||||
|
||||
if body:
|
||||
sha = hashlib.sha256(body).hexdigest()
|
||||
print(f"{name:<10} {sha}")
|
||||
|
||||
if failed:
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+124
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Replace a stopped RustProxy source tree, preserving old code, data and .env."""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
MANAGED = ["build.rs", "langs", "deploy", "debian", "grafana", "make_zip.py", "src", "tests", "scripts", "nagios", "docker-agent", "migrations", "examples", "docs", ".github", ".cargo",
|
||||
"Cargo.toml", "Cargo.lock", "rust-toolchain.toml", "dev.sh", "upgrade.sh", "install.sh", "update.sh",
|
||||
"README.md", "ROADMAP.md", "CHANGELOG.md", "AGENTS.md", "SHA256SUMS",
|
||||
".env.example", ".gitignore"]
|
||||
|
||||
|
||||
def upgrade(source: Path, target: Path, *, stopped: bool, data_dir: Path | None = None) -> Path:
|
||||
source = source.resolve()
|
||||
if target.is_symlink():
|
||||
raise ValueError("Target must not be a symlink.")
|
||||
target = target.resolve()
|
||||
if not stopped:
|
||||
raise ValueError("Stop RustProxy and pass --stopped. Source replacement is not a live-update operation.")
|
||||
if target == source or target in source.parents or source in target.parents:
|
||||
raise ValueError("Source and destination must be separate, non-nested directories.")
|
||||
manifest = target / "Cargo.toml"
|
||||
if not manifest.is_file() or 'name = "rust-proxy"' not in manifest.read_text(encoding="utf-8"):
|
||||
raise ValueError("Target is not an existing RustProxy source directory.")
|
||||
locks = []
|
||||
try:
|
||||
if os.name == "posix":
|
||||
import fcntl
|
||||
for directory in {target / "data", data_dir or target / "data"}:
|
||||
path = directory / "process.lock"
|
||||
if path.exists():
|
||||
if path.is_symlink():
|
||||
raise ValueError("Refusing a symlinked process lock.")
|
||||
stream = path.open("r+")
|
||||
try:
|
||||
fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
stream.close()
|
||||
raise ValueError("A RustProxy server still owns the data directory. Stop it first.") from None
|
||||
locks.append(stream)
|
||||
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.24.5-" + uuid.uuid4().hex[:12])
|
||||
backup.mkdir(parents=True, mode=0o700)
|
||||
installed: list[str] = []
|
||||
moved: list[str] = []
|
||||
try:
|
||||
for name in MANAGED:
|
||||
old, new = target / name, source / name
|
||||
if old.exists():
|
||||
old.rename(backup / name)
|
||||
moved.append(name)
|
||||
if new.is_dir():
|
||||
installed.append(name)
|
||||
shutil.copytree(new, old)
|
||||
if name == "langs" and (backup / name).is_dir():
|
||||
# Preserve additional site language packs; bundled packs are
|
||||
# updated, with their previous bytes retained in the backup.
|
||||
for pack in (backup / name).glob("*.json"):
|
||||
if pack.is_symlink():
|
||||
raise ValueError("Language packs must not be symlinks.")
|
||||
if not (old / pack.name).exists():
|
||||
shutil.copy2(pack, old / pack.name)
|
||||
elif new.is_file():
|
||||
installed.append(name)
|
||||
shutil.copy2(new, old)
|
||||
# Old renewal observations were diagnostic scheduler state only;
|
||||
# certificates, accounts and renewal profiles live elsewhere. 0.19.1
|
||||
# has no runtime JSON importer. Validate the obsolete sidecar now,
|
||||
# write the completed source-upgrade manifest, then remove the file
|
||||
# as the final fallible operation. SQLite observations are rebuilt by
|
||||
# the normal renewal scheduler. No second legacy copy is retained.
|
||||
runtime_data = (data_dir or (target / "data")).resolve()
|
||||
certs_dir = runtime_data / "certs"
|
||||
if certs_dir.is_symlink():
|
||||
raise ValueError("Certificate data directory must not be a symlink during legacy cleanup.")
|
||||
legacy = certs_dir / "renewals.json"
|
||||
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.24.5", "managed_paths":MANAGED, "removed_obsolete_runtime_state": remove_legacy}, indent=2)+"\n", encoding="utf-8")
|
||||
if remove_legacy:
|
||||
legacy.unlink()
|
||||
except BaseException:
|
||||
for name in reversed(installed):
|
||||
path = target / name
|
||||
if path.is_dir():
|
||||
shutil.rmtree(path)
|
||||
elif path.exists():
|
||||
path.unlink()
|
||||
for name in moved:
|
||||
(backup / name).rename(target / name)
|
||||
raise
|
||||
return backup
|
||||
finally:
|
||||
for stream in locks:
|
||||
stream.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("target", type=Path)
|
||||
parser.add_argument("--stopped", action="store_true")
|
||||
parser.add_argument("--data-dir", type=Path, help="also check the process lock when data lives outside target/data")
|
||||
args = parser.parse_args()
|
||||
os.umask(0o077)
|
||||
data_dir = args.data_dir or (Path(os.environ["RUST_PROXY_DATA_DIR"]) if os.environ.get("RUST_PROXY_DATA_DIR") else None)
|
||||
try:
|
||||
backup = upgrade(Path(__file__).resolve().parents[1], 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.24.5. 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")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user