v0.31.0
This commit is contained in:
1 parent
f213d853e2
commit
76b8bb7ee2
78 files changed
+7750
-5204
No files matched your search
Executable
+114
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail-closed source regression checks. Not a replacement for running Rust tests."""
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
def read(name):
|
||||
return (root / name).read_text(encoding="utf-8")
|
||||
|
||||
def must(source, *text):
|
||||
for item in text:
|
||||
if item not in source:
|
||||
raise AssertionError(f"missing security invariant: {item}")
|
||||
|
||||
router = read('src/admin/router.rs')
|
||||
access = read('src/admin/access.rs')
|
||||
session = read('src/admin/session.rs')
|
||||
proxy = read('src/proxy/forward_entry.rs')
|
||||
maintenance = read('src/proxy/cache_http_invalidation.rs')
|
||||
operations = read('src/admin/operations.rs')
|
||||
cache_policy = read('src/cache/model/http_invalidation.rs')
|
||||
app = read('src/runtime/types.rs') + read('src/runtime/constructors.rs')
|
||||
|
||||
must(router,
|
||||
'fn anonymous_allowed', 'panel_document_path(path)',
|
||||
'/api/auth/status', '/api/auth/setup', '/api/auth/login',
|
||||
'.layer(middleware::from_fn_with_state(state.clone(), guard))',
|
||||
'"content-security-policy"', '"x-frame-options"',
|
||||
'"x-content-type-options"', '"no-referrer"',
|
||||
'"/api/system-info", get(system_info)',
|
||||
'"/api/events", get(events)',
|
||||
'"/api/cache/invalidate"',
|
||||
'"/api/config", get(configuration).merge(put(update))')
|
||||
must(session,
|
||||
'if !public && session.is_none()',
|
||||
'if unsafe_method && !external_certificate_api',
|
||||
'single_header(headers, "origin")',
|
||||
'single_header(headers, "x-rustproxy-request") != Some("1")',
|
||||
'single_header(headers, "x-csrf-token")',
|
||||
'auth::equal_secret',
|
||||
'session_at_with_browser_lifetime',
|
||||
'secure(next.run(request).await)',
|
||||
'h == "cross-site"')
|
||||
must(access, 'admin_request_authority', 'admin_origin_matches',
|
||||
'trusted_admin_proxy', 'single_header(headers, "x-forwarded-host")',
|
||||
'certificate_api_credentials', 'valid_certificate_api_token')
|
||||
must(session, 'verify_certificate_api_credential')
|
||||
|
||||
# Only the intended bootstrap/discovery APIs may appear inside the anonymous allowlist.
|
||||
allowlist = router.split('fn anonymous_allowed(', 1)[1].split('\n}', 1)[0]
|
||||
allowed_api_paths = set(re.findall(r'"(/api/[^" ]+)"', allowlist))
|
||||
assert allowed_api_paths == {'/api/auth/status', '/api/auth/setup', '/api/auth/login'}, allowed_api_paths
|
||||
|
||||
must(maintenance,
|
||||
'permits_http_invalidation(client_ip)',
|
||||
'snapshot.forwarding.trusted(peer_ip)',
|
||||
'return error(StatusCode::FORBIDDEN, "RustProxy: missing trusted proxy client identity',
|
||||
'cache_http_invalidation_request(request.method()',
|
||||
'duplicate BAN/PURGE control header',
|
||||
'request.body().is_end_stream()',
|
||||
'get_all(http::header::CONTENT_LENGTH)',
|
||||
'http::header::TRANSFER_ENCODING',
|
||||
'X-RustProxy-Confirm-Wide: 1',
|
||||
'"{scheme}://{authority}{target}"',
|
||||
'cache_invalidation_slots.clone().try_acquire_owned()',
|
||||
'state.store.audit(',
|
||||
'tokio::task::spawn_blocking',
|
||||
'let _permit = permit;',
|
||||
'StatusCode::UNPROCESSABLE_ENTITY',
|
||||
'StatusCode::TOO_MANY_REQUESTS')
|
||||
# Axum 0.8 requires the `forward` fallback future to be Send. Its Body is
|
||||
# not Sync, so an async helper must own Request<Body> rather than retain
|
||||
# &Request<Body> across the audit/cache await points.
|
||||
async_maintenance = maintenance.split('async fn cache_http_invalidation(', 1)[1].split(') -> Response {', 1)[0]
|
||||
assert 'request: Request,' in async_maintenance, 'cache maintenance must own Request<Body> in its async future'
|
||||
assert 'request: &Request' not in async_maintenance, 'borrowed Request<Body> makes fallback handler non-Send'
|
||||
must(maintenance, 'let method = request.method().clone();', 'drop(request);')
|
||||
assert maintenance.index('drop(request);') < maintenance.index('state.store.audit('), 'drop body before asynchronous audit'
|
||||
assert 'frontend, &host, request, context.client_ip' in proxy, 'forward must transfer request ownership'
|
||||
|
||||
must(proxy, 'snapshot.host_allowed(&host, context.client_ip)',
|
||||
'crate::acl::allows_with_country(',
|
||||
'context.client_ip, peer.0.ip(), context.scheme',
|
||||
'return cache_http_invalidation(')
|
||||
assert proxy.find('snapshot.host_allowed(&host, context.client_ip)') < proxy.find('return cache_http_invalidation(')
|
||||
assert proxy.find('if cache_http_invalidation_method(request.method())') < proxy.find('return cache_http_invalidation(')
|
||||
must(cache_policy, 'http_invalidation_allowed_cidrs.len() <= 32',
|
||||
'net.prefix_len() > 0', 'pub fn permits_http_invalidation')
|
||||
must(app, 'cache_invalidation_slots: Arc<Semaphore>',
|
||||
'cache_invalidation_slots: Arc::new(Semaphore::new(2))')
|
||||
must(operations, 'cache_invalidation_slots.clone().try_acquire_owned()',
|
||||
'tokio::task::spawn_blocking(move ||',
|
||||
'"cache.invalidate"', '"cache.purge"',
|
||||
'"cache.ban.remove"')
|
||||
start = operations.index('async fn cache_ban_delete(')
|
||||
end = operations.index('async fn active_tunnels(', start)
|
||||
assert operations[start:end].index('.audit(') < operations[start:end].index('remove_ban(id)'), 'BAN deletion must audit before mutation'
|
||||
|
||||
# Frontend: no HTML-string DOM injection or dynamic code execution in first-party scripts.
|
||||
js_files = list((root/'src/admin/web').rglob('*.js'))
|
||||
for path in js_files:
|
||||
text = path.read_text(encoding='utf-8')
|
||||
dangerous = (r'\.innerHTML\s*=', r'\.outerHTML\s*=', r'\.insertAdjacentHTML\s*\(',
|
||||
r'\beval\s*\(', r'\bnew\s+Function\s*\(')
|
||||
for pattern in dangerous:
|
||||
if re.search(pattern, text):
|
||||
raise AssertionError(f'unsafe DOM/code sink: {path.relative_to(root)} matches {pattern}')
|
||||
|
||||
html = read('src/admin/web/index.html')
|
||||
for fragment in ['class="metric-info cache-maintenance-info"',
|
||||
'cacheHttpInvalidationCurlLabel',
|
||||
'curl -i -X PURGE', 'curl -i -X BAN']:
|
||||
must(html, fragment)
|
||||
print(f'PASS: 0.31.0 admin/HTTP maintenance security source contracts; scanned {len(js_files)} first-party JS files. Actual Rust/wire behavior not tested.')
|
||||
@@ -24,7 +24,7 @@ def read_docker_agent_source() -> str:
|
||||
|
||||
preflight(root)
|
||||
manifest = tomllib.loads((root / "Cargo.toml").read_text())
|
||||
assert manifest["package"]["version"] == "0.30.1"
|
||||
assert manifest["package"]["version"] == "0.31.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"]
|
||||
@@ -32,14 +32,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.30.1"' in lock
|
||||
assert 'name = "rust-proxy"\nversion = "0.31.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.30.1",
|
||||
"CHANGELOG.md": "## 0.30.1",
|
||||
"ROADMAP.md": "## 0.30.1",
|
||||
"AGENTS.md": "Current source version: 0.30.1.",
|
||||
"docs/TEST_REPORT.md": "# Verification report - RustProxy 0.30.1",
|
||||
"README.md": "# RustProxy 0.31.0",
|
||||
"CHANGELOG.md": "## 0.31.0",
|
||||
"ROADMAP.md": "## 0.31.0",
|
||||
"AGENTS.md": "Current source version: 0.31.0.",
|
||||
"docs/TEST_REPORT.md": "# Verification report - RustProxy 0.31.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"
|
||||
@@ -162,7 +162,7 @@ assert 'class="vhost-dialog logging-format-dialog"' in html
|
||||
assert 'data-panel="streams"' in html and 'id="panel-streams"' in html
|
||||
for marker in ['id="vhost-sort"', 'id="vhost-sort-direction"', 'id="docker-vhost-sort"', 'id="docker-vhost-sort-direction"', 'id="redirect-sort"', 'id="redirect-sort-direction"', 'id="notfound-sort"', 'id="notfound-sort-direction"', 'id="stream-sort"', 'id="stream-sort-direction"']:
|
||||
assert marker in html, marker
|
||||
preferences_source = (root / "src/admin/preferences.rs").read_text(encoding="utf-8")
|
||||
preferences_source = rust_source(root, "src/admin/preferences.rs")
|
||||
for marker in ['vhost_list_sort', 'docker_vhost_sort', 'redirect_list_sort', 'notfound_list_sort', 'stream_list_sort', 'VHOST_LIST_SORT_COLUMNS', 'DOCKER_VHOST_SORT_COLUMNS', 'REDIRECT_LIST_SORT_COLUMNS', 'NOTFOUND_LIST_SORT_COLUMNS', 'STREAM_LIST_SORT_COLUMNS']:
|
||||
assert marker in preferences_source, marker
|
||||
for source_name, markers in {
|
||||
@@ -251,7 +251,7 @@ for marker in ['vhBackendDiagnostics', 'backend_header: yes("backend-header")',
|
||||
assert 'cache: { enabled: false }' in vhosts_js, "new vhost drafts must default cache off"
|
||||
docker_js = (root / "src/admin/web/docker.js").read_text()
|
||||
assert 'preserve.checked = assignment?.preserve_host !== false' in docker_js, "new Docker backends must preserve public Host by default"
|
||||
for marker in ['saveSection("docker-agents"', '/api/docker/refresh', 'docker-publish', 'docker-hostname', 'host-chip-editor docker-alias-editor', 'docker-target-ip', 'default_target_ip', 'docker-agent-fingerprint', 'docker-agent-certificate', 'docker-health-mode', 'docker-passive-enabled', 'docker-passive-5xx', 'docker-search', 'docker-show-hidden', 'dockerHideVhost', 'pool_routes', 'setInterval']:
|
||||
for marker in ['saveSection("docker-agents"', '/api/docker/refresh', 'docker-publish', 'docker-hostname', 'host-chip-editor docker-alias-editor', 'docker-target-ip', 'default_target_ip', 'docker-agent-fingerprint', 'docker-agent-certificate', 'docker-health-mode', 'docker-passive-enabled', 'docker-passive-5xx', 'docker-search', 'docker-show-hidden', 'dockerHideVhost', 'pool_routes', 'rustProxyRefresh.register("docker"']:
|
||||
assert marker in docker_js, marker
|
||||
docker_source = rust_source(root, "src/docker.rs")
|
||||
for marker in ['pub struct Settings', 'pub struct Observation', 'pub struct HostAddress', 'pub struct Binding', 'aliases: Vec<String>', 'target_ip: Option<IpAddr>', 'tls_fingerprint_sha256', 'certificate_id: Option<String>', 'health_check: crate::balance::HealthCheck', 'passive_health: crate::balance::PassiveHealth', 'PinnedCertificateVerifier', 'fingerprint mismatch', 'effective_config', 'poll_loop', 'address_allowlist']:
|
||||
@@ -285,7 +285,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.30.1"', '"/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']:
|
||||
for marker in ['"version": "0.31.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
|
||||
@@ -1150,7 +1150,7 @@ assert 'transport-action' not in html and '.transport-grid .transport-action' no
|
||||
assert '#panel-cache .cache-copy-cell' in style_source and 'cursor: copy' in style_source
|
||||
assert '.operation-message' in style_source and '.operation-message.error' in style_source
|
||||
for marker_0221 in [
|
||||
'api("/api/tunnels")', 'tunnel-kill-reason', 'tunnelKillReasonDefault',
|
||||
'api("/api/tunnels", context?.signal ? { signal: context.signal } : {})', 'tunnel-kill-reason', 'tunnelKillReasonDefault',
|
||||
'api(`/api/tunnels/${encodeURIComponent(item.id)}/kill`', 'startTunnelRefresh()',
|
||||
]:
|
||||
assert marker_0221 in app_js, marker_0221
|
||||
@@ -1396,7 +1396,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.30.1"' in openapi
|
||||
assert '"RuntimeInformation"' in openapi and '"version": "0.31.0"' in openapi
|
||||
|
||||
# 0.24.1 compiler and hostname-cardinality regressions.
|
||||
tls_0241 = rust_source(root, "src/tls.rs")
|
||||
@@ -1635,5 +1635,5 @@ for marker_0282 in [
|
||||
assert 'Zewnętrzne ścieżki plików' in (root / 'langs/pl_pl.json').read_text()
|
||||
assert 'setfacl -m u:rustproxy:--x /etc/ssl/' in (root / 'docs/V0.28.2_IMPLEMENTATION.md').read_text()
|
||||
|
||||
print("PASS: 0.30.1 manifest/package, shared system-statistics/runtime metadata, Settings logging, retained H2, external-packaging/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-v9 migrations.")
|
||||
print("PASS: 0.31.0 manifest/package, shared system-statistics/runtime metadata, Settings logging, retained H2, external-packaging/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-v9 migrations.")
|
||||
print("NOT VERIFIED: Rust parsing, types, compilation, linking or runtime behavior. Run ./dev.sh test.")
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env node
|
||||
// Network-independent browser bootstrap contract: visible recovery and early shell.
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const read = path => readFileSync(resolve(root, path), 'utf8');
|
||||
const html = read('src/admin/web/index.html');
|
||||
const css = read('src/admin/web/style.css');
|
||||
const core = read('src/admin/web/app/core.js');
|
||||
const runtime = read('src/admin/web/app/runtime.js');
|
||||
|
||||
assert.match(html, /<html class="app-loading"/);
|
||||
assert.match(html, /id="app-startup"[^>]*role="status"/);
|
||||
assert.match(html, /id="app-startup-title"/);
|
||||
assert.match(html, /class="app-startup-spinner"/);
|
||||
assert.match(html, /class="app-startup-recovery"/);
|
||||
assert.match(html, /class="outline" href="" data-i18n="startupRetry"/);
|
||||
assert.match(html, /<noscript>/);
|
||||
assert.doesNotMatch(css, /html\.app-loading\s+body\s*\{\s*visibility:\s*hidden/);
|
||||
assert.match(css, /html\.app-loading \.shell/);
|
||||
assert.match(css, /app-startup-reveal 0s linear 12s forwards/);
|
||||
assert.match(css, /prefers-reduced-motion: reduce/);
|
||||
assert.match(runtime, /showStartupFailure\(e\)/);
|
||||
|
||||
const titleRow = html.match(/<div class="page-intro-title-row">([\s\S]*?)<\/div>/)?.[1];
|
||||
assert(titleRow, 'title row exists');
|
||||
assert.match(titleRow, /id="page-title"/);
|
||||
assert.match(titleRow, /id="overview-refresh"[^>]*hidden/);
|
||||
assert.equal((html.match(/id="overview-refresh"/g) || []).length, 1);
|
||||
assert.match(core, /\$\("overview-refresh"\)\.hidden = name !== "overview"/);
|
||||
assert.match(css, /\.page-intro-title-row\s*\{\s*display:\s*flex/);
|
||||
assert.match(css, /\.page-intro-title-row \.overview-refresh/);
|
||||
|
||||
// Exercise the real activate() and failure handler against a small DOM fake.
|
||||
const activateSource = core.slice(core.indexOf('async function activate(info) {'), core.indexOf('async function loadAudit()'));
|
||||
const failureSource = core.slice(core.indexOf('function showStartupFailure(error)'));
|
||||
assert(activateSource.startsWith('async function activate(info)'));
|
||||
assert(failureSource.startsWith('function showStartupFailure(error)'));
|
||||
|
||||
const nodes = new Map();
|
||||
const node = key => {
|
||||
if (!nodes.has(key)) nodes.set(key, { hidden: true, value: 'x', textContent: '' });
|
||||
return nodes.get(key);
|
||||
};
|
||||
const classes = new Set(['app-loading']);
|
||||
let startCount = 0, configCount = 0, statusCount = 0, prefResolve, configResolve;
|
||||
const preferencesPromise = new Promise(resolve => { prefResolve = resolve; });
|
||||
const configPromise = new Promise(resolve => { configResolve = resolve; });
|
||||
const context = {
|
||||
$: node,
|
||||
document: { documentElement: { classList: {
|
||||
add(...keys) { for (const key of keys) classes.add(key); },
|
||||
remove(...keys) { for (const key of keys) classes.delete(key); },
|
||||
} } },
|
||||
pendingAuthDestination: null,
|
||||
authMessage() {},
|
||||
authEpoch: 0, authenticated: false, csrf: '', sessionInfo: null,
|
||||
renderSession() {},
|
||||
rustProxyRefresh: { start() { startCount++; } },
|
||||
loadAdminPreferences: () => preferencesPromise,
|
||||
loadConfig: () => { configCount++; return configPromise; },
|
||||
refresh: async () => { statusCount++; },
|
||||
toast() { throw new Error('Unexpected toast'); },
|
||||
window: {}, languagePacks: new Map(), t: key => key,
|
||||
};
|
||||
runInNewContext(`${activateSource}\n${failureSource}`, context);
|
||||
const activating = context.activate({ username: 'admin', csrf_token: 'CSRF' });
|
||||
assert.equal(startCount, 1, 'SSE/polling starts without waiting for preferences');
|
||||
assert.equal(context.authenticated, true);
|
||||
assert.equal(node('workspace').hidden, false, 'shell is shown immediately');
|
||||
assert(!classes.has('app-loading'), 'spinner is cleared before preferences/config/status');
|
||||
assert.equal(configCount, 0, 'preferences request is still pending');
|
||||
assert.equal(statusCount, 0);
|
||||
prefResolve({ refresh: {} });
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
assert.equal(configCount, 1);
|
||||
assert.equal(node('workspace').hidden, false, 'slow configuration does not hide shell');
|
||||
configResolve({});
|
||||
await activating;
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
assert.equal(statusCount, 1);
|
||||
context.showStartupFailure(new Error('Offline'));
|
||||
assert(classes.has('app-loading') && classes.has('app-startup-failed'));
|
||||
assert.equal(node('app-startup-detail').textContent, 'Offline');
|
||||
assert.equal(node('app-startup-detail').hidden, false);
|
||||
assert.equal(node('workspace').hidden, true, 'failed shell cannot obscure retry UI');
|
||||
|
||||
console.log('PASS: startup spinner/retry, shell before slow API, error recovery and compact Overview title row.');
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rust-independent documentation/Reader consistency checks for the 0.30.1 release."""
|
||||
"""Rust-independent documentation/Reader consistency checks for the 0.31.0 release."""
|
||||
from pathlib import Path
|
||||
import re
|
||||
from rust_source import rust_source
|
||||
@@ -29,6 +29,7 @@ required = [
|
||||
DOCS / "V0.25.2_AUDIT.md",
|
||||
DOCS / "V0.25.1_AUDIT.md",
|
||||
DOCS / "V0.25.0_AUDIT.md",
|
||||
DOCS / "IMPLEMENTATION_0.31.0.md",
|
||||
DOCS / "TEST_REPORT.md",
|
||||
DOCS / "CHANGELOG_TECHNICAL_ARCHIVE.md",
|
||||
DOCS / "ROADMAP_HISTORY.md",
|
||||
@@ -64,7 +65,7 @@ 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.30.1")
|
||||
assert readme.startswith("# RustProxy 0.31.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
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env node
|
||||
// Real browser form callbacks with fake DOM; protocol/security checks are
|
||||
// source-level assertions only. Rust unit and live HTTP tests need Cargo.
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const read = path => readFileSync(resolve(root, path), 'utf8');
|
||||
const html = read('src/admin/web/index.html');
|
||||
const css = read('src/admin/web/style.css');
|
||||
const administration = read('src/admin/web/settings.js');
|
||||
const cache = read('src/admin/web/app/settings.js');
|
||||
const router = read('src/admin/router.rs');
|
||||
const assets = read('src/admin/panel_assets.rs');
|
||||
const compression = read('src/admin/panel_content.rs');
|
||||
const ip = read('src/cache/model/http_invalidation.rs');
|
||||
const cacheMethods = read('src/proxy/cache_http_invalidation.rs');
|
||||
|
||||
for (const codec of ['zstd', 'br', 'gzip']) {
|
||||
assert.match(html, new RegExp(`id="admin-compression-${codec}"[^>]*checked`));
|
||||
assert(assets.includes('"/assets/app.js"'));
|
||||
}
|
||||
for (const path of ['app.js','management.js','vhosts.js','settings.js','cache-panel.js','traffic-panel.js','style.css','langs.json']) {
|
||||
assert(router.includes(`"/assets/${path}", get(panel_assets::serve)`), path);
|
||||
assert(assets.includes(`"/assets/${path}"`), path);
|
||||
}
|
||||
assert(compression.includes('preferred_encoding(headers, &settings)'));
|
||||
assert(compression.includes('HeaderValue::from_static("Accept-Encoding")'));
|
||||
assert(compression.includes('header::CONTENT_LENGTH'));
|
||||
assert(compression.includes('header::CONTENT_ENCODING'));
|
||||
assert(compression.includes('crate::compression::no_transform(headers)'));
|
||||
assert(html.includes('id="cache-http-invalidation-cidrs"'));
|
||||
assert(html.includes('class="metric-info cache-maintenance-info"'));
|
||||
assert(html.includes('data-i18n-aria="cacheHttpInvalidationCurlLabel"'));
|
||||
for (const snippet of ["curl -i -X PURGE", "curl -i -X BAN", "X-RustProxy-Confirm-Wide: 1"]) {
|
||||
assert(html.includes(snippet), snippet);
|
||||
}
|
||||
assert(css.includes('.cache-maintenance-info .metric-info-popover'));
|
||||
|
||||
assert(cache.includes('http_invalidation_allowed_cidrs: $("cache-http-invalidation-cidrs")'));
|
||||
assert(ip.includes('http_invalidation_allowed_cidrs.len() <= 32'));
|
||||
assert(ip.includes('net.prefix_len() > 0'));
|
||||
assert(cacheMethods.includes('snapshot.envelope.config.cache.permits_http_invalidation(client_ip)'));
|
||||
assert(cacheMethods.includes('state.store.audit('));
|
||||
assert(cacheMethods.includes('spawn_blocking'));
|
||||
assert(cacheMethods.includes('x-rustproxy-confirm-wide'));
|
||||
assert(cacheMethods.includes('InvalidationSelector::Exact'));
|
||||
assert(cacheMethods.includes('InvalidationSelector::Prefix'));
|
||||
assert(read('src/proxy/forward_entry.rs').includes('return cache_http_invalidation('));
|
||||
const heading = html.lastIndexOf('data-i18n="systemInformation"');
|
||||
assert(heading > 0);
|
||||
const headerArea = html.slice(heading, heading + 600);
|
||||
assert(headerArea.includes('<span class="pill">API</span>'));
|
||||
assert(headerArea.indexOf('<span class="pill">API</span>') < headerArea.indexOf('id="system-info-refresh"'));
|
||||
assert.match(css, /\.system-info-heading-actions\s*\{[^}]*display:\s*flex/);
|
||||
for (const lang of ['en_US','pl_pl']) {
|
||||
const strings = JSON.parse(read(`langs/${lang}.json`));
|
||||
const t = strings.translations;
|
||||
assert(t.panelCompressionTitle && t.panelCompressionHelp && t.cacheHttpInvalidationHelp, lang);
|
||||
assert(t.cacheHttpInvalidationCurlLabel && t.cacheHttpInvalidationCurlIntro && t.cacheHttpInvalidationCurlNote, lang);
|
||||
}
|
||||
|
||||
// Execute the actual callbacks captured from the shipped frontend settings JS.
|
||||
const fields = new Map();
|
||||
const field = name => {
|
||||
if (!fields.has(name)) fields.set(name, { checked: false, value: '' });
|
||||
return fields.get(name);
|
||||
};
|
||||
for (const name of ['zstd', 'br', 'gzip']) field(`admin-compression-${name}`).checked = true;
|
||||
field('admin-listen').value = '127.0.0.1:9090';
|
||||
field('admin-session-days').value = '30';
|
||||
let handlers = new Map();
|
||||
const bindSettingsForm = (id, handler) => handlers.set(id, handler);
|
||||
const adminStart = administration.indexOf('bindSettingsForm("administration-form", next => {');
|
||||
const adminEnd = administration.indexOf('bindSettingsForm("tls-policy-form",', adminStart);
|
||||
assert(adminStart > 0 && adminEnd > adminStart);
|
||||
runInNewContext(administration.slice(adminStart, adminEnd), {
|
||||
$: field, bindSettingsForm, latestStatus: { administration: { listen_locked: false } },
|
||||
});
|
||||
const adminNext = {};
|
||||
handlers.get('administration-form')(adminNext);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(adminNext.administration.panel_compression)),
|
||||
{ zstd: true, br: true, gzip: true });
|
||||
field('admin-compression-br').checked = false;
|
||||
field('admin-compression-zstd').checked = false;
|
||||
const offNext = {};
|
||||
handlers.get('administration-form')(offNext);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(offNext.administration.panel_compression)),
|
||||
{ zstd: false, br: false, gzip: true });
|
||||
|
||||
const cacheStart = cache.indexOf('bindSettingsForm("cache-form", next => {');
|
||||
const cacheEnd = cache.indexOf('bindSettingsForm("transform-form",', cacheStart);
|
||||
assert(cacheStart > 0 && cacheEnd > cacheStart);
|
||||
handlers = new Map();
|
||||
field('cache-http-invalidation-cidrs').value = ' 192.0.2.50\n 2001:db8:2::/64, 198.51.100.9 \n';
|
||||
field('cache-sie-statuses').value = '500,502,503,504';
|
||||
runInNewContext(cache.slice(cacheStart, cacheEnd), {
|
||||
$: field, bindSettingsForm, selectedCacheMemoryPreset: null, selectedCacheColdPreset: null,
|
||||
});
|
||||
const cacheNext = { cache: { max_old_setting: 'kept' } };
|
||||
handlers.get('cache-form')(cacheNext);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(cacheNext.cache.http_invalidation_allowed_cidrs)),
|
||||
['192.0.2.50', '2001:db8:2::/64', '198.51.100.9']);
|
||||
assert.equal(cacheNext.cache.max_old_setting, 'kept');
|
||||
field('cache-http-invalidation-cidrs').value = '\n';
|
||||
handlers.get('cache-form')(cacheNext);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(cacheNext.cache.http_invalidation_allowed_cidrs)), []);
|
||||
console.log('PASS: cache CIDR/administration codec UI roundtrip, headers, security source wiring, API badge layout');
|
||||
@@ -75,10 +75,10 @@ def check(root: Path, repair_known: bool = False) -> list[str]:
|
||||
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.rs", "src/diagnostics.rs", "src/traffic.rs", "src/system_stats/mod.rs", "src/system_stats/linux.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.rs", "src/admin/web/vhosts/core.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/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.rs", "tools/dev/mock_server.py", "tools/dev/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/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", "docs/TODO.md", "docs/V0.25.5_AUDIT.md", "docs/V0.25.4_AUDIT.md", "docs/V0.25.3_AUDIT.md", "docs/V0.25.2_AUDIT.md", "docs/CHANGELOG_TECHNICAL_ARCHIVE.md", "docs/ROADMAP_HISTORY.md", "tools/checks/docs_check.py", "tools/checks/web_server_contracts.py", "tools/checks/rust_source.py", "tools/checks/rust_module_contracts.py", "tools/checks/source_module_contracts.py", "scripts/runtime/acme_provider.py", "src/basic_auth.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/NPM_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/PROMETHEUS.md", "docs/NOT_FOUND_HOSTS.md", "grafana/rustproxy-overview.json"]:
|
||||
if not (root / path).is_file():
|
||||
raise ValueError(f"Incomplete RustProxy 0.30.1 source tree: missing {path}")
|
||||
raise ValueError(f"Incomplete RustProxy 0.31.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.30.1 entry point; extract the complete package, not individual files.")
|
||||
raise ValueError("src/lib.rs is not the 0.31.0 entry point; extract the complete package, not individual files.")
|
||||
return messages
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env node
|
||||
// Deterministic browser-coordinator regression without a production browser or network.
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolve, dirname } from "node:path";
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const scheduler = readFileSync(resolve(root, "src/admin/web/app/refresh.js"), "utf8");
|
||||
const core = readFileSync(resolve(root, "src/admin/web/app/core.js"), "utf8");
|
||||
const bridge = core.slice(core.indexOf("function renderLiveStatus(value)"), core.indexOf("async function refreshCertificateInventory()"));
|
||||
assert.match(bridge, /function startLive\(/);
|
||||
|
||||
function fixture({ withoutSse = false } = {}) {
|
||||
let time = 0, nextId = 1, renderCount = 0, logCount = 0, diagCount = 0;
|
||||
const timers = new Map(), listeners = new Map(), streams = [], requests = [], rendered = [], failures = [];
|
||||
let response = async () => ({});
|
||||
class FakeEventSource {
|
||||
constructor(url) { this.url = url; this.closed = false; this.handlers = new Map(); streams.push(this); }
|
||||
addEventListener(type, callback) { this.handlers.set(type, callback); }
|
||||
close() { this.closed = true; }
|
||||
emit(type, value) { this.handlers.get(type)?.({ data: JSON.stringify(value) }); }
|
||||
fail() { this.onerror?.({ type: "error" }); }
|
||||
}
|
||||
const nodes = { "connection-label": { textContent: "" } };
|
||||
const document = {
|
||||
hidden: false,
|
||||
addEventListener(type, callback) { assert(!listeners.has(type)); listeners.set(type, callback); },
|
||||
removeEventListener(type, callback) { assert.equal(listeners.get(type), callback); listeners.delete(type); }
|
||||
};
|
||||
const ctx = {
|
||||
document, AbortController, URLSearchParams,
|
||||
EventSource: withoutSse ? undefined : FakeEventSource,
|
||||
setTimeout(fn, ms) { const id = nextId++; timers.set(id, { at: time + ms, fn }); return id; },
|
||||
clearTimeout(id) { timers.delete(id); },
|
||||
console: { warn(...args) { failures.push(args); } },
|
||||
authenticated: true, authEpoch: 1, currentPanel: "overview", liveConnected: false, liveFallback: false,
|
||||
liveEvents: null, liveInterval: null, latestStatus: null,
|
||||
window: { rustProxySettings: { current: () => "listeners", render: () => undefined } },
|
||||
$: name => nodes[name] || null,
|
||||
t: name => name,
|
||||
api: (url, args = {}) => { requests.push({ url, args, time }); return response(url, args); },
|
||||
renderStatus(value) { renderCount++; rendered.push(value); },
|
||||
renderLogs() { logCount++; },
|
||||
renderDiagnostics() { diagCount++; },
|
||||
renderLiveStatus() { throw new Error("bridge not loaded"); },
|
||||
revalidateSession: async () => undefined,
|
||||
loadTunnels: async () => undefined
|
||||
};
|
||||
runInNewContext(bridge + "\n" + scheduler, ctx, { filename: "coordinator-browser-bundle.js" });
|
||||
const refresh = ctx.window.rustProxyRefresh;
|
||||
const settled = async () => { for (let i = 0; i < 12; i++) await Promise.resolve(); };
|
||||
async function advance(ms = 0) {
|
||||
const until = time + ms;
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const due = [...timers.entries()].filter(([, timer]) => timer.at <= until).sort((a, b) => a[1].at - b[1].at)[0];
|
||||
if (!due) { time = until; await settled(); return; }
|
||||
const [id, timer] = due; time = timer.at; timers.delete(id); timer.fn(); await settled();
|
||||
}
|
||||
throw new Error("unbounded polling timer loop");
|
||||
}
|
||||
function switchPanel(name) { ctx.currentPanel = name; refresh.setPanel(name); }
|
||||
function changeVisibility(hidden) { document.hidden = hidden; listeners.get("visibilitychange")?.(); }
|
||||
function count(url) { return requests.filter(item => item.url.startsWith(url)).length; }
|
||||
function registerViews() {
|
||||
for (const name of ["traffic", "cache"]) {
|
||||
refresh.register(name, {
|
||||
interval: () => refresh.current()[`${name}_seconds`],
|
||||
active: () => ctx.currentPanel === name,
|
||||
run: async ({ signal, isCurrent }) => {
|
||||
const value = await ctx.api(`/api/${name}`, { signal });
|
||||
if (isCurrent()) rendered.push({ name, value });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
ctx, refresh, requests, rendered, streams, listeners, timers, failures, advance, switchPanel, count,
|
||||
registerViews, changeVisibility,
|
||||
setResponse(fn) { response = fn; },
|
||||
get renderCount() { return renderCount; },
|
||||
get logCount() { return logCount; },
|
||||
get diagCount() { return diagCount; },
|
||||
};
|
||||
}
|
||||
|
||||
const h = fixture();
|
||||
h.registerViews();
|
||||
assert.equal(h.refresh.current().overview_seconds, 2);
|
||||
assert.equal(h.refresh.current().system_info_seconds, 60);
|
||||
assert.equal(h.refresh.current().traffic_seconds, 5);
|
||||
assert.equal(h.refresh.current().cache_seconds, 5);
|
||||
assert.equal(h.refresh.current().live_enabled, true);
|
||||
assert.equal(h.streams.length, 0);
|
||||
h.refresh.start(); await h.advance();
|
||||
assert.equal(h.streams.length, 1, "one shared SSE connection");
|
||||
assert.equal(h.streams[0].url, "/api/events?interval=2");
|
||||
assert.equal(h.count("/api/traffic"), 0, "no Traffic on Overview");
|
||||
assert.equal(h.count("/api/cache"), 0, "no Cache on Overview");
|
||||
assert.equal(h.count("/api/system-info"), 1, "Overview uses slow diagnostics");
|
||||
assert.equal(h.count("/api/status"), 0, "no duplicate status polling while SSE connects");
|
||||
assert.equal(h.count("/api/logs"), 0);
|
||||
await h.advance(10_000);
|
||||
assert.equal(h.count("/api/system-info"), 1, "diagnostics not collected at live interval");
|
||||
|
||||
h.switchPanel("traffic"); await h.advance();
|
||||
assert.equal(h.count("/api/traffic"), 1);
|
||||
assert.equal(h.count("/api/cache"), 0);
|
||||
for (let i = 0; i < 20; i++) { h.switchPanel("overview"); h.switchPanel("traffic"); }
|
||||
assert.equal(h.refresh.diagnostics().filter(row => row.id === "traffic").length, 1);
|
||||
await h.advance(0);
|
||||
assert.equal(h.count("/api/traffic"), 2, "tab switches do not multiply immediate requests");
|
||||
await h.advance(15_000);
|
||||
assert.equal(h.count("/api/traffic"), 5, "one request each 5 seconds");
|
||||
assert.equal(h.count("/api/cache"), 0);
|
||||
|
||||
h.refresh.update({ traffic_seconds: 3, cache_seconds: 0, system_info_seconds: 0, overview_seconds: 5 });
|
||||
assert(h.streams[0].closed, "SSE is replaced when the administrator updates their interval");
|
||||
assert.equal(h.streams.at(-1).url, "/api/events?interval=5");
|
||||
await h.advance();
|
||||
assert.equal(h.count("/api/traffic"), 6);
|
||||
await h.advance(6_000);
|
||||
assert.equal(h.count("/api/traffic"), 8);
|
||||
h.switchPanel("cache"); await h.advance(20_000);
|
||||
assert.equal(h.count("/api/cache"), 0, "Cache manual mode cancels polling");
|
||||
await h.refresh.manual("cache");
|
||||
assert.equal(h.count("/api/cache"), 1, "manual Cache works with interval=0");
|
||||
await h.advance(40_000);
|
||||
assert.equal(h.count("/api/cache"), 1);
|
||||
assert.equal(h.count("/api/traffic"), 8, "Traffic is stopped in Cache view");
|
||||
|
||||
// SSE failures activate both fallbacks and a recovered snapshot switches them off.
|
||||
h.streams.at(-1).fail(); await h.advance();
|
||||
assert.equal(h.count("/api/status?view=live"), 1);
|
||||
assert.equal(h.count("/api/logs"), 1);
|
||||
await h.advance(5_000);
|
||||
assert.equal(h.count("/api/status?view=live"), 2);
|
||||
h.streams.at(-1).emit("snapshot", { status: { metrics: { requests: 5 } }, logs: { entries: [] } });
|
||||
assert.equal(h.ctx.liveConnected, true);
|
||||
await h.advance(20_000);
|
||||
assert.equal(h.count("/api/status?view=live"), 2, "SSE recovery stops fallback polling");
|
||||
assert.equal(h.count("/api/logs"), 2);
|
||||
assert.equal(h.renderCount, 3, "two fallback status renders followed by recovered SSE");
|
||||
|
||||
// Hidden browser tabs pause even previously active sources, then resume cleanly.
|
||||
h.refresh.update({ traffic_seconds: 3, cache_seconds: 3, system_info_seconds: 15 });
|
||||
h.switchPanel("traffic"); await h.advance();
|
||||
const beforeHidden = h.count("/api/traffic");
|
||||
h.changeVisibility(true);
|
||||
assert(h.streams.at(-1).closed, "SSE closes when hidden");
|
||||
assert.equal(h.refresh.diagnostics().filter(row => row.scheduled).length, 0);
|
||||
await h.advance(120_000);
|
||||
assert.equal(h.count("/api/traffic"), beforeHidden);
|
||||
h.changeVisibility(false); await h.advance();
|
||||
assert.equal(h.count("/api/traffic"), beforeHidden + 1);
|
||||
assert.equal(h.streams.filter(stream => !stream.closed).length, 1);
|
||||
|
||||
// Off pauses all scheduled work, but retains explicit manual requests.
|
||||
h.refresh.update({ live_enabled: false, traffic_seconds: 3 });
|
||||
const requestsBeforeOff = h.requests.length;
|
||||
await h.advance(120_000);
|
||||
assert.equal(h.requests.length, requestsBeforeOff);
|
||||
await h.refresh.manual("traffic");
|
||||
assert.equal(h.count("/api/traffic"), beforeHidden + 2);
|
||||
assert.equal(h.streams.filter(stream => !stream.closed).length, 0);
|
||||
h.refresh.update({ live_enabled: true, traffic_seconds: 3 }); await h.advance();
|
||||
assert.equal(h.streams.filter(stream => !stream.closed).length, 1);
|
||||
|
||||
// Aborting an old view prevents the obsolete response from modifying UI.
|
||||
let resolveDelayed;
|
||||
h.setResponse(url => url === "/api/traffic" ? new Promise(resolve => { resolveDelayed = resolve; }) : Promise.resolve({ ok: true }));
|
||||
await h.advance(3_000);
|
||||
const staleCount = h.rendered.length;
|
||||
const request = h.requests.at(-1);
|
||||
assert.equal(request.url, "/api/traffic");
|
||||
h.switchPanel("cache");
|
||||
assert.equal(request.args.signal.aborted, true);
|
||||
resolveDelayed({ stale: true }); await h.advance();
|
||||
assert.equal(h.rendered.filter(v => v.name === "traffic").length, h.rendered.slice(0, staleCount).filter(v => v.name === "traffic").length);
|
||||
assert.equal(h.count("/api/cache"), 2);
|
||||
|
||||
// Manual diagnostics still work when their own automatic interval is zero.
|
||||
h.refresh.update({ live_enabled: false, system_info_seconds: 0 });
|
||||
const diagnosticRequests = h.count("/api/system-info");
|
||||
await h.refresh.manual("system-info");
|
||||
assert.equal(h.count("/api/system-info"), diagnosticRequests + 1);
|
||||
h.refresh.update({ live_enabled: true });
|
||||
|
||||
// A failed source does not stop other source registrations.
|
||||
const e = fixture({ withoutSse: true });
|
||||
e.registerViews();
|
||||
e.setResponse(url => url === "/api/traffic" ? Promise.reject(new Error("Traffic disconnected")) : Promise.resolve({ ok: true }));
|
||||
e.refresh.start(); e.switchPanel("traffic"); await e.advance();
|
||||
assert.equal(e.count("/api/traffic"), 1);
|
||||
assert.equal(e.count("/api/status?view=live"), 1, "browsers without EventSource use status polling");
|
||||
assert.equal(e.count("/api/logs"), 1, "browsers without EventSource use logs polling");
|
||||
e.switchPanel("cache"); await e.advance();
|
||||
assert.equal(e.count("/api/cache"), 1);
|
||||
assert.equal(e.failures.length, 1);
|
||||
await e.advance(5_000);
|
||||
assert.equal(e.count("/api/cache"), 2);
|
||||
|
||||
// Multiple registrations with identical IDs must be rejected.
|
||||
assert.throws(() => h.refresh.register("traffic", { interval: () => 5, run: async () => {} }), /Duplicate refresh task/);
|
||||
// Logout clears timers, active SSE streams, observer and pending fetches.
|
||||
h.refresh.stop();
|
||||
assert.equal(h.listeners.has("visibilitychange"), false);
|
||||
assert.equal(h.streams.filter(stream => !stream.closed).length, 0);
|
||||
assert.equal(h.refresh.diagnostics().filter(row => row.scheduled).length, 0);
|
||||
const afterLogout = h.requests.length;
|
||||
h.ctx.authenticated = false;
|
||||
await h.advance(600_000);
|
||||
assert.equal(h.requests.length, afterLogout);
|
||||
await h.refresh.manual("traffic");
|
||||
assert.equal(h.requests.length, afterLogout);
|
||||
|
||||
// Validate incoming preferences in the browser, including malformed partial records.
|
||||
const invalid = runInNewContext(scheduler + ";normalizeRefreshPreferences({ live_enabled: 1, overview_seconds: 0, traffic_seconds: 17, cache_seconds: 0, system_info_seconds: 300 });", {
|
||||
document: { hidden: true, addEventListener() {}, removeEventListener() {} },
|
||||
AbortController, window: {}, authenticated: false, authEpoch: 0, currentPanel: "overview", liveConnected: false,
|
||||
liveFallback: false, stopLive() {}, startLive() {}, $: () => null
|
||||
});
|
||||
assert.equal(invalid.live_enabled, true);
|
||||
assert.equal(invalid.overview_seconds, 2);
|
||||
assert.equal(invalid.traffic_seconds, 5);
|
||||
assert.equal(invalid.cache_seconds, 0);
|
||||
assert.equal(invalid.system_info_seconds, 300);
|
||||
console.log("PASS: per-account refresh validation, SSE/fallback, 5/3/0-s scheduling, tab/visibility/logout lifecycle, isolation, no duplicate/inflight/stale requests, manual mode.");
|
||||
@@ -135,7 +135,7 @@ 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.30.1"' in (target / "Cargo.toml").read_text(encoding="utf-8")
|
||||
assert 'version = "0.31.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()
|
||||
@@ -146,7 +146,7 @@ def upgrade_smoke(tmp: Path) -> None:
|
||||
assert (target / "docker-agent/certificate.go").is_file()
|
||||
assert (target / "docker-agent/server.go").is_file()
|
||||
manifest = json.loads((backup / "upgrade.json").read_text(encoding="utf-8"))
|
||||
assert manifest["to_version"] == "0.30.1"
|
||||
assert manifest["to_version"] == "0.31.0"
|
||||
assert manifest["removed_obsolete_runtime_state"] is True
|
||||
assert not legacy.exists()
|
||||
assert not (backup / "legacy-data").exists()
|
||||
@@ -357,7 +357,7 @@ def ui_contract_smoke() -> None:
|
||||
assert '/api/traffic' in traffic and 'rustProxyTrafficPanel' in traffic
|
||||
for marker in ['function selectTab(', 'function renderDonut(', 'function renderBars(', 'renderBars("client-ips"', 'renderBars("user-agents"', 'renderBars("paths"', 'renderBars("routes"', 'renderBars("volume"']:
|
||||
assert marker in traffic, marker
|
||||
assert 'traffic: "/traffic"' in app and 'window.rustProxyTrafficPanel?.refresh()' in app
|
||||
assert 'traffic: "/traffic"' in app and 'window.rustProxyRefresh?.setPanel(name)' 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
|
||||
@@ -619,7 +619,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.30.1"' in openapi_0241
|
||||
assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.31.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
|
||||
@@ -726,7 +726,7 @@ def source_contract_smoke() -> None:
|
||||
assert 'values.length ? values : [{ id: "root"' not in vhosts_js
|
||||
for marker in ["PinnedCertificateVerifier", "tls_fingerprint_sha256", "health_check: crate::balance::HealthCheck", "certificate fingerprint mismatch", "generated_id"]:
|
||||
assert marker in docker_source, marker
|
||||
for marker in ["docker-agent-fingerprint", "docker-agent-certificate", "docker-health-mode", "docker-search", "docker-show-hidden", "pool_routes", "dockerBackendUp", "setInterval"]:
|
||||
for marker in ["docker-agent-fingerprint", "docker-agent-certificate", "docker-health-mode", "docker-search", "docker-show-hidden", "pool_routes", "dockerBackendUp", 'rustProxyRefresh.register("docker"']:
|
||||
assert marker in docker_js, marker
|
||||
assert 'function checkboxField(key, control)' in docker_js
|
||||
assert 'checkboxField("dockerPreserveHost", preserve)' in docker_js and 'checkboxField("dockerAclInline", inline)' in docker_js
|
||||
@@ -972,8 +972,9 @@ def source_contract_smoke() -> None:
|
||||
assert marker in proxy, marker
|
||||
assert proxy.index("RustProxy: denied by access policy") < proxy.index("if let Some(spec) = &route.response") < proxy.index("let (backend, backend_lease, affinity_cookie)")
|
||||
assert "pub client_ip: Option<String>" in logging and "client_ip" in logging
|
||||
assert '"metrics": stream_metrics' in admin and '"/assets/streams.js"' in admin and 'include_str!("web/streams.js")' in admin
|
||||
assert '.route("/404-hosts", get(public::index))' in admin and '"/assets/host404.js"' in admin and 'include_str!("web/host404.js")' in admin and '"not_found_hosts"' in admin
|
||||
admin_assets = (ROOT / "src/admin/panel_assets.rs").read_text(encoding="utf-8")
|
||||
assert '"metrics": stream_metrics' in admin and '"/assets/streams.js"' in admin and 'include_str!("web/streams.js")' in admin_assets
|
||||
assert '.route("/404-hosts", get(public::index))' in admin and '"/assets/host404.js"' in admin and 'include_str!("web/host404.js")' in admin_assets and '"not_found_hosts"' in admin
|
||||
assert "session_at_with_browser_lifetime" in admin and "session_lifetime_seconds" in admin
|
||||
assert '/api/preferences' in admin and 'live_log_columns' in admin
|
||||
assert 'default_admin_theme' in admin and '"dark" | "light"' in admin
|
||||
@@ -990,7 +991,7 @@ def source_contract_smoke() -> None:
|
||||
tls_source = rust_source(ROOT, "src/tls.rs")
|
||||
for marker in ["CertificateMetadata", "not_before_unix", "not_after_unix", "days_remaining", "expiry_status"]:
|
||||
assert marker in tls_source, marker
|
||||
assert '"/assets/redirects.js"' in admin and 'include_str!("web/redirects.js")' in admin and '.route("/redirects", get(public::index))' in admin
|
||||
assert '"/assets/redirects.js"' in admin and 'include_str!("web/redirects.js")' in admin_assets and '.route("/redirects", get(public::index))' in admin
|
||||
assert '/Ubuntu%2014.04%20LTS/' in acl and 'repeated_slashes_are_normalized_for_routing' in acl and 'percent_encoded_spaces_are_valid_request_paths' in policies and 'repeated_slashes_route_normally_but_reach_upstream_unchanged' in policies and 'local_location_response_uses_response_rules_without_opening_backend' in policies and 'local_location_response_still_respects_route_acl' in policies
|
||||
db_source = rust_source(ROOT, "src/db.rs")
|
||||
acme_source = rust_source(ROOT, "src/acme.rs")
|
||||
@@ -1109,7 +1110,7 @@ def source_contract_smoke() -> None:
|
||||
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")
|
||||
assert '"/api/config/history/restore"' in openapi
|
||||
for marker in ['"version": "0.30.1"', '"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.31.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"']:
|
||||
@@ -1314,7 +1315,7 @@ def main() -> None:
|
||||
source_contract_smoke()
|
||||
container_contract_smoke()
|
||||
grafana_contract_smoke()
|
||||
print("PASS: 0.30.1 SQLite/schema-9/upgrade, local-response 404 hosts, Prometheus/Grafana diagnostics, external Debian-packaging separation, cache/dashboard/logging/Nagios, retained admin/H3/stream/TLS contracts and reproducible source archives.")
|
||||
print("PASS: 0.31.0 SQLite/schema-9/upgrade, local-response 404 hosts, Prometheus/Grafana diagnostics, external Debian-packaging separation, cache/dashboard/logging/Nagios, retained admin/H3/stream/TLS contracts and reproducible source archives.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -43,7 +43,7 @@ const code = js.slice(js.indexOf(' const runtime = latestStatus?.runtime || {
|
||||
const elements = new Map();
|
||||
const dollar = id => { if (!elements.has(id)) elements.set(id, { textContent: '' }); return elements.get(id); };
|
||||
const status = {
|
||||
version: '0.30.1', uptime_seconds: 4201,
|
||||
version: '0.31.0', uptime_seconds: 4201,
|
||||
runtime: {
|
||||
platform: 'Linux', operating_system: 'Debian GNU/Linux 13', kernel_version: '6.12.0', architecture: 'x86_64',
|
||||
statistics_source: 'systemstat+sysinfo', data_directory: '/var/lib/rustproxy', database_path: '/var/lib/rustproxy/config.sqlite3',
|
||||
@@ -63,7 +63,7 @@ const context = {
|
||||
vm.runInNewContext(code, context);
|
||||
assert.equal(dollar('runtime-operating-system').textContent, 'Debian GNU/Linux 13');
|
||||
assert.equal(dollar('runtime-kernel-version').textContent, '6.12.0');
|
||||
assert.equal(dollar('runtime-version').textContent, '0.30.1');
|
||||
assert.equal(dollar('runtime-version').textContent, '0.31.0');
|
||||
assert.equal(dollar('runtime-logical-cpus').textContent, '8');
|
||||
assert.equal(dollar('runtime-cpu-usage').textContent, '17.2%');
|
||||
assert.equal(dollar('runtime-python').textContent, 'Available · 3.13.5');
|
||||
@@ -124,4 +124,4 @@ for (const key of ['python_version','pip_version','certbot_version']) {
|
||||
assert.ok(runtimeSchema.properties.certificate_runtime.properties[key]);
|
||||
assert.equal(runtimeSchema.properties.certificate_runtime.properties[key].nullable, true);
|
||||
}
|
||||
console.log('PASS: 0.30.1 legend palette, About cleanup, system tab/navigation, read-only API schema and simulated status rendering.');
|
||||
console.log('PASS: 0.31.0 legend palette, About cleanup, system tab/navigation, read-only API schema and simulated status rendering.');
|
||||
@@ -2,6 +2,7 @@
|
||||
"""Offline source guard for subprocess-free system information; not a Rust runtime test."""
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
from rust_source import rust_source
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
manifest = tomllib.loads((ROOT / 'Cargo.toml').read_text())
|
||||
@@ -13,7 +14,7 @@ assert {'network', 'system'} <= set(sysinfo_features), (
|
||||
)
|
||||
assert any('systemstat' in target.get('dependencies', {}) for target in manifest.get('target', {}).values())
|
||||
|
||||
status = (ROOT / 'src/admin/status.rs').read_text()
|
||||
status = rust_source(ROOT, 'src/admin/status.rs')
|
||||
assert 'sysinfo::System::long_os_version()' in status
|
||||
assert 'sysinfo::System::kernel_version()' in status
|
||||
assert 'std::sync::OnceLock' in status
|
||||
|
||||
@@ -12,7 +12,9 @@ for name in ('src/main/bootstrap.rs','src/main/tasks.rs','src/web_server/fastcgi
|
||||
assert 'env!("CARGO_PKG_VERSION")' in (root/'src/web_server/fastcgi/protocol.rs').read_text()
|
||||
assert 'env!("CARGO_PKG_VERSION")' in (root/'src/web_server/response/file.rs').read_text()
|
||||
assert '__RUSTPROXY_VERSION__' in (root/'src/admin/web/index.html').read_text()
|
||||
assert 'OnceLock<String>' in (root/'src/admin/public.rs').read_text()
|
||||
assert 'OnceLock<PanelContent>' in (root/'src/admin/public.rs').read_text()
|
||||
assert 'PanelContent::new' in (root/'src/admin/public.rs').read_text()
|
||||
assert 'preferred_encoding' in (root/'src/admin/panel_content.rs').read_text()
|
||||
assert 'include_str!("web/index.html")' in (root/'src/admin/public.rs').read_text()
|
||||
assert 'env!("CARGO_PKG_VERSION")' in (root/'src/admin/public.rs').read_text()
|
||||
assert 'admin_index.html' not in (root/'build.rs').read_text()
|
||||
|
||||
Reference in new issue
Block a user