This commit is contained in:
Mateusz Gruszczyński committed 2026-10-08 09:42:10 +02:00
1 parent c24bc4279b
commit 3b3f0db377
84 files changed
+2686 -392

No files matched your search

+12 -1
View File
@@ -320,7 +320,18 @@ pub async fn runtime_setup_once(state: Weak<App>) {
let Some(state) = state.upgrade() else {
return;
};
let _ = ensure_runtime(&state).await;
let setup_ok = ensure_runtime(&state).await.is_ok();
let runtime = state.acme.runtime_view();
tracing::info!(
status = runtime.status,
mode = runtime.mode,
python = runtime.python,
pip = runtime.pip,
certbot = runtime.certbot,
managed_env = runtime.managed_directory.as_deref().unwrap_or("-"),
setup_ok,
"ACME runtime check complete"
);
}
struct ProcessGroup(Option<u32>);
+1
View File
@@ -409,6 +409,7 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
"/docs/V0.25.1_AUDIT.md"
)),
),
reader_doc!("docs/V0.28.0_IMPLEMENTATION.md", "technical"),
reader_doc!("docs/V0.27.3_IMPLEMENTATION.md", "technical"),
reader_doc!("docs/V0.27.2_IMPLEMENTATION.md", "technical"),
reader_doc!("docs/V0.27.1_IMPLEMENTATION.md", "historical"),
+5 -1
View File
@@ -70,6 +70,10 @@ async fn update_section(
config.prometheus =
serde_json::from_value(value).map_err(|e| invalid_section(&section, e))?
}
"geoip" => {
config.geoip_updates =
serde_json::from_value(value).map_err(|e| invalid_section(&section, e))?
}
"administration" => {
config.administration =
serde_json::from_value(value).map_err(|e| invalid_section(&section, e))?
@@ -375,7 +379,7 @@ async fn traffic_status(
"capture_user_agent": snapshot.envelope.config.logging.capture_user_agent,
"capture_country": snapshot.envelope.config.logging.capture_country
},
"geoip": state.geoip.view(),
"geoip": state.geoip.load().view(),
"analytics": state.traffic.view(Some(scope)),
"vhosts": vhosts
})))
+272 -10
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.27.3",
"version": "0.28.0",
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust."
},
"servers": [
@@ -606,6 +606,9 @@
"prometheus": {
"$ref": "#/components/schemas/PrometheusSettings"
},
"geoip_updates": {
"$ref": "#/components/schemas/GeoIpUpdateSettings"
},
"host_policies": {
"type": "array",
"items": {
@@ -1593,6 +1596,13 @@
"expose_resident_age": {
"type": "boolean"
},
"via_header": {
"type": "string",
"nullable": true,
"maxLength": 256,
"default": "RustProxy",
"description": "Optional response Via field value appended by RustProxy cache handling. Null disables the header. This response-only setting does not invalidate cached objects."
},
"cold_max_hot_seconds": {
"type": "integer",
"minimum": 5,
@@ -2258,6 +2268,103 @@
"frontend"
]
},
"GeoIpUpdateSettings": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"default": true
},
"format": {
"type": "string",
"enum": [
"csv_pair",
"mmdb"
],
"default": "csv_pair",
"description": "GeoIP database format. csv_pair uses separate IPv4/IPv6 range files; mmdb uses one native MaxMind DB file."
},
"ipv4_url": {
"type": "string",
"format": "uri",
"default": "https://github.com/sapics/ip-location-db/releases/download/latest/user-country-ipv4.csv",
"description": "Direct HTTPS URL for the IPv4 CSV database."
},
"ipv6_url": {
"type": "string",
"format": "uri",
"default": "https://github.com/sapics/ip-location-db/releases/download/latest/user-country-ipv6.csv",
"description": "Direct HTTPS URL for the IPv6 CSV database."
},
"mmdb_url": {
"type": "string",
"default": "https://github.com/sapics/ip-location-db/releases/download/latest/user-country.mmdb",
"description": "Direct HTTPS URL for a native MaxMind DB. May be empty only when MMDB automatic updates are disabled and country.mmdb is managed locally."
},
"ipv4_checksum_url": {
"type": "string",
"format": "uri",
"nullable": true,
"default": null,
"description": "Optional HTTPS URL containing the publisher SHA-256 for the IPv4 CSV."
},
"ipv6_checksum_url": {
"type": "string",
"format": "uri",
"nullable": true,
"default": null,
"description": "Optional HTTPS URL containing the publisher SHA-256 for the IPv6 CSV."
},
"mmdb_checksum_url": {
"type": "string",
"format": "uri",
"nullable": true,
"default": null,
"description": "Optional HTTPS URL containing the publisher SHA-256 for the MMDB file."
},
"max_bytes": {
"type": "integer",
"format": "int64",
"minimum": 1048576,
"maximum": 268435456,
"default": 268435456
},
"interval_seconds": {
"type": "integer",
"format": "int64",
"minimum": 60,
"maximum": 2678400,
"default": 86400
},
"release_url": {
"type": "string",
"format": "uri",
"nullable": true,
"deprecated": true,
"description": "Legacy release base URL accepted for migration from pre-direct-source GeoIP settings."
},
"checksum_url": {
"type": "string",
"format": "uri",
"nullable": true,
"deprecated": true,
"description": "Legacy checksum base URL accepted for migration. The former sapics checksum default is migrated to optional/no publisher checksum."
}
},
"required": [
"enabled",
"format",
"ipv4_url",
"ipv6_url",
"mmdb_url",
"ipv4_checksum_url",
"ipv6_checksum_url",
"mmdb_checksum_url",
"max_bytes",
"interval_seconds"
]
},
"PrometheusSettings": {
"type": "object",
"additionalProperties": false,
@@ -4472,15 +4579,49 @@
"type": "object",
"description": "Python/pip/Certbot capability used by managed certificate automation.",
"properties": {
"status": { "type": "string", "enum": ["checking", "initializing", "ready", "unavailable"] },
"mode": { "type": "string", "enum": ["none", "managed", "system"] },
"message": { "type": "string" },
"managed_directory": { "type": "string", "nullable": true },
"python": { "type": "boolean" },
"pip": { "type": "boolean" },
"certbot": { "type": "boolean" }
"status": {
"type": "string",
"enum": [
"checking",
"initializing",
"ready",
"unavailable"
]
},
"mode": {
"type": "string",
"enum": [
"none",
"managed",
"system"
]
},
"message": {
"type": "string"
},
"managed_directory": {
"type": "string",
"nullable": true
},
"python": {
"type": "boolean"
},
"pip": {
"type": "boolean"
},
"certbot": {
"type": "boolean"
}
},
"required": ["status", "mode", "message", "managed_directory", "python", "pip", "certbot"]
"required": [
"status",
"mode",
"message",
"managed_directory",
"python",
"pip",
"certbot"
]
}
},
"required": [
@@ -5276,6 +5417,68 @@
}
},
"description": "Scoped dynamic-compression policy. The enabled switch and individual codecs inherit by default; global Response codec switches are the hard availability ceiling and min_bytes remains global."
},
"GeoIpView": {
"type": "object",
"additionalProperties": false,
"properties": {
"loaded": {
"type": "boolean"
},
"ipv4_ranges": {
"type": "integer",
"minimum": 0
},
"ipv6_ranges": {
"type": "integer",
"minimum": 0
},
"source": {
"type": "string"
},
"format": {
"type": "string",
"enum": [
"csv_pair",
"mmdb"
]
}
},
"required": [
"loaded",
"ipv4_ranges",
"ipv6_ranges",
"source",
"format"
]
},
"GeoIpUpdateResult": {
"type": "object",
"additionalProperties": false,
"properties": {
"outcome": {
"type": "string",
"enum": [
"updated",
"current",
"busy"
]
},
"updated_files": {
"type": "array",
"items": {
"type": "string"
}
},
"database": {
"$ref": "#/components/schemas/GeoIpView"
}
},
"required": [
"outcome",
"updated_files",
"database"
]
}
}
},
@@ -7290,6 +7493,64 @@
}
}
},
"/api/geoip/update": {
"post": {
"summary": "Check, download and hot-reload GeoIP country databases now",
"description": "Download the configured direct GeoIP source(s), optionally verify publisher SHA-256 values, validate the selected CSV/MMDB format, atomically publish changed files and hot-reload future GeoIP lookups.",
"tags": [
"Settings"
],
"operationId": "post_api_geoip_update",
"responses": {
"200": {
"description": "GeoIP check completed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GeoIpUpdateResult"
}
}
}
},
"401": {
"description": "Authentication required or revoked",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Host, origin or CSRF validation failed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"502": {
"description": "Publisher check, download, validation or reload failed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"security": [
{
"AdminSession": [],
"CsrfToken": []
}
]
}
},
"/api/cache/purge": {
"post": {
"summary": "Purge all cache entries or one vhost",
@@ -7956,7 +8217,7 @@
}
}
},
"description": "Preferred UI/control-plane save path. Replaces only the selected section while preserving every other section from the current revision. This is not a merge patch. Cache identity is independent from the global configuration revision: logging, listeners, TLS inventory, metrics and administration saves do not purge cached objects. Changes which can alter origin requests or cached responses (cache/transform/response/forwarding/vhosts) rotate the cache compatibility identity and purge incompatible hot/cold objects. Supported section names include docker-agents, vhosts, redirects, not_found_hosts, streams, listeners, http2, tls, pages, cache, transform, logging, forwarding, headers, prometheus, administration, acl-profiles, host-policies and response.",
"description": "Preferred UI/control-plane save path. Replaces only the selected section while preserving every other section from the current revision. This is not a merge patch. Cache identity is independent from the global configuration revision: logging, listeners, TLS inventory, metrics and administration saves do not purge cached objects. Changes which can alter origin requests or cached responses (cache/transform/response/forwarding/vhosts) rotate the cache compatibility identity and purge incompatible hot/cold objects. Supported section names include docker-agents, vhosts, redirects, not_found_hosts, streams, listeners, http2, tls, pages, cache, transform, logging, forwarding, headers, prometheus, geoip, administration, acl-profiles, host-policies and response.",
"parameters": [
{
"name": "section",
@@ -7975,6 +8236,7 @@
"forwarding",
"headers",
"prometheus",
"geoip",
"administration",
"http2",
"tls",
+31
View File
@@ -1,3 +1,34 @@
async fn geoip_update(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
) -> Result<Json<crate::geoip::UpdateResult>, ApiError> {
let result = crate::geoip::update_now(&state).await.map_err(|error| {
tracing::warn!(error = %format!("{error:#}"), "manual GeoIP update failed");
ApiError(
StatusCode::BAD_GATEWAY,
format!("GeoIP update failed: {error:#}"),
)
})?;
state
.store
.audit(
&session.username,
"geoip.update",
&format!(
"outcome={}; files={}",
result.outcome,
if result.updated_files.is_empty() {
"none".to_string()
} else {
result.updated_files.join(",")
}
),
)
.await
.map_err(auth::internal)?;
Ok(Json(result))
}
async fn transform_status(State(state): State<Arc<App>>) -> Json<Value> {
let snapshot = state.config.load_full();
let global = &snapshot.envelope.config.transform;
+1
View File
@@ -342,6 +342,7 @@ pub fn router(state: Arc<App>) -> Router {
.route("/api/traffic", get(traffic_status))
.route("/api/cache", get(cache_status))
.route("/api/transform", get(transform_status))
.route("/api/geoip/update", post(geoip_update))
.route("/api/cache/purge", post(cache_purge))
.route(
"/api/cache/invalidate",
+1 -1
View File
@@ -156,7 +156,7 @@ fn status_payload(state: &App) -> Value {
let transform_resources = crate::transform::resource_usage(&snapshot.envelope.config.transform);
payload["cache"] = json!(&cache_view);
payload["transform_resources"] = json!(&transform_resources);
payload["geoip"] = json!(state.geoip.view());
payload["geoip"] = json!(state.geoip.load().view());
let system = state.diagnostics.view();
let cache_ram_bytes =
(cache_view.hot_allocated_bytes as u64).saturating_add(cache_view.transient_bytes);
+1
View File
@@ -396,6 +396,7 @@
("/api/acme/issue", "POST"),
("/api/acme/renew", "POST"),
("/api/docker/refresh", "POST"),
("/api/geoip/update", "POST"),
("/api/cache/purge", "POST"),
("/api/cache/invalidate", "POST"),
("/api/cache/bans", "GET"),
+1 -1
View File
@@ -116,7 +116,7 @@ function appConfirm(message, options = {}) {
}
function apiArea(path) {
const section = path.match(/^\/api\/config\/([^/?]+)/)?.[1];
if (section) return ({ "listeners": "settings", "http2": "settings", "docker-agents": "dockerVhosts", "response": "settings", "pages": "settings", "cache": "cache", "transform": "settings", "logging": "settings", "forwarding": "settings", "headers": "settings", "prometheus": "settings", "administration": "administration", "tls": "tls", "vhosts": "vhosts", "redirects": "redirects", "not_found_hosts": "notfound", "streams": "streams", "acl-profiles": "aclProfiles", "host-policies": "security" })[section] || "configuration";
if (section) return ({ "listeners": "settings", "http2": "settings", "docker-agents": "dockerVhosts", "response": "settings", "pages": "settings", "cache": "cache", "transform": "settings", "logging": "settings", "geoip": "settings", "forwarding": "settings", "headers": "settings", "prometheus": "settings", "administration": "administration", "tls": "tls", "vhosts": "vhosts", "redirects": "redirects", "not_found_hosts": "notfound", "streams": "streams", "acl-profiles": "aclProfiles", "host-policies": "security" })[section] || "configuration";
if (path === "/api/config") return "configuration";
if (path.startsWith("/api/traffic")) return "traffic";
if (path.startsWith("/api/cache")) return "cache";
+22 -1
View File
@@ -359,7 +359,28 @@ function renderDiagnostics(status, appendSample = true) {
const l = status.listeners || {};
$("listener-pending").hidden = !l.restart_required;
$("listener-pending").textContent = t("restartPending");
$("listener-active").textContent = `${t("activeListeners")}: ${l.active_endpoints?.map(e => `${e.tls ? "HTTPS" : "HTTP"} ${e.address}${e.http3 ? " + H3/UDP" : ""}`).join(" | ") || `HTTP ${status.proxy_listen} / HTTPS ${status.tls_listen || t("notEnabled")}`}`;
const activeListeners = $("listener-active"), listenerItems = el("div", undefined, "listener-active-items");
let endpoints = Array.isArray(l.active_endpoints) ? l.active_endpoints : [];
if (!endpoints.length) {
endpoints = [];
if (status.proxy_listen) endpoints.push({ tls: false, address: status.proxy_listen, http3: false });
if (status.tls_listen) endpoints.push({ tls: true, address: status.tls_listen, http3: false });
}
for (const endpoint of endpoints) {
const item = el("div", undefined, "listener-active-item");
item.append(el("strong", endpoint.tls ? "HTTPS" : "HTTP", "listener-active-protocol"), el("code", endpoint.address || "-", "listener-active-address"));
const protocols = ["H1"];
if (endpoint.tls) {
if (endpoint.http2) protocols.push("H2");
if (endpoint.http3) protocols.push("H3/UDP");
} else if (endpoint.h2c) {
protocols.push("H2C");
}
for (const protocol of protocols) item.append(el("span", protocol, "listener-active-feature"));
listenerItems.append(item);
}
if (!endpoints.length) listenerItems.append(el("span", t("notEnabled"), "fine"));
activeListeners.replaceChildren(el("strong", t("activeListeners"), "listener-active-title"), listenerItems);
}
function renderHeaderRules(direction, rules) {
const target = $(`${direction}-header-rules`); target.replaceChildren();
+8 -1
View File
@@ -30,6 +30,8 @@ function renderSettings() {
$("cache-query").checked = !!c.bypass_query; $("cache-prefixes").value = (c.bypass_prefixes || []).join("\n");
$("cache-static-cookie").checked = c.static_ignore_cookies !== false; $("cache-static-extensions").value = (c.static_extensions || []).join("\n");
$("cache-status-header").checked = !!c.expose_status; $("cache-hits-header").checked = !!c.expose_hits; $("cache-rfc-header").checked = !!c.expose_cache_status; $("cache-resident-age-header").checked = !!c.expose_resident_age;
const viaHeader = c.via_header === undefined ? "RustProxy" : c.via_header;
$("cache-via-enabled").checked = viaHeader !== null; $("cache-via-value").value = viaHeader || "RustProxy"; syncCacheVia();
setCacheMemoryPresetSelection(["conservative", "recommended", "performance"].includes(c.memory_preset) ? c.memory_preset : null);
setCacheColdPresetSelection(c.cold_preset === "recommended" ? "recommended" : null);
syncColdCachePolicy();
@@ -170,6 +172,10 @@ function applyCacheMemoryPreset(kind) {
setCacheMemoryPresetSelection(kind);
setSettingsDirty("cache-form"); updateCacheMemorySizing();
}
function syncCacheVia() {
const enabled = $("cache-via-enabled")?.checked;
if ($("cache-via-value")) $("cache-via-value").disabled = !enabled;
}
function applyRecommendedColdCachePreset() {
const transient = Number($("cache-transient-memory").value);
const queue = Number.isFinite(transient) && transient > 0 ? Math.max(0.0625, Math.floor((transient * .5) / 8) * 8 || Math.min(transient, 8)) : 32;
@@ -279,7 +285,7 @@ bindSettingsForm("response-form", next => {
next.compression = { min_bytes: Number($("compress-min").value) }; for (const name of ["gzip", "br", "zstd", "deflate"]) next.compression[name] = $(`compress-${name}`).checked;
}, "response", next => ({ server_header: next.server_header, diagnostic_headers: next.diagnostic_headers, compression: next.compression }));
bindSettingsForm("cache-form", next => {
next.cache = { ...(next.cache || {}), enabled: $("cache-enabled").checked, memory_preset: selectedCacheMemoryPreset, max_bytes: Math.round(Number($("cache-memory").value) * 1048576), transient_max_bytes: Math.round(Number($("cache-transient-memory").value) * 1048576), max_object_bytes: Number($("cache-object").value) * 1024, max_entries: Number($("cache-entries").value), hot_eviction_max_objects: Number($("cache-eviction-limit").value), hot_auto_trim_enabled: $("cache-auto-trim-enabled").checked, hot_auto_trim_after_seconds: Number($("cache-auto-trim-after").value), hot_auto_trim_target_percent: Number($("cache-auto-trim-target").value), cold_enabled: $("cache-cold-enabled").checked, cold_preset: selectedCacheColdPreset, cold_path: $("cache-cold-path").value.trim(), cold_max_bytes: Math.round(Number($("cache-cold-bytes").value) * 1048576), cold_max_entries: Number($("cache-cold-entries").value), cold_queue_max_bytes: Math.round(Number($("cache-cold-queue-bytes").value) * 1048576), cold_gc_interval_seconds: Number($("cache-cold-gc-interval").value), cold_gc_max_delete: Number($("cache-cold-gc-max-delete").value), cold_after_seconds: Number($("cache-cold-after").value), cold_max_hot_seconds: Number($("cache-cold-max-hot").value), cold_on_idle: $("cache-cold-on-idle").checked, cold_on_age: $("cache-cold-on-age").checked, cold_on_lru: $("cache-cold-on-lru").checked, cold_on_shutdown: $("cache-cold-on-shutdown").checked, max_ttl_seconds: Number($("cache-ttl").value), default_ttl_seconds: Number($("cache-default-ttl").value), collapse_enabled: $("cache-collapse-enabled").checked, collapse_wait_timeout_ms: Number($("cache-collapse-timeout").value), collapse_timeout_fallback: $("cache-collapse-fallback").value, stale_if_error_timeout: $("cache-sie-timeout").checked, stale_if_error_connection: $("cache-sie-connection").checked, stale_if_error_dns: $("cache-sie-dns").checked, stale_if_error_tls: $("cache-sie-tls").checked, stale_if_error_statuses: $("cache-sie-statuses").value.split(/[\s,]+/).filter(Boolean).map(Number), proactive_refresh_enabled: $("cache-proactive-enabled").checked, proactive_refresh_before_expiry_seconds: Number($("cache-proactive-before").value), proactive_refresh_percent: Number($("cache-proactive-percent").value), proactive_refresh_max_inflight: Number($("cache-proactive-inflight").value), bypass_query: $("cache-query").checked, bypass_prefixes: $("cache-prefixes").value.split(/\r?\n/).map(v => v.trim()).filter(Boolean), static_ignore_cookies: $("cache-static-cookie").checked, static_extensions: $("cache-static-extensions").value.split(/[\n,]+/).map(v => v.trim().replace(/^\./, "").toLowerCase()).filter(Boolean), expose_status: $("cache-status-header").checked, expose_hits: $("cache-hits-header").checked, expose_cache_status: $("cache-rfc-header").checked, expose_resident_age: $("cache-resident-age-header").checked };
next.cache = { ...(next.cache || {}), enabled: $("cache-enabled").checked, memory_preset: selectedCacheMemoryPreset, max_bytes: Math.round(Number($("cache-memory").value) * 1048576), transient_max_bytes: Math.round(Number($("cache-transient-memory").value) * 1048576), max_object_bytes: Number($("cache-object").value) * 1024, max_entries: Number($("cache-entries").value), hot_eviction_max_objects: Number($("cache-eviction-limit").value), hot_auto_trim_enabled: $("cache-auto-trim-enabled").checked, hot_auto_trim_after_seconds: Number($("cache-auto-trim-after").value), hot_auto_trim_target_percent: Number($("cache-auto-trim-target").value), cold_enabled: $("cache-cold-enabled").checked, cold_preset: selectedCacheColdPreset, cold_path: $("cache-cold-path").value.trim(), cold_max_bytes: Math.round(Number($("cache-cold-bytes").value) * 1048576), cold_max_entries: Number($("cache-cold-entries").value), cold_queue_max_bytes: Math.round(Number($("cache-cold-queue-bytes").value) * 1048576), cold_gc_interval_seconds: Number($("cache-cold-gc-interval").value), cold_gc_max_delete: Number($("cache-cold-gc-max-delete").value), cold_after_seconds: Number($("cache-cold-after").value), cold_max_hot_seconds: Number($("cache-cold-max-hot").value), cold_on_idle: $("cache-cold-on-idle").checked, cold_on_age: $("cache-cold-on-age").checked, cold_on_lru: $("cache-cold-on-lru").checked, cold_on_shutdown: $("cache-cold-on-shutdown").checked, max_ttl_seconds: Number($("cache-ttl").value), default_ttl_seconds: Number($("cache-default-ttl").value), collapse_enabled: $("cache-collapse-enabled").checked, collapse_wait_timeout_ms: Number($("cache-collapse-timeout").value), collapse_timeout_fallback: $("cache-collapse-fallback").value, stale_if_error_timeout: $("cache-sie-timeout").checked, stale_if_error_connection: $("cache-sie-connection").checked, stale_if_error_dns: $("cache-sie-dns").checked, stale_if_error_tls: $("cache-sie-tls").checked, stale_if_error_statuses: $("cache-sie-statuses").value.split(/[\s,]+/).filter(Boolean).map(Number), proactive_refresh_enabled: $("cache-proactive-enabled").checked, proactive_refresh_before_expiry_seconds: Number($("cache-proactive-before").value), proactive_refresh_percent: Number($("cache-proactive-percent").value), proactive_refresh_max_inflight: Number($("cache-proactive-inflight").value), bypass_query: $("cache-query").checked, bypass_prefixes: $("cache-prefixes").value.split(/\r?\n/).map(v => v.trim()).filter(Boolean), static_ignore_cookies: $("cache-static-cookie").checked, static_extensions: $("cache-static-extensions").value.split(/[\n,]+/).map(v => v.trim().replace(/^\./, "").toLowerCase()).filter(Boolean), expose_status: $("cache-status-header").checked, expose_hits: $("cache-hits-header").checked, expose_cache_status: $("cache-rfc-header").checked, expose_resident_age: $("cache-resident-age-header").checked, via_header: $("cache-via-enabled").checked ? $("cache-via-value").value.trim() : null };
}, "cache");
bindSettingsForm("transform-form", next => {
next.transform = {
@@ -384,6 +390,7 @@ $("server-mode").addEventListener("change", syncServerMode);
$("log-file-enabled").addEventListener("change", syncLoggingFileMode);
$("log-file-per-vhost").addEventListener("change", syncLoggingFileMode);
for (const id of ["cache-cold-enabled", "cache-cold-on-idle", "cache-cold-on-age"]) $(id).addEventListener("change", syncColdCachePolicy);
$("cache-via-enabled").addEventListener("change", syncCacheVia);
document.querySelectorAll("[data-cache-memory-preset]").forEach(button => button.addEventListener("click", () => applyCacheMemoryPreset(button.dataset.cacheMemoryPreset)));
document.querySelectorAll("[data-cache-cold-preset]").forEach(button => button.addEventListener("click", applyRecommendedColdCachePreset));
document.querySelectorAll("[data-transform-preset]").forEach(button => button.addEventListener("click", () => applyTransformPreset(button.dataset.transformPreset)));
+61 -6
View File
@@ -1749,9 +1749,12 @@
data-settings-tab="pages" id="settings-tab-pages" role="tab" tabindex="-1"
type="button"></button><button aria-controls="settings-page-logging" aria-selected="false"
data-i18n="logging" data-settings-tab="logging" id="settings-tab-logging" role="tab"
tabindex="-1" type="button"></button><button aria-controls="settings-page-prometheus"
aria-selected="false" data-i18n="prometheusTitle" data-settings-tab="prometheus"
id="settings-tab-prometheus" role="tab" tabindex="-1" type="button"></button><button
tabindex="-1" type="button"></button><button aria-controls="settings-page-geoip"
aria-selected="false" data-i18n="geoipUpdatesTitle" data-settings-tab="geoip"
id="settings-tab-geoip" role="tab" tabindex="-1" type="button"></button><button
aria-controls="settings-page-prometheus" aria-selected="false" data-i18n="prometheusTitle"
data-settings-tab="prometheus" id="settings-tab-prometheus" role="tab" tabindex="-1"
type="button"></button><button
aria-controls="settings-page-administration" aria-selected="false"
data-i18n="administration" data-settings-tab="administration"
id="settings-tab-administration" role="tab" tabindex="-1" type="button"></button><button
@@ -1915,7 +1918,7 @@
<p class="fine" data-i18n="lowPortsHelp">Privileged ports may need CAP_NET_BIND_SERVICE.
Do not run the whole application as root.</p>
<p class="pending-note" hidden="" id="listener-pending"></p>
<p class="fine" id="listener-active"></p>
<div class="listener-active-summary" id="listener-active" role="status"></div>
<div class="form-actions"><button class="primary" data-i18n="saveListeners"
type="submit">Save listeners</button></div>
</form>
@@ -2264,6 +2267,15 @@
type="checkbox" /><span>Cache-Status (RFC 9211)</span></label><label
class="check"><input id="cache-resident-age-header"
type="checkbox" /><span>X-Cache-Resident-Age</span></label></div>
<div class="cache-via-setting">
<label class="check"><input id="cache-via-enabled" type="checkbox" /><span
data-i18n="cacheViaEnable">Add Via response header</span></label>
<label><span data-i18n="cacheViaValue">Via header value</span><input
id="cache-via-value" maxlength="256" placeholder="RustProxy"
required="" type="text" /></label>
</div>
<p class="fine" data-i18n="cacheViaHelp">Defaults to Via: RustProxy. Changing this
response-only header does not purge cached objects.</p>
<p class="fine" data-i18n="cacheAgeHelp">Age is sent on cache hits unless an
explicit response-header rule removes or replaces it. Such Age rules are
wire-only and do not change cache freshness.</p>
@@ -2548,6 +2560,49 @@
</form>
</div>
</div>
<div aria-labelledby="settings-tab-geoip" class="settings-page"
data-settings-page="geoip" hidden="" id="settings-page-geoip" role="tabpanel">
<form class="card edit-form" id="geoip-form">
<div class="card-heading">
<h2 data-i18n="geoipUpdatesTitle"></h2><span class="pill">AUTO</span>
</div>
<p class="fine" data-i18n="geoipUpdatesHelp"></p>
<label class="check"><input id="geoip-enabled" type="checkbox" /><span
data-i18n="geoipUpdatesEnable"></span></label>
<div class="field-grid">
<label><span data-i18n="geoipFormat"></span><select id="geoip-format">
<option data-i18n="geoipFormatCsvPair" value="csv_pair"></option>
<option data-i18n="geoipFormatMmdb" value="mmdb"></option>
</select></label>
<label><span data-i18n="geoipMaxFileMiB"></span><input id="geoip-max-mib" min="1"
max="256" required="" type="number" /></label>
<label><span data-i18n="geoipIntervalHours"></span><input id="geoip-interval-hours"
min="0.0166667" max="744" step="any" required="" type="number" /></label>
</div>
<div class="field-grid" id="geoip-csv-fields">
<label><span data-i18n="geoipIpv4Url"></span><input id="geoip-ipv4-url"
maxlength="2048" required="" type="url" /></label>
<label><span data-i18n="geoipIpv4ChecksumUrl"></span><input id="geoip-ipv4-checksum-url"
maxlength="2048" type="url" /></label>
<label><span data-i18n="geoipIpv6Url"></span><input id="geoip-ipv6-url"
maxlength="2048" required="" type="url" /></label>
<label><span data-i18n="geoipIpv6ChecksumUrl"></span><input id="geoip-ipv6-checksum-url"
maxlength="2048" type="url" /></label>
</div>
<div class="field-grid" id="geoip-mmdb-fields" hidden="">
<label><span data-i18n="geoipMmdbUrl"></span><input id="geoip-mmdb-url"
maxlength="2048" type="url" /></label>
<label><span data-i18n="geoipMmdbChecksumUrl"></span><input id="geoip-mmdb-checksum-url"
maxlength="2048" type="url" /></label>
</div>
<p class="fine" data-i18n="geoipSourceHelp"></p>
<p class="fine" id="geoip-status" role="status"></p>
<div class="form-actions"><button class="primary" data-i18n="apply"
type="submit"></button><button class="outline" data-i18n="geoipUpdateNow"
id="geoip-update-now" type="button"></button></div>
<p class="fine" data-i18n="geoipUpdateNowHelp"></p>
</form>
</div>
<div aria-labelledby="settings-tab-prometheus" class="settings-page"
data-settings-page="prometheus" hidden="" id="settings-page-prometheus" role="tabpanel">
<form class="card edit-form" id="prometheus-form">
@@ -2823,7 +2878,7 @@
</section>
</div>
</section>
<footer class="page-footer"><span>RustProxy <b>0.27.3</b></span><span id="footer-status">API - /
<footer class="page-footer"><span>RustProxy <b>0.28.0</b></span><span id="footer-status">API - /
dropped: 0</span></footer>
</div>
</main>
@@ -3567,7 +3622,7 @@ max_execution_time = 30</code></pre>
<article class="card about-card">
<div class="about-product">
<span aria-hidden="true" class="brand-mark">r<span>/</span>p</span>
<div><span class="small-label">RustProxy</span><strong>0.27.3</strong></div>
<div><span class="small-label">RustProxy</span><strong>0.28.0</strong></div>
</div>
<dl class="about-meta">
<div>
+1 -1
View File
@@ -5,7 +5,7 @@
const themes = new Set(["dark", "light"]);
try { const cached = localStorage.getItem("rp-theme"); document.documentElement.dataset.theme = themes.has(cached) ? cached : "dark"; } catch { document.documentElement.dataset.theme = "dark"; }
const panelPaths = new Set(["/overview", "/vhosts", "/docker-vhosts", "/redirects", "/404-hosts", "/streams", "/acl-profiles", "/configuration", "/tls", "/traffic", "/cache", "/transform", "/logging", "/logs", "/settings"]);
const settingsTabs = new Set(["appearance", "listeners", "connections", "docker-agents", "response", "cache", "transform", "headers", "forwarding", "pages", "logging", "prometheus", "administration", "tls-policy", "manual-config", "security"]);
const settingsTabs = new Set(["appearance", "listeners", "connections", "docker-agents", "response", "cache", "transform", "headers", "forwarding", "pages", "logging", "geoip", "prometheus", "administration", "tls-policy", "manual-config", "security"]);
let packs = new Map(), fallback = null, language = null, setupRequired = false, ready = false;
try { language = localStorage.getItem("rp-language"); } catch { }
const t = key => packs.get(language)?.translations?.[key] ?? packs.get(fallback)?.translations?.[key] ?? key;
+63
View File
@@ -142,6 +142,29 @@ curl --fail --show-error --request POST \
finally { certificateApiLoading = false; }
}
const defaults = { enabled: false, listen: "127.0.0.1:9191", path: "/metrics", allowed_cidrs: ["127.0.0.0/8", "::1/128"], bearer_token_env: null, allow_trusted_network_without_token: false, include_system: true, include_vhosts: true, include_traffic: true, include_backends: true, include_docker_vhosts: true, include_cache_domains: true, include_streams: true, include_logging: true };
const geoipDefaults = {
enabled: true, format: "csv_pair",
ipv4_url: "https://github.com/sapics/ip-location-db/releases/download/latest/user-country-ipv4.csv",
ipv6_url: "https://github.com/sapics/ip-location-db/releases/download/latest/user-country-ipv6.csv",
mmdb_url: "https://github.com/sapics/ip-location-db/releases/download/latest/user-country.mmdb", ipv4_checksum_url: null, ipv6_checksum_url: null, mmdb_checksum_url: null,
max_bytes: 268435456, interval_seconds: 86400
};
const legacyGeoipRelease = "https://github.com/sapics/ip-location-db/releases/download/latest/";
const legacyGeoipChecksum = "https://github.com/sapics/ip-location-db/releases/download/checksum/";
function normalizedGeoip(raw = {}) {
const geoip = { ...geoipDefaults, ...raw };
if (!raw.ipv4_url && raw.release_url && raw.release_url !== legacyGeoipRelease) geoip.ipv4_url = `${raw.release_url.replace(/\/?$/, "/")}user-country-ipv4.csv`;
if (!raw.ipv6_url && raw.release_url && raw.release_url !== legacyGeoipRelease) geoip.ipv6_url = `${raw.release_url.replace(/\/?$/, "/")}user-country-ipv6.csv`;
if (!raw.ipv4_checksum_url && raw.checksum_url && raw.checksum_url !== legacyGeoipChecksum) geoip.ipv4_checksum_url = `${raw.checksum_url.replace(/\/?$/, "/")}user-country-ipv4.csv.sha256`;
if (!raw.ipv6_checksum_url && raw.checksum_url && raw.checksum_url !== legacyGeoipChecksum) geoip.ipv6_checksum_url = `${raw.checksum_url.replace(/\/?$/, "/")}user-country-ipv6.csv.sha256`;
return geoip;
}
function syncGeoipFormat() {
const mmdb = $("geoip-format").value === "mmdb";
$("geoip-csv-fields").hidden = mmdb; $("geoip-mmdb-fields").hidden = !mmdb;
$("geoip-ipv4-url").required = !mmdb; $("geoip-ipv6-url").required = !mmdb;
$("geoip-mmdb-url").required = mmdb && $("geoip-enabled").checked;
}
function example() {
const addr = $("prom-listen").value.trim(), path = $("prom-path").value.trim(), env = $("prom-token-env").value.trim();
$("prom-scrape-example").textContent = `scrape_configs:\n - job_name: rustproxy\n metrics_path: ${JSON.stringify(path)}\n static_configs:\n - targets: [${JSON.stringify(addr)}]${env ? "\n authorization:\n credentials_file: /etc/prometheus/rustproxy.token" : ""}`;
@@ -156,6 +179,22 @@ curl --fail --show-error --request POST \
const status = latestStatus?.prometheus;
$("prom-status").textContent = status?.restart_required ? t("restartPending") : status?.active?.enabled ? `${t("prometheusActive")}: ${status.active.listen}${p.path}` : t("prometheusDisabled"); example();
const geoip = normalizedGeoip(envelope.config.geoip_updates || {});
if (!settingsDirty.has("geoip-form")) {
$("geoip-enabled").checked = geoip.enabled !== false; $("geoip-format").value = geoip.format || "csv_pair";
$("geoip-ipv4-url").value = geoip.ipv4_url || ""; $("geoip-ipv6-url").value = geoip.ipv6_url || "";
$("geoip-mmdb-url").value = geoip.mmdb_url || "";
$("geoip-ipv4-checksum-url").value = geoip.ipv4_checksum_url || ""; $("geoip-ipv6-checksum-url").value = geoip.ipv6_checksum_url || "";
$("geoip-mmdb-checksum-url").value = geoip.mmdb_checksum_url || "";
$("geoip-max-mib").value = Number(geoip.max_bytes || geoipDefaults.max_bytes) / 1048576;
$("geoip-interval-hours").value = Number(geoip.interval_seconds || geoipDefaults.interval_seconds) / 3600;
syncGeoipFormat();
}
const geoStatus = latestStatus?.geoip;
$("geoip-status").textContent = geoStatus?.loaded
? geoStatus.format === "mmdb" ? `${t("geoipStatusLoaded")}: ${geoStatus.source}` : `${t("geoipStatusLoaded")}: IPv4 ${fmt(geoStatus.ipv4_ranges)} · IPv6 ${fmt(geoStatus.ipv6_ranges)}`
: geoip.enabled ? t("geoipStatusMissing") : t("geoipStatusDisabled");
const admin = envelope.config.administration || {}, adminStatus = latestStatus?.administration;
if (!settingsDirty.has("administration-form")) {
$("admin-listen").value = adminStatus?.locked_listen || admin.listen || adminStatus?.desired?.listen || latestStatus?.admin_listen || "127.0.0.1:9090";
@@ -186,6 +225,30 @@ curl --fail --show-error --request POST \
$("tls-policy-available").textContent = available.length ? `${t("tlsAvailableCiphers")}: ${available.join(", ")}` : "";
renderCertificateApi(); loadCertificateApi();
}
$("geoip-format").addEventListener("change", syncGeoipFormat); $("geoip-enabled").addEventListener("change", syncGeoipFormat);
bindSettingsForm("geoip-form", next => {
next.geoip_updates = {
enabled: $("geoip-enabled").checked, format: $("geoip-format").value,
ipv4_url: $("geoip-ipv4-url").value.trim(), ipv6_url: $("geoip-ipv6-url").value.trim(), mmdb_url: $("geoip-mmdb-url").value.trim(),
ipv4_checksum_url: $("geoip-ipv4-checksum-url").value.trim() || null,
ipv6_checksum_url: $("geoip-ipv6-checksum-url").value.trim() || null,
mmdb_checksum_url: $("geoip-mmdb-checksum-url").value.trim() || null,
max_bytes: Math.round(Number($("geoip-max-mib").value) * 1048576),
interval_seconds: Math.round(Number($("geoip-interval-hours").value) * 3600)
};
}, "geoip", next => next.geoip_updates);
$("geoip-update-now").addEventListener("click", async () => {
if (settingsDirty.has("geoip-form")) { toast(t("geoipUpdateSaveFirst"), true); return; }
const button = $("geoip-update-now"); button.disabled = true;
$("geoip-status").textContent = t("geoipUpdateWorking");
try {
const result = await api("/api/geoip/update", { method: "POST" });
if (result.database) latestStatus = { ...(latestStatus || {}), geoip: result.database };
render();
toast(t(result.outcome === "updated" ? "geoipUpdateUpdated" : result.outcome === "busy" ? "geoipUpdateBusy" : "geoipUpdateCurrent"));
} catch (error) { toast(error.message, true); render(); }
finally { button.disabled = false; }
});
bindSettingsForm("prometheus-form", next => {
next.prometheus = {
enabled: $("prom-enabled").checked, listen: $("prom-listen").value.trim(), path: $("prom-path").value.trim(), allowed_cidrs: $("prom-cidrs").value.split(/[\n,]/).map(v => v.trim()).filter(Boolean), bearer_token_env: $("prom-token-env").value.trim() || null, allow_trusted_network_without_token: $("prom-trusted-no-token").checked,
+69 -3
View File
@@ -247,13 +247,13 @@ h3 {
.shell {
display: grid;
grid-template-columns: 218px minmax(0, 1fr);
min-height: 100vh
min-height: calc(100vh / var(--ui-scale, 1))
}
.sidebar {
position: sticky;
top: 0;
height: 100vh;
height: calc(100vh / var(--ui-scale, 1));
background: var(--sidebar-bg);
border-right: 1px solid var(--line);
padding: 34px 22px;
@@ -9646,4 +9646,70 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
.acl-policy-step-head>button {
width: 100%
}
}
}
.listener-active-summary {
margin-top: 10px;
padding: 10px 12px;
border: 1px solid var(--line);
background: var(--panel2);
display: grid;
gap: 8px
}
.listener-active-title {
font-size: 10px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: .45px
}
.listener-active-items {
display: flex;
flex-wrap: wrap;
gap: 8px
}
.listener-active-item {
display: inline-flex;
align-items: center;
gap: 7px;
min-width: 0;
padding: 7px 9px;
border: 1px solid var(--line);
background: var(--panel)
}
.listener-active-protocol {
font: 600 10px var(--mono);
color: var(--accent)
}
.listener-active-address {
overflow-wrap: anywhere
}
.listener-active-feature {
padding: 2px 5px;
border: 1px solid var(--line);
font: 600 9px var(--mono);
color: var(--muted)
}
.cache-via-setting {
display: grid;
grid-template-columns: minmax(180px, .8fr) minmax(220px, 1.2fr);
gap: 8px 14px;
align-items: end;
margin-top: 10px
}
.location-header-editor .vh-header-rule-head > button {
margin-left: auto;
flex: 0 0 auto
}
@media(max-width:650px) {
.cache-via-setting {
grid-template-columns: 1fr
}
}
+1 -1
View File
@@ -97,7 +97,7 @@
target.value = JSON.stringify(result, null, 2); target.setCustomValidity(""); onChange();
};
for (const direction of ["request", "response"]) {
const block = el("section", undefined, "vh-wide location-header-direction"); block.dataset.headerDirection = direction;
const block = el("section", undefined, "vh-wide location-header-direction location-header-editor"); block.dataset.headerDirection = direction;
const heading = el("div", undefined, "location-header-direction-head");
heading.append(text("h3", direction === "response" ? "locationResponseHeaders" : "locationProxyHeaders"), text("p", "headerRuleHelp", "fine"));
block.append(heading);
+1
View File
@@ -3,6 +3,7 @@
//! logs, sockets and temporary runtime state.
include!("backup/archive.rs");
include!("backup/ownership.rs");
include!("backup/snapshot.rs");
include!("backup/validation.rs");
+56
View File
@@ -0,0 +1,56 @@
#[cfg(unix)]
#[derive(Clone, Copy)]
struct TreeOwner {
uid: u32,
gid: u32,
}
#[cfg(unix)]
fn tree_owner(path: &Path) -> Result<TreeOwner> {
use std::os::unix::fs::MetadataExt;
let metadata = fs::symlink_metadata(path)?;
Ok(TreeOwner {
uid: metadata.uid(),
gid: metadata.gid(),
})
}
#[cfg(not(unix))]
#[derive(Clone, Copy)]
struct TreeOwner;
#[cfg(not(unix))]
fn tree_owner(_path: &Path) -> Result<TreeOwner> {
Ok(TreeOwner)
}
#[cfg(unix)]
fn set_tree_owner(path: &Path, owner: TreeOwner) -> Result<()> {
use std::{ffi::CString, os::unix::ffi::OsStrExt};
fn visit(path: &Path, owner: TreeOwner) -> Result<()> {
let metadata = fs::symlink_metadata(path)?;
if metadata.is_dir() && !metadata.file_type().is_symlink() {
for entry in fs::read_dir(path)? {
visit(&entry?.path(), owner)?;
}
}
let raw = CString::new(path.as_os_str().as_bytes())
.context("restore path contains an embedded NUL")?;
// lchown changes the link itself instead of following a restored symlink.
let rc = unsafe { libc::lchown(raw.as_ptr(), owner.uid, owner.gid) };
if rc != 0 {
return Err(std::io::Error::last_os_error())
.with_context(|| format!("restore ownership update failed for {}", path.display()));
}
Ok(())
}
visit(path, owner)
}
#[cfg(not(unix))]
fn set_tree_owner(_path: &Path, _owner: TreeOwner) -> Result<()> {
Ok(())
}
+1
View File
@@ -1,5 +1,6 @@
fn excluded_file(name: &str) -> bool {
name == "process.lock"
|| name == ".geoip-update.lock"
|| name == "config.sqlite3"
|| name == "config.sqlite3-wal"
|| name == "config.sqlite3-shm"
+25
View File
@@ -33,6 +33,7 @@ mod tests {
fn backup_excludes_rebuildable_managed_python_runtime() {
assert!(excluded_directory("acme-python"));
assert!(excluded_directory("acme_python"));
assert!(excluded_file(".geoip-update.lock"));
assert!(!excluded_directory("certs"));
}
@@ -84,6 +85,30 @@ mod tests {
assert_eq!(fs::read(restored_link).unwrap(), b"certificate-v1");
}
#[cfg(unix)]
#[test]
fn restore_owner_is_applied_recursively() {
use std::os::unix::fs::MetadataExt;
if unsafe { libc::geteuid() } != 0 {
return;
}
let dir = tempfile::tempdir().unwrap();
let stage = dir.path().join("stage");
fs::create_dir(&stage).unwrap();
fs::write(stage.join("config.sqlite3"), b"test").unwrap();
let owner = TreeOwner {
uid: 12_345,
gid: 12_346,
};
set_tree_owner(&stage, owner).unwrap();
let root = fs::symlink_metadata(&stage).unwrap();
let file = fs::symlink_metadata(stage.join("config.sqlite3")).unwrap();
assert_eq!((root.uid(), root.gid()), (owner.uid, owner.gid));
assert_eq!((file.uid(), file.gid()), (owner.uid, owner.gid));
}
#[test]
fn symlink_targets_must_remain_inside_data_directory() {
assert!(validate_symlink_target(
+8
View File
@@ -360,6 +360,11 @@ pub async fn restore(archive: &Path, data_dir: &Path, password: &str) -> Result<
);
let parent = parent_or_dot(data_dir);
ensure!(parent.is_dir(), "restore parent directory does not exist");
let restore_owner = if data_dir.exists() {
Some(tree_owner(data_dir)?)
} else {
None
};
let old_lock = if data_dir.exists() {
crate::auth::prepare_directory(data_dir)?;
Some(crate::auth::lock_directory(data_dir).context("stop RustProxy before restore")?)
@@ -393,6 +398,9 @@ pub async fn restore(archive: &Path, data_dir: &Path, password: &str) -> Result<
let _ = fs::remove_file(PathBuf::from(name));
}
let stage_lock = crate::auth::lock_directory(&stage)?;
if let Some(owner) = restore_owner {
set_tree_owner(&stage, owner)?;
}
let previous = temp.0.join("previous-data");
if data_dir.exists() {
fs::rename(data_dir, &previous).context("could not move current data directory aside")?;
+15
View File
@@ -126,6 +126,15 @@ impl Settings {
(1..=64).contains(&self.proactive_refresh_max_inflight),
"cache.proactive_refresh_max_inflight must be 1..64"
);
if let Some(value) = &self.via_header {
ensure!(
!value.is_empty()
&& value.len() <= 256
&& value.bytes().all(|byte| byte >= 0x20 && byte != 0x7f)
&& value.parse::<HeaderValue>().is_ok(),
"cache.via_header must be a valid 1..256 byte HTTP header value"
);
}
Ok(())
}
pub fn active_for(&self, route: Option<bool>) -> bool {
@@ -173,6 +182,12 @@ impl Settings {
if !self.enabled {
return;
}
if let Some(value) = &self.via_header {
headers.append(
http::header::VIA,
value.parse::<HeaderValue>().expect("validated cache Via header"),
);
}
if self.expose_status {
headers.insert("x-cache", HeaderValue::from_static(outcome));
}
+4
View File
@@ -173,6 +173,9 @@ pub struct Settings {
pub expose_hits: bool,
pub expose_cache_status: bool,
pub expose_resident_age: bool,
/// Optional response Via field value added by RustProxy cache handling.
/// `None` disables the header; the default is `Via: RustProxy`.
pub via_header: Option<String>,
}
impl Default for Settings {
fn default() -> Self {
@@ -240,6 +243,7 @@ impl Default for Settings {
expose_hits: false,
expose_cache_status: false,
expose_resident_age: false,
via_header: Some("RustProxy".into()),
}
}
}
+19
View File
@@ -126,6 +126,25 @@ mod dashboard_tests {
"RustProxy; fwd=bypass; detail=\"request_cookie\"; transformed"
);
}
#[test]
fn via_header_is_configurable_and_preserves_upstream_chain() {
let settings = Settings::default();
let mut headers = HeaderMap::new();
headers.append("via", HeaderValue::from_static("1.1 upstream"));
settings.annotate(&mut headers, "HIT", 1, 20);
let values: Vec<_> = headers
.get_all("via")
.iter()
.map(|value| value.to_str().unwrap())
.collect();
assert_eq!(values, vec!["1.1 upstream", "RustProxy"]);
let disabled = Settings { via_header: None, ..Settings::default() };
let mut headers = HeaderMap::new();
disabled.annotate(&mut headers, "MISS", 0, 0);
assert!(!headers.contains_key("via"));
}
#[test]
fn disabled_cache_strips_diagnostics_without_exposing_bypass_headers() {
let settings = Settings {
+1
View File
@@ -41,6 +41,7 @@ pub fn compile_with_active(
}
}
config.prometheus.validate()?;
config.geoip_updates.validate()?;
config.cache.validate()?;
config.transform.validate()?;
config.pages.validate()?;
+2
View File
@@ -33,6 +33,8 @@ pub struct Config {
#[serde(default)]
pub prometheus: crate::prometheus::Settings,
#[serde(default)]
pub geoip_updates: crate::geoip::UpdateSettings,
#[serde(default)]
pub listeners: crate::settings::Listeners,
/// Legacy/default HTTP/2 lifecycle limits for listeners that do not yet
/// persist an explicit `binding.http2_settings` object.
+93 -30
View File
@@ -1,8 +1,16 @@
//! Minimal reader for sapics/ip-location-db user-country CSV files.
//! Installer verification pins each downloaded file to the publisher-provided
//! SHA-256 checksum; runtime parsing stays dependency-free and bounded.
//! Bounded country GeoIP reader for direct CSV pairs and native MaxMind DB files.
//! Managed downloads use direct HTTPS sources; publisher checksum URLs are optional.
mod mmdb;
mod update;
mod update_http;
pub use update::{
UpdateResult, UpdateSettings, DEFAULT_INTERVAL_SECONDS, DEFAULT_IPV4_URL, DEFAULT_IPV6_URL,
DEFAULT_MAX_BYTES, DEFAULT_MMDB_URL,
};
pub(crate) use update::{poll_loop as update_poll_loop, update_now};
use anyhow::{bail, ensure, Context, Result};
use serde::Serialize;
use serde::{Deserialize, Serialize};
use std::{
fs::File,
io::{BufRead, BufReader},
@@ -25,52 +33,101 @@ struct V6Range {
end: u128,
country: [u8; 2],
}
#[derive(Debug, Default)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DatabaseFormat {
CsvPair,
Mmdb,
}
impl Default for DatabaseFormat {
fn default() -> Self {
Self::CsvPair
}
}
#[derive(Debug)]
enum Backend {
Csv { ipv4: Vec<V4Range>, ipv6: Vec<V6Range> },
Mmdb(mmdb::Database),
}
#[derive(Debug)]
pub struct Database {
ipv4: Vec<V4Range>,
ipv6: Vec<V6Range>,
backend: Backend,
}
#[derive(Debug, Clone, Serialize)]
pub struct View {
pub loaded: bool,
pub ipv4_ranges: usize,
pub ipv6_ranges: usize,
pub source: &'static str,
pub source: String,
pub format: &'static str,
}
impl Database {
pub fn empty() -> Self {
Self::default()
Self { backend: Backend::Csv { ipv4: Vec::new(), ipv6: Vec::new() } }
}
pub fn load(directory: &Path) -> Result<Self> {
let ipv4 = parse_v4(&directory.join("user-country-ipv4.csv"))?;
let ipv6 = parse_v6(&directory.join("user-country-ipv6.csv"))?;
ensure!(
!ipv4.is_empty() && !ipv6.is_empty(),
"GeoIP country database is empty"
);
Ok(Self { ipv4, ipv6 })
if directory.join("country.mmdb").is_file() {
Self::load_format(directory, DatabaseFormat::Mmdb)
} else {
Self::load_format(directory, DatabaseFormat::CsvPair)
}
}
pub fn load_format(directory: &Path, format: DatabaseFormat) -> Result<Self> {
match format {
DatabaseFormat::CsvPair => {
let ipv4 = parse_v4(&directory.join("user-country-ipv4.csv"))?;
let ipv6 = parse_v6(&directory.join("user-country-ipv6.csv"))?;
ensure!(!ipv4.is_empty() && !ipv6.is_empty(), "GeoIP country database is empty");
Ok(Self { backend: Backend::Csv { ipv4, ipv6 } })
}
DatabaseFormat::Mmdb => Ok(Self {
backend: Backend::Mmdb(mmdb::Database::load(&directory.join("country.mmdb"), MAX_FILE_BYTES)?),
}),
}
}
pub fn loaded(&self) -> bool {
!self.ipv4.is_empty() || !self.ipv6.is_empty()
match &self.backend {
Backend::Csv { ipv4, ipv6 } => !ipv4.is_empty() || !ipv6.is_empty(),
Backend::Mmdb(_) => true,
}
}
pub fn view(&self) -> View {
View {
loaded: self.loaded(),
ipv4_ranges: self.ipv4.len(),
ipv6_ranges: self.ipv6.len(),
source: "sapics/ip-location-db user-country (PDDL)",
match &self.backend {
Backend::Csv { ipv4, ipv6 } => View {
loaded: self.loaded(),
ipv4_ranges: ipv4.len(),
ipv6_ranges: ipv6.len(),
source: "CSV country ranges".into(),
format: "csv_pair",
},
Backend::Mmdb(database) => View {
loaded: true,
ipv4_ranges: 0,
ipv6_ranges: 0,
source: format!("MaxMind DB ({})", database.database_type()),
format: "mmdb",
},
}
}
pub fn country(&self, ip: IpAddr) -> Option<String> {
let code = match crate::vhost::canonical_ip(ip) {
IpAddr::V4(ip) => find_v4(&self.ipv4, u32::from(ip)),
IpAddr::V6(ip) => find_v6(&self.ipv6, u128::from(ip)),
}?;
Some(String::from_utf8_lossy(&code).into_owned())
let ip = crate::vhost::canonical_ip(ip);
match &self.backend {
Backend::Csv { ipv4, ipv6 } => {
let code = match ip {
IpAddr::V4(ip) => find_v4(ipv4, u32::from(ip)),
IpAddr::V6(ip) => find_v6(ipv6, u128::from(ip)),
}?;
Some(String::from_utf8_lossy(&code).into_owned())
}
Backend::Mmdb(database) => database.country(ip),
}
}
}
fn country_code(raw: &str) -> Option<[u8; 2]> {
pub(super) fn normalize_country_code(raw: &str) -> Option<[u8; 2]> {
let raw = raw.trim().trim_matches('"').as_bytes();
if raw.len() == 2 && raw.iter().all(|b| b.is_ascii_alphabetic()) {
Some([raw[0].to_ascii_uppercase(), raw[1].to_ascii_uppercase()])
@@ -129,7 +186,7 @@ fn parse_v4(path: &Path) -> Result<Vec<V4Range>> {
);
}
previous_end = Some(end);
if let Some(country) = country_code(country) {
if let Some(country) = normalize_country_code(country) {
result.push(V4Range {
start,
end,
@@ -168,7 +225,7 @@ fn parse_v6(path: &Path) -> Result<Vec<V6Range>> {
);
}
previous_end = Some(end);
if let Some(country) = country_code(country) {
if let Some(country) = normalize_country_code(country) {
result.push(V6Range {
start,
end,
@@ -200,5 +257,11 @@ pub fn validate_directory(directory: &Path) -> Result<()> {
.or_else(|error| bail!("GeoIP country database is invalid: {error:#}"))
}
pub fn validate_directory_format(directory: &Path, format: DatabaseFormat) -> Result<()> {
Database::load_format(directory, format)
.map(|_| ())
.or_else(|error| bail!("GeoIP country database is invalid: {error:#}"))
}
#[cfg(test)]
include!("geoip/tests.rs");
+334
View File
@@ -0,0 +1,334 @@
use anyhow::{bail, ensure, Context, Result};
use std::{fs, net::IpAddr, path::Path};
const METADATA_MARKER: &[u8] = b"\xab\xcd\xefMaxMind.com";
const MAX_METADATA_BYTES: usize = 128 * 1024;
const MAX_VALUES: usize = 65_536;
const MAX_DEPTH: usize = 512;
const MAX_STRING_BYTES: usize = 4096;
#[derive(Debug)]
pub(super) struct Database {
bytes: Vec<u8>,
node_count: u32,
record_size: u16,
ip_version: u16,
search_tree_size: usize,
data_section_start: usize,
metadata_offset: usize,
database_type: String,
}
#[derive(Clone, Copy)]
struct Header {
kind: u8,
size: usize,
payload: usize,
next: usize,
pointer: Option<usize>,
}
#[derive(Default)]
struct Budget {
values: usize,
}
impl Budget {
fn charge(&mut self) -> Result<()> {
self.values = self.values.saturating_add(1);
ensure!(self.values <= MAX_VALUES, "MMDB value traversal limit exceeded");
Ok(())
}
}
impl Database {
pub(super) fn load(path: &Path, max_bytes: u64) -> Result<Self> {
let metadata = fs::symlink_metadata(path)
.with_context(|| format!("open GeoIP MMDB {}", path.display()))?;
ensure!(metadata.is_file() && !metadata.file_type().is_symlink(), "GeoIP MMDB must be a regular file");
ensure!(metadata.len() <= max_bytes, "GeoIP MMDB exceeds configured maximum size");
let bytes = fs::read(path).with_context(|| format!("read GeoIP MMDB {}", path.display()))?;
let marker = metadata_marker(&bytes).context("MMDB metadata marker not found")?;
let metadata_offset = marker + METADATA_MARKER.len();
let base = metadata_offset;
let node_count = u32::try_from(read_uint_path(&bytes, metadata_offset, base, &["node_count"])?).context("MMDB node_count is too large")?;
let record_size = u16::try_from(read_uint_path(&bytes, metadata_offset, base, &["record_size"])?).context("MMDB record_size is too large")?;
let ip_version = u16::try_from(read_uint_path(&bytes, metadata_offset, base, &["ip_version"])?).context("MMDB ip_version is too large")?;
let major = read_uint_path(&bytes, metadata_offset, base, &["binary_format_major_version"])?;
let database_type = read_string_path(&bytes, metadata_offset, base, &["database_type"])?;
ensure!(node_count > 0, "MMDB node_count must be positive");
ensure!(matches!(record_size, 24 | 28 | 32), "unsupported MMDB record_size {record_size}");
ensure!(matches!(ip_version, 4 | 6), "invalid MMDB ip_version {ip_version}");
ensure!(major == 2, "unsupported MMDB major format version {major}");
let node_bytes = usize::from(record_size)
.checked_mul(2)
.and_then(|bits| bits.checked_div(8))
.context("MMDB node size overflow")?;
let search_tree_size = (node_count as usize)
.checked_mul(node_bytes)
.context("MMDB search tree size overflow")?;
let data_section_start = search_tree_size.checked_add(16).context("MMDB data section overflow")?;
ensure!(data_section_start <= marker, "MMDB search tree overlaps metadata");
ensure!(bytes.get(search_tree_size..data_section_start) == Some(&[0u8; 16][..]), "MMDB data-section separator is invalid");
Ok(Self {
bytes,
node_count,
record_size,
ip_version,
search_tree_size,
data_section_start,
metadata_offset,
database_type,
})
}
pub(super) fn database_type(&self) -> &str {
&self.database_type
}
pub(super) fn country(&self, ip: IpAddr) -> Option<String> {
let offset = self.lookup_offset(ip).ok().flatten()?;
for path in [
&["country", "iso_code"][..],
&["registered_country", "iso_code"][..],
&["country_code"][..],
] {
if let Ok(code) = read_string_path(&self.bytes, offset, self.data_section_start, path) {
if let Some(code) = super::normalize_country_code(&code) {
return Some(String::from_utf8_lossy(&code).into_owned());
}
}
}
let mut budget = Budget::default();
if let Ok(code) = read_string(&self.bytes, offset, self.data_section_start, 0, &mut budget) {
if let Some(code) = super::normalize_country_code(&code) {
return Some(String::from_utf8_lossy(&code).into_owned());
}
}
None
}
fn lookup_offset(&self, ip: IpAddr) -> Result<Option<usize>> {
let (bytes, bits): ([u8; 16], usize) = match (self.ip_version, ip) {
(4, IpAddr::V4(value)) => {
let mut out = [0u8; 16];
out[..4].copy_from_slice(&value.octets());
(out, 32)
}
(4, IpAddr::V6(_)) => return Ok(None),
(6, IpAddr::V4(value)) => {
let mut out = [0u8; 16];
out[12..].copy_from_slice(&value.octets());
(out, 128)
}
(6, IpAddr::V6(value)) => (value.octets(), 128),
_ => bail!("invalid MMDB IP version"),
};
let mut node = 0u32;
for bit_index in 0..bits {
if node >= self.node_count {
break;
}
let bit = (bytes[bit_index / 8] >> (7 - (bit_index % 8))) & 1;
node = self.read_node(node, bit as usize)?;
}
if node == self.node_count {
return Ok(None);
}
ensure!(node >= self.node_count.saturating_add(16), "invalid MMDB data pointer");
let offset = (node as usize)
.checked_sub(self.node_count as usize)
.and_then(|value| value.checked_add(self.search_tree_size))
.context("MMDB data pointer overflow")?;
ensure!(offset >= self.data_section_start && offset < self.metadata_offset - METADATA_MARKER.len(), "MMDB data pointer is out of bounds");
Ok(Some(offset))
}
fn read_node(&self, node: u32, side: usize) -> Result<u32> {
ensure!((node as usize) < self.node_count as usize, "MMDB node is out of range");
let base = match self.record_size {
24 => node as usize * 6,
28 => node as usize * 7,
32 => node as usize * 8,
_ => unreachable!(),
};
let value = match self.record_size {
24 => read_be(&self.bytes, base + side * 3, 3)? as u32,
28 => {
let middle = *self.bytes.get(base + 3).context("MMDB node is truncated")?;
if side == 0 {
(((middle & 0xf0) as u32) << 20) | read_be(&self.bytes, base, 3)? as u32
} else {
(((middle & 0x0f) as u32) << 24) | read_be(&self.bytes, base + 4, 3)? as u32
}
}
32 => read_be(&self.bytes, base + side * 4, 4)? as u32,
_ => unreachable!(),
};
Ok(value)
}
}
fn metadata_marker(bytes: &[u8]) -> Option<usize> {
let start = bytes.len().saturating_sub(MAX_METADATA_BYTES + METADATA_MARKER.len());
bytes[start..]
.windows(METADATA_MARKER.len())
.rposition(|window| window == METADATA_MARKER)
.map(|offset| start + offset)
}
fn read_uint_path(bytes: &[u8], offset: usize, base: usize, path: &[&str]) -> Result<u64> {
let mut budget = Budget::default();
let value = find_path(bytes, offset, base, path, 0, &mut budget)?.context("MMDB metadata field missing")?;
read_uint(bytes, value, base, 0, &mut budget)
}
fn read_string_path(bytes: &[u8], offset: usize, base: usize, path: &[&str]) -> Result<String> {
let mut budget = Budget::default();
let value = find_path(bytes, offset, base, path, 0, &mut budget)?.context("MMDB string field missing")?;
read_string(bytes, value, base, 0, &mut budget)
}
fn find_path(bytes: &[u8], offset: usize, base: usize, path: &[&str], depth: usize, budget: &mut Budget) -> Result<Option<usize>> {
ensure!(depth <= MAX_DEPTH, "MMDB nesting limit exceeded");
budget.charge()?;
let header = header(bytes, offset, base)?;
if let Some(pointer) = header.pointer {
return find_path(bytes, pointer, base, path, depth + 1, budget);
}
if path.is_empty() {
return Ok(Some(offset));
}
ensure!(header.kind == 7, "MMDB selected value is not a map");
let mut cursor = header.payload;
for _ in 0..header.size {
let (key, next) = inline_string(bytes, cursor, base)?;
cursor = next;
let value_offset = cursor;
if key == path[0] {
if path.len() == 1 {
return Ok(Some(value_offset));
}
if let Some(found) = find_path(bytes, value_offset, base, &path[1..], depth + 1, budget)? {
return Ok(Some(found));
}
}
cursor = skip(bytes, value_offset, base, depth + 1, budget)?;
}
Ok(None)
}
fn read_string(bytes: &[u8], offset: usize, base: usize, depth: usize, budget: &mut Budget) -> Result<String> {
ensure!(depth <= MAX_DEPTH, "MMDB nesting limit exceeded");
budget.charge()?;
let h = header(bytes, offset, base)?;
if let Some(pointer) = h.pointer {
return read_string(bytes, pointer, base, depth + 1, budget);
}
ensure!(h.kind == 2, "MMDB value is not a string");
ensure!(h.size <= MAX_STRING_BYTES, "MMDB string exceeds the decode limit");
let data = bytes.get(h.payload..h.next).context("MMDB string is truncated")?;
Ok(std::str::from_utf8(data).context("MMDB string is not UTF-8")?.to_owned())
}
fn read_uint(bytes: &[u8], offset: usize, base: usize, depth: usize, budget: &mut Budget) -> Result<u64> {
ensure!(depth <= MAX_DEPTH, "MMDB nesting limit exceeded");
budget.charge()?;
let h = header(bytes, offset, base)?;
if let Some(pointer) = h.pointer {
return read_uint(bytes, pointer, base, depth + 1, budget);
}
ensure!(matches!(h.kind, 5 | 6 | 9), "MMDB value is not an unsigned integer");
ensure!(h.size <= 8, "MMDB integer is too wide");
read_be(bytes, h.payload, h.size)
}
fn inline_string<'a>(bytes: &'a [u8], offset: usize, base: usize) -> Result<(&'a str, usize)> {
let h = header(bytes, offset, base)?;
ensure!(h.pointer.is_none() && h.kind == 2, "MMDB map key is not an inline string");
let value = bytes.get(h.payload..h.next).context("MMDB map key is truncated")?;
Ok((std::str::from_utf8(value).context("MMDB map key is not UTF-8")?, h.next))
}
fn skip(bytes: &[u8], offset: usize, base: usize, depth: usize, budget: &mut Budget) -> Result<usize> {
ensure!(depth <= MAX_DEPTH, "MMDB nesting limit exceeded");
budget.charge()?;
let h = header(bytes, offset, base)?;
if h.pointer.is_some() {
return Ok(h.next);
}
let mut cursor = h.payload;
match h.kind {
7 => {
for _ in 0..h.size {
cursor = skip(bytes, cursor, base, depth + 1, budget)?;
cursor = skip(bytes, cursor, base, depth + 1, budget)?;
}
Ok(cursor)
}
11 => {
for _ in 0..h.size {
cursor = skip(bytes, cursor, base, depth + 1, budget)?;
}
Ok(cursor)
}
14 => Ok(h.payload),
12 | 13 => bail!("unsupported deprecated MMDB data type {}", h.kind),
_ => Ok(h.next),
}
}
fn header(bytes: &[u8], offset: usize, base: usize) -> Result<Header> {
let control = *bytes.get(offset).context("MMDB value is truncated")?;
let primary = control >> 5;
let size_bits = (control & 0x1f) as usize;
if primary == 1 {
let class = (size_bits >> 3) & 0x03;
let low = size_bits & 0x07;
let count = class + 1;
let payload = offset + 1;
let raw = read_be(bytes, payload, count)? as usize;
let pointer = match class {
0 => (low << 8) | raw,
1 => ((low << 16) | raw).checked_add(2048).context("MMDB pointer overflow")?,
2 => ((low << 24) | raw).checked_add(526_336).context("MMDB pointer overflow")?,
3 => raw,
_ => unreachable!(),
};
let target = base.checked_add(pointer).context("MMDB pointer overflow")?;
ensure!(target < bytes.len(), "MMDB pointer is out of bounds");
return Ok(Header { kind: 1, size: 0, payload, next: payload + count, pointer: Some(target) });
}
let mut cursor = offset + 1;
let kind = if primary == 0 {
let extended = *bytes.get(cursor).context("MMDB extended type is truncated")?;
cursor += 1;
extended.checked_add(7).context("MMDB type overflow")?
} else {
primary
};
let size = match size_bits {
0..=28 => size_bits,
29 => 29 + *bytes.get(cursor).context("MMDB size is truncated")? as usize,
30 => 285 + read_be(bytes, cursor, 2)? as usize,
31 => 65_821 + read_be(bytes, cursor, 3)? as usize,
_ => unreachable!(),
};
cursor += match size_bits { 29 => 1, 30 => 2, 31 => 3, _ => 0 };
let next = match kind {
7 | 11 | 14 => cursor,
_ => cursor.checked_add(size).context("MMDB field size overflow")?,
};
ensure!(next <= bytes.len(), "MMDB field is out of bounds");
Ok(Header { kind, size, payload: cursor, next, pointer: None })
}
fn read_be(bytes: &[u8], offset: usize, count: usize) -> Result<u64> {
ensure!(count <= 8, "MMDB integer is too wide");
let end = offset.checked_add(count).context("MMDB read overflow")?;
let data = bytes.get(offset..end).context("MMDB value is truncated")?;
Ok(data.iter().fold(0u64, |value, byte| (value << 8) | u64::from(*byte)))
}
#[cfg(test)]
include!("mmdb_tests.rs");
+92
View File
@@ -0,0 +1,92 @@
#[cfg(test)]
mod tests {
use super::*;
fn string(value: &str) -> Vec<u8> {
assert!(value.len() <= 28);
let mut out = vec![0x40 | value.len() as u8];
out.extend_from_slice(value.as_bytes());
out
}
fn uint16(value: u8) -> [u8; 2] {
[0xa1, value]
}
fn uint32(value: u8) -> [u8; 2] {
[0xc1, value]
}
fn fixture_with_record(record: Vec<u8>) -> Vec<u8> {
let mut bytes = vec![0, 0, 17, 0, 0, 17];
bytes.extend_from_slice(&[0; 16]);
bytes.extend(record);
bytes.extend_from_slice(METADATA_MARKER);
bytes.push(0xe5);
bytes.extend(string("node_count"));
bytes.extend(uint32(1));
bytes.extend(string("record_size"));
bytes.extend(uint16(24));
bytes.extend(string("ip_version"));
bytes.extend(uint16(4));
bytes.extend(string("binary_format_major_version"));
bytes.extend(uint16(2));
bytes.extend(string("database_type"));
bytes.extend(string("GeoLite2-Country"));
bytes
}
fn fixture(country: &str) -> Vec<u8> {
let mut record = vec![0xe1];
record.extend(string("country"));
record.push(0xe1);
record.extend(string("iso_code"));
record.extend(string(country));
fixture_with_record(record)
}
fn sapics_fixture(country: &str) -> Vec<u8> {
let mut record = vec![0xe1];
record.extend(string("country_code"));
record.extend(string(country));
fixture_with_record(record)
}
#[test]
fn reads_standard_country_iso_code_from_mmdb() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("country.mmdb");
std::fs::write(&path, fixture("PL")).unwrap();
let database = Database::load(&path, 1024 * 1024).unwrap();
assert_eq!(database.database_type(), "GeoLite2-Country");
assert_eq!(database.country("192.0.2.1".parse().unwrap()).as_deref(), Some("PL"));
assert!(database.country("2001:db8::1".parse().unwrap()).is_none());
}
#[test]
fn reads_sapics_country_code_from_mmdb() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("country.mmdb");
std::fs::write(&path, sapics_fixture("DE")).unwrap();
let database = Database::load(&path, 1024 * 1024).unwrap();
assert_eq!(database.country("198.51.100.8".parse().unwrap()).as_deref(), Some("DE"));
}
#[test]
fn reads_root_country_code_string_from_custom_mmdb() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("country.mmdb");
std::fs::write(&path, fixture_with_record(string("FR"))).unwrap();
let database = Database::load(&path, 1024 * 1024).unwrap();
assert_eq!(database.country("192.0.2.44".parse().unwrap()).as_deref(), Some("FR"));
}
#[test]
fn rejects_invalid_country_code_without_breaking_lookup() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("country.mmdb");
std::fs::write(&path, fixture("POL")).unwrap();
let database = Database::load(&path, 1024 * 1024).unwrap();
assert!(database.country("203.0.113.4".parse().unwrap()).is_none());
}
}
+14 -12
View File
@@ -1,19 +1,21 @@
mod tests {
use super::*;
#[test]
fn range_lookup_handles_gaps() {
let db = Database {
ipv4: vec![V4Range {
start: u32::from(Ipv4Addr::new(192, 0, 2, 0)),
end: u32::from(Ipv4Addr::new(192, 0, 2, 255)),
country: *b"PL",
}],
ipv6: vec![],
};
assert_eq!(
db.country("192.0.2.7".parse().unwrap()).as_deref(),
Some("PL")
);
let directory = tempfile::tempdir().unwrap();
std::fs::write(
directory.path().join("user-country-ipv4.csv"),
"192.0.2.0,192.0.2.255,PL\n",
)
.unwrap();
std::fs::write(
directory.path().join("user-country-ipv6.csv"),
"2001:db8::,2001:db8::ffff,PL\n",
)
.unwrap();
let db = Database::load_format(directory.path(), DatabaseFormat::CsvPair).unwrap();
assert_eq!(db.country("192.0.2.7".parse().unwrap()).as_deref(), Some("PL"));
assert!(db.country("198.51.100.1".parse().unwrap()).is_none());
}
}
+442
View File
@@ -0,0 +1,442 @@
use anyhow::{ensure, Context, Result};
use fs2::FileExt;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{
fs::{self, File, OpenOptions},
io::Read,
path::{Path, PathBuf},
sync::{Arc, Weak},
time::Duration,
};
use tokio_util::sync::CancellationToken;
use url::Url;
pub const DEFAULT_IPV4_URL: &str =
"https://github.com/sapics/ip-location-db/releases/download/latest/user-country-ipv4.csv";
pub const DEFAULT_IPV6_URL: &str =
"https://github.com/sapics/ip-location-db/releases/download/latest/user-country-ipv6.csv";
pub const DEFAULT_MMDB_URL: &str =
"https://github.com/sapics/ip-location-db/releases/download/latest/user-country.mmdb";
pub const DEFAULT_MAX_BYTES: u64 = 256 * 1024 * 1024;
pub const DEFAULT_INTERVAL_SECONDS: u64 = 24 * 60 * 60;
const LEGACY_RELEASE_URL: &str =
"https://github.com/sapics/ip-location-db/releases/download/latest/";
const LEGACY_CHECKSUM_URL: &str =
"https://github.com/sapics/ip-location-db/releases/download/checksum/";
const MAX_CONFIGURED_BYTES: u64 = DEFAULT_MAX_BYTES;
const MAX_CHECKSUM_BYTES: usize = 4096;
const RETRY_INTERVAL: Duration = Duration::from_secs(15 * 60);
const CONFIG_POLL_INTERVAL: Duration = Duration::from_secs(60);
const LOCK_FILE: &str = ".geoip-update.lock";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct UpdateSettings {
pub enabled: bool,
pub format: super::DatabaseFormat,
pub ipv4_url: String,
pub ipv6_url: String,
pub mmdb_url: String,
pub ipv4_checksum_url: Option<String>,
pub ipv6_checksum_url: Option<String>,
pub mmdb_checksum_url: Option<String>,
pub max_bytes: u64,
pub interval_seconds: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub release_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub checksum_url: Option<String>,
}
impl Default for UpdateSettings {
fn default() -> Self {
Self {
enabled: true,
format: super::DatabaseFormat::CsvPair,
ipv4_url: DEFAULT_IPV4_URL.into(),
ipv6_url: DEFAULT_IPV6_URL.into(),
mmdb_url: DEFAULT_MMDB_URL.into(),
ipv4_checksum_url: None,
ipv6_checksum_url: None,
mmdb_checksum_url: None,
max_bytes: DEFAULT_MAX_BYTES,
interval_seconds: DEFAULT_INTERVAL_SECONDS,
release_url: None,
checksum_url: None,
}
}
}
impl UpdateSettings {
pub fn validate(&mut self) -> Result<()> {
self.migrate_legacy()?;
self.ipv4_url = self.ipv4_url.trim().to_owned();
self.ipv6_url = self.ipv6_url.trim().to_owned();
self.mmdb_url = self.mmdb_url.trim().to_owned();
match self.format {
super::DatabaseFormat::CsvPair => {
self.ipv4_url = validate_direct_url("GeoIP IPv4 URL", &self.ipv4_url)?;
self.ipv6_url = validate_direct_url("GeoIP IPv6 URL", &self.ipv6_url)?;
if !self.mmdb_url.is_empty() {
self.mmdb_url = validate_direct_url("GeoIP MMDB URL", &self.mmdb_url)?;
}
}
super::DatabaseFormat::Mmdb => {
if self.enabled || !self.mmdb_url.is_empty() {
self.mmdb_url = validate_direct_url("GeoIP MMDB URL", &self.mmdb_url)?;
}
if !self.ipv4_url.is_empty() {
self.ipv4_url = validate_direct_url("GeoIP IPv4 URL", &self.ipv4_url)?;
}
if !self.ipv6_url.is_empty() {
self.ipv6_url = validate_direct_url("GeoIP IPv6 URL", &self.ipv6_url)?;
}
}
}
self.ipv4_checksum_url = validate_optional_url("GeoIP IPv4 checksum URL", self.ipv4_checksum_url.take())?;
self.ipv6_checksum_url = validate_optional_url("GeoIP IPv6 checksum URL", self.ipv6_checksum_url.take())?;
self.mmdb_checksum_url = validate_optional_url("GeoIP MMDB checksum URL", self.mmdb_checksum_url.take())?;
ensure!(
(1024 * 1024..=MAX_CONFIGURED_BYTES).contains(&self.max_bytes),
"GeoIP maximum file size must be between 1 MiB and 256 MiB"
);
ensure!(
(60..=31 * 24 * 60 * 60).contains(&self.interval_seconds),
"GeoIP update interval must be between 60 seconds and 31 days"
);
Ok(())
}
fn migrate_legacy(&mut self) -> Result<()> {
if let Some(release) = self.release_url.take() {
let base = validate_legacy_base("GeoIP legacy release URL", &release)?;
if base != LEGACY_RELEASE_URL
&& self.ipv4_url == DEFAULT_IPV4_URL
&& self.ipv6_url == DEFAULT_IPV6_URL
{
self.ipv4_url = format!("{base}user-country-ipv4.csv");
self.ipv6_url = format!("{base}user-country-ipv6.csv");
}
}
if let Some(checksum) = self.checksum_url.take() {
let base = validate_legacy_base("GeoIP legacy checksum URL", &checksum)?;
if base != LEGACY_CHECKSUM_URL
&& self.ipv4_checksum_url.is_none()
&& self.ipv6_checksum_url.is_none()
{
self.ipv4_checksum_url = Some(format!("{base}user-country-ipv4.csv.sha256"));
self.ipv6_checksum_url = Some(format!("{base}user-country-ipv6.csv.sha256"));
}
}
Ok(())
}
}
#[derive(Clone)]
struct Asset {
name: &'static str,
url: String,
checksum_url: Option<String>,
}
fn assets(settings: &UpdateSettings) -> Vec<Asset> {
match settings.format {
super::DatabaseFormat::CsvPair => vec![
Asset {
name: "user-country-ipv4.csv",
url: settings.ipv4_url.clone(),
checksum_url: settings.ipv4_checksum_url.clone(),
},
Asset {
name: "user-country-ipv6.csv",
url: settings.ipv6_url.clone(),
checksum_url: settings.ipv6_checksum_url.clone(),
},
],
super::DatabaseFormat::Mmdb => vec![Asset {
name: "country.mmdb",
url: settings.mmdb_url.clone(),
checksum_url: settings.mmdb_checksum_url.clone(),
}],
}
}
fn validate_direct_url(label: &str, value: &str) -> Result<String> {
let parsed = Url::parse(value).with_context(|| format!("invalid {label}"))?;
ensure!(parsed.scheme() == "https", "{label} must use https://");
ensure!(parsed.host_str().is_some(), "{label} must contain a host");
ensure!(parsed.username().is_empty() && parsed.password().is_none(), "{label} must not contain URL credentials");
ensure!(parsed.fragment().is_none(), "{label} must not contain a fragment");
Ok(parsed.to_string())
}
fn validate_optional_url(label: &str, value: Option<String>) -> Result<Option<String>> {
let value = value.unwrap_or_default();
let value = value.trim();
if value.is_empty() {
Ok(None)
} else {
validate_direct_url(label, value).map(Some)
}
}
fn validate_legacy_base(label: &str, value: &str) -> Result<String> {
let mut parsed = Url::parse(value).with_context(|| format!("invalid {label}"))?;
ensure!(parsed.scheme() == "https" && parsed.host_str().is_some(), "{label} must be an HTTPS URL");
ensure!(parsed.username().is_empty() && parsed.password().is_none(), "{label} must not contain credentials");
ensure!(parsed.query().is_none() && parsed.fragment().is_none(), "{label} must not contain a query or fragment");
if !parsed.path().ends_with('/') {
parsed.set_path(&format!("{}/", parsed.path()));
}
Ok(parsed.to_string())
}
struct UpdateLock(File);
impl Drop for UpdateLock {
fn drop(&mut self) {
let _ = FileExt::unlock(&self.0);
}
}
fn try_update_lock(data_dir: &Path) -> Result<Option<UpdateLock>> {
let path = data_dir.join(LOCK_FILE);
let mut options = OpenOptions::new();
options.create(true).read(true).write(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
}
let file = options.open(&path).with_context(|| format!("open GeoIP update lock {}", path.display()))?;
match file.try_lock_exclusive() {
Ok(()) => Ok(Some(UpdateLock(file))),
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => Ok(None),
Err(error) => Err(error).context("lock GeoIP updater"),
}
}
pub async fn poll_loop(app: Weak<crate::runtime::App>, shutdown: CancellationToken) {
let mut observed: Option<UpdateSettings> = None;
let mut next_attempt = tokio::time::Instant::now();
loop {
if shutdown.is_cancelled() { return; }
let Some(app) = app.upgrade() else { return; };
let settings = app.config.load().envelope.config.geoip_updates.clone();
if observed.as_ref() != Some(&settings) {
next_attempt = tokio::time::Instant::now();
observed = Some(settings.clone());
}
let now = tokio::time::Instant::now();
if settings.enabled && now >= next_attempt {
let interval = match update_once(&app, &settings).await {
Ok(UpdateOutcome::Updated(names)) => {
tracing::info!(files = %names.join(", "), "GeoIP database updated and hot-reloaded");
Duration::from_secs(settings.interval_seconds)
}
Ok(UpdateOutcome::Current) => Duration::from_secs(settings.interval_seconds),
Ok(UpdateOutcome::Busy) => RETRY_INTERVAL.min(Duration::from_secs(settings.interval_seconds)),
Err(error) => {
tracing::warn!(error = %format!("{error:#}"), "GeoIP update failed; proxy traffic is unaffected");
RETRY_INTERVAL.min(Duration::from_secs(settings.interval_seconds))
}
};
next_attempt = tokio::time::Instant::now() + interval;
}
let wait = if settings.enabled {
next_attempt.saturating_duration_since(tokio::time::Instant::now()).min(CONFIG_POLL_INTERVAL)
} else {
CONFIG_POLL_INTERVAL
};
drop(app);
tokio::select! {
_ = shutdown.cancelled() => return,
_ = tokio::time::sleep(wait) => {}
}
}
}
enum UpdateOutcome { Updated(Vec<String>), Current, Busy }
#[derive(Debug, Clone, Serialize)]
pub struct UpdateResult {
pub outcome: &'static str,
pub updated_files: Vec<String>,
pub database: super::View,
}
pub async fn update_now(app: &Arc<crate::runtime::App>) -> Result<UpdateResult> {
let settings = app.config.load().envelope.config.geoip_updates.clone();
let outcome = update_once(app, &settings).await?;
let (outcome, updated_files) = match outcome {
UpdateOutcome::Updated(files) => ("updated", files),
UpdateOutcome::Current => ("current", Vec::new()),
UpdateOutcome::Busy => ("busy", Vec::new()),
};
Ok(UpdateResult { outcome, updated_files, database: app.geoip.load().view() })
}
async fn update_once(app: &Arc<crate::runtime::App>, settings: &UpdateSettings) -> Result<UpdateOutcome> {
let data_dir = app.policy.data_dir.as_deref().context("GeoIP updater requires a data directory")?;
let Some(_lock) = try_update_lock(data_dir)? else { return Ok(UpdateOutcome::Busy); };
let directory = data_dir.join("geoip");
ensure_safe_directory(&directory)?;
if settings.format == super::DatabaseFormat::Mmdb && settings.mmdb_url.trim().is_empty() {
reload(app, directory, settings.format).await?;
return Ok(UpdateOutcome::Current);
}
let assets = assets(settings);
let mut expected = Vec::with_capacity(assets.len());
for asset in &assets {
expected.push(match asset.checksum_url.as_deref() {
Some(url) => Some(publisher_checksum(url).await?),
None => None,
});
}
let mut current = Vec::with_capacity(assets.len());
for asset in &assets {
current.push(file_digest(&directory.join(asset.name), settings.max_bytes)?);
}
if assets.iter().enumerate().all(|(index, _)| expected[index].as_ref().is_some_and(|digest| current[index].as_deref() == Some(digest))) {
reload_if_needed(app, directory, settings.format).await?;
return Ok(UpdateOutcome::Current);
}
let stage = data_dir.join(format!(".geoip-stage-{:016x}", rand::random::<u64>()));
private_directory(&stage)?;
let result = async {
for (index, asset) in assets.iter().enumerate() {
let target = stage.join(asset.name);
if expected[index].as_ref().is_some_and(|digest| current[index].as_deref() == Some(digest)) {
copy_private(&directory.join(asset.name), &target, settings.max_bytes)?;
} else {
super::update_http::download(&asset.url, &target, settings.max_bytes, expected[index].as_deref()).await?;
}
}
let validation_stage = stage.clone();
let format = settings.format;
tokio::task::spawn_blocking(move || super::validate_directory_format(&validation_stage, format))
.await.context("GeoIP validation worker failed")??;
let mut changed = Vec::new();
for (index, asset) in assets.iter().enumerate() {
let staged = file_digest(&stage.join(asset.name), settings.max_bytes)?.context("staged GeoIP file disappeared")?;
if current[index].as_deref() != Some(staged.as_str()) {
publish_private(&stage.join(asset.name), &directory.join(asset.name), settings.max_bytes)?;
changed.push(asset.name.to_string());
}
}
reload(app, directory.clone(), settings.format).await?;
Ok::<Vec<String>, anyhow::Error>(changed)
}.await;
let _ = fs::remove_dir_all(&stage);
let changed = result?;
if changed.is_empty() { Ok(UpdateOutcome::Current) } else { Ok(UpdateOutcome::Updated(changed)) }
}
async fn reload_if_needed(app: &Arc<crate::runtime::App>, directory: PathBuf, format: super::DatabaseFormat) -> Result<()> {
let expected = match format { super::DatabaseFormat::CsvPair => "csv_pair", super::DatabaseFormat::Mmdb => "mmdb" };
let view = app.geoip.load().view();
if !view.loaded || view.format != expected {
reload(app, directory, format).await?;
}
Ok(())
}
async fn reload(app: &Arc<crate::runtime::App>, directory: PathBuf, format: super::DatabaseFormat) -> Result<()> {
let database = tokio::task::spawn_blocking(move || super::Database::load_format(&directory, format))
.await.context("GeoIP reload worker failed")??;
publish_database(&app.geoip, database);
Ok(())
}
fn publish_database(slot: &arc_swap::ArcSwap<super::Database>, database: super::Database) {
slot.store(Arc::new(database));
}
async fn publisher_checksum(url: &str) -> Result<String> {
let payload = super::update_http::fetch_bytes(url, MAX_CHECKSUM_BYTES).await?;
let text = std::str::from_utf8(&payload).context("GeoIP checksum is not UTF-8")?;
text.split(|character: char| !character.is_ascii_hexdigit())
.find(|token| token.len() == 64)
.map(str::to_ascii_lowercase)
.context("GeoIP publisher checksum is invalid")
}
fn ensure_safe_directory(directory: &Path) -> Result<()> {
match fs::symlink_metadata(directory) {
Ok(metadata) => ensure!(metadata.is_dir() && !metadata.file_type().is_symlink(), "GeoIP directory must be a real directory"),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => private_directory(directory)?,
Err(error) => return Err(error.into()),
}
Ok(())
}
fn private_directory(path: &Path) -> Result<()> {
fs::create_dir_all(path)?;
let metadata = fs::symlink_metadata(path)?;
ensure!(metadata.is_dir() && !metadata.file_type().is_symlink(), "GeoIP directory must not be a symlink");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
}
Ok(())
}
fn file_digest(path: &Path, max_bytes: u64) -> Result<Option<String>> {
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error.into()),
};
if !metadata.is_file() || metadata.file_type().is_symlink() || metadata.len() > max_bytes { return Ok(None); }
let mut options = OpenOptions::new(); options.read(true);
#[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; options.custom_flags(libc::O_NOFOLLOW); }
let mut file = options.open(path)?;
let mut digest = Sha256::new();
let mut buffer = [0u8; 1024 * 1024];
loop {
let read = file.read(&mut buffer)?;
if read == 0 { break; }
digest.update(&buffer[..read]);
}
Ok(Some(format!("{:x}", digest.finalize())))
}
fn copy_private(source: &Path, target: &Path, max_bytes: u64) -> Result<()> {
let metadata = fs::symlink_metadata(source)?;
ensure!(metadata.is_file() && !metadata.file_type().is_symlink(), "GeoIP source must be a regular file");
ensure!(metadata.len() <= max_bytes, "GeoIP source exceeds configured maximum size");
let mut source_options = OpenOptions::new(); source_options.read(true);
let mut target_options = OpenOptions::new(); target_options.write(true).create_new(true);
#[cfg(unix)] {
use std::os::unix::fs::OpenOptionsExt;
source_options.custom_flags(libc::O_NOFOLLOW);
target_options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
}
let source = source_options.open(source)?;
let mut input = source.take(max_bytes.saturating_add(1));
let mut output = target_options.open(target)?;
let copied = std::io::copy(&mut input, &mut output)?;
ensure!(copied <= max_bytes, "GeoIP source exceeds configured maximum size");
output.sync_all()?;
Ok(())
}
fn publish_private(source: &Path, target: &Path, max_bytes: u64) -> Result<()> {
let parent = target.parent().context("GeoIP target has no parent")?;
ensure_safe_directory(parent)?;
let temporary = parent.join(format!(".{}.tmp-{:016x}", target.file_name().unwrap_or_default().to_string_lossy(), rand::random::<u64>()));
if let Err(error) = copy_private(source, &temporary, max_bytes) { let _ = fs::remove_file(&temporary); return Err(error); }
if let Err(error) = fs::rename(&temporary, target) { let _ = fs::remove_file(&temporary); return Err(error.into()); }
#[cfg(unix)] {
use std::os::unix::fs::OpenOptionsExt;
let directory = OpenOptions::new().read(true).custom_flags(libc::O_DIRECTORY).open(parent)?;
directory.sync_all()?;
}
Ok(())
}
#[cfg(test)]
include!("update_tests.rs");
+200
View File
@@ -0,0 +1,200 @@
use anyhow::{ensure, Context, Result};
use axum::body::Body;
use http::{header, Request, Uri};
use http_body_util::{BodyExt, Limited};
use hyper_rustls::HttpsConnectorBuilder;
use hyper_util::{
client::legacy::{connect::HttpConnector, Client},
rt::TokioExecutor,
};
use rustls::ClientConfig;
use sha2::{Digest, Sha256};
use std::{
fs::OpenOptions,
path::Path,
sync::Arc,
time::Duration,
};
use tokio::io::AsyncWriteExt;
use url::Url;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const MAX_REDIRECTS: usize = 5;
pub(super) async fn fetch_bytes(url: &str, max_bytes: usize) -> Result<Vec<u8>> {
let client = client()?;
let mut current = initial_url(url)?;
for redirects in 0..=MAX_REDIRECTS {
let response = request(&client, &current).await?;
if response.status().is_redirection() {
current = redirect(&current, &response, redirects)?;
continue;
}
ensure_success_and_length(&response, max_bytes as u64)?;
let body = tokio::time::timeout(
REQUEST_TIMEOUT,
Limited::new(response.into_body(), max_bytes).collect(),
)
.await
.context("GeoIP response body timed out")?
.map_err(|error| anyhow::anyhow!("GeoIP download is too large or incomplete: {error}"))?
.to_bytes();
return Ok(body.to_vec());
}
unreachable!("GeoIP redirect loop always returns or fails")
}
pub(super) async fn download(
url: &str,
target: &Path,
max_bytes: u64,
expected_digest: Option<&str>,
) -> Result<String> {
let client = client()?;
let mut current = initial_url(url)?;
for redirects in 0..=MAX_REDIRECTS {
let response = request(&client, &current).await?;
if response.status().is_redirection() {
current = redirect(&current, &response, redirects)?;
continue;
}
ensure_success_and_length(&response, max_bytes)?;
return stream_to_file(response.into_body(), target, max_bytes, expected_digest).await;
}
unreachable!("GeoIP redirect loop always returns or fails")
}
fn client() -> Result<Client<hyper_rustls::HttpsConnector<HttpConnector>, Body>> {
let roots = crate::update_check::load_root_store();
let tls = ClientConfig::builder_with_provider(Arc::new(
rustls::crypto::ring::default_provider(),
))
.with_safe_default_protocol_versions()?
.with_root_certificates(roots)
.with_no_client_auth();
let mut http = HttpConnector::new();
http.enforce_http(false);
http.set_connect_timeout(Some(REQUEST_TIMEOUT));
http.set_nodelay(true);
let connector = HttpsConnectorBuilder::new()
.with_tls_config(tls)
.https_only()
.enable_http1()
.wrap_connector(http);
Ok(Client::builder(TokioExecutor::new()).build(connector))
}
fn initial_url(url: &str) -> Result<Url> {
let parsed = Url::parse(url).context("invalid GeoIP URL")?;
ensure!(parsed.scheme() == "https", "GeoIP downloads require HTTPS");
Ok(parsed)
}
async fn request(
client: &Client<hyper_rustls::HttpsConnector<HttpConnector>, Body>,
current: &Url,
) -> Result<hyper::Response<hyper::body::Incoming>> {
let uri: Uri = current.as_str().parse().context("invalid GeoIP URI")?;
let request = Request::builder()
.method(http::Method::GET)
.uri(uri)
.header(header::ACCEPT, "application/octet-stream, text/plain;q=0.9")
.header(
header::USER_AGENT,
concat!("RustProxy/", env!("CARGO_PKG_VERSION"), " GeoIP-update"),
)
.body(Body::empty())?;
tokio::time::timeout(REQUEST_TIMEOUT, client.request(request))
.await
.context("GeoIP request timed out")?
.with_context(|| format!("GeoIP request failed for {current}"))
}
fn redirect(
current: &Url,
response: &hyper::Response<hyper::body::Incoming>,
redirects: usize,
) -> Result<Url> {
ensure!(redirects < MAX_REDIRECTS, "GeoIP redirect limit exceeded");
let location = response
.headers()
.get(header::LOCATION)
.context("GeoIP redirect is missing Location")?
.to_str()
.context("GeoIP redirect Location is not UTF-8")?;
let target = crate::update_check::redirect_target(current, location)?;
ensure!(target.scheme() == "https", "GeoIP redirect must remain HTTPS");
Ok(target)
}
fn ensure_success_and_length(
response: &hyper::Response<hyper::body::Incoming>,
max_bytes: u64,
) -> Result<()> {
ensure!(
response.status().is_success(),
"GeoIP request returned HTTP {}",
response.status()
);
if let Some(length) = response.headers().get(header::CONTENT_LENGTH) {
let length = length
.to_str()?
.parse::<u64>()
.context("invalid GeoIP Content-Length")?;
ensure!(
length <= max_bytes,
"GeoIP download is larger than the configured safety limit"
);
}
Ok(())
}
async fn stream_to_file(
mut body: hyper::body::Incoming,
target: &Path,
max_bytes: u64,
expected_digest: Option<&str>,
) -> Result<String> {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
}
let file = options
.open(target)
.with_context(|| format!("create GeoIP staging file {}", target.display()))?;
let mut output = tokio::fs::File::from_std(file);
let mut digest = Sha256::new();
let mut written = 0u64;
while let Some(frame) = tokio::time::timeout(REQUEST_TIMEOUT, body.frame())
.await
.context("GeoIP response body timed out")?
{
let frame = frame.context("read GeoIP response body")?;
if let Some(data) = frame.data_ref() {
written = written
.checked_add(data.len() as u64)
.context("GeoIP download size overflow")?;
ensure!(
written <= max_bytes,
"GeoIP download is larger than the configured safety limit"
);
digest.update(data);
output.write_all(data).await?;
}
}
output.flush().await?;
output.sync_all().await?;
let actual = format!("{:x}", digest.finalize());
if let Some(expected) = expected_digest {
ensure!(
actual == expected,
"GeoIP SHA-256 mismatch for {}",
target.file_name().unwrap_or_default().to_string_lossy()
);
}
Ok(actual)
}
+94
View File
@@ -0,0 +1,94 @@
mod tests {
use super::*;
#[test]
fn defaults_use_direct_csv_sources_without_mandatory_checksums() {
let settings = UpdateSettings::default();
assert!(settings.enabled);
assert_eq!(settings.format, crate::geoip::DatabaseFormat::CsvPair);
assert_eq!(settings.ipv4_url, DEFAULT_IPV4_URL);
assert_eq!(settings.ipv6_url, DEFAULT_IPV6_URL);
assert!(settings.ipv4_checksum_url.is_none());
assert!(settings.ipv6_checksum_url.is_none());
assert_eq!(settings.mmdb_url, DEFAULT_MMDB_URL);
assert!(settings.mmdb_checksum_url.is_none());
assert_eq!(settings.max_bytes, 256 * 1024 * 1024);
assert_eq!(settings.interval_seconds, 24 * 60 * 60);
}
#[test]
fn settings_require_https_and_safe_bounds() {
let mut settings = UpdateSettings::default();
settings.ipv4_url = "http://example.invalid/db.csv".into();
assert!(settings.validate().is_err());
let mut settings = UpdateSettings::default();
settings.interval_seconds = 59;
assert!(settings.validate().is_err());
let mut settings = UpdateSettings::default();
settings.max_bytes = DEFAULT_MAX_BYTES + 1;
assert!(settings.validate().is_err());
}
#[test]
fn local_mmdb_can_disable_downloader_and_leave_url_empty() {
let mut settings = UpdateSettings {
enabled: false,
format: crate::geoip::DatabaseFormat::Mmdb,
mmdb_url: String::new(),
..UpdateSettings::default()
};
settings.validate().unwrap();
assert!(settings.mmdb_url.is_empty());
}
#[test]
fn enabled_mmdb_requires_a_direct_url() {
let mut settings = UpdateSettings {
enabled: true,
format: crate::geoip::DatabaseFormat::Mmdb,
mmdb_url: String::new(),
..UpdateSettings::default()
};
assert!(settings.validate().is_err());
}
#[test]
fn legacy_default_checksum_configuration_migrates_to_checksum_optional_defaults() {
let mut settings = UpdateSettings {
release_url: Some(LEGACY_RELEASE_URL.into()),
checksum_url: Some(LEGACY_CHECKSUM_URL.into()),
..UpdateSettings::default()
};
settings.validate().unwrap();
assert_eq!(settings.ipv4_url, DEFAULT_IPV4_URL);
assert_eq!(settings.ipv6_url, DEFAULT_IPV6_URL);
assert!(settings.ipv4_checksum_url.is_none());
assert!(settings.ipv6_checksum_url.is_none());
assert!(settings.release_url.is_none());
assert!(settings.checksum_url.is_none());
}
#[test]
fn publishing_a_downloaded_database_hot_swaps_future_geoip_lookups() {
let directory = tempfile::tempdir().unwrap();
let ipv4 = directory.path().join("user-country-ipv4.csv");
let ipv6 = directory.path().join("user-country-ipv6.csv");
std::fs::write(&ipv4, "192.0.2.0,192.0.2.255,PL\n").unwrap();
std::fs::write(&ipv6, "2001:db8::,2001:db8::ffff,PL\n").unwrap();
let slot = arc_swap::ArcSwap::from_pointee(
crate::geoip::Database::load_format(directory.path(), crate::geoip::DatabaseFormat::CsvPair).unwrap(),
);
let previous = slot.load_full();
assert_eq!(slot.load().country("192.0.2.7".parse().unwrap()).as_deref(), Some("PL"));
std::fs::write(&ipv4, "192.0.2.0,192.0.2.255,DE\n").unwrap();
std::fs::write(&ipv6, "2001:db8::,2001:db8::ffff,DE\n").unwrap();
publish_database(
&slot,
crate::geoip::Database::load_format(directory.path(), crate::geoip::DatabaseFormat::CsvPair).unwrap(),
);
assert_eq!(slot.load().country("192.0.2.7".parse().unwrap()).as_deref(), Some("DE"));
assert_eq!(previous.country("192.0.2.7".parse().unwrap()).as_deref(), Some("PL"));
}
}
+7 -29
View File
@@ -145,7 +145,8 @@ async fn run(
authentication.remove_bootstrap_files().await;
}
let renewal_observations = store.load_certificate_renewals().await?;
let geoip = match rust_proxy::geoip::Database::load(&data_dir.join("geoip")) {
let geoip_format = snapshot.envelope.config.geoip_updates.format;
let geoip = match rust_proxy::geoip::Database::load_format(&data_dir.join("geoip"), geoip_format) {
Ok(database) => std::sync::Arc::new(database),
Err(error) => {
tracing::warn!(%error, "GeoIP country database unavailable; country allow lists fail closed");
@@ -166,7 +167,6 @@ async fn run(
geoip,
);
state.acme.restore_renewals(renewal_observations);
state.start_update_check(update_check);
// Bind EVERY listener before spawning servers: a missing private IP or
// occupied port fails startup atomically, never falling back to 0.0.0.0.
let mut public_listeners = Vec::new();
@@ -254,6 +254,11 @@ async fn run(
} else {
None
};
// Release checks are control-plane best effort only. Start them after every
// socket has bound successfully so DNS/network failures can never obscure or
// interfere with listener startup failures.
state.start_geoip_updates();
state.start_update_check(update_check);
let shutdown = state.shutdown.clone();
let grace = Duration::from_secs(options.shutdown_timeout_secs);
let mut tasks = JoinSet::new();
@@ -396,35 +401,8 @@ async fn run(
} else {
println!(" Sign in with your administrator account.");
}
let certificate_runtime = state.acme.runtime_view();
let runtime_pending = matches!(certificate_runtime.status, "checking" | "initializing");
let component_state = |available: bool| {
if available {
"available"
} else if runtime_pending {
"checking"
} else {
"unavailable"
}
};
let managed_python_directory = certificate_runtime
.managed_directory
.clone()
.unwrap_or_else(|| data_dir.join("acme-python").to_string_lossy().into_owned());
println!(" Source: https://git.linuxiarz.pl/gru/RustProxy");
println!(" Packages: https://rustproxy.linuxiarz.pl/");
println!(
" Python: {} (ACME mode: {}, pip: {}, Certbot: {}; managed env: {})",
component_state(certificate_runtime.python),
if runtime_pending {
"preparing"
} else {
certificate_runtime.mode
},
component_state(certificate_runtime.pip),
component_state(certificate_runtime.certbot),
managed_python_directory
);
println!(" Data: {}\n Stop: Ctrl+C\n", data_dir.display());
if state
.active_listeners
+2 -2
View File
@@ -41,14 +41,14 @@ use zeroize::Zeroizing;
#[command(
name = "rust-proxy",
version,
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.27.3"
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.28.0"
)]
struct Cli {
#[arg(
long,
global = true,
env = "RUST_PROXY_DATA_DIR",
default_value = "./data"
default_value = "/var/lib/rustproxy"
)]
data_dir: PathBuf,
#[arg(
+1 -1
View File
@@ -239,7 +239,7 @@ async fn live_logs_command(dir: PathBuf, addr: SocketAddr) -> Result<()> {
}
});
let interactive = io::stdin().is_terminal() && io::stdout().is_terminal();
println!("RustProxy live logs 0.27.3 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
println!("RustProxy live logs 0.28.0 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
if interactive { print!("> "); io::stdout().flush()?; }
let mut filters = LiveLogFilters::default();
let mut history = std::collections::BTreeMap::<u64, serde_json::Value>::new();
+1 -1
View File
@@ -70,7 +70,7 @@ fn render_core(app: &App, snapshot: &crate::config::Snapshot, out: &mut String,
config.acl_profiles.len(),
);
if settings.include_system {
let geo = app.geoip.view();
let geo = app.geoip.load().view();
f.add(
"rust_proxy_geoip_loaded",
"gauge",
+2 -2
View File
@@ -197,7 +197,7 @@ async fn forward(
{
country = cloudflare_country
.clone()
.or_else(|| state.geoip.country(context.client_ip));
.or_else(|| state.geoip.load().country(context.client_ip));
country_resolved = true;
}
if capture_country {
@@ -277,7 +277,7 @@ async fn forward(
{
country = cloudflare_country
.clone()
.or_else(|| state.geoip.country(context.client_ip));
.or_else(|| state.geoip.load().country(context.client_ip));
if capture_country {
if let Some(exchange) = request.extensions().get::<Arc<Exchange>>() {
*exchange.country.lock().unwrap_or_else(|e| e.into_inner()) = country.clone();
+9 -2
View File
@@ -283,8 +283,15 @@ pub fn validate_response(
"invalid upstream upgrade"
);
ensure!(
!headers.contains_key("transfer-encoding") && !headers.contains_key("content-length"),
"101 response must not include body framing"
!headers.contains_key("transfer-encoding"),
"101 response must not use Transfer-Encoding"
);
ensure!(
headers.get_all("content-length").iter().count() <= 1
&& headers
.get("content-length")
.is_none_or(|value| value.to_str().is_ok_and(|value| value.trim() == "0")),
"101 response Content-Length must be 0 when present"
);
for value in headers.get_all("connection").iter() {
for field in value.to_str()?.split(',') {
+23
View File
@@ -200,6 +200,29 @@ mod tests {
assert!(validate_response(&headers, &expected, true).is_err());
}
#[test]
fn strict_upstream_101_body_framing_matrix() {
let expected = validate(&request()).unwrap();
let base = || {
let mut headers = HeaderMap::new();
headers.insert("connection", "Upgrade".parse().unwrap());
headers.insert("upgrade", "websocket".parse().unwrap());
headers.insert("sec-websocket-accept", expected.accept.parse().unwrap());
headers
};
assert!(validate_response(&base(), &expected, false).is_ok());
let mut content_length_zero = base();
content_length_zero.insert("content-length", "0".parse().unwrap());
assert!(validate_response(&content_length_zero, &expected, false).is_ok());
let mut content_length_body = base();
content_length_body.insert("content-length", "1".parse().unwrap());
assert!(validate_response(&content_length_body, &expected, false).is_err());
let mut transfer_encoding = base();
transfer_encoding.insert("transfer-encoding", "chunked".parse().unwrap());
assert!(validate_response(&transfer_encoding, &expected, false).is_err());
}
#[test]
fn normal_tcp_close_is_not_a_websocket_proxy_error() {
for kind in [
+1 -1
View File
@@ -163,7 +163,7 @@ impl App {
active_streams,
stream_metrics,
cache,
geoip,
geoip: ArcSwap::from(geoip),
rate_limiter: Arc::new(crate::rate_limit::Limiter::new()),
acme: crate::acme::Manager::new(),
diagnostics,
+14
View File
@@ -53,6 +53,20 @@ mod cache_revision_tests {
transform_body_limit.transform.max_body_bytes += 1024;
assert_ne!(cache_revision(&transform_body_limit), identity);
let mut wire_only = base.clone();
wire_only.cache.bypass_query = !wire_only.cache.bypass_query;
wire_only.cache.bypass_prefixes.push("/private".into());
wire_only.cache.expose_status = !wire_only.cache.expose_status;
wire_only.cache.expose_hits = !wire_only.cache.expose_hits;
wire_only.cache.expose_cache_status = !wire_only.cache.expose_cache_status;
wire_only.cache.expose_resident_age = !wire_only.cache.expose_resident_age;
wire_only.cache.via_header = Some("RustProxy edge".into());
assert_eq!(cache_revision(&wire_only), identity);
assert!(matches!(
cache_invalidation(&base, &wire_only),
CacheInvalidation::None
));
let mut cache = base.clone();
cache.cache.max_entries += 1;
let cache_identity = cache_revision(&cache);
+11 -1
View File
@@ -42,7 +42,7 @@ pub struct App {
pub transform_metrics: Arc<crate::transform::Metrics>,
pub traffic: Arc<crate::traffic::Analytics>,
pub cache: Arc<crate::cache::Cache>,
pub geoip: Arc<crate::geoip::Database>,
pub geoip: ArcSwap<crate::geoip::Database>,
pub rate_limiter: Arc<crate::rate_limit::Limiter>,
pub acme: Arc<crate::acme::Manager>,
pub diagnostics: Arc<crate::diagnostics::Diagnostics>,
@@ -90,6 +90,16 @@ fn cache_revision(config: &config::Config) -> u64 {
let mut cache = config.cache.clone();
cache.memory_preset = None;
cache.cold_preset = None;
// Request bypass switches and response-only diagnostic headers do not alter
// already cached representations. Keep cache objects across these policy
// edits; the new behavior applies immediately on the next request.
cache.bypass_query = false;
cache.bypass_prefixes.clear();
cache.expose_status = false;
cache.expose_hits = false;
cache.expose_cache_status = false;
cache.expose_resident_age = false;
cache.via_header = None;
// Resource-only transform controls decide how much optimization work may
// happen concurrently, but do not change the transformed representation.
// Keep those out of the cache identity while including representation and
+7
View File
@@ -1,4 +1,11 @@
impl App {
pub fn start_geoip_updates(self: &Arc<Self>) {
self.tunnels.spawn(crate::geoip::update_poll_loop(
Arc::downgrade(self),
self.shutdown.clone(),
));
}
pub fn start_update_check(self: &Arc<Self>, config: crate::update_check::Config) {
self.software_update.configure(&config);
self.tunnels.spawn(crate::update_check::poll_loop(
+14
View File
@@ -73,6 +73,20 @@ fn ensure_locked_listener(config: &mut crate::config::Config, address: SocketAdd
endpoint.enabled = true;
return;
}
// An explicit startup HTTP/HTTPS address overrides the canonical default
// listener created by earlier starts. Keep separately named/custom listeners
// intact, but do not retain the old default :80/:443 binding alongside a
// command-line/systemd override.
let canonical_id = if tls { "https" } else { "http" };
if let Some(endpoint) = config.listeners.endpoints.iter_mut().find(|endpoint| {
endpoint.id == canonical_id && endpoint.binding.tls == tls
}) {
endpoint.binding.address = address;
endpoint.additional_addresses.clear();
endpoint.enabled = true;
return;
}
let base = if tls { "systemd-https" } else { "systemd-http" };
let mut id = base.to_owned();
let mut suffix = 2usize;
+30
View File
@@ -99,6 +99,36 @@ mod tests {
.is_err());
}
#[test]
fn explicit_startup_bind_replaces_canonical_saved_binding() {
let locks = StartupLocks {
http: Some("0.0.0.0:8181".parse().unwrap()),
https: Some("0.0.0.0:8443".parse().unwrap()),
administration: None,
};
let mut config = crate::config::Config::default();
config.listeners = Listeners::from_startup(
"0.0.0.0:80".parse().unwrap(),
Some("0.0.0.0:443".parse().unwrap()),
);
config.listeners.endpoints[0]
.additional_addresses
.push("127.0.0.1:80".parse().unwrap());
config.listeners.endpoints[1]
.additional_addresses
.push("127.0.0.1:443".parse().unwrap());
locks.ensure_config(&mut config);
let http = config.listeners.endpoints.iter().find(|item| item.id == "http").unwrap();
let https = config.listeners.endpoints.iter().find(|item| item.id == "https").unwrap();
assert_eq!(http.binding.address, "0.0.0.0:8181".parse().unwrap());
assert!(http.additional_addresses.is_empty());
assert_eq!(https.binding.address, "0.0.0.0:8443".parse().unwrap());
assert!(https.additional_addresses.is_empty());
assert!(!config.listeners.endpoints.iter().filter(|item| item.enabled).flat_map(|item| item.bindings()).any(|binding| binding.address.port() == 80 || binding.address.port() == 443));
}
#[test]
fn startup_defaults_become_named_listeners() {
let saved = Listeners::from_startup(
+3 -3
View File
@@ -204,7 +204,7 @@ pub async fn poll_loop(status: Arc<SharedStatus>, config: Config, shutdown: Canc
loop {
match check_once(&config).await {
Ok(version) => status.record_success(version),
Err(error) => tracing::error!(
Err(error) => tracing::warn!(
error = %format!("{error:#}"),
version_url = %config.version_url,
"update check failed; retrying on the next interval"
@@ -299,7 +299,7 @@ async fn fetch(url: &str) -> Result<String> {
unreachable!("redirect loop always returns or fails at the configured limit")
}
fn load_root_store() -> RootCertStore {
pub(crate) fn load_root_store() -> RootCertStore {
let mut roots = RootCertStore::empty();
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
@@ -332,7 +332,7 @@ fn add_system_ca_bundle(roots: &mut RootCertStore, path: &Path) {
}
}
fn redirect_target(base: &Url, location: &str) -> Result<Url> {
pub(crate) fn redirect_target(base: &Url, location: &str) -> Result<Url> {
let target = base
.join(location)
.context("invalid update version redirect URL")?;
+1 -1
View File
@@ -159,7 +159,7 @@ pub(super) fn fastcgi_params(
if context.tls { "on" } else { "off" }.into(),
);
params.insert("GATEWAY_INTERFACE".into(), "CGI/1.1".into());
params.insert("SERVER_SOFTWARE".into(), "RustProxy/0.27.3".into());
params.insert("SERVER_SOFTWARE".into(), "RustProxy/0.28.0".into());
params.insert("REDIRECT_STATUS".into(), "200".into());
if let Some(value) = headers
.get(CONTENT_TYPE)
+1 -1
View File
@@ -185,7 +185,7 @@ pub(super) fn directory_listing(path: &str, entries: &[DirectoryEntry]) -> Vec<u
"<a class=\"{class}\" href=\"{href}\"><span class=\"type\">{type_label}</span><span class=\"name\">{label}{slash}</span><span class=\"kind\">{kind}</span></a>"
));
}
html.push_str(r#"</div></section><footer class="page-footer"><span>RustProxy <b>0.27.3</b></span><span>Web Server · Directory listing</span></footer></main></body></html>"#);
html.push_str(r#"</div></section><footer class="page-footer"><span>RustProxy <b>0.28.0</b></span><span>Web Server · Directory listing</span></footer></main></body></html>"#);
html.into_bytes()
}
+1 -1
View File
@@ -202,7 +202,7 @@ async fn empty_index_can_go_directly_to_directory_listing() {
let body = std::str::from_utf8(&body).unwrap();
assert!(body.contains("file.txt"));
assert!(body.contains("DIRECTORY LISTING"));
assert!(body.contains("RustProxy <b>0.27.3</b>"));
assert!(body.contains("RustProxy <b>0.28.0</b>"));
}
#[tokio::test]