v0.25.0
This commit is contained in:
1 parent
2e4aa4df4d
commit
87e571c170
106 files changed
+17952
-6185
No files matched your search
@@ -13,12 +13,12 @@ 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',
|
||||
PRIVATE = ('/api/status','/api/config','/api/backends','/api/traffic','/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')
|
||||
'/assets/traffic-panel.js','/assets/cache-panel.js','/assets/transform-panel.js','/api/does-not-exist')
|
||||
|
||||
class NoRedirect(HTTPRedirectHandler):
|
||||
def redirect_request(self,*args,**kwargs):
|
||||
|
||||
+23
-11
@@ -16,7 +16,7 @@ def read_cache_source() -> str:
|
||||
|
||||
preflight(root)
|
||||
manifest = tomllib.loads((root / "Cargo.toml").read_text())
|
||||
assert manifest["package"]["version"] == "0.24.10"
|
||||
assert manifest["package"]["version"] == "0.25.0"
|
||||
assert manifest["dependencies"]["quinn"]["version"] == "0.11.12"
|
||||
h3dep = manifest["dependencies"]["h3"]
|
||||
assert h3dep["package"] == "hpx-h3" and h3dep["version"] == "=2.5.19" and "quinn" in h3dep["features"]
|
||||
@@ -24,14 +24,14 @@ assert "h3-quinn" not in manifest["dependencies"]
|
||||
lock_path = root / "Cargo.lock"
|
||||
if lock_path.exists():
|
||||
lock = lock_path.read_text()
|
||||
assert 'name = "rust-proxy"\nversion = "0.24.10"' in lock
|
||||
assert 'name = "rust-proxy"\nversion = "0.25.0"' in lock
|
||||
assert 'name = "systemstat"' in lock and 'name = "metrics-process"' in lock and 'name = "sysinfo"' in lock, "Cargo.lock must be resolver-generated after adding cross-platform statistics libraries"
|
||||
for release_doc, marker in {
|
||||
"README.md": "# RustProxy 0.24.10",
|
||||
"CHANGELOG.md": "## 0.24.10",
|
||||
"ROADMAP.md": "## 0.24.10",
|
||||
"AGENTS.md": "Current source version: 0.24.10.",
|
||||
"docs/TEST_REPORT.md": "# Verification report - RustProxy 0.24.10",
|
||||
"README.md": "# RustProxy 0.25.0",
|
||||
"CHANGELOG.md": "## 0.25.0",
|
||||
"ROADMAP.md": "## 0.25.0",
|
||||
"AGENTS.md": "Current source version: 0.25.0.",
|
||||
"docs/TEST_REPORT.md": "# Verification report - RustProxy 0.25.0",
|
||||
}.items():
|
||||
assert marker in (root / release_doc).read_text(), f"stale release identity in {release_doc}"
|
||||
assert tomllib.loads((root / "rust-toolchain.toml").read_text())["toolchain"]["channel"] == "stable"
|
||||
@@ -65,7 +65,7 @@ for marker in [
|
||||
]:
|
||||
assert marker in update_check_source, marker
|
||||
assert 'pub last_error:' not in update_check_source, "update-check errors must not change dashboard/API state"
|
||||
for name in ["README.md", "ROADMAP.md", "AGENTS.md", "CHANGELOG.md", "UPDATE_CHECK.toml", "Dockerfile", ".dockerignore", "docs/TEST_REPORT.md", "docs/V0.24.8_AUDIT.md", "docs/V0.24.7_AUDIT.md", "docs/V0.24.6_AUDIT.md", "docs/V0.24.5_AUDIT.md", "docs/V0.24.1_AUDIT.md", "docs/V0.24.0_AUDIT.md", "docs/V0.23.2_AUDIT.md", "docs/V0.23.1_AUDIT.md", "docs/V0.22.3_AUDIT.md", "docs/LIVE_LOGS.md", "docs/README.md", "docs/API.md", "docs/ARCHITECTURE.md", "docs/VHOSTS.md", "docs/STREAMS.md", "docs/REDIRECTS.md", "docs/CONTAINER.md", "docs/NPM_MIGRATION.md", "docs/DOCKER_DISCOVERY.md", "docker-agent/main.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/docker-agent.env.example", "docker-agent/install.sh", "src/docker.rs", "src/admin/web/docker.js", "scripts/migrate_npm.py", "docs/CUSTOM_LOCATIONS.md", "nagios/README.md", "nagios/check_rustproxy.py", "nagios/check_rustproxy_api", "nagios/check_rustproxy_backends", "nagios/check_rustproxy_latency", "nagios/check_rustproxy_queues", "src/admin/web/error_401.html", "src/admin/web/error_404.html", "src/admin/web/streams.js", "src/admin/web/redirects.js", "src/admin/web/host404.js", "docs/NOT_FOUND_HOSTS.md", "debian/control", "debian/rules", "debian/rustproxy.service", "debian/rustproxy.logrotate", "debian/build-package.sh", "debian/build-repository.sh", "grafana/rustproxy-overview.json", "grafana/README.md"]:
|
||||
for name in ["README.md", "ROADMAP.md", "AGENTS.md", "CHANGELOG.md", "UPDATE_CHECK.toml", "Dockerfile", ".dockerignore", "docs/TEST_REPORT.md", "docs/V0.24.8_AUDIT.md", "docs/V0.24.7_AUDIT.md", "docs/V0.24.6_AUDIT.md", "docs/V0.24.5_AUDIT.md", "docs/V0.24.1_AUDIT.md", "docs/V0.24.0_AUDIT.md", "docs/V0.23.2_AUDIT.md", "docs/V0.23.1_AUDIT.md", "docs/V0.22.3_AUDIT.md", "docs/LIVE_LOGS.md", "docs/README.md", "docs/API.md", "docs/ARCHITECTURE.md", "docs/WEB_SERVER.md", "docs/PHP_FPM.md", "docs/VHOSTS.md", "docs/STREAMS.md", "docs/REDIRECTS.md", "docs/CONTAINER.md", "docs/NPM_MIGRATION.md", "docs/DOCKER_DISCOVERY.md", "docker-agent/main.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/docker-agent.env.example", "docker-agent/install.sh", "src/docker.rs", "src/admin/web/docker.js", "scripts/migrate_npm.py", "docs/CUSTOM_LOCATIONS.md", "nagios/README.md", "nagios/check_rustproxy.py", "nagios/check_rustproxy_api", "nagios/check_rustproxy_backends", "nagios/check_rustproxy_latency", "nagios/check_rustproxy_queues", "src/admin/web/error_401.html", "src/admin/web/error_404.html", "src/admin/web/streams.js", "src/admin/web/redirects.js", "src/admin/web/host404.js", "docs/NOT_FOUND_HOSTS.md", "debian/control", "debian/rules", "debian/rustproxy.service", "debian/rustproxy.logrotate", "debian/build-package.sh", "debian/build-repository.sh", "grafana/rustproxy-overview.json", "grafana/README.md"]:
|
||||
assert (root / name).is_file(), name
|
||||
for path in (root / "examples").glob("*.json"):
|
||||
data = json.loads(path.read_text())
|
||||
@@ -115,6 +115,7 @@ assert "Environment=MALLOC_ARENA_MAX=2" in (root / "debian/rustproxy.service").r
|
||||
assert "Environment=MALLOC_ARENA_MAX=2" in (root / "scripts/deploy.py").read_text()
|
||||
assert not re.search(r"\b20\d{2}[-/]\d{1,2}[-/]\d{1,2}\b", (root / "CHANGELOG.md").read_text()), "changelog contains a release date"
|
||||
subprocess.run(["python3", str(root / "scripts/langs_check.py")], check=True)
|
||||
subprocess.run(["python3", str(root / "scripts/web_server_contracts.py")], check=True)
|
||||
dockerfile = (root / "Dockerfile").read_text()
|
||||
assert "cargo generate-lockfile" in dockerfile and "cargo build --locked --release" in dockerfile
|
||||
assert "USER rustproxy:rustproxy" in dockerfile
|
||||
@@ -212,7 +213,7 @@ assert 'Opt-in health decisions based on ordinary user traffic. Defaults to disa
|
||||
assert '"force_ssl"' in openapi and '"forward_client_ip"' in openapi
|
||||
assert '"backend_header"' in openapi and '"backend_timing_header"' in openapi
|
||||
assert '"StreamMetrics"' in openapi and '"streams"' in openapi
|
||||
for marker in ['"version": "0.24.10"', '"/api/config/{section}"', '"ConfigSectionUpdate"', '"cold_on_idle"', '"cold_on_age"', '"cold_max_hot_seconds"', '"cold_on_lru"', '"cold_on_shutdown"', '"cold_gc_interval_seconds"', '"cold_gc_max_delete"', '"collapse_enabled"', '"collapse_wait_timeout_ms"', '"collapse_timeout_fallback"', '"stale_if_error_timeout"', '"stale_if_error_connection"', '"stale_if_error_dns"', '"stale_if_error_tls"', '"stale_if_error_statuses"', '"proactive_refresh_enabled"', '"proactive_refresh_before_expiry_seconds"', '"proactive_refresh_percent"', '"proactive_refresh_max_inflight"', '"TransformSettings"', '"TransformOverrides"', '"/api/cache/cold/cleanup"', '"/api/cache/invalidate"', '"/api/cache/bans"', '"/api/cache/bans/{id}"', '"CacheInvalidationRequest"', '"CacheInvalidationResponse"', '"ColdCleanupJob"', '"match_type"', '"regex_insensitive"', '"rewrite"', '"response"', '"covered_hosts"', '"default": true']:
|
||||
for marker in ['"version": "0.25.0"', '"/api/traffic"', '"capture_user_agent"', '"capture_country"', '"hot_auto_trim_enabled"', '"hot_auto_trim_after_seconds"', '"hot_auto_trim_target_percent"', '"/api/config/{section}"', '"ConfigSectionUpdate"', '"cold_on_idle"', '"cold_on_age"', '"cold_max_hot_seconds"', '"cold_on_lru"', '"cold_on_shutdown"', '"cold_gc_interval_seconds"', '"cold_gc_max_delete"', '"collapse_enabled"', '"collapse_wait_timeout_ms"', '"collapse_timeout_fallback"', '"stale_if_error_timeout"', '"stale_if_error_connection"', '"stale_if_error_dns"', '"stale_if_error_tls"', '"stale_if_error_statuses"', '"proactive_refresh_enabled"', '"proactive_refresh_before_expiry_seconds"', '"proactive_refresh_percent"', '"proactive_refresh_max_inflight"', '"TransformSettings"', '"TransformOverrides"', '"/api/cache/cold/cleanup"', '"/api/cache/invalidate"', '"/api/cache/bans"', '"/api/cache/bans/{id}"', '"CacheInvalidationRequest"', '"CacheInvalidationResponse"', '"ColdCleanupJob"', '"match_type"', '"regex_insensitive"', '"rewrite"', '"response"', '"covered_hosts"', '"default": true']:
|
||||
assert marker in openapi, marker
|
||||
for marker in ['"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']:
|
||||
assert marker in openapi, marker
|
||||
@@ -814,6 +815,17 @@ assert 'hash_scheme.as_bytes()' in cache_source and 'cache_key_separates_fronten
|
||||
assert 'function rateBytes(value)' in app_js and 'if (value === 0) return "0.0 KiB";' in app_js and 'return "<0.1 KiB";' in app_js
|
||||
assert 'function backendTrafficRate(' in vhosts_js and 'member.bytes_in' in vhosts_js and 'member.bytes_out' in vhosts_js and 'rateBytes(memberTraffic)' in vhosts_js and 'rateBytes(d.traffic)' in vhosts_js
|
||||
cache_panel_js = (root / "src/admin/web/cache-panel.js").read_text()
|
||||
traffic_panel_js = (root / "src/admin/web/traffic-panel.js").read_text()
|
||||
assert 'id="panel-traffic"' in html and 'data-panel="traffic"' in html
|
||||
for marker in ['/api/traffic', 'trafficTopCountries', 'trafficTopUserAgents', 'trafficTopClientIps', 'traffic-donut', 'rustProxyTrafficPanel']:
|
||||
assert marker in traffic_panel_js or marker in html, marker
|
||||
traffic_source = (root / "src/traffic.rs").read_text()
|
||||
for marker in ['QUEUE_CAPACITY: usize = 2048', 'TOP_CAPACITY: usize = 32', 'MAX_VHOSTS: usize = 64', 'self.tx.try_send(sample)', 'client_ips: TopTable']:
|
||||
assert marker in traffic_source, marker
|
||||
assert 'if (envelope) render();' in (root / "src/admin/web/web-server.js").read_text()
|
||||
assert 'pub capture_user_agent: bool' in logging_source and 'pub capture_country: bool' in logging_source
|
||||
assert 'spawn_blocking(crate::diagnostics::trim_allocator)' in (root / 'src/runtime.rs').read_text()
|
||||
assert 'fn trim_hot_idle(&self)' in cache_source and 'hot_auto_trim_target_percent' in cache_source
|
||||
assert 'id="cache-domain-filter"' in html and 'cacheDomainFilterEnabled' in html and 'cacheDomainFilterDisabled' in html
|
||||
assert 'const visibleDomains = (data, groups) =>' in cache_panel_js and 'scoped?.settings?.enabled ?? data.settings?.enabled ?? false' in cache_panel_js
|
||||
assert 'groups = d.domains || [], shownGroups = visibleDomains(data, groups)' in cache_panel_js
|
||||
@@ -1133,7 +1145,7 @@ assert '"error_log" => {' not in admin_0241 and 'request("/api/config/error_log"
|
||||
assert '"error_log"' in openapi and 'logging, error_log, forwarding' not in openapi
|
||||
assert 'logging_section_persists_error_log_settings_atomically' in admin_0241 and 'error_log_is_not_a_standalone_configuration_section' in admin_0241
|
||||
assert 'location.pathname === "/logging"' in settings_js_0241
|
||||
assert '"RuntimeInformation"' in openapi and '"version": "0.24.10"' in openapi
|
||||
assert '"RuntimeInformation"' in openapi and '"version": "0.25.0"' in openapi
|
||||
|
||||
# 0.24.1 compiler and hostname-cardinality regressions.
|
||||
tls_0241 = (root / "src/tls.rs").read_text()
|
||||
@@ -1178,5 +1190,5 @@ for schema_name in ["RedirectHost", "NotFoundHost"]:
|
||||
node = openapi_0241["components"]["schemas"][schema_name]["properties"]["frontend"]["properties"]["hosts"]
|
||||
assert "maxItems" not in node, schema_name
|
||||
|
||||
print("PASS: 0.24.10 manifest/package, shared system-statistics/runtime metadata, Settings logging, retained H2, Debian/Grafana/Prometheus/404-host/custom-location/path/cache/dashboard/Nagios UI/source contracts, stopped legacy-renewal removal, session/admin/transport contracts, deterministic package source and embedded SQLite schema-v7 migrations.")
|
||||
print("PASS: 0.25.0 manifest/package, shared system-statistics/runtime metadata, Settings logging, retained H2, Debian/Grafana/Prometheus/404-host/custom-location/path/cache/dashboard/Nagios UI/source contracts, stopped legacy-renewal removal, session/admin/transport contracts, deterministic package source and embedded SQLite schema-v7 migrations.")
|
||||
print("NOT VERIFIED: Rust parsing, types, compilation, linking or runtime behavior. Run ./dev.sh test.")
|
||||
+6
-1
@@ -97,7 +97,7 @@ def digest(path: Path) -> str:
|
||||
def fetch_https(url: str, max_bytes: int) -> bytes:
|
||||
if urllib.parse.urlparse(url).scheme != 'https':
|
||||
raise ValueError('GeoIP downloads require HTTPS.')
|
||||
request = urllib.request.Request(url, headers={'User-Agent': 'RustProxy-installer/0.24.10'})
|
||||
request = urllib.request.Request(url, headers={'User-Agent': 'RustProxy-installer/0.25.0'})
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
if urllib.parse.urlparse(response.geturl()).scheme != 'https':
|
||||
raise ValueError('GeoIP download redirected away from HTTPS.')
|
||||
@@ -328,6 +328,7 @@ AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectSystem=strict
|
||||
LogsDirectory=rustproxy
|
||||
LogsDirectoryMode=0770
|
||||
@@ -335,9 +336,13 @@ ReadWritePaths={data} /var/log/rustproxy
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelLogs=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictNamespaces=true
|
||||
RestrictRealtime=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
SystemCallArchitectures=native
|
||||
LockPersonality=true
|
||||
|
||||
[Install]
|
||||
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rust-independent documentation/Reader consistency checks for the 0.25 release."""
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOCS = ROOT / "docs"
|
||||
|
||||
required = [
|
||||
ROOT / "README.md",
|
||||
ROOT / "ROADMAP.md",
|
||||
ROOT / "CHANGELOG.md",
|
||||
DOCS / "README.md",
|
||||
DOCS / "WEB_SERVER.md",
|
||||
DOCS / "TODO.md",
|
||||
DOCS / "V0.25.0_AUDIT.md",
|
||||
DOCS / "TEST_REPORT.md",
|
||||
DOCS / "CHANGELOG_TECHNICAL_ARCHIVE.md",
|
||||
DOCS / "ROADMAP_HISTORY.md",
|
||||
]
|
||||
for path in required:
|
||||
assert path.is_file(), f"missing required documentation: {path.relative_to(ROOT)}"
|
||||
|
||||
index = (DOCS / "README.md").read_text(encoding="utf-8")
|
||||
for marker in ["Functional documentation", "Development and testing", "Technical documentation", "Historical or version-labelled context", "TODO.md", "V0.25.0_AUDIT.md", "CHANGELOG_TECHNICAL_ARCHIVE.md", "ROADMAP_HISTORY.md", "/docs"]:
|
||||
assert marker in index, marker
|
||||
for document in sorted(DOCS.glob("*.md")):
|
||||
if document.name == "README.md" or (document.name.startswith("V") and document.name.endswith("_AUDIT.md")):
|
||||
continue
|
||||
assert document.name in index, f"documentation index does not classify {document.name}"
|
||||
|
||||
reader_html = (ROOT / "src/admin/web/docs.html").read_text(encoding="utf-8")
|
||||
reader_js = (ROOT / "src/admin/web/docs.js").read_text(encoding="utf-8")
|
||||
build = (ROOT / "build.rs").read_text(encoding="utf-8")
|
||||
admin = (ROOT / "src/admin/mod.rs").read_text(encoding="utf-8")
|
||||
for marker in ["docs-tab-reader", "docs-reader-list", "docs-reader-content", "docs-tab-api"]:
|
||||
assert marker in reader_html, marker
|
||||
for marker in ["renderMarkdown", "selectDocument", 'get("/docs/content.json")', "docsByPath", "docsCategoryHistorical"]:
|
||||
assert marker in reader_js, marker
|
||||
documentation_rs = (ROOT / "src/admin/documentation.rs").read_text(encoding="utf-8")
|
||||
assert "READER_DOCUMENTS" in documentation_rs and "reader_bundle" in documentation_rs
|
||||
assert 'include_str!(concat!(env!("CARGO_MANIFEST_DIR")' in documentation_rs
|
||||
assert 'docs-content.json' not in documentation_rs and 'documentation_bundle' not in build
|
||||
for document in sorted(DOCS.glob("*.md")):
|
||||
marker = f'/{document.relative_to(ROOT).as_posix()}")))'
|
||||
assert marker in documentation_rs, f"Reader does not embed {document.relative_to(ROOT)}"
|
||||
assert '"/docs/content.json"' in admin
|
||||
|
||||
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
||||
roadmap = (ROOT / "ROADMAP.md").read_text(encoding="utf-8")
|
||||
changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
|
||||
assert readme.startswith("# RustProxy 0.25.0")
|
||||
assert "docs/WEB_SERVER.md" in readme and "docs/README.md" in readme
|
||||
assert readme.count("## What changed in ") == 1, "README duplicates historical release notes; use CHANGELOG/archive"
|
||||
assert "## Release history" in readme and "docs/ROADMAP_HISTORY.md" in readme
|
||||
assert roadmap.startswith("# RustProxy roadmap") and "## 0.25.0" in roadmap
|
||||
assert "## 0.24." not in roadmap and "## Web Server follow-up" in roadmap and "docs/ROADMAP_HISTORY.md" in roadmap
|
||||
assert changelog.startswith("# Changelog\n") and "## 0.25.0" in changelog[:800]
|
||||
assert "CHANGELOG_TECHNICAL_ARCHIVE.md" in changelog
|
||||
for developer_only in ["Clean the remaining compiler warnings", "Update package/source/UI/OpenAPI/release identity", "source regressions and update release identity"]:
|
||||
assert developer_only not in changelog, f"developer-only CHANGELOG detail: {developer_only}"
|
||||
|
||||
link_re = re.compile(r"\[[^\]]+\]\(([^)]+)\)")
|
||||
paths = [ROOT / "README.md", ROOT / "ROADMAP.md", ROOT / "CHANGELOG.md", *sorted(DOCS.glob("*.md"))]
|
||||
for source in paths:
|
||||
text = source.read_text(encoding="utf-8")
|
||||
for raw in link_re.findall(text):
|
||||
target = raw.strip().split()[0].strip("<>")
|
||||
if not target or target.startswith(("http://", "https://", "mailto:", "#", "/")):
|
||||
continue
|
||||
target = target.split("#", 1)[0].split("?", 1)[0]
|
||||
if not target:
|
||||
continue
|
||||
resolved = (source.parent / target).resolve()
|
||||
assert resolved.exists(), f"broken documentation link: {source.relative_to(ROOT)} -> {target}"
|
||||
|
||||
print("PASS: documentation index, Reader/API split, release docs and local Markdown links are consistent.")
|
||||
@@ -62,6 +62,9 @@ require(
|
||||
"BACKGROUND_REVALIDATION_LIMIT: usize = 8",
|
||||
"pub cold_limit_bytes: u64",
|
||||
"pub cold_limit_entries: usize",
|
||||
"pub hot_auto_trim_enabled: bool",
|
||||
"pub hot_auto_trim_after_seconds: u64",
|
||||
"pub hot_auto_trim_target_percent: u8",
|
||||
)
|
||||
require(
|
||||
"src/admin/mod.rs",
|
||||
@@ -91,6 +94,8 @@ require(
|
||||
"mpsc::channel(COLD_QUEUE)",
|
||||
"cold_delete_fallbacks: Arc::new(Semaphore::new(COLD_DELETE_FALLBACKS))",
|
||||
"cleanup_slots: Arc::new(Semaphore::new(COLD_CLEANUP_SLOTS))",
|
||||
"cache.trim_hot_idle();",
|
||||
"fn trim_hot_idle(&self)",
|
||||
)
|
||||
|
||||
# Other long-lived/background facilities: queue/task/worker cardinality is bounded
|
||||
@@ -101,11 +106,27 @@ require(
|
||||
"LIVE_STREAM_CAPACITY: usize = 4096",
|
||||
"mpsc::channel(SINK_QUEUE)",
|
||||
"self.sink.try_send(entry)",
|
||||
"pub capture_user_agent: bool",
|
||||
"pub capture_country: bool",
|
||||
)
|
||||
require(
|
||||
"src/traffic.rs",
|
||||
"QUEUE_CAPACITY: usize = 2048",
|
||||
"TOP_CAPACITY: usize = 32",
|
||||
"MAX_VHOSTS: usize = 64",
|
||||
"self.tx.try_send(sample)",
|
||||
"client_ips: TopTable",
|
||||
)
|
||||
require(
|
||||
"src/diagnostics.rs",
|
||||
"tokio::task::spawn_blocking(move || system_stats::sample(previous)).await",
|
||||
"stop.cancelled()",
|
||||
"pub fn trim_allocator() -> bool",
|
||||
)
|
||||
require(
|
||||
"src/runtime.rs",
|
||||
"Duration::from_secs(60)",
|
||||
"spawn_blocking(crate::diagnostics::trim_allocator)",
|
||||
)
|
||||
require(
|
||||
"src/upstream.rs",
|
||||
|
||||
@@ -31,12 +31,12 @@ 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/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"]:
|
||||
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/traffic.rs", "src/system_stats/mod.rs", "src/system_stats/linux.rs", "src/vhost.rs", "src/web_server.rs", "src/balance.rs", "src/security.rs", "src/admin/web/vhosts.js", "src/admin/web/web-server.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/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/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", "docs/TODO.md", "docs/V0.25.0_AUDIT.md", "docs/CHANGELOG_TECHNICAL_ARCHIVE.md", "docs/ROADMAP_HISTORY.md", "scripts/docs_check.py", "scripts/web_server_contracts.py", "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.10 source tree: missing {path}")
|
||||
raise ValueError(f"Incomplete RustProxy 0.25.0 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.10 entry point; extract the complete package, not individual files.")
|
||||
raise ValueError("src/lib.rs is not the 0.25.0 entry point; extract the complete package, not individual files.")
|
||||
return messages
|
||||
|
||||
|
||||
|
||||
@@ -114,13 +114,13 @@ def upgrade_smoke(tmp: Path) -> None:
|
||||
assert backup.is_dir()
|
||||
assert (backup / "src/obsolete.rs").is_file()
|
||||
assert not (target / "src/obsolete.rs").exists()
|
||||
assert 'version = "0.24.10"' in (target / "Cargo.toml").read_text(encoding="utf-8")
|
||||
assert 'version = "0.25.0"' in (target / "Cargo.toml").read_text(encoding="utf-8")
|
||||
assert (target / "data/keep.txt").read_text(encoding="utf-8") == "preserve\n"
|
||||
assert (target / ".env").read_text(encoding="utf-8") == "RUST_PROXY_DEMO=0\n"
|
||||
assert (target / "langs/site.json").is_file()
|
||||
assert (target / "docker-agent/main.go").is_file()
|
||||
manifest = json.loads((backup / "upgrade.json").read_text(encoding="utf-8"))
|
||||
assert manifest["to_version"] == "0.24.10"
|
||||
assert manifest["to_version"] == "0.25.0"
|
||||
assert manifest["removed_obsolete_runtime_state"] is True
|
||||
assert not legacy.exists()
|
||||
assert not (backup / "legacy-data").exists()
|
||||
@@ -160,6 +160,8 @@ def deployment_gate_smoke(tmp: Path) -> None:
|
||||
assert "ExecStart=/opt/rustproxy/current/rust-proxy --data-dir /var/lib/rustproxy --admin-listen 0.0.0.0:9090 serve --proxy-listen 0.0.0.0:80 --tls-listen 0.0.0.0:443 --allow-remote-admin" in remote_unit
|
||||
assert "ReadWritePaths=/var/lib/rustproxy /var/log/rustproxy" in remote_unit
|
||||
assert "TimeoutStopSec=120" in remote_unit
|
||||
for marker in ["PrivateDevices=true", "ProtectKernelLogs=true", "RestrictNamespaces=true", "RestrictRealtime=true", "SystemCallArchitectures=native"]:
|
||||
assert marker in remote_unit, marker
|
||||
deploy_source = (ROOT / "scripts/deploy.py").read_text(encoding="utf-8")
|
||||
assert "def stop_for_managed_update" in deploy_source and ".managed-update" in deploy_source
|
||||
assert "systemctl', 'stop', SERVICE" in deploy_source
|
||||
@@ -240,10 +242,19 @@ def ui_contract_smoke() -> None:
|
||||
host404 = (ROOT / "src/admin/web/host404.js").read_text(encoding="utf-8")
|
||||
integrations = (ROOT / "src/admin/web/integrations.js").read_text(encoding="utf-8")
|
||||
acme_ui = (ROOT / "src/admin/web/acme.js").read_text(encoding="utf-8")
|
||||
traffic = (ROOT / "src/admin/web/traffic-panel.js").read_text(encoding="utf-8")
|
||||
cache = (ROOT / "src/admin/web/cache-panel.js").read_text(encoding="utf-8")
|
||||
transform_panel = (ROOT / "src/admin/web/transform-panel.js").read_text(encoding="utf-8")
|
||||
css = (ROOT / "src/admin/web/style.css").read_text(encoding="utf-8")
|
||||
required_html = [
|
||||
'id="panel-traffic"',
|
||||
'id="traffic-scope"',
|
||||
'id="traffic-chart-countries"',
|
||||
'id="traffic-chart-client-ips"',
|
||||
'id="traffic-client-ips"',
|
||||
'id="log-capture-user-agent"',
|
||||
'id="log-capture-country"',
|
||||
'id="cache-auto-trim-enabled"',
|
||||
'id="settings-tab-logging"',
|
||||
'id="settings-page-logging"',
|
||||
'id="logging-form"',
|
||||
@@ -304,6 +315,8 @@ def ui_contract_smoke() -> None:
|
||||
]
|
||||
for marker in required_html:
|
||||
assert marker in html, marker
|
||||
assert '/api/traffic' in traffic and 'rustProxyTrafficPanel' in traffic
|
||||
assert 'traffic: "/traffic"' in app and 'window.rustProxyTrafficPanel?.refresh()' in app
|
||||
for marker in ['id="settings-tab-manual-config"', 'id="settings-page-manual-config"', 'id="config-editor"', 'id="save-json"', 'id="export-config"']:
|
||||
assert marker in html, marker
|
||||
assert 'data-panel="configuration"' not in html and 'id="panel-configuration"' not in html
|
||||
@@ -508,7 +521,7 @@ def source_contract_smoke() -> None:
|
||||
assert marker in admin, marker
|
||||
anonymous_block = admin.split("fn anonymous_allowed", 1)[1].split("const CERTIFICATE_API_KEY_HEADER", 1)[0]
|
||||
assert '"/api/status"' not in anonymous_block
|
||||
assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.24.10"' in openapi_0241
|
||||
assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.25.0"' in openapi_0241
|
||||
assert 'id="panel-logging"' not in html
|
||||
for marker in ['id="settings-tab-logging"', 'id="settings-page-logging"', 'id="logging-form"', 'id="error-log-form"']:
|
||||
assert marker in html, marker
|
||||
@@ -952,7 +965,7 @@ def source_contract_smoke() -> None:
|
||||
assert "removed_obsolete_runtime_state" in upgrade_source and "legacy-data" not in upgrade_source and "certs_dir.is_symlink()" in upgrade_source
|
||||
assert '.managed-update' in main and "flush_hot_to_cold_for_update" in main
|
||||
openapi = (ROOT / "src/admin/openapi.json").read_text(encoding="utf-8")
|
||||
for marker in ['"version": "0.24.10"', '"memory_preset"', '"cold_preset"', '"memory_budget_bytes"', '"max_inflight"', '"preset"', '"session_days"', '"session_lifetime_seconds"', '"accept_local_listener_risk"', '"backend_header"', '"backend_timing_header"', '"cold_on_age"', '"match_type"', '"regex_insensitive"', '"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']:
|
||||
for marker in ['"version": "0.25.0"', '"memory_preset"', '"cold_preset"', '"memory_budget_bytes"', '"max_inflight"', '"preset"', '"session_days"', '"session_lifetime_seconds"', '"accept_local_listener_risk"', '"backend_header"', '"backend_timing_header"', '"cold_on_age"', '"match_type"', '"regex_insensitive"', '"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']:
|
||||
assert marker in openapi, marker
|
||||
assert '"software_update"' in openapi and '"update_available"' in openapi
|
||||
for marker in ['"/api/certificates/deploy"', '"CertificateDeploy"', '"CertificateDeployResult"', '"/api/certificates/reload"']:
|
||||
@@ -1072,6 +1085,10 @@ def reproducible_archive_smoke(tmp: Path) -> None:
|
||||
assert "RustProxy/src/admin/web/error_401.html" in names
|
||||
assert "RustProxy/src/admin/web/error_404.html" in names
|
||||
assert "RustProxy/docs/CUSTOM_LOCATIONS.md" in names
|
||||
assert "RustProxy/docs/WEB_SERVER.md" in names
|
||||
assert "RustProxy/docs/PHP_FPM.md" in names
|
||||
assert "RustProxy/src/web_server.rs" in names
|
||||
assert "RustProxy/src/admin/web/web-server.js" in names
|
||||
assert "RustProxy/nagios/check_rustproxy.py" in names
|
||||
assert "RustProxy/nagios/check_rustproxy_api" in names
|
||||
assert "RustProxy/src/admin/web/host404.js" in names
|
||||
@@ -1084,6 +1101,8 @@ def reproducible_archive_smoke(tmp: Path) -> None:
|
||||
assert "RustProxy/src/update_check.rs" in names
|
||||
assert "RustProxy/docker-agent/docker-agent" not in names
|
||||
assert all("/data/" not in name and "/target/" not in name for name in names)
|
||||
assert all(not name.endswith("/.env") for name in names), "local .env leaked into release archive"
|
||||
assert "RustProxy/.env.example" in names
|
||||
timestamps = {item.date_time for item in archive.infolist()}
|
||||
assert len(timestamps) == 1
|
||||
|
||||
@@ -1100,7 +1119,7 @@ def main() -> None:
|
||||
source_contract_smoke()
|
||||
container_contract_smoke()
|
||||
debian_grafana_contract_smoke()
|
||||
print("PASS: 0.24.10 SQLite/upgrade, local-response 404 hosts, Prometheus/Grafana diagnostics, Debian packaging, cache/dashboard/logging/Nagios, retained admin/H3/stream/TLS contracts and reproducible source archives.")
|
||||
print("PASS: 0.25.0 SQLite/upgrade, local-response 404 hosts, Prometheus/Grafana diagnostics, Debian packaging, cache/dashboard/logging/Nagios, retained admin/H3/stream/TLS contracts and reproducible source archives.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -76,10 +76,10 @@ ENV_FILE=scripts/routeros-oci-build/.env \
|
||||
bash scripts/routeros-oci-build/build-routeros-oci.sh push
|
||||
```
|
||||
|
||||
With RustProxy version `0.24.10` and the defaults this publishes:
|
||||
With RustProxy version `0.25.0` and the defaults this publishes:
|
||||
|
||||
```text
|
||||
zot.example.net/network/rustproxy:0.24.10
|
||||
zot.example.net/network/rustproxy:0.25.0
|
||||
zot.example.net/network/rustproxy:latest
|
||||
zot.example.net/network/rustproxy:routeros
|
||||
```
|
||||
|
||||
Executable
+104
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Source-level security invariants that must hold even before Rust runtime tests."""
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
def need(text: str, *needles: str) -> None:
|
||||
for needle in needles:
|
||||
if needle not in text:
|
||||
raise AssertionError(f"missing security invariant: {needle}")
|
||||
|
||||
web = read("src/web_server.rs")
|
||||
htaccess = read("src/web_server/htaccess.rs")
|
||||
acl = read("src/acl.rs")
|
||||
admin = read("src/admin/mod.rs")
|
||||
auth = read("src/auth/mod.rs")
|
||||
web_ui = read("src/admin/web/web-server.js")
|
||||
|
||||
# Preset regression: selecting a preset must actually materialize normal config fields.
|
||||
need(web_ui,
|
||||
"function presetChanged()",
|
||||
'if ($("web-service-preset").value !== "custom") applyPreset();',
|
||||
'$("web-service-preset").addEventListener("change", presetChanged);')
|
||||
|
||||
# Request target/path canonicalization: reject double decoding, encoded separators and dot traversal.
|
||||
need(acl,
|
||||
"ambiguous encoded path character",
|
||||
"ambiguous path segments",
|
||||
"!matches!(decoded, b'/' | b'\\\\' | b'%' | b'?' | b'#' | b';')",
|
||||
"path.len() <= 8192")
|
||||
|
||||
# FastCGI must not inherit client proxy settings (httpoxy) and must not control HTTP framing.
|
||||
need(web,
|
||||
"HTTP_PROXY is a well-known CGI/FastCGI injection vector",
|
||||
'if name.as_str() == "proxy"',
|
||||
'"content-length"',
|
||||
'"transfer-encoding"',
|
||||
'"connection"',
|
||||
'"upgrade"',
|
||||
'"proxy-authenticate"',
|
||||
'"proxy-authorization"',
|
||||
"(200..=599).contains(&code)",
|
||||
"unsupported informational/invalid status",
|
||||
"reserved_fastcgi_param",
|
||||
'"PHP_ADMIN_VALUE"',
|
||||
"pass_authorization",
|
||||
"max_request_body_bytes",
|
||||
"StatusCode::PAYLOAD_TOO_LARGE",
|
||||
"max_response_header_bytes",
|
||||
"stderr_log_bytes")
|
||||
|
||||
# File-system confinement and .htaccess loading must not follow attacker-controlled symlinks.
|
||||
need(web, "libc::openat", "libc::O_NOFOLLOW", "secure filesystem confinement")
|
||||
need(htaccess,
|
||||
"read_htaccess_nofollow",
|
||||
"libc::O_NOFOLLOW",
|
||||
"fs::symlink_metadata",
|
||||
".htaccess scan escaped the Web Service document root",
|
||||
"must not be a symlink",
|
||||
"MAX_HTACCESS_FILES",
|
||||
"MAX_HTACCESS_TOTAL_BYTES")
|
||||
need(web, "htaccess_configuration_file_cannot_be_a_symlink")
|
||||
|
||||
# PHP source must fail closed instead of becoming a static download when FPM is unavailable.
|
||||
need(web,
|
||||
"php_source_extensions_are_not_served_without_php_fpm",
|
||||
"php_fpm_failure_returns_gateway_error_instead_of_php_source",
|
||||
"fastcgi_never_forwards_proxy_header_as_http_proxy",
|
||||
"fastcgi_response_cannot_control_downstream_framing_or_hop_headers",
|
||||
"fastcgi_response_rejects_informational_status_codes",
|
||||
"php_custom_fastcgi_params_cannot_override_core_or_php_admin_values",
|
||||
"php_request_body_limit_returns_413_before_fastcgi_connect")
|
||||
|
||||
# Admin plane: exact anonymous allowlist + cookie/CSRF/origin/browser marker + CSP/frame denial.
|
||||
need(admin,
|
||||
"fn anonymous_allowed",
|
||||
'single_header(headers, "origin")',
|
||||
'single_header(headers, "x-rustproxy-request") != Some("1")',
|
||||
'single_header(headers, "x-csrf-token")',
|
||||
'"auth.csrf"',
|
||||
"frame-ancestors 'none'",
|
||||
"object-src 'none'",
|
||||
"anonymous_operational_paths_fail_closed_before_extractors",
|
||||
"duplicate_csrf_or_marker_is_rejected_and_session_survives")
|
||||
need(auth,
|
||||
"HttpOnly; SameSite=Strict",
|
||||
'if secure { "; Secure" } else { "" }',
|
||||
"Argon2id",
|
||||
"(12..=128).contains(&value.len())")
|
||||
|
||||
# Web serving/admin code must not execute shell commands as part of request/config handling.
|
||||
for path, text in {
|
||||
"src/web_server.rs": web,
|
||||
"src/web_server/htaccess.rs": htaccess,
|
||||
"src/admin/mod.rs": admin,
|
||||
}.items():
|
||||
if "Command::new" in text or "std::process::Command" in text:
|
||||
raise AssertionError(f"unexpected process execution primitive in {path}")
|
||||
|
||||
print("PASS: production security source invariants (paths, FastCGI, htaccess, admin auth/CSRF, no shell execution) are present.")
|
||||
Executable
+78
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Offline minimum-version gates for network-facing RustSec advisories reviewed 2026-10-04.
|
||||
|
||||
This is deliberately not a replacement for `cargo audit`: it prevents known-fixed
|
||||
packages from being accidentally downgraded when the lockfile changes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
LOCK = (ROOT / "Cargo.lock").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def versions() -> dict[str, list[str]]:
|
||||
found: dict[str, list[str]] = {}
|
||||
for block in LOCK.split("[[package]]")[1:]:
|
||||
match = re.search(r'\n?name = "([^"]+)"\nversion = "([^"]+)"', block)
|
||||
if match:
|
||||
found.setdefault(match.group(1), []).append(match.group(2))
|
||||
return found
|
||||
|
||||
|
||||
def triplet(value: str) -> tuple[int, int, int]:
|
||||
match = re.match(r"^(\d+)\.(\d+)\.(\d+)", value)
|
||||
if not match:
|
||||
raise AssertionError(f"unsupported version syntax: {value}")
|
||||
return tuple(map(int, match.groups()))
|
||||
|
||||
|
||||
packages = versions()
|
||||
minimums = {
|
||||
# RUSTSEC-2026-0285
|
||||
"rustls": (0, 23, 45),
|
||||
# RUSTSEC-2026-0185 and RUSTSEC-2026-0037
|
||||
"quinn-proto": (0, 11, 15),
|
||||
# RUSTSEC-2026-0104 (also covers 0049/0098/0099)
|
||||
"rustls-webpki": (0, 103, 13),
|
||||
# RUSTSEC-2025-0009
|
||||
"ring": (0, 17, 12),
|
||||
# RUSTSEC-2024-0363
|
||||
"sqlx": (0, 8, 1),
|
||||
# RUSTSEC-2026-0258
|
||||
"h2": (0, 4, 16),
|
||||
# RUSTSEC-2026-0007
|
||||
"bytes": (1, 11, 1),
|
||||
# RUSTSEC-2026-0009
|
||||
"time": (0, 3, 47),
|
||||
}
|
||||
for package, minimum in minimums.items():
|
||||
locked = packages.get(package)
|
||||
if not locked:
|
||||
raise AssertionError(f"expected security-critical dependency {package} in Cargo.lock")
|
||||
for value in locked:
|
||||
if triplet(value) < minimum:
|
||||
raise AssertionError(f"{package} {value} is below reviewed patched minimum {'.'.join(map(str, minimum))}")
|
||||
|
||||
# 2026 malicious-crate campaign snapshot reviewed against RustSec on 2026-10-04.
|
||||
# Keep the exact arrayref version gate because earlier arrayref releases are legitimate.
|
||||
for value in packages.get("arrayref", []):
|
||||
if value == "0.3.10":
|
||||
raise AssertionError("Cargo.lock contains compromised arrayref 0.3.10")
|
||||
|
||||
malicious_packages = {
|
||||
"greentic-setup", "greentic-setup-dev", "append-only-vec", "proc-macro1",
|
||||
"internment", "proc-macro-en", "onering", "logflux", "exploration",
|
||||
"sui-execution-cut", "mysten-metrics", "microsoftsystem64",
|
||||
"pretty-changelog-logger", "safe-agent-rs", "logtrace", "jfrog_quotes",
|
||||
"tree-sitter-pkl", "custom-req-on-workers", "sophosfirewall-python",
|
||||
"statsrelay-protobuf", "time_calibrator", "tracings", "tracing_checks",
|
||||
"rpc-check", "tracing-check", "clob-sdk", "polymarkets-rs-clob-client",
|
||||
"polymarkets-client-sdk", "polymarket-client-sdks", "finch-rst",
|
||||
}
|
||||
present = sorted(malicious_packages.intersection(packages))
|
||||
if present:
|
||||
raise AssertionError(f"Cargo.lock contains RustSec-listed malicious package(s): {', '.join(present)}")
|
||||
|
||||
print("PASS: Cargo.lock stays above reviewed RustSec minimums and excludes the reviewed 2026 malicious-crate set (snapshot 2026-10-04).")
|
||||
@@ -85,7 +85,7 @@ def download(destination: Path) -> None:
|
||||
"""TLS verification uses Python's default trust store; never executes a shell."""
|
||||
started = time.monotonic()
|
||||
opener = build_opener(HttpsRedirects())
|
||||
request = Request(DOWNLOAD_URL, headers={"User-Agent": "RustProxy-build/0.24.10"})
|
||||
request = Request(DOWNLOAD_URL, headers={"User-Agent": "RustProxy-build/0.25.0"})
|
||||
with opener.open(request, timeout=30) as response, destination.open("wb") as output:
|
||||
length = response.headers.get("Content-Length")
|
||||
if length and int(length) > MAX_ARCHIVE:
|
||||
|
||||
+3
-3
@@ -46,7 +46,7 @@ def upgrade(source: Path, target: Path, *, stopped: bool, data_dir: Path | None
|
||||
for name in MANAGED:
|
||||
if (target / name).is_symlink():
|
||||
raise ValueError(f"Managed target {name} is a symlink; refusing to replace it.")
|
||||
backup = target / ".upgrade-backup" / ("before-0.24.10-" + uuid.uuid4().hex[:12])
|
||||
backup = target / ".upgrade-backup" / ("before-0.25.0-" + uuid.uuid4().hex[:12])
|
||||
backup.mkdir(parents=True, mode=0o700)
|
||||
installed: list[str] = []
|
||||
moved: list[str] = []
|
||||
@@ -84,7 +84,7 @@ def upgrade(source: Path, target: Path, *, stopped: bool, data_dir: Path | None
|
||||
remove_legacy = legacy.exists()
|
||||
if remove_legacy and (legacy.is_symlink() or not legacy.is_file()):
|
||||
raise ValueError("Obsolete renewal observation sidecar must be a regular file.")
|
||||
(backup / "upgrade.json").write_text(json.dumps({"from_directory":str(target), "to_version":"0.24.10", "managed_paths":MANAGED, "removed_obsolete_runtime_state": remove_legacy}, indent=2)+"\n", encoding="utf-8")
|
||||
(backup / "upgrade.json").write_text(json.dumps({"from_directory":str(target), "to_version":"0.25.0", "managed_paths":MANAGED, "removed_obsolete_runtime_state": remove_legacy}, indent=2)+"\n", encoding="utf-8")
|
||||
if remove_legacy:
|
||||
legacy.unlink()
|
||||
except BaseException:
|
||||
@@ -115,7 +115,7 @@ def main() -> None:
|
||||
backup = upgrade(Path(__file__).resolve().parents[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.10. Previous managed source files: {backup}")
|
||||
print(f"Installed RustProxy 0.25.0. Previous managed source files: {backup}")
|
||||
print("Preserved data, .env, .git, target and other unmanaged files. No database migration was run.")
|
||||
print(f"Next: cd {args.target}; ./dev.sh test; ./dev.sh")
|
||||
|
||||
|
||||
Executable
+181
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rust-independent 0.25 Web Server architecture/security contract checks."""
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
def need(text: str, *markers: str) -> None:
|
||||
for marker in markers:
|
||||
assert marker in text, marker
|
||||
|
||||
config = (ROOT / "src/config.rs").read_text()
|
||||
vhost = (ROOT / "src/vhost.rs").read_text()
|
||||
web = (ROOT / "src/web_server.rs").read_text()
|
||||
proxy = (ROOT / "src/proxy/mod.rs").read_text()
|
||||
admin = (ROOT / "src/admin/mod.rs").read_text()
|
||||
ui = (ROOT / "src/admin/web/vhosts.js").read_text()
|
||||
web_ui = (ROOT / "src/admin/web/web-server.js").read_text()
|
||||
app_ui = (ROOT / "src/admin/web/app.js").read_text()
|
||||
html = (ROOT / "src/admin/web/index.html").read_text()
|
||||
settings_ui = (ROOT / "src/admin/web/settings.js").read_text()
|
||||
logging = (ROOT / "src/logging.rs").read_text()
|
||||
metrics = (ROOT / "src/metrics.rs").read_text()
|
||||
acl = (ROOT / "src/acl.rs").read_text()
|
||||
openapi = json.loads((ROOT / "src/admin/openapi.json").read_text())
|
||||
|
||||
need(config,
|
||||
"pub web_services: Vec<crate::web_server::Service>",
|
||||
"pub enum CompiledSource",
|
||||
"CompiledSource::WebServer",
|
||||
"unknown Web Service",
|
||||
"web_server_main_source_needs_no_backend_pool",
|
||||
"web_server_and_reverse_proxy_can_share_one_vhost",
|
||||
"one_web_service_can_be_reused_by_multiple_vhosts",
|
||||
"missing_source_fields_remain_reverse_proxy_for_old_configs")
|
||||
need(vhost,
|
||||
"pub enum Source",
|
||||
"WebServer { web_service_id: String }",
|
||||
"pub source: Source",
|
||||
"vhost requires at least one backend member for Reverse Proxy sources",
|
||||
"Web Server location cannot pin a reverse-proxy backend")
|
||||
need(web,
|
||||
"pub struct Service",
|
||||
"pub document_root: PathBuf",
|
||||
"pub index: Vec<String>",
|
||||
"pub directory_listing: bool",
|
||||
"pub mime_types: BTreeMap<String, String>",
|
||||
"pub default_mime_type: String",
|
||||
"pub cache_control: Option<String>",
|
||||
"pub cache_rules: Vec<CacheRule>",
|
||||
"pub response_headers: BTreeMap<String, String>",
|
||||
"pub try_files: Vec<String>",
|
||||
"pub rewrites: Vec<RewriteRule>",
|
||||
"pub precompressed: Precompressed",
|
||||
"pub download_extensions: Vec<String>",
|
||||
"pub symlink_policy: SymlinkPolicy",
|
||||
"pub php: Php",
|
||||
"pub htaccess: HtaccessConfig",
|
||||
"pub hardening: Hardening",
|
||||
"hardening_blocks_common_probe_paths_and_trace",
|
||||
"hardening_strips_fingerprint_headers_after_source_rules",
|
||||
"HTTP_PROXY is a well-known CGI/FastCGI injection vector",
|
||||
'if name.as_str() == "proxy"',
|
||||
'"content-length"',
|
||||
'"proxy-authenticate"',
|
||||
'"proxy-authorization"',
|
||||
"mod htaccess",
|
||||
"htaccess::apply_rewrites",
|
||||
"htaccess::protected_component",
|
||||
"htaccess_wordpress_style_front_controller_and_protected_files_work",
|
||||
"htaccess_directory_index_mime_headers_expires_and_nested_scope_work",
|
||||
"htaccess_strict_mode_rejects_unsupported_directives",
|
||||
"htaccess_security_sensitive_unsupported_directives_fail_closed",
|
||||
"php_source_extensions_are_not_served_without_php_fpm",
|
||||
"htaccess_php_addhandler_requires_php_fpm",
|
||||
"FastCgiEndpoint",
|
||||
"FCGI_BEGIN_REQUEST",
|
||||
"try_acquire_owned",
|
||||
"CONTENT_DISPOSITION",
|
||||
"CONTENT_ENCODING",
|
||||
"libc::openat",
|
||||
"libc::O_NOFOLLOW",
|
||||
"ReaderStream::new(file.take(body_length))",
|
||||
"IF_NONE_MATCH", "IF_MODIFIED_SINCE", "IF_RANGE", "RANGE",
|
||||
"StatusCode::NOT_MODIFIED", "StatusCode::PARTIAL_CONTENT", "StatusCode::RANGE_NOT_SATISFIABLE",
|
||||
"listing_escapes_html_and_encodes_urls",
|
||||
"root_directory_listing_is_repeatable_across_refreshes",
|
||||
"index_precedes_listing_and_missing_index_falls_back_to_listing_per_directory",
|
||||
"mime_overrides_default_mime_and_cache_control_are_applied_to_files",
|
||||
'HeaderValue::from_static("no-store")',
|
||||
"builtin_error_response(StatusCode::FORBIDDEN, &method)",
|
||||
"empty_index_can_go_directly_to_directory_listing",
|
||||
"RustProxy <b>0.25.0</b>",
|
||||
"traversal_and_symlink_escape_are_not_served",
|
||||
"try_files_rewrites_cache_rules_headers_and_downloads_work",
|
||||
"precompressed_sidecars_keep_mime_validators_and_range_semantics",
|
||||
"php_fpm_executes_index_and_try_files_front_controller_without_source_disclosure",
|
||||
"php_fpm_executes_configured_directory_index",
|
||||
"php_fpm_failure_returns_gateway_error_instead_of_php_source",
|
||||
"inside_root_symlink_policy_allows_only_targets_beneath_root",
|
||||
"secure filesystem confinement currently requires a Unix platform")
|
||||
need(proxy,
|
||||
"if let crate::config::CompiledSource::WebServer(service) = &route.source",
|
||||
"let static_method = request.method().clone()",
|
||||
"crate::web_server::serve(",
|
||||
"static_headers",
|
||||
"response.extensions_mut().insert(StaticSourceResponse)",
|
||||
"let custom_error =",
|
||||
"crate::pages::replace_error",
|
||||
"apply_response_rules(",
|
||||
"finish_headers(",
|
||||
"crate::compression::encode(")
|
||||
assert "crate::web_server::serve(service, &request" not in proxy
|
||||
assert proxy.index("basic_identity =") < proxy.index("CompiledSource::WebServer(service)")
|
||||
assert proxy.index("let identity =") < proxy.index("CompiledSource::WebServer(service)")
|
||||
assert proxy.index("CompiledSource::WebServer(service)") < proxy.index("let (backend, backend_lease, affinity_cookie)")
|
||||
custom_error_pos = proxy.index("let custom_error =")
|
||||
assert proxy.find("apply_response_rules(", custom_error_pos) > custom_error_pos
|
||||
need(acl, "ambiguous encoded path character", '"/%2e%2e/secret"', '"/a%2fb"', '"/a%5cb"', '"/bad%"')
|
||||
need(admin, '"/web-server"', '"/assets/web-server.js"', '"web-services"', '"vhost_metrics"')
|
||||
need(html, 'data-panel="web-server"', 'id="panel-web-server"', 'id="web-service-dialog"',
|
||||
'id="web-service-tabs"', 'data-web-service-page="general"', 'data-web-service-page="routing"',
|
||||
'data-web-service-page="mime"', 'data-web-service-page="cache"', 'data-web-service-page="delivery"',
|
||||
'data-web-service-page="php"', 'data-web-service-page="security"', 'data-web-service-page="htaccess"',
|
||||
'id="web-service-htaccess-enabled"', 'id="web-service-htaccess-help"', 'id="htaccess-help-dialog"', 'id="web-service-try-files"',
|
||||
'id="web-service-cache-rules"', 'id="web-service-precompressed"', 'id="web-service-php-enabled"',
|
||||
'id="web-service-php-pass-authorization"', 'id="web-service-php-body-limit"',
|
||||
'id="web-service-php-header-limit"', 'id="web-service-php-stderr-limit"',
|
||||
'id="web-service-php-custom-params"', 'id="web-service-php-values"', 'id="php-help-dialog"',
|
||||
'id="web-service-symlink-policy"', 'id="web-service-preset"', 'id="web-service-preset-apply"',
|
||||
'id="web-service-hardening-attacks"', 'id="web-service-hardening-headers"', 'data-logging-settings-tab="outputs"',
|
||||
'data-logging-settings-tab="format"', 'data-logging-settings-tab="errors"')
|
||||
need(settings_ui, "selectLoggingTab", 'data-logging-settings-page', 'selectLoggingTab("outputs")')
|
||||
need(web_ui, 'saveSection("web-services"', "directory_listing", "web_service_id", "mime_types", "default_mime_type",
|
||||
"cache_control", "cache_rules", "response_headers", "try_files", "rewrites", "precompressed",
|
||||
"download_extensions", "symlink_policy", "php", "htaccess", "hardening", "presetConfig", "phpFrontController", "staticCacheRules",
|
||||
"pass_authorization", "max_request_body_bytes", "max_response_header_bytes", "stderr_log_bytes", "custom_params", "php_values",
|
||||
"syncHtaccess", "htaccess-help-dialog", "php-help-dialog", "parseFastCgiParams", "parsePhpValues", "validatePhpCustomData", "activateTab", "initTabs", "presetChanged",
|
||||
'button = el("button", t(key), "quiet")', 'button.dataset.tab = name', 'button.setAttribute("role", "tab")', "vhost-detail",
|
||||
'el("span", t(key), "small-label")', "if (envelope) render();")
|
||||
need(logging, '"route"', '"cache_effective_enabled"', '"transform_effective_enabled"', 'value["cache_enabled"]', 'value["transform_enabled"]')
|
||||
need(metrics, "pub struct VhostMetricsView", "ewma_duration_us", "pub fn view(&self) -> VhostMetricsView")
|
||||
assert 'item.append(text("span"' not in web_ui
|
||||
need(app_ui, 'function showStartupFailure(error)', '$("login").hidden = true', 'const info = await api("/api/auth/session")')
|
||||
assert '$("login").hidden = false; document.documentElement.classList.remove("app-loading")' not in app_ui
|
||||
need(ui, 'type: "web_server"', "web_service_id", "sourceReverseProxy", "vhBackendRequired", "backend-diagnostics-card", "status.vhost_metrics", "vhostTrafficRate")
|
||||
assert "WebService" in openapi.get("components", {}).get("schemas", {})
|
||||
web_service_schema = openapi["components"]["schemas"]["WebService"]
|
||||
assert web_service_schema["properties"]["index"].get("minItems", 0) == 0
|
||||
assert web_service_schema["properties"]["index"]["maxItems"] == 16
|
||||
assert web_service_schema["properties"]["mime_types"]["maxProperties"] == 64
|
||||
assert web_service_schema["properties"]["default_mime_type"]["default"] == "application/octet-stream"
|
||||
assert web_service_schema["properties"]["cache_control"]["nullable"] is True
|
||||
for prop in ["cache_rules", "response_headers", "try_files", "rewrites", "precompressed", "download_extensions", "symlink_policy", "php", "htaccess", "hardening"]:
|
||||
assert prop in web_service_schema["properties"], prop
|
||||
assert web_service_schema["properties"]["symlink_policy"]["default"] == "deny"
|
||||
assert web_service_schema["properties"]["php"]["properties"]["enabled"]["default"] is False
|
||||
php_schema = web_service_schema["properties"]["php"]["properties"]
|
||||
assert php_schema["pass_authorization"]["default"] is True
|
||||
assert php_schema["max_request_body_bytes"]["default"] == 0
|
||||
assert php_schema["max_response_header_bytes"]["default"] == 65536
|
||||
assert php_schema["stderr_log_bytes"]["default"] == 4096
|
||||
assert php_schema["custom_params"]["maxProperties"] == 64
|
||||
assert php_schema["php_values"]["maxProperties"] == 64
|
||||
assert web_service_schema["properties"]["htaccess"]["properties"]["enabled"]["default"] is False
|
||||
assert web_service_schema["properties"]["htaccess"]["properties"]["filename"]["default"] == ".htaccess"
|
||||
assert web_service_schema["properties"]["hardening"]["properties"]["block_common_attacks"]["default"] is False
|
||||
assert web_service_schema["properties"]["hardening"]["properties"]["strip_unnecessary_headers"]["default"] is False
|
||||
htaccess_source = (ROOT / "src/web_server/htaccess.rs").read_text()
|
||||
need(htaccess_source, "MAX_HTACCESS_FILES", "MAX_HTACCESS_TOTAL_BYTES", "RewriteEngine", "RewriteCond", "RewriteRule", "DirectoryIndex", "AddType", "AddHandler", "ExpiresByType", "Require all granted/denied", "general Files/FilesMatch semantics are not supported", "security/handler directive", "apply_response", "apply_rewrites")
|
||||
assert "Source" in openapi.get("components", {}).get("schemas", {})
|
||||
assert "client_ips" in openapi["components"]["schemas"]["TrafficCounters"]["properties"]
|
||||
assert (ROOT / "docs/WEB_SERVER.md").is_file()
|
||||
assert (ROOT / "docs/PHP_FPM.md").is_file()
|
||||
assert (ROOT / "docs/V0.25.0_AUDIT.md").is_file()
|
||||
assert (ROOT / "docs/TODO.md").is_file()
|
||||
docs_html = (ROOT / "src/admin/web/docs.html").read_text()
|
||||
docs_js = (ROOT / "src/admin/web/docs.js").read_text()
|
||||
need(docs_html, "docs-tab-reader", "docs-reader-content", "docs-tab-api")
|
||||
need(docs_js, 'get("/docs/content.json")', "renderMarkdown", "selectDocument")
|
||||
print("PASS: 0.25 Web Server separation, pipeline, filesystem, HTTP, UI/API and regression contracts are present.")
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import vm from "node:vm";
|
||||
|
||||
class ClassList {
|
||||
constructor() { this.values = new Set(); }
|
||||
toggle(name, active) { active ? this.values.add(name) : this.values.delete(name); }
|
||||
}
|
||||
class Element {
|
||||
constructor(id = "") {
|
||||
this.id = id;
|
||||
this.value = "";
|
||||
this.checked = false;
|
||||
this.disabled = false;
|
||||
this.hidden = false;
|
||||
this.open = false;
|
||||
this.dataset = {};
|
||||
this.children = [];
|
||||
this.listeners = new Map();
|
||||
this.classList = new ClassList();
|
||||
this.attributes = new Map();
|
||||
this.tabIndex = 0;
|
||||
this.textContent = "";
|
||||
this.type = "";
|
||||
this.readOnly = false;
|
||||
this.elements = [];
|
||||
}
|
||||
addEventListener(name, callback) { this.listeners.set(name, callback); }
|
||||
dispatch(name) { return this.listeners.get(name)?.({ target: this, preventDefault() {} }); }
|
||||
setAttribute(name, value) { this.attributes.set(name, String(value)); }
|
||||
append(...items) { this.children.push(...items); }
|
||||
replaceChildren(...items) { this.children = [...items]; }
|
||||
showModal() { this.open = true; }
|
||||
close() { this.open = false; }
|
||||
reset() {}
|
||||
}
|
||||
|
||||
const ids = [
|
||||
"htaccess-help-close","htaccess-help-dialog","php-help-close","php-help-dialog","web-service-cache-control","web-service-cache-rules",
|
||||
"web-service-close","web-service-default-mime","web-service-dialog","web-service-download-extensions",
|
||||
"web-service-editing","web-service-form","web-service-hardening-attacks","web-service-hardening-headers",
|
||||
"web-service-htaccess-enabled","web-service-htaccess-filename","web-service-htaccess-help","web-service-htaccess-recursive",
|
||||
"web-service-htaccess-strict","web-service-id","web-service-index","web-service-list","web-service-listing",
|
||||
"web-service-mime-types","web-service-name","web-service-new","web-service-php-concurrency",
|
||||
"web-service-php-connect-timeout","web-service-php-enabled","web-service-php-endpoint","web-service-php-extensions",
|
||||
"web-service-php-request-timeout","web-service-php-pass-authorization","web-service-php-body-limit","web-service-php-header-limit",
|
||||
"web-service-php-stderr-limit","web-service-php-custom-params","web-service-php-values","web-service-php-help",
|
||||
"web-service-precompressed","web-service-precompressed-br","web-service-precompressed-gzip",
|
||||
"web-service-preset","web-service-preset-apply","web-service-preset-summary","web-service-reset",
|
||||
"web-service-response-headers","web-service-rewrites","web-service-root","web-service-search",
|
||||
"web-service-symlink-policy","web-service-tabs","web-service-try-files"
|
||||
];
|
||||
const elements = new Map(ids.map(id => [id, new Element(id)]));
|
||||
const pages = ["general","routing","htaccess","mime","cache","delivery","php","security"].map(name => {
|
||||
const page = new Element(); page.dataset.webServicePage = name; return page;
|
||||
});
|
||||
|
||||
elements.get("web-service-php-endpoint").value = "unix:/run/php/php-fpm.sock";
|
||||
elements.get("web-service-php-connect-timeout").value = "1000";
|
||||
elements.get("web-service-php-request-timeout").value = "30000";
|
||||
elements.get("web-service-php-concurrency").value = "64";
|
||||
elements.get("web-service-php-pass-authorization").checked = true;
|
||||
elements.get("web-service-php-body-limit").value = "0";
|
||||
elements.get("web-service-php-header-limit").value = "65536";
|
||||
elements.get("web-service-php-stderr-limit").value = "4096";
|
||||
|
||||
const document = {
|
||||
querySelectorAll(selector) {
|
||||
if (selector === "#web-service-tabs button") return elements.get("web-service-tabs").children;
|
||||
if (selector === "[data-web-service-page]") return pages;
|
||||
return [];
|
||||
}
|
||||
};
|
||||
let savedSection = null;
|
||||
const context = {
|
||||
console,
|
||||
structuredClone,
|
||||
document,
|
||||
window: {},
|
||||
envelope: null,
|
||||
busy: false,
|
||||
$: id => {
|
||||
const value = elements.get(id);
|
||||
if (!value) throw new Error(`missing fake element ${id}`);
|
||||
return value;
|
||||
},
|
||||
t: key => key,
|
||||
el: (_tag, text = "", className = "") => {
|
||||
const node = new Element();
|
||||
node.textContent = text ?? "";
|
||||
if (className) node.classList.values.add(className);
|
||||
return node;
|
||||
},
|
||||
toast: () => {},
|
||||
saveSection: async (name, value) => { savedSection = { name, value: structuredClone(value) }; return true; },
|
||||
appConfirm: async () => true,
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync("src/admin/web/web-server.js", "utf8"), context, { filename: "web-server.js" });
|
||||
|
||||
const preset = elements.get("web-service-preset");
|
||||
preset.value = "wordpress";
|
||||
preset.dispatch("change");
|
||||
|
||||
const expectedIndex = "index.php\nindex.html\nindex.htm";
|
||||
const expectedTry = "$uri\n$uri/\n/index.php?$query_string";
|
||||
if (elements.get("web-service-index").value !== expectedIndex) throw new Error("WordPress preset did not populate index files");
|
||||
if (elements.get("web-service-try-files").value !== expectedTry) throw new Error("WordPress preset did not populate try_files");
|
||||
if (!elements.get("web-service-php-enabled").checked) throw new Error("WordPress preset did not enable PHP-FPM");
|
||||
if (!elements.get("web-service-precompressed").checked) throw new Error("WordPress preset did not enable precompressed sidecars");
|
||||
if (!elements.get("web-service-cache-rules").value.includes("ext:css = public, max-age=604800")) throw new Error("WordPress preset did not populate cache rules");
|
||||
|
||||
preset.value = "downloads";
|
||||
preset.dispatch("change");
|
||||
if (elements.get("web-service-index").value !== "") throw new Error("Downloads preset should clear index files");
|
||||
if (!elements.get("web-service-listing").checked) throw new Error("Downloads preset should enable directory listing");
|
||||
if (elements.get("web-service-php-enabled").checked) throw new Error("Downloads preset should disable PHP-FPM");
|
||||
if (elements.get("web-service-cache-control").value !== "public, max-age=3600") throw new Error("Downloads preset cache policy missing");
|
||||
|
||||
// The save path must serialize the values materialized by preset selection, not
|
||||
// the previous/custom values that were present when the dialog opened.
|
||||
context.envelope = { config: { web_services: [] } };
|
||||
elements.get("web-service-id").value = "web1";
|
||||
elements.get("web-service-name").value = "web1";
|
||||
elements.get("web-service-root").value = "/srv/web1";
|
||||
elements.get("web-service-php-pass-authorization").checked = false;
|
||||
elements.get("web-service-php-body-limit").value = "1048576";
|
||||
elements.get("web-service-php-header-limit").value = "131072";
|
||||
elements.get("web-service-php-stderr-limit").value = "8192";
|
||||
elements.get("web-service-php-custom-params").value = "APP_ENV = production";
|
||||
elements.get("web-service-php-values").value = "memory_limit = 256M";
|
||||
preset.value = "wordpress";
|
||||
preset.dispatch("change");
|
||||
await elements.get("web-service-form").dispatch("submit");
|
||||
if (savedSection?.name !== "web-services" || savedSection.value.length !== 1) throw new Error("WordPress preset save did not persist a Web Service");
|
||||
const saved = savedSection.value[0];
|
||||
if (saved.index.join("\n") !== expectedIndex) throw new Error("Saved WordPress preset lost index files");
|
||||
if (saved.try_files.join("\n") !== expectedTry) throw new Error("Saved WordPress preset lost try_files");
|
||||
if (!saved.php.enabled) throw new Error("Saved WordPress preset lost PHP-FPM enablement");
|
||||
if (!saved.precompressed.enabled) throw new Error("Saved WordPress preset lost precompressed enablement");
|
||||
if (saved.php.pass_authorization !== false) throw new Error("Saved WordPress preset lost Authorization forwarding choice");
|
||||
if (saved.php.max_request_body_bytes !== 1048576 || saved.php.max_response_header_bytes !== 131072 || saved.php.stderr_log_bytes !== 8192) throw new Error("Saved WordPress preset lost advanced FastCGI limits");
|
||||
if (saved.php.custom_params.APP_ENV !== "production") throw new Error("Saved WordPress preset lost custom FastCGI parameters");
|
||||
if (saved.php.php_values.memory_limit !== "256M") throw new Error("Saved WordPress preset lost PHP_VALUE settings");
|
||||
if (!saved.cache_rules.some(rule => rule.matcher === "ext:css" && rule.cache_control.includes("604800"))) throw new Error("Saved WordPress preset lost cache rules");
|
||||
|
||||
console.log("PASS: Web Service preset selection materializes and persists CMS/download settings.");
|
||||
Reference in new issue
Block a user