This commit is contained in:
gru committed 2026-10-03 00:27:09 +01:00
commit 5e23e9af02
240 files changed
+99957

No files matched your search

+449
View File
@@ -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()
+5
View File
@@ -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()
+63
View File
@@ -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()
+99
View File
@@ -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()
+1105
View File
File diff suppressed because it is too large. Load diff
+770
View File
@@ -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()
+39
View File
@@ -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.")
+589
View File
@@ -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()
+147
View File
@@ -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()
+191
View File
@@ -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()
+107
View File
@@ -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
View File
@@ -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
+56
View File
@@ -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()
+1067
View File
File diff suppressed because it is too large. Load diff
+39
View File
@@ -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
+137
View File
@@ -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
View File
@@ -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
View File
@@ -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 "$@"
+232
View File
@@ -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())
+249
View File
@@ -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()
+124
View File
@@ -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()