v0.35.1: performance, AGPL licensing and documentation overhaul

- Web Server reads static files inline; proxy middleware chain flattened
- About window with license summary and project links; AGPL-3.0-or-later
- Rewritten documentation: feature list, screenshots, performance guide
- Fix false spikes in per-Vhost request-rate charts
This commit is contained in:
gru committed 2026-11-05 18:29:56 +01:00
1 parent d3b492874b
commit eba9f89283
135 files changed
+1425 -1064

No files matched your search

+1 -3
View File
@@ -99,9 +99,7 @@ def structural_inventory(paths: list[Path]) -> dict:
families = [tuple(sorted({family for selector in entry['selectors']
for family in VENDOR_SELECTOR.findall(selector)}))
for entry in entries]
# A browser can reject an entire comma-separated selector list when it
# contains another engine's pseudo-element. Keep one body per compatible
# family, while still rejecting copies within the same family.
# Vendor pseudo-element families legitimately repeat one rule body.
vendor_only = (any(families) and all(len(family) <= 1 for family in families)
and len(families) == len(set(families)))
repeated_bodies.append({'scope': list(scope), 'rules': entries,
+1 -1
View File
@@ -33,7 +33,7 @@ ASSETS = {
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.33.1.zip "
"to a reviewed copy of the same tag archive. See docs/API_ACCESS.md.")
"to a reviewed copy of the same tag archive. See docs/INSTALL.md.")
class AssetError(ValueError):
+4 -10
View File
@@ -211,9 +211,7 @@ def admin_health_endpoint(record: dict) -> tuple[str, bool, str]:
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.
# Fall back to the startup listener for old or uninitialized databases.
pass
host, port = (raw[1:].split(']:') if raw.startswith('[') else raw.rsplit(':', 1))
@@ -234,9 +232,7 @@ def healthy(record: dict, version: str, attempts: int = 30) -> bool:
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.
# Local liveness probe only; the admin certificate may be self-signed.
conn = http.client.HTTPSConnection(host, int(port), timeout=2, context=ssl._create_unverified_context())
else:
conn = http.client.HTTPConnection(host, int(port), timeout=2)
@@ -362,14 +358,12 @@ def update(record: dict, binary: Path, sha: str, start: bool, refresh_geoip: boo
# Repair missing or stale installer-owned shell setup on upgrades.
install_shell_path(prefix)
check_shell_path(prefix)
# Repair stale root:root/0700 metadata before staging/network work and before
# stopping the currently healthy service.
# Repair stale ownership before stopping the running service.
ensure_service_data_access(data, account)
release, version = stage(binary, prefix, record['user'], sha)
geoip_names: list[str] = []
if refresh_geoip:
# Keep the same lock for checksum/download staging and publication. The
# runtime worker uses this file too, so manual upgrades cannot race it.
# Shared lock with the runtime worker prevents concurrent upgrades.
geoip_lock_fd = acquire_geoip_update_lock(data, account.pw_uid, account.pw_gid)
geoip_stage, geoip_names = prepare_geoip_update(data, prefix)
active = subprocess.run(['systemctl', 'is-active', '--quiet', SERVICE], check=False, stdout=subprocess.DEVNULL).returncode == 0
+2 -7
View File
@@ -13,7 +13,7 @@ from scripts.version import project_version
MANAGED = ["build.rs", "langs", "deploy", "debian", "grafana", "make_zip.py", "src", "tests", "scripts", "tools", "integrations", "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",
"README.md", "LICENSE", "CHANGELOG.md", "AGENTS.md", "SHA256SUMS",
".env.example", ".gitignore"]
@@ -72,12 +72,7 @@ def upgrade(source: Path, target: Path, *, stopped: bool, data_dir: Path | None
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.
# Obsolete renewal sidecar: validate, record the manifest, then remove it last.
runtime_data = (data_dir or (target / "data")).resolve()
certs_dir = runtime_data / "certs"
if certs_dir.is_symlink():
+1 -1
View File
@@ -132,7 +132,7 @@ def check(root: Path, repair_known: bool = False) -> list[str]:
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/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/config/internal_routing.rs", "src/proxy/websocket.rs", "src/proxy/error_fallback.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/mod.rs", "src/diagnostics.rs", "src/traffic/mod.rs", "src/system_stats/mod.rs", "src/system_stats/linux/mod.rs", "src/vhost.rs", "src/vhost/error_fallback.rs", "src/web_server/mod.rs", "src/web_server/config.rs", "src/web_server/filesystem.rs", "src/web_server/security.rs", "src/web_server/response.rs", "src/web_server/request.rs", "src/web_server/routing.rs", "src/web_server/fastcgi.rs", "src/web_server/htaccess.rs", "src/web_server/serve.rs", "src/web_server/tests.rs", "src/balance.rs", "src/security/mod.rs", "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/rates.js", "src/admin/web/vhosts/health.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js", "src/admin/web/web-server.js", "src/admin/web/streams.js", "src/admin/web/host404.js", "src/admin/web/app/core.js", "src/admin/web/app/session-resume.js", "src/admin/web/app/preferences.js", "src/admin/web/app/config-history.js", "src/admin/web/app/runtime.js", "src/admin/web/app/settings.js", "src/admin/web/management.js", "src/admin/web/drafts.js", "src/pages/mod.rs", "tools/dev/mock_server.py", "tools/dev/mock_auth.py", "src/forwarding.rs", "src/tinyauth/mod.rs", "src/http3.rs", "src/stream.rs", "src/admin/web/integrations.js", "src/admin/web/traffic-panel.js", "src/prometheus.rs", "src/admin/web/settings.js", "src/admin/web/docker.js", "src/docker.rs", "docker-agent/main.go", "docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go", "docker-agent/certificate.go", "docker-agent/server.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/install.sh", "docs/DOCKER_DISCOVERY.md", "docs/WEB_SERVER.md", "docs/PHP_FPM.md", "docs/README.md", "CHANGELOG.md", "ROADMAP.md", "scripts/runtime/acme_provider.py", "src/basic_auth/mod.rs", "scripts/deploy/deploy.py", "scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py", "scripts/migration/migrate_npm.py", "scripts/migration/migrate_npm_core.py", "scripts/migration/migrate_npm_api.py", "docs/MIGRATION.md", "docs/STREAMS.md", "install.sh", "install-debian13.sh", "update.sh", "build.rs", "scripts/build/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/MONITORING.md", "docs/CUSTOM_LOCATIONS.md", "grafana/rustproxy-overview.json"]:
for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/config/internal_routing.rs", "src/proxy/websocket.rs", "src/proxy/error_fallback.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/mod.rs", "src/diagnostics.rs", "src/traffic/mod.rs", "src/system_stats/mod.rs", "src/system_stats/linux/mod.rs", "src/vhost.rs", "src/vhost/error_fallback.rs", "src/web_server/mod.rs", "src/web_server/config.rs", "src/web_server/filesystem.rs", "src/web_server/security.rs", "src/web_server/response.rs", "src/web_server/request.rs", "src/web_server/routing.rs", "src/web_server/fastcgi.rs", "src/web_server/htaccess.rs", "src/web_server/serve.rs", "src/web_server/tests.rs", "src/balance.rs", "src/security/mod.rs", "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/rates.js", "src/admin/web/vhosts/health.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js", "src/admin/web/web-server.js", "src/admin/web/streams.js", "src/admin/web/host404.js", "src/admin/web/app/core.js", "src/admin/web/app/session-resume.js", "src/admin/web/app/preferences.js", "src/admin/web/app/config-history.js", "src/admin/web/app/runtime.js", "src/admin/web/app/settings.js", "src/admin/web/management.js", "src/admin/web/drafts.js", "src/pages/mod.rs", "tools/dev/mock_server.py", "tools/dev/mock_auth.py", "src/forwarding.rs", "src/tinyauth/mod.rs", "src/http3.rs", "src/stream.rs", "src/admin/web/integrations.js", "src/admin/web/traffic-panel.js", "src/prometheus.rs", "src/admin/web/settings.js", "src/admin/web/docker.js", "src/docker.rs", "docker-agent/main.go", "docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go", "docker-agent/certificate.go", "docker-agent/server.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/install.sh", "docs/DOCKER_DISCOVERY.md", "docs/WEB_SERVER.md", "docs/PHP_FPM.md", "docs/README.md", "CHANGELOG.md", "LICENSE", "docs/FEATURES.md", "docs/PERFORMANCE.md", "scripts/runtime/acme_provider.py", "src/basic_auth/mod.rs", "scripts/deploy/deploy.py", "scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py", "scripts/migration/migrate_npm.py", "scripts/migration/migrate_npm_core.py", "scripts/migration/migrate_npm_api.py", "docs/MIGRATION.md", "docs/STREAMS.md", "install.sh", "install-debian13.sh", "update.sh", "build.rs", "scripts/build/swagger_assets.py", "src/admin/documentation.rs", "src/admin/openapi.json", "langs/en_US.json", "langs/pl_pl.json", "Dockerfile", ".dockerignore", "docs/INSTALL.md", "docs/MONITORING.md", "docs/CUSTOM_LOCATIONS.md", "grafana/rustproxy-overview.json"]:
if not (root / path).is_file():
raise ValueError(f"Incomplete RustProxy 0.34.2 source tree: missing {path}")
for path in ("scripts/__init__.py", "scripts/build/__init__.py", "scripts/build/panel_css.py", "src/admin/web/css/manifest.json"):
+7 -15
View File
@@ -39,8 +39,7 @@ PROVIDER_FIELDS = {
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.
# API keys are ASCII tokens; reject anything that could inject INI content.
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
@@ -196,8 +195,7 @@ def certbot_command(value: dict, work: Path, credential_file: Path, command_pref
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.
# Resolve live symlinks only inside Certbot's archive.
archive = (work / "config" / "archive").resolve(strict=True)
path = (work / "config" / "live" / "rustproxy" / name).resolve(strict=True)
if not path.is_relative_to(archive):
@@ -220,8 +218,7 @@ def issue(raw: dict, certificate_directory: Path, executable: str | None = None)
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.
# Each profile has isolated CA accounts, credentials and lineages.
suffix = "" if value["provider"] == "rfc2136" else "-" + value["provider"]
work = root / (value["environment"] + "-" + value["certificate_id"] + suffix)
private_directory(work)
@@ -236,9 +233,7 @@ def issue(raw: dict, certificate_directory: Path, executable: str | None = None)
raise AcmeError("acme.profile_busy") from None
for folder in ["config", "work", "logs"]:
private_directory(work / folder)
# DNS credentials are supplied by RustProxy's encrypted Secret Store.
# Certbot needs a file path, but the plaintext file exists only while the
# provider command runs and is deleted before this worker returns.
# Secret Store credentials exist as a plaintext file only while Certbot runs.
credential_file = work / (value["provider"] + ".ini")
if value["provider"] == "http01":
webroot = certificate_directory / "acme-http01"
@@ -265,8 +260,7 @@ def issue(raw: dict, certificate_directory: Path, executable: str | None = None)
credential_file.unlink(missing_ok=True)
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.
# Work on immutable copies so the active certificate never changes mid-run.
stem = "acme-" + uuid.uuid4().hex
cert_path = certificate_directory / (stem + ".pem")
key_path = certificate_directory / (stem + ".key")
@@ -392,8 +386,7 @@ def renew(raw: dict, certificate_directory: Path, executable: str | None = None)
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]
# The renewal config persists, but provider credentials do not. Recreate
# the exact path referenced by Certbot only for this invocation.
# Credentials are recreated only for the duration of this invocation.
read_private(work / "config" / "renewal" / "rustproxy.conf", 65536)
credential_file = work / (value["provider"] + ".ini")
if value["provider"] != "http01":
@@ -436,8 +429,7 @@ def renew(raw: dict, certificate_directory: Path, executable: str | None = None)
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).
# Content-addressed staging keeps retries idempotent.
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():
-1
View File
@@ -1,7 +1,6 @@
#!/usr/bin/env bash
# Start two TCP and two UDP mock backends for RustProxy Stream tests.
# Usage: ./start_stream_backends.sh [bind-host]
# Defaults to loopback for safety. Use 0.0.0.0 only on a controlled test network.
set -euo pipefail
DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"