v0.3
This commit is contained in:
1 parent
28e39f7ce4
commit
b57beb83c7
34 files changed
+1037
-314
No files matched your search
@@ -26,7 +26,11 @@ fn general_api_token_allowed(method: &Method, path: &str, permission: &str) -> b
|
||||
&& ((method == Method::PUT
|
||||
&& (path == "/api/config"
|
||||
|| path.strip_prefix("/api/config/").is_some_and(|part| {
|
||||
!part.is_empty() && part != "history" && part != "reload" && !part.contains('/')
|
||||
!part.is_empty()
|
||||
&& part != "history"
|
||||
&& part != "reload"
|
||||
&& part != "storage"
|
||||
&& !part.contains('/')
|
||||
})))
|
||||
|| (method == Method::POST
|
||||
&& matches!(
|
||||
|
||||
+57
-5
@@ -2,8 +2,8 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "RustProxy Administration API",
|
||||
"version": "0.34.1",
|
||||
"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. Version 0.34.1 also permits explicitly scoped GeneralApiBearer tokens on a narrow route allowlist, with RO/RW and source CIDR checks; API-token management and Prometheus-token management are browser-session-only."
|
||||
"version": "0.34.2",
|
||||
"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. Version 0.34.2 also permits explicitly scoped GeneralApiBearer tokens on a narrow route allowlist, with RO/RW and source CIDR checks; API-token management and Prometheus-token management are browser-session-only."
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
@@ -954,6 +954,28 @@
|
||||
"config"
|
||||
]
|
||||
},
|
||||
"ConfigurationRead": {
|
||||
"type": "object",
|
||||
"description": "Authenticated GET /api/config response; storage is read-only runtime metadata, excluded from configuration updates and revisioned snapshots.",
|
||||
"properties": {
|
||||
"revision": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"config": {
|
||||
"$ref": "#/components/schemas/Config"
|
||||
},
|
||||
"storage": {
|
||||
"$ref": "#/components/schemas/ConfigStorage"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"revision",
|
||||
"config",
|
||||
"storage"
|
||||
]
|
||||
},
|
||||
"Update": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -6750,6 +6772,35 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"ConfigStorage": {
|
||||
"type": "object",
|
||||
"description": "Read-only metadata in the GET /api/config response. Null paths indicate that a path is not applicable or unavailable.",
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"sqlite",
|
||||
"file"
|
||||
]
|
||||
},
|
||||
"config_path": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Absolute JSON path only in file mode."
|
||||
},
|
||||
"database_path": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Private SQLite config/history database path."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"mode",
|
||||
"config_path",
|
||||
"database_path"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -7761,7 +7812,8 @@
|
||||
},
|
||||
"/api/config": {
|
||||
"get": {
|
||||
"summary": "Read the revisioned configuration",
|
||||
"summary": "Read the revisioned configuration and storage source",
|
||||
"description": "The storage member reports runtime SQLite/JSON paths and is not part of the writable config or revisioned snapshots.",
|
||||
"tags": [
|
||||
"Configuration"
|
||||
],
|
||||
@@ -7772,7 +7824,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Envelope"
|
||||
"$ref": "#/components/schemas/ConfigurationRead"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12792,4 +12844,4 @@
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
}
|
||||
}
|
||||
+41
-2
@@ -312,8 +312,47 @@ async fn certificate_inventory(State(state): State<Arc<App>>) -> Json<Value> {
|
||||
json!({"revision":snapshot.envelope.revision,"certificates":entries,"acme":true, "providers":["http01", "cloudflare", "ovh", "rfc2136"]}),
|
||||
)
|
||||
}
|
||||
async fn configuration(State(state): State<Arc<App>>) -> Json<crate::config::Envelope> {
|
||||
Json(state.config.load().envelope.clone())
|
||||
// Storage metadata belongs to GET /api/config, not to the editable `config`
|
||||
// document or the revisioned envelopes returned by update/restore endpoints.
|
||||
#[derive(Serialize)]
|
||||
struct ConfigurationRead {
|
||||
#[serde(flatten)]
|
||||
envelope: crate::config::Envelope,
|
||||
storage: ConfigStorage,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ConfigStorage {
|
||||
mode: &'static str,
|
||||
config_path: Option<String>,
|
||||
database_path: Option<String>,
|
||||
}
|
||||
|
||||
async fn configuration(State(state): State<Arc<App>>) -> Json<ConfigurationRead> {
|
||||
let envelope = state.config.load().envelope.clone();
|
||||
let config_path = state
|
||||
.config_file
|
||||
.lock()
|
||||
.await
|
||||
.as_ref()
|
||||
.map(|(path, _)| path.to_string_lossy().into_owned());
|
||||
let database_path = state
|
||||
.policy
|
||||
.data_dir
|
||||
.as_ref()
|
||||
.map(|dir| dir.join("config.sqlite3").to_string_lossy().into_owned());
|
||||
Json(ConfigurationRead {
|
||||
envelope,
|
||||
storage: ConfigStorage {
|
||||
mode: if config_path.is_some() {
|
||||
"file"
|
||||
} else {
|
||||
"sqlite"
|
||||
},
|
||||
config_path,
|
||||
database_path,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
||||
+258
-56
@@ -1664,72 +1664,133 @@ mod tests {
|
||||
overview_samples: 600,
|
||||
};
|
||||
let body = serde_json::to_string(&preset).unwrap();
|
||||
let anon = router.clone().oneshot(
|
||||
request("/api/dashboard-presets").body(Body::empty()).unwrap()
|
||||
).await.unwrap();
|
||||
let anon = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/dashboard-presets")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(anon.status(), StatusCode::UNAUTHORIZED);
|
||||
let forbidden = router.clone().oneshot(
|
||||
request("/api/dashboard-presets/Daily").method("PUT")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(body.clone())).unwrap()
|
||||
).await.unwrap();
|
||||
let forbidden = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/dashboard-presets/Daily")
|
||||
.method("PUT")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(body.clone()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
|
||||
let saved = router.clone().oneshot(
|
||||
request("/api/dashboard-presets/Daily").method("PUT")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(body.clone())).unwrap()
|
||||
).await.unwrap();
|
||||
let saved = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/dashboard-presets/Daily")
|
||||
.method("PUT")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(body.clone()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(saved.status(), StatusCode::NO_CONTENT);
|
||||
let listing = router.clone().oneshot(
|
||||
request("/api/dashboard-presets")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty()).unwrap()
|
||||
).await.unwrap();
|
||||
let listing = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/dashboard-presets")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(listing.status(), StatusCode::OK);
|
||||
let entries = json_body(listing).await;
|
||||
assert_eq!(entries[0]["name"], "Daily");
|
||||
assert_eq!(entries[0]["overview_samples"], 600);
|
||||
assert_eq!(entries[0]["dashboard_widget_sizes"], Value::Null);
|
||||
assert_eq!(store.list_admin_dashboard_presets("owner").await.unwrap().len(), 1);
|
||||
assert!(store.list_admin_dashboard_presets("someone-else").await.unwrap().is_empty());
|
||||
let rejected = router.clone().oneshot(
|
||||
request("/api/dashboard-presets/Invalid").method("PUT")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(body.replace("\"overview_samples\":600", "\"overview_samples\":999"))).unwrap()
|
||||
).await.unwrap();
|
||||
assert_eq!(
|
||||
store
|
||||
.list_admin_dashboard_presets("owner")
|
||||
.await
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
assert!(store
|
||||
.list_admin_dashboard_presets("someone-else")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
let rejected = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/dashboard-presets/Invalid")
|
||||
.method("PUT")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(body.replace(
|
||||
"\"overview_samples\":600",
|
||||
"\"overview_samples\":999",
|
||||
)))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let renamed = router.clone().oneshot(
|
||||
request("/api/dashboard-presets/Daily").method("PATCH")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"name":"Nightly"}"#)).unwrap()
|
||||
).await.unwrap();
|
||||
let renamed = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/dashboard-presets/Daily")
|
||||
.method("PATCH")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"name":"Nightly"}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(renamed.status(), StatusCode::NO_CONTENT);
|
||||
assert_eq!(store.list_admin_dashboard_presets("owner").await.unwrap()[0].0, "Nightly");
|
||||
let deleted = router.oneshot(
|
||||
request("/api/dashboard-presets/Nightly").method("DELETE")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::empty()).unwrap()
|
||||
).await.unwrap();
|
||||
assert_eq!(
|
||||
store.list_admin_dashboard_presets("owner").await.unwrap()[0].0,
|
||||
"Nightly"
|
||||
);
|
||||
let deleted = router
|
||||
.oneshot(
|
||||
request("/api/dashboard-presets/Nightly")
|
||||
.method("DELETE")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
|
||||
assert!(store.list_admin_dashboard_presets("owner").await.unwrap().is_empty());
|
||||
assert!(store
|
||||
.list_admin_dashboard_presets("owner")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
@@ -1896,7 +1957,11 @@ mod tests {
|
||||
let mut upgraded = normalized.clone();
|
||||
for widget in DASHBOARD_WIDGETS.iter() {
|
||||
let id = json!(widget.id);
|
||||
if selected["dashboard_widgets"].as_array().unwrap().contains(&id) {
|
||||
if selected["dashboard_widgets"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
upgraded["dashboard_widgets"]
|
||||
@@ -2346,6 +2411,12 @@ mod tests {
|
||||
"cookie"
|
||||
);
|
||||
assert!(spec["paths"].get("/api/config").is_some());
|
||||
assert!(spec["paths"].get("/api/config/storage").is_none());
|
||||
assert_eq!(
|
||||
spec["paths"]["/api/config"]["get"]["responses"]["200"]["content"]["application/json"]
|
||||
["schema"]["$ref"],
|
||||
"#/components/schemas/ConfigurationRead"
|
||||
);
|
||||
assert!(spec["paths"].get("/api/config/history/restore").is_some());
|
||||
let response = router
|
||||
.clone()
|
||||
@@ -3483,5 +3554,136 @@ mod tests {
|
||||
assert_eq!(result.status(), StatusCode::FORBIDDEN);
|
||||
store.close().await;
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn configuration_get_requires_authentication_and_reports_sqlite_storage() {
|
||||
let (router, store, _dir, secret, _) = fixture().await;
|
||||
let unauthenticated = router
|
||||
.clone()
|
||||
.oneshot(request("/api/config").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(unauthenticated.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/config")
|
||||
.header("cookie", format!("{}={secret}", auth::COOKIE_NAME))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload = json_body(response).await;
|
||||
assert!(payload["revision"].is_number());
|
||||
assert!(payload["config"].is_object());
|
||||
assert!(payload["config"].get("storage").is_none());
|
||||
assert_eq!(payload["storage"]["mode"], "sqlite");
|
||||
assert!(payload["storage"]["config_path"].is_null());
|
||||
assert!(payload["storage"]["database_path"].is_null()); // fixture omits data_dir
|
||||
|
||||
// The old GET endpoint is gone. A dynamic PUT-only section route may
|
||||
// match the path, but it must not respond to GET.
|
||||
let old_route = router
|
||||
.oneshot(
|
||||
request("/api/config/storage")
|
||||
.header("cookie", format!("{}={secret}", auth::COOKIE_NAME))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(old_route.status(), StatusCode::METHOD_NOT_ALLOWED);
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn configuration_get_reports_explicit_json_and_private_sqlite_paths() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = Config::default();
|
||||
let policy = ValidationPolicy {
|
||||
tls_addr: None,
|
||||
cert_dir: None,
|
||||
data_dir: Some(dir.path().to_path_buf()),
|
||||
proxy_addr: "127.0.0.1:8080".parse().unwrap(),
|
||||
admin_addr: "127.0.0.1:9090".parse().unwrap(),
|
||||
};
|
||||
let store = Store::open(&dir.path().join("config.sqlite3"), &config)
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.initialize_admin("owner", "unused-test-hash", "test")
|
||||
.await
|
||||
.unwrap();
|
||||
let auth = auth::Auth::new(store.clone(), dir.path().to_path_buf())
|
||||
.await
|
||||
.unwrap();
|
||||
let issued = store
|
||||
.create_session(1, "browser", None)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let snapshot = config::compile(config, 0, &policy).unwrap();
|
||||
let state = App::new(
|
||||
store.clone(),
|
||||
snapshot,
|
||||
policy,
|
||||
auth,
|
||||
10,
|
||||
Duration::from_secs(30),
|
||||
Duration::from_secs(30),
|
||||
Duration::from_secs(300),
|
||||
);
|
||||
let file = dir.path().join("config.json");
|
||||
state.set_config_file(file.clone(), [0u8; 32]).await;
|
||||
|
||||
let response = router(state)
|
||||
.oneshot(
|
||||
request("/api/config")
|
||||
.header("cookie", format!("{}={}", auth::COOKIE_NAME, issued.secret))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload = json_body(response).await;
|
||||
assert!(payload["revision"].is_number());
|
||||
assert!(payload["config"].is_object());
|
||||
assert!(payload["config"].get("storage").is_none());
|
||||
assert_eq!(payload["storage"]["mode"], "file");
|
||||
assert_eq!(
|
||||
payload["storage"]["config_path"],
|
||||
file.to_string_lossy().as_ref()
|
||||
);
|
||||
assert_eq!(
|
||||
payload["storage"]["database_path"],
|
||||
dir.path().join("config.sqlite3").to_string_lossy().as_ref()
|
||||
);
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_metadata_is_exposed_only_through_configuration_get_to_api_tokens() {
|
||||
assert!(general_api_token_allowed(&Method::GET, "/api/config", "ro"));
|
||||
assert!(general_api_token_allowed(&Method::GET, "/api/config", "rw"));
|
||||
assert!(!general_api_token_allowed(
|
||||
&Method::GET,
|
||||
"/api/config/storage",
|
||||
"ro"
|
||||
));
|
||||
assert!(!general_api_token_allowed(
|
||||
&Method::PUT,
|
||||
"/api/config/storage",
|
||||
"rw"
|
||||
));
|
||||
assert!(!general_api_token_allowed(
|
||||
&Method::POST,
|
||||
"/api/config/storage",
|
||||
"rw"
|
||||
));
|
||||
}
|
||||
|
||||
include!("preferences_tests.rs");
|
||||
}
|
||||
@@ -22,7 +22,7 @@ function clearInvalidOriginRecovery() {
|
||||
}
|
||||
const PANEL_PATHS = { overview: "/overview", vhosts: "/vhosts", "web-server": "/web-server", "docker-vhosts": "/docker-vhosts", redirects: "/redirects", notfound: "/404-hosts", streams: "/streams", "acl-profiles": "/acl-profiles", tls: "/tls", traffic: "/traffic", cache: "/cache", transform: "/transform", filelogs: "/logs", settings: "/settings" };
|
||||
function panelFromLocation() { if (typeof location === "undefined") return "overview"; if (location.pathname === "/configuration" || location.pathname === "/logging") return "settings"; const found = Object.entries(PANEL_PATHS).find(([, path]) => path === location.pathname); return found?.[0] || "overview"; }
|
||||
let envelope = null, busy = false, connected = false, currentPanel = panelFromLocation(), previous = null;
|
||||
let envelope = null, configStorage = null, busy = false, connected = false, currentPanel = panelFromLocation(), previous = null;
|
||||
let samples = [], protocolSamples = { h1: [], h2: [], h3: [] }, websocketProtocolSamples = { h1: [], h2: [], h3: [] }, transformSamples = [], toastTimer, editingBackend = null, editingRoute = null;
|
||||
let liveEvents = null, liveConnected = false, liveFallback = false, liveInterval = null, authEpoch = 0, pendingAuthDestination = null;
|
||||
let lastApiLatencyMs = null, sessionCheckPromise = null;
|
||||
@@ -57,7 +57,7 @@ function translate() {
|
||||
window.rustProxyDrafts?.refresh();
|
||||
window.rustProxyPreferences?.refreshAppearanceUi?.();
|
||||
$("connection-label").textContent = t(connected ? "connected" : "disconnected");
|
||||
if (envelope) renderConfig(); if (latestStatus) { renderFooter(latestStatus); renderDiagnostics(latestStatus, false); window.rustProxyVhosts?.renderStatus(latestStatus); window.rustProxyStreams?.renderStatus(latestStatus); window.rustProxyStreamDashboard?.render(latestStatus); window.rustProxyBackendDashboard?.render(latestStatus, null, false); window.rustProxyExtraOverview?.render(latestStatus, false); window.rustProxyWideOverview?.render(latestStatus, false); window.rustProxyWebOverview?.render(latestStatus, false); } if (lastLogs) renderLogs(lastLogs);
|
||||
if (envelope) renderConfig(); renderConfigStorage(); if (latestStatus) { renderFooter(latestStatus); renderDiagnostics(latestStatus, false); window.rustProxyVhosts?.renderStatus(latestStatus); window.rustProxyStreams?.renderStatus(latestStatus); window.rustProxyStreamDashboard?.render(latestStatus); window.rustProxyBackendDashboard?.render(latestStatus, null, false); window.rustProxyExtraOverview?.render(latestStatus, false); window.rustProxyWideOverview?.render(latestStatus, false); window.rustProxyWebOverview?.render(latestStatus, false); } if (lastLogs) renderLogs(lastLogs);
|
||||
syncSettingsDirtyIndicators();
|
||||
}
|
||||
function hideToast() { const node = $("toast"); if (typeof node.hidePopover === "function" && node.matches(":popover-open")) node.hidePopover(); node.hidden = true; }
|
||||
@@ -335,7 +335,28 @@ async function refreshCertificateInventory() {
|
||||
if (e.status !== 401 && authenticated && epoch === authEpoch) certificateInventory = new Map();
|
||||
}
|
||||
}
|
||||
async function loadConfig() { const epoch = authEpoch; const value = await api("/api/config"); if (!authenticated || epoch !== authEpoch) return; envelope = value; await refreshCertificateInventory(); if (!authenticated || epoch !== authEpoch) return; renderConfig(); }
|
||||
function renderConfigStorage() {
|
||||
const mode = $("config-storage-mode"), location = $("config-storage-location");
|
||||
const description = $("config-storage-description"), history = $("config-storage-history");
|
||||
if (!configStorage) {
|
||||
mode.textContent = t("configStorageLoading");
|
||||
location.textContent = ""; description.textContent = ""; history.textContent = "";
|
||||
return;
|
||||
}
|
||||
if (configStorage.error) {
|
||||
mode.textContent = t("configStorageUnavailable");
|
||||
location.textContent = ""; description.textContent = ""; history.textContent = "";
|
||||
return;
|
||||
}
|
||||
const file = configStorage.mode === "file";
|
||||
mode.textContent = t(file ? "configStorageFile" : "configStorageSqlite");
|
||||
const source = file ? configStorage.config_path : configStorage.database_path;
|
||||
location.textContent = source ? `${t("configStoragePath")}: ${source}` : "";
|
||||
description.textContent = t(file ? "configStorageFileHelp" : "configStorageSqliteHelp");
|
||||
history.textContent = file && configStorage.database_path
|
||||
? `${t("configStorageHistory")}: ${configStorage.database_path}` : "";
|
||||
}
|
||||
async function loadConfig() { const epoch = authEpoch; const value = await api("/api/config"); if (!authenticated || epoch !== authEpoch) return; envelope = value; configStorage = value.storage || { error: true }; renderConfigStorage(); await refreshCertificateInventory(); if (!authenticated || epoch !== authEpoch) return; renderConfig(); }
|
||||
function rulesText(acl) { return (acl?.rules || []).map(r => `${r.action} ${r.network}`).join("\n"); }
|
||||
function normalizeAclRuleText(value) {
|
||||
return String(value ?? "").normalize("NFKC")
|
||||
@@ -376,7 +397,7 @@ function lock(notify = true) {
|
||||
window.rustProxyInlineLayout?.cancel?.();
|
||||
window.rustProxyRefresh?.stop();
|
||||
const destination = pendingAuthDestination || authReturnDestination(), navigating = typeof location !== "undefined" && (location.protocol === "http:" || location.protocol === "https:");
|
||||
pendingAuthDestination = destination; if (notify) adminAuthChannel?.postMessage("locked"); $("api-docs").hidden = true; $("logs-menu").hidden = true; resetAdminPreferences(); window.rustProxyCertificates?.reset(); window.rustProxySettings?.reset(); window.rustProxyTrafficPanel?.reset(); window.rustProxyCachePanel?.reset(); window.rustProxyTransformPanel?.reset(); window.rustProxyAcme?.reset(); window.rustProxyIntegrations?.reset(); window.rustProxyLogs?.reset(); window.rustProxyFileLogs?.reset(); window.rustProxyDrafts?.reset(); $("logs-open").hidden = true; $("file-logs-open").hidden = true; window.rustProxyAclProfiles?.reset(); window.rustProxyVhosts?.reset(); window.rustProxyWebServer?.reset(); window.rustProxyDocker?.reset(); window.rustProxyRedirects?.reset(); window.rustProxyNotFound?.reset(); window.rustProxyStreams?.reset(); window.rustProxyStreamDashboard?.reset(); window.rustProxyBackendDashboard?.reset(); window.rustProxyExtraOverview?.reset(); window.rustProxyWideOverview?.reset(); window.rustProxyWebOverview?.reset(); authEpoch++; stopLive(); csrf = ""; authenticated = false; sessionInfo = null; envelope = null; certificateInventory = new Map(); previous = null; samples = []; protocolSamples = { h1: [], h2: [], h3: [] }; websocketProtocolSamples = { h1: [], h2: [], h3: [] }; transformSamples = []; systemSamples = []; connectionOpenSamples = []; connectionExchangeSamples = []; networkInterfaceSamples.clear(); lastLogs = null; latestStatus = null; clearSettingsDirty(); connected = false; busy = false; $("workspace").hidden = true; $("login").hidden = navigating; $("logout").hidden = true; $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("password-form").reset(); $("config-editor").value = ""; $("logs").replaceChildren(); $("audit-rows").replaceChildren(); authMessage("");
|
||||
pendingAuthDestination = destination; if (notify) adminAuthChannel?.postMessage("locked"); $("api-docs").hidden = true; $("logs-menu").hidden = true; resetAdminPreferences(); window.rustProxyCertificates?.reset(); window.rustProxySettings?.reset(); window.rustProxyTrafficPanel?.reset(); window.rustProxyCachePanel?.reset(); window.rustProxyTransformPanel?.reset(); window.rustProxyAcme?.reset(); window.rustProxyIntegrations?.reset(); window.rustProxyLogs?.reset(); window.rustProxyFileLogs?.reset(); window.rustProxyDrafts?.reset(); $("logs-open").hidden = true; $("file-logs-open").hidden = true; window.rustProxyAclProfiles?.reset(); window.rustProxyVhosts?.reset(); window.rustProxyWebServer?.reset(); window.rustProxyDocker?.reset(); window.rustProxyRedirects?.reset(); window.rustProxyNotFound?.reset(); window.rustProxyStreams?.reset(); window.rustProxyStreamDashboard?.reset(); window.rustProxyBackendDashboard?.reset(); window.rustProxyExtraOverview?.reset(); window.rustProxyWideOverview?.reset(); window.rustProxyWebOverview?.reset(); authEpoch++; stopLive(); csrf = ""; authenticated = false; sessionInfo = null; envelope = null; configStorage = null; certificateInventory = new Map(); previous = null; samples = []; protocolSamples = { h1: [], h2: [], h3: [] }; websocketProtocolSamples = { h1: [], h2: [], h3: [] }; transformSamples = []; systemSamples = []; connectionOpenSamples = []; connectionExchangeSamples = []; networkInterfaceSamples.clear(); lastLogs = null; latestStatus = null; clearSettingsDirty(); connected = false; busy = false; $("workspace").hidden = true; $("login").hidden = navigating; $("logout").hidden = true; $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("password-form").reset(); $("config-editor").value = ""; $("logs").replaceChildren(); $("audit-rows").replaceChildren(); authMessage("");
|
||||
for (const id of ["certificate-list", "host-policy-list"]) { $(id)?.replaceChildren(); }
|
||||
$("workspace").querySelectorAll("form").forEach(form => form.reset());
|
||||
$("workspace").querySelectorAll("input:not([type=checkbox]):not([type=radio]),textarea").forEach(input => { input.value = ""; });
|
||||
|
||||
+60
-24
@@ -2769,14 +2769,23 @@
|
||||
id="prom-path" maxlength="128" required="" value="/metrics" /></label></div>
|
||||
<label><span data-i18n="prometheusCidrs"></span><textarea id="prom-cidrs" required=""
|
||||
rows="3" spellcheck="false"></textarea></label>
|
||||
<div class="card-heading"><h3 data-i18n="promTokenTitle">Prometheus bearer token</h3></div>
|
||||
<p class="fine" data-i18n="promTokenHelp">Generate a token directly in the panel. Only its SHA-256 hash is retained; rotating invalidates the previous token immediately.</p>
|
||||
<div class="card-heading">
|
||||
<h3 data-i18n="promTokenTitle">Prometheus bearer token</h3>
|
||||
</div>
|
||||
<p class="fine" data-i18n="promTokenHelp">Generate a token directly in the panel. Only
|
||||
its SHA-256 hash is retained; rotating invalidates the previous token immediately.
|
||||
</p>
|
||||
<p class="fine" id="prom-token-status" role="status"></p>
|
||||
<div class="form-actions">
|
||||
<button class="outline" id="prom-token-generate" type="button" data-i18n="promTokenGenerate">Generate / rotate token</button>
|
||||
<button class="quiet danger" id="prom-token-revoke" type="button" data-i18n="promTokenRevoke">Revoke token</button>
|
||||
<button class="outline" id="prom-token-generate" type="button"
|
||||
data-i18n="promTokenGenerate">Generate / rotate token</button>
|
||||
<button class="quiet danger" id="prom-token-revoke" type="button"
|
||||
data-i18n="promTokenRevoke">Revoke token</button>
|
||||
</div>
|
||||
<div hidden id="prom-token-secret-wrap"><label data-i18n="apiTokenCopyNow">Copy the
|
||||
token now — it will not be displayed again</label>
|
||||
<pre class="metrics-example" id="prom-token-secret"></pre>
|
||||
</div>
|
||||
<div hidden id="prom-token-secret-wrap"><label data-i18n="apiTokenCopyNow">Copy the token now — it will not be displayed again</label><pre class="metrics-example" id="prom-token-secret"></pre></div>
|
||||
<label class="check"><input id="prom-trusted-no-token" type="checkbox" /><span
|
||||
data-i18n="prometheusTrustedNoToken"></span></label>
|
||||
<p class="fine" data-i18n="prometheusTrustedNoTokenHelp"></p>
|
||||
@@ -2869,9 +2878,14 @@
|
||||
type="submit">Validate, save and apply</button></div>
|
||||
</form>
|
||||
<article class="card edit-form" id="api-tokens-shortcut">
|
||||
<div class="card-heading"><h2 data-i18n="generalApiTokensTitle">API tokens (RO/RW)</h2></div>
|
||||
<p class="fine" data-i18n="apiTokensMovedHelp">API token management has moved to Settings → Security.</p>
|
||||
<div class="form-actions"><a class="outline" id="api-tokens-security-link" href="/settings?tab=security#api-tokens-card" data-i18n="apiTokensOpenSecurity">Open API tokens in Security</a></div>
|
||||
<div class="card-heading">
|
||||
<h2 data-i18n="generalApiTokensTitle">API tokens (RO/RW)</h2>
|
||||
</div>
|
||||
<p class="fine" data-i18n="apiTokensMovedHelp">API token management has moved to
|
||||
Settings → Security.</p>
|
||||
<div class="form-actions"><a class="outline" id="api-tokens-security-link"
|
||||
href="/settings?tab=security#api-tokens-card"
|
||||
data-i18n="apiTokensOpenSecurity">Open API tokens in Security</a></div>
|
||||
</article>
|
||||
</div>
|
||||
<div aria-labelledby="settings-tab-system-info" class="settings-page"
|
||||
@@ -3039,7 +3053,7 @@
|
||||
role="tabpanel">
|
||||
<article class="card edit-form">
|
||||
<div class="card-heading">
|
||||
<div><span class="small-label">DESIRED STATE / SQLITE</span>
|
||||
<div><span class="small-label" data-i18n="configStorageLabel">CONFIGURATION SOURCE</span>
|
||||
<h2 data-i18n="manualConfig">Manual config</h2>
|
||||
</div>
|
||||
<div class="manual-config-tools"><button class="quiet" data-i18n="configHistory"
|
||||
@@ -3047,6 +3061,12 @@
|
||||
class="outline" data-i18n="export" id="export-config" type="button">Export
|
||||
JSON</button></div>
|
||||
</div>
|
||||
<div class="manual-config-source" role="status" aria-live="polite">
|
||||
<strong id="config-storage-mode" data-i18n="configStorageLoading">Checking storage...</strong>
|
||||
<p id="config-storage-location" class="manual-config-path"></p>
|
||||
<p id="config-storage-description" class="fine"></p>
|
||||
<p id="config-storage-history" class="fine"></p>
|
||||
</div>
|
||||
<p class="fine" data-i18n="configHelp">A valid save creates one new revision. Invalid or
|
||||
stale changes leave the running configuration untouched.</p>
|
||||
<label class="sr-only" for="config-editor">JSON</label><textarea id="config-editor"
|
||||
@@ -3055,8 +3075,6 @@
|
||||
id="save-json">Validate, save and apply</button><span class="fine"
|
||||
data-i18n="noRestart">Listener changes require restart; other settings apply
|
||||
live.</span></div>
|
||||
<p class="fine" data-i18n="roadmapHelp">Release details and planned features are
|
||||
documented in ROADMAP.md.</p>
|
||||
</article>
|
||||
</div>
|
||||
<section aria-labelledby="settings-tab-security" class="settings-page"
|
||||
@@ -3071,19 +3089,37 @@
|
||||
type="button">Administration audit</button>
|
||||
</div>
|
||||
<div class="security-stack">
|
||||
<article class="card edit-form" id="api-tokens-card" tabindex="-1">
|
||||
<div class="card-heading"><h2 data-i18n="generalApiTokensTitle">API tokens (RO/RW)</h2></div>
|
||||
<p class="fine" data-i18n="generalApiTokensHelp">Generate independent API tokens with read-only or read-write access, restricted to your client IP/CIDR. Existing browser login remains available. Non-loopback requests require HTTPS.</p>
|
||||
<form id="api-tokens-form">
|
||||
<label><span data-i18n="apiTokenComment">Description</span><input id="api-token-comment" maxlength="128" placeholder="external-automation" /></label>
|
||||
<label><span data-i18n="apiTokenPermission">Permissions</span><select id="api-token-permission"><option value="ro" data-i18n="apiTokenReadOnly">Read only (RO)</option><option value="rw" data-i18n="apiTokenReadWrite">Read and write (RW)</option></select></label>
|
||||
<label><span data-i18n="apiTokenIpAllow">Allowed source IP/CIDR (one per line)</span><textarea id="api-token-cidrs" rows="3" required placeholder="192.0.2.10/32"></textarea></label>
|
||||
<div class="form-actions"><button class="primary" type="submit" data-i18n="apiTokenCreate">Generate API token</button></div>
|
||||
</form>
|
||||
<div id="api-token-secret-wrap" hidden><label data-i18n="apiTokenCopyNow">Copy the token now — it will not be displayed again</label><pre id="api-token-secret" class="metrics-example"></pre></div>
|
||||
<div id="api-token-list" class="certificate-api-credentials"></div>
|
||||
<pre class="metrics-example">curl -H 'Authorization: Bearer $RUSTPROXY_API_TOKEN' https://panel.example.com/api/config</pre>
|
||||
</article>
|
||||
<article class="card edit-form" id="api-tokens-card" tabindex="-1">
|
||||
<div class="card-heading">
|
||||
<h2 data-i18n="generalApiTokensTitle">API tokens (RO/RW)</h2>
|
||||
</div>
|
||||
<p class="fine" data-i18n="generalApiTokensHelp">Generate independent API tokens
|
||||
with read-only or read-write access, restricted to your client IP/CIDR. Existing
|
||||
browser login remains available. Non-loopback requests require HTTPS.</p>
|
||||
<form id="api-tokens-form">
|
||||
<label><span data-i18n="apiTokenComment">Description</span><input
|
||||
id="api-token-comment" maxlength="128"
|
||||
placeholder="external-automation" /></label>
|
||||
<label><span data-i18n="apiTokenPermission">Permissions</span><select
|
||||
id="api-token-permission">
|
||||
<option value="ro" data-i18n="apiTokenReadOnly">Read only (RO)</option>
|
||||
<option value="rw" data-i18n="apiTokenReadWrite">Read and write (RW)
|
||||
</option>
|
||||
</select></label>
|
||||
<label><span data-i18n="apiTokenIpAllow">Allowed source IP/CIDR (one per
|
||||
line)</span><textarea id="api-token-cidrs" rows="3" required
|
||||
placeholder="192.0.2.10/32"></textarea></label>
|
||||
<div class="form-actions"><button class="primary" type="submit"
|
||||
data-i18n="apiTokenCreate">Generate API token</button></div>
|
||||
</form>
|
||||
<div id="api-token-secret-wrap" hidden><label data-i18n="apiTokenCopyNow">Copy the
|
||||
token now — it will not be displayed again</label>
|
||||
<pre id="api-token-secret" class="metrics-example"></pre>
|
||||
</div>
|
||||
<div id="api-token-list" class="certificate-api-credentials"></div>
|
||||
<pre
|
||||
class="metrics-example">curl -H 'Authorization: Bearer $RUSTPROXY_API_TOKEN' https://panel.example.com/api/config</pre>
|
||||
</article>
|
||||
<article class="card security-account-card">
|
||||
<div class="card-heading">
|
||||
<div><span class="small-label" data-i18n="currentSession">CURRENT SESSION</span>
|
||||
|
||||
@@ -1033,6 +1033,23 @@ button.is-saving {
|
||||
margin-top: 20px
|
||||
}
|
||||
|
||||
.manual-config-source {
|
||||
padding: 12px 16px;
|
||||
margin-top: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius, 8px);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.manual-config-source p {
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
.manual-config-path {
|
||||
font: 12px/1.6 var(--mono);
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.page-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
+6
-1
@@ -137,7 +137,12 @@ impl Limits {
|
||||
}
|
||||
}
|
||||
fn denied(status: StatusCode, challenge: Option<&HeaderValue>) -> Response {
|
||||
let mut response = (status, "RustProxy: authentication required\n").into_response();
|
||||
let body = format!(
|
||||
"{} {}\n",
|
||||
status.as_u16(),
|
||||
status.canonical_reason().unwrap_or("Error")
|
||||
);
|
||||
let mut response = (status, body).into_response();
|
||||
response.headers_mut().insert(
|
||||
"cache-control",
|
||||
HeaderValue::from_static("private, no-store"),
|
||||
|
||||
@@ -39,10 +39,6 @@ impl PeerFormatting {
|
||||
untrusted_headers: std::sync::Mutex::new(None),
|
||||
}
|
||||
}
|
||||
fn peer_header(&self) -> &HeaderValue {
|
||||
&self.peer_header
|
||||
}
|
||||
|
||||
fn cached_untrusted(&self, authority: &HeaderValue, tls: bool) -> Option<HeaderMap> {
|
||||
self.untrusted_headers
|
||||
.lock()
|
||||
|
||||
+128
-54
@@ -19,7 +19,11 @@ mod tests {
|
||||
for (peer, local, networks) in [
|
||||
("192.0.2.51:32000", "127.0.0.1:8080", Vec::<String>::new()),
|
||||
("[2001:db8::8]:32100", "[2001:db8::1]:8443", Vec::new()),
|
||||
("10.2.3.4:4123", "10.0.0.1:8443", vec!["10.0.0.0/8".to_owned()]),
|
||||
(
|
||||
"10.2.3.4:4123",
|
||||
"10.0.0.1:8443",
|
||||
vec!["10.0.0.0/8".to_owned()],
|
||||
),
|
||||
] {
|
||||
let peer: SocketAddr = peer.parse().unwrap();
|
||||
let local: SocketAddr = local.parse().unwrap();
|
||||
@@ -27,25 +31,38 @@ mod tests {
|
||||
let compiled = Settings {
|
||||
trusted_proxies: networks,
|
||||
trust_forwarded_proto: true,
|
||||
}.compile().unwrap();
|
||||
}
|
||||
.compile()
|
||||
.unwrap();
|
||||
let mut incoming = HeaderMap::new();
|
||||
incoming.insert("x-forwarded-for", "198.51.100.8, 10.3.2.1".parse().unwrap());
|
||||
incoming.insert("x-forwarded-proto", "https".parse().unwrap());
|
||||
let authority = HeaderValue::from_static("app.example:8443");
|
||||
let uri: Uri = "/some%20file?q=4".parse().unwrap();
|
||||
let plain = compiled.context_with_original_url(
|
||||
&incoming, peer, local, true, &authority, &uri, false
|
||||
).unwrap();
|
||||
let cached = compiled.context_with_peer(
|
||||
&incoming, peer, local, true, &authority, &uri, false,
|
||||
Some(&formatter), true,
|
||||
).unwrap();
|
||||
let plain = compiled
|
||||
.context_with_original_url(&incoming, peer, local, true, &authority, &uri, false)
|
||||
.unwrap();
|
||||
let cached = compiled
|
||||
.context_with_peer(
|
||||
&incoming,
|
||||
peer,
|
||||
local,
|
||||
true,
|
||||
&authority,
|
||||
&uri,
|
||||
false,
|
||||
Some(&formatter),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cached.client_ip, plain.client_ip);
|
||||
assert_eq!(cached.scheme, plain.scheme);
|
||||
assert_eq!(cached.original_url, plain.original_url);
|
||||
assert_eq!(cached.values, plain.values);
|
||||
assert_eq!(formatter.peer_header().to_str().unwrap(),
|
||||
crate::vhost::canonical_ip(peer.ip()).to_string());
|
||||
assert_eq!(
|
||||
formatter.peer_header.to_str().unwrap(),
|
||||
crate::vhost::canonical_ip(peer.ip()).to_string()
|
||||
);
|
||||
for include_ip in [false, true] {
|
||||
let mut a = incoming.clone();
|
||||
let mut b = incoming.clone();
|
||||
@@ -87,13 +104,30 @@ mod tests {
|
||||
let host = HeaderValue::from_str(host).unwrap();
|
||||
let uri: Uri = path.parse().unwrap();
|
||||
for original_url in [false, true] {
|
||||
let direct = compiled.context_with_original_url(
|
||||
&incoming, peer, local, tls, &host, &uri, original_url,
|
||||
).unwrap();
|
||||
let cached = compiled.context_with_peer(
|
||||
&incoming, peer, local, tls, &host, &uri, original_url,
|
||||
Some(&formatter), false,
|
||||
).unwrap();
|
||||
let direct = compiled
|
||||
.context_with_original_url(
|
||||
&incoming,
|
||||
peer,
|
||||
local,
|
||||
tls,
|
||||
&host,
|
||||
&uri,
|
||||
original_url,
|
||||
)
|
||||
.unwrap();
|
||||
let cached = compiled
|
||||
.context_with_peer(
|
||||
&incoming,
|
||||
peer,
|
||||
local,
|
||||
tls,
|
||||
&host,
|
||||
&uri,
|
||||
original_url,
|
||||
Some(&formatter),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cached.client_ip, direct.client_ip);
|
||||
assert_eq!(cached.scheme, direct.scheme);
|
||||
assert_eq!(cached.original_url, direct.original_url);
|
||||
@@ -111,19 +145,30 @@ mod tests {
|
||||
let trusted = Settings {
|
||||
trusted_proxies: vec!["192.0.2.0/24".to_owned()],
|
||||
trust_forwarded_proto: true,
|
||||
}.compile().unwrap();
|
||||
}
|
||||
.compile()
|
||||
.unwrap();
|
||||
let mut forwarded = HeaderMap::new();
|
||||
forwarded.insert("x-forwarded-for", "198.51.100.9".parse().unwrap());
|
||||
forwarded.insert("x-forwarded-proto", "https".parse().unwrap());
|
||||
let host = HeaderValue::from_static("app.example");
|
||||
let uri: Uri = "/trusted".parse().unwrap();
|
||||
let direct = trusted.context_with_original_url(
|
||||
&forwarded, peer, local, false, &host, &uri, false,
|
||||
).unwrap();
|
||||
let cached = trusted.context_with_peer(
|
||||
&forwarded, peer, local, false, &host, &uri, false,
|
||||
Some(&formatter), false,
|
||||
).unwrap();
|
||||
let direct = trusted
|
||||
.context_with_original_url(&forwarded, peer, local, false, &host, &uri, false)
|
||||
.unwrap();
|
||||
let cached = trusted
|
||||
.context_with_peer(
|
||||
&forwarded,
|
||||
peer,
|
||||
local,
|
||||
false,
|
||||
&host,
|
||||
&uri,
|
||||
false,
|
||||
Some(&formatter),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cached.client_ip, "198.51.100.9".parse::<IpAddr>().unwrap());
|
||||
assert_eq!(cached.scheme, "https");
|
||||
assert_eq!(cached.values, direct.values);
|
||||
@@ -136,12 +181,19 @@ mod tests {
|
||||
let compiled = Settings::default().compile().unwrap();
|
||||
let peer: SocketAddr = "192.0.2.9:4000".parse().unwrap();
|
||||
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
|
||||
let value = compiled.context_with_peer(
|
||||
&HeaderMap::new(), peer, local, false,
|
||||
&HeaderValue::from_static("app.example"),
|
||||
&"/".parse().unwrap(), false,
|
||||
Some(&PeerFormatting::new(peer, local)), false,
|
||||
).unwrap();
|
||||
let value = compiled
|
||||
.context_with_peer(
|
||||
&HeaderMap::new(),
|
||||
peer,
|
||||
local,
|
||||
false,
|
||||
&HeaderValue::from_static("app.example"),
|
||||
&"/".parse().unwrap(),
|
||||
false,
|
||||
Some(&PeerFormatting::new(peer, local)),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let mut headers = HeaderMap::new();
|
||||
for (name, val) in [
|
||||
("forwarded", "for=203.0.113.1"),
|
||||
@@ -173,10 +225,19 @@ mod tests {
|
||||
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
|
||||
let authority = HeaderValue::from_static("app.example");
|
||||
let uri: Uri = "/safe".parse().unwrap();
|
||||
let value = compiled.context_with_peer(
|
||||
&HeaderMap::new(), actual, local, false,
|
||||
&authority, &uri, false, Some(&different), false,
|
||||
).unwrap();
|
||||
let value = compiled
|
||||
.context_with_peer(
|
||||
&HeaderMap::new(),
|
||||
actual,
|
||||
local,
|
||||
false,
|
||||
&authority,
|
||||
&uri,
|
||||
false,
|
||||
Some(&different),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(value.client_ip, actual.ip());
|
||||
assert_eq!(value.values["x-forwarded-for"], "198.51.100.6");
|
||||
}
|
||||
@@ -373,23 +434,29 @@ mod tests {
|
||||
let source = Settings {
|
||||
trusted_proxies: vec!["10.0.0.0/8".into()],
|
||||
trust_forwarded_proto: true,
|
||||
}.compile().unwrap();
|
||||
}
|
||||
.compile()
|
||||
.unwrap();
|
||||
let mut supplied = HeaderMap::new();
|
||||
supplied.insert("forwarded", "for=203.0.113.8".parse().unwrap());
|
||||
supplied.insert("x-forwarded-proto", "http".parse().unwrap());
|
||||
let context = source.context(
|
||||
&supplied,
|
||||
"[2001:db8::5]:5050".parse().unwrap(),
|
||||
"[2001:db8::10]:8080".parse().unwrap(),
|
||||
true,
|
||||
&"app.example:443".parse().unwrap(),
|
||||
&"/s?q=1".parse().unwrap(),
|
||||
).unwrap();
|
||||
let context = source
|
||||
.context(
|
||||
&supplied,
|
||||
"[2001:db8::5]:5050".parse().unwrap(),
|
||||
"[2001:db8::10]:8080".parse().unwrap(),
|
||||
true,
|
||||
&"app.example:443".parse().unwrap(),
|
||||
&"/s?q=1".parse().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(context.client_ip, "2001:db8::5".parse::<IpAddr>().unwrap());
|
||||
assert_eq!(context.scheme, "https");
|
||||
assert_eq!(context.original_url, "https://app.example:443/s?q=1");
|
||||
assert_eq!(context.values["forwarded"],
|
||||
"for=\"[2001:db8::5]\";by=\"[2001:db8::10]\";proto=https;host=\"app.example:443\"");
|
||||
assert_eq!(
|
||||
context.values["forwarded"],
|
||||
"for=\"[2001:db8::5]\";by=\"[2001:db8::10]\";proto=https;host=\"app.example:443\""
|
||||
);
|
||||
assert_eq!(context.values["x-forwarded-for"], "2001:db8::5");
|
||||
assert_eq!(context.values["x-real-ip"], "2001:db8::5");
|
||||
assert_eq!(context.values["x-forwarded-proto"], "https");
|
||||
@@ -404,9 +471,15 @@ mod tests {
|
||||
(false, "[2001:db8::a]:5010", "[2001:db8::b]:8080"),
|
||||
] {
|
||||
let settings = Settings {
|
||||
trusted_proxies: if trusted { vec!["10.0.0.0/8".into()] } else { vec![] },
|
||||
trusted_proxies: if trusted {
|
||||
vec!["10.0.0.0/8".into()]
|
||||
} else {
|
||||
vec![]
|
||||
},
|
||||
trust_forwarded_proto: true,
|
||||
}.compile().unwrap();
|
||||
}
|
||||
.compile()
|
||||
.unwrap();
|
||||
let mut incoming = HeaderMap::new();
|
||||
incoming.insert("x-forwarded-for", "192.0.2.15".parse().unwrap());
|
||||
incoming.insert("x-forwarded-proto", "http".parse().unwrap());
|
||||
@@ -414,10 +487,12 @@ mod tests {
|
||||
let local: SocketAddr = local.parse().unwrap();
|
||||
let host: HeaderValue = "app.example:8443".parse().unwrap();
|
||||
let uri: Uri = "/route?q=hello".parse().unwrap();
|
||||
let full = settings.context(&incoming, peer, local, true, &host, &uri).unwrap();
|
||||
let compact = settings.context_with_original_url(
|
||||
&incoming, peer, local, true, &host, &uri, false,
|
||||
).unwrap();
|
||||
let full = settings
|
||||
.context(&incoming, peer, local, true, &host, &uri)
|
||||
.unwrap();
|
||||
let compact = settings
|
||||
.context_with_original_url(&incoming, peer, local, true, &host, &uri, false)
|
||||
.unwrap();
|
||||
assert!(!full.original_url.is_empty());
|
||||
assert!(compact.original_url.is_empty());
|
||||
assert_eq!(full.client_ip, compact.client_ip);
|
||||
@@ -430,5 +505,4 @@ mod tests {
|
||||
assert_eq!(full_headers, compact_headers);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+30
-10
@@ -96,7 +96,12 @@ fn enforce_local_page_no_cache(response: &mut Response) {
|
||||
.insert("cache-control", HeaderValue::from_static("no-store"));
|
||||
}
|
||||
|
||||
fn error(status: StatusCode, message: &'static str) -> Response {
|
||||
fn error(status: StatusCode, detail: &'static str) -> Response {
|
||||
tracing::debug!(
|
||||
status = status.as_u16(),
|
||||
detail = detail.trim_end(),
|
||||
"proxy generated error"
|
||||
);
|
||||
let mut response = if let Some(html) = crate::pages::builtin_gateway_error(status) {
|
||||
let mut response = (status, html).into_response();
|
||||
response.headers_mut().insert(
|
||||
@@ -109,11 +114,22 @@ fn error(status: StatusCode, message: &'static str) -> Response {
|
||||
);
|
||||
response
|
||||
} else {
|
||||
(status, message).into_response()
|
||||
// For statuses without a built-in error page (405, 421, 501, ...),
|
||||
// emit the HTTP status instead of implementation-specific diagnostics.
|
||||
let body = format!(
|
||||
"{} {}\n",
|
||||
status.as_u16(),
|
||||
status.canonical_reason().unwrap_or("Error")
|
||||
);
|
||||
(status, body).into_response()
|
||||
};
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("cache-control", HeaderValue::from_static("no-store"));
|
||||
response.headers_mut().insert(
|
||||
"x-content-type-options",
|
||||
HeaderValue::from_static("nosniff"),
|
||||
);
|
||||
response.extensions_mut().insert(LocalGeneratedResponse);
|
||||
response
|
||||
}
|
||||
@@ -154,10 +170,7 @@ fn encoded_origin_path(path: &str) -> Option<String> {
|
||||
url.set_path(path);
|
||||
Some(url.path().to_owned())
|
||||
}
|
||||
fn response_context(
|
||||
snapshot: &crate::config::Snapshot,
|
||||
request: &Request,
|
||||
) -> SelectedRouteContext {
|
||||
fn response_context(snapshot: &crate::config::Snapshot, request: &Request) -> SelectedRouteContext {
|
||||
let tls = request
|
||||
.extensions()
|
||||
.get::<Transport>()
|
||||
@@ -171,7 +184,9 @@ fn response_context(
|
||||
let frontend = snapshot.frontend(&host, local, tls).cloned();
|
||||
let path = crate::acl::routing_path_view(request.uri().path()).ok();
|
||||
let route = path.as_ref().and_then(|path| {
|
||||
snapshot.select_on_arc(&host, path.as_ref(), local, tls).cloned()
|
||||
snapshot
|
||||
.select_on_arc(&host, path.as_ref(), local, tls)
|
||||
.cloned()
|
||||
});
|
||||
// An ordinary path borrows the original URI in forward_request. Only
|
||||
// a decoded path needs to be moved between middleware and forwarding.
|
||||
@@ -368,8 +383,11 @@ async fn empty_for_cache(request: &mut Request) -> Result<bool, axum::Error> {
|
||||
for _ in 0..8 {
|
||||
let next = if bounded_h3_navigation {
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_millis(35), request.body_mut().frame(),
|
||||
).await {
|
||||
std::time::Duration::from_millis(35),
|
||||
request.body_mut().frame(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(frame) => frame,
|
||||
Err(_) => return Ok(false),
|
||||
}
|
||||
@@ -437,7 +455,9 @@ async fn normalize_h3_h1_unknown_body(
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_millis(35),
|
||||
request.body_mut().frame(),
|
||||
).await {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(frame) => frame,
|
||||
Err(_) => return Ok(()),
|
||||
}
|
||||
|
||||
@@ -713,3 +713,31 @@ mod graceful_h2_tests {
|
||||
assert_eq!(backend.origin, "https://127.0.0.1:9000");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn proxy_generated_error_responses_use_generic_statuses() {
|
||||
use http_body_util::BodyExt;
|
||||
let unsupported = error(
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"RustProxy: internal CONNECT implementation detail\n",
|
||||
);
|
||||
assert_eq!(unsupported.status(), StatusCode::NOT_IMPLEMENTED);
|
||||
assert_eq!(unsupported.headers()["cache-control"], "no-store");
|
||||
assert_eq!(
|
||||
unsupported.into_body().collect().await.unwrap().to_bytes(),
|
||||
"501 Not Implemented\n"
|
||||
);
|
||||
let not_found = error(
|
||||
StatusCode::NOT_FOUND,
|
||||
"RustProxy: internal routing detail\n",
|
||||
);
|
||||
assert_eq!(not_found.status(), StatusCode::NOT_FOUND);
|
||||
assert_eq!(
|
||||
not_found.headers()["content-type"],
|
||||
"text/html; charset=utf-8"
|
||||
);
|
||||
let bytes = not_found.into_body().collect().await.unwrap().to_bytes();
|
||||
let page = std::str::from_utf8(&bytes).unwrap();
|
||||
assert!(page.contains("404"));
|
||||
assert!(!page.contains("RustProxy: internal"));
|
||||
}
|
||||
+6
-5
@@ -210,11 +210,12 @@ impl Settings {
|
||||
}
|
||||
}
|
||||
fn failure(status: StatusCode) -> Response {
|
||||
let mut response = (
|
||||
status,
|
||||
"RustProxy: authentication required or authentication service unavailable\n",
|
||||
)
|
||||
.into_response();
|
||||
let body = format!(
|
||||
"{} {}\n",
|
||||
status.as_u16(),
|
||||
status.canonical_reason().unwrap_or("Error")
|
||||
);
|
||||
let mut response = (status, body).into_response();
|
||||
response.headers_mut().insert(
|
||||
"cache-control",
|
||||
HeaderValue::from_static("private, no-store"),
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
use super::{
|
||||
config::CompiledCacheMatcher,
|
||||
filesystem::{DirectoryEntry, Opened},
|
||||
htaccess,
|
||||
CompiledService,
|
||||
htaccess, CompiledService,
|
||||
};
|
||||
use axum::{body::Body, response::Response};
|
||||
use http::{
|
||||
header::{
|
||||
ACCEPT_RANGES, CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING,
|
||||
CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH,
|
||||
IF_RANGE, LAST_MODIFIED, LOCATION, RANGE, VARY,
|
||||
ACCEPT_RANGES, CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LENGTH,
|
||||
CONTENT_RANGE, CONTENT_TYPE, ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, IF_RANGE,
|
||||
LAST_MODIFIED, LOCATION, RANGE, VARY,
|
||||
},
|
||||
HeaderMap, HeaderValue, Method, StatusCode, Uri,
|
||||
};
|
||||
@@ -21,13 +20,37 @@ use std::{
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
pub(super) fn status_response(status: StatusCode, message: &'static str) -> Response {
|
||||
let mut response = Response::new(Body::from(message));
|
||||
pub(super) fn status_response(status: StatusCode, detail: &'static str) -> Response {
|
||||
// Keep internal diagnostics in server logs, not in public HTTP bodies.
|
||||
if status.is_server_error() {
|
||||
tracing::warn!(
|
||||
status = status.as_u16(),
|
||||
detail = detail.trim_end(),
|
||||
"Web Server error"
|
||||
);
|
||||
}
|
||||
let message = format!(
|
||||
"{} {}\n",
|
||||
status.as_u16(),
|
||||
status.canonical_reason().unwrap_or("Error")
|
||||
);
|
||||
let mut response = Response::new(Body::from(message.clone()));
|
||||
*response.status_mut() = status;
|
||||
response.headers_mut().insert(
|
||||
CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/plain; charset=utf-8"),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
CONTENT_LENGTH,
|
||||
HeaderValue::from_str(&message.len().to_string()).unwrap(),
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
response.headers_mut().insert(
|
||||
"x-content-type-options",
|
||||
HeaderValue::from_static("nosniff"),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
@@ -60,15 +83,20 @@ pub(super) fn builtin_error_response(status: StatusCode, method: &Method) -> Res
|
||||
pub(super) fn io_response(error: &io::Error) -> Response {
|
||||
#[cfg(unix)]
|
||||
if matches!(error.raw_os_error(), Some(libc::ELOOP)) {
|
||||
return status_response(StatusCode::FORBIDDEN, "RustProxy: Web Server path is not allowed\n");
|
||||
return status_response(
|
||||
StatusCode::FORBIDDEN,
|
||||
"RustProxy: Web Server path is not allowed\n",
|
||||
);
|
||||
}
|
||||
match error.kind() {
|
||||
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory => {
|
||||
status_response(StatusCode::NOT_FOUND, "RustProxy: Web Server resource not found\n")
|
||||
}
|
||||
io::ErrorKind::PermissionDenied | io::ErrorKind::InvalidInput => {
|
||||
status_response(StatusCode::FORBIDDEN, "RustProxy: Web Server path is not allowed\n")
|
||||
}
|
||||
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory => status_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"RustProxy: Web Server resource not found\n",
|
||||
),
|
||||
io::ErrorKind::PermissionDenied | io::ErrorKind::InvalidInput => status_response(
|
||||
StatusCode::FORBIDDEN,
|
||||
"RustProxy: Web Server path is not allowed\n",
|
||||
),
|
||||
_ => status_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"RustProxy: Web Server filesystem error\n",
|
||||
@@ -172,10 +200,7 @@ fn cache_control_for(
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
let mime = content_type
|
||||
.to_str()
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
let mime = content_type.to_str().unwrap_or("").to_ascii_lowercase();
|
||||
for rule in &service.cache_rules {
|
||||
let matches = match &rule.matcher {
|
||||
CompiledCacheMatcher::Extension(value) => extension == *value,
|
||||
@@ -216,8 +241,14 @@ fn add_download_header(response: &mut Response, service: &CompiledService, path:
|
||||
if !download_extension(service, path) {
|
||||
return;
|
||||
}
|
||||
let filename = path.file_name().and_then(|value| value.to_str()).unwrap_or("download");
|
||||
let value = format!("attachment; filename*=UTF-8''{}", percent_encode_segment(filename));
|
||||
let filename = path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("download");
|
||||
let value = format!(
|
||||
"attachment; filename*=UTF-8''{}",
|
||||
percent_encode_segment(filename)
|
||||
);
|
||||
if let Ok(value) = HeaderValue::from_str(&value) {
|
||||
response.headers_mut().insert(CONTENT_DISPOSITION, value);
|
||||
}
|
||||
@@ -372,11 +403,7 @@ fn if_range_allows(headers: &HeaderMap, etag: Option<&str>, modified: Option<Sys
|
||||
.is_some_and(|date| modified <= date)
|
||||
}
|
||||
|
||||
fn add_validators(
|
||||
response: &mut Response,
|
||||
etag: Option<&str>,
|
||||
modified: Option<SystemTime>,
|
||||
) {
|
||||
fn add_validators(response: &mut Response, etag: Option<&str>, modified: Option<SystemTime>) {
|
||||
if let Some(etag) = etag.and_then(|value| HeaderValue::from_str(value).ok()) {
|
||||
response.headers_mut().insert(ETAG, etag);
|
||||
}
|
||||
|
||||
+18
-14
@@ -347,20 +347,19 @@ async fn serve_inner(
|
||||
}
|
||||
let root = FilesystemRoot::for_service(service);
|
||||
let components_for_list = components.clone();
|
||||
let mut entries = match tokio::task::spawn_blocking(move || {
|
||||
list_directory_sync(&root, &components_for_list)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(entries)) => entries,
|
||||
Ok(Err(error)) => return io_response(&error),
|
||||
Err(_) => {
|
||||
return status_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"RustProxy: Web Server directory task failed\n",
|
||||
)
|
||||
}
|
||||
};
|
||||
let mut entries =
|
||||
match tokio::task::spawn_blocking(move || list_directory_sync(&root, &components_for_list))
|
||||
.await
|
||||
{
|
||||
Ok(Ok(entries)) => entries,
|
||||
Ok(Err(error)) => return io_response(&error),
|
||||
Err(_) => {
|
||||
return status_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"RustProxy: Web Server directory task failed\n",
|
||||
)
|
||||
}
|
||||
};
|
||||
// Filtering in place avoids a second Vec allocation for large directories
|
||||
// while retaining exactly the same visibility and hardening checks.
|
||||
entries.retain(|entry| {
|
||||
@@ -419,6 +418,11 @@ pub async fn serve(
|
||||
crate::web_server::metrics::HeadersInFlight::new(&service.web_metrics.in_flight_headers);
|
||||
let request_method = method.clone();
|
||||
let mut response = serve_inner(service, method, headers, uri, path, body, context).await;
|
||||
// HEAD carries the same metadata/status as GET, but no response body,
|
||||
// including the generic filesystem and rewrite error paths.
|
||||
if request_method == Method::HEAD {
|
||||
*response.body_mut() = Body::empty();
|
||||
}
|
||||
add_service_headers(&mut response, service);
|
||||
htaccess::apply_response(service.htaccess.as_ref(), &mut response, path);
|
||||
strip_unnecessary_headers(&mut response, service);
|
||||
|
||||
+114
-12
@@ -118,6 +118,28 @@ async fn missing_files_and_disabled_directory_listing_return_normal_errors() {
|
||||
)
|
||||
.await;
|
||||
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
|
||||
assert_eq!(missing.headers()[CACHE_CONTROL], "no-store");
|
||||
assert_eq!(missing.headers()[CONTENT_TYPE], "text/plain; charset=utf-8");
|
||||
assert_eq!(
|
||||
missing.into_body().collect().await.unwrap().to_bytes(),
|
||||
"404 Not Found\n"
|
||||
);
|
||||
|
||||
let missing_head = serve_request(
|
||||
&service,
|
||||
&request(Method::HEAD, "/missing.txt"),
|
||||
"/missing.txt",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(missing_head.status(), StatusCode::NOT_FOUND);
|
||||
assert_eq!(missing_head.headers()[CONTENT_LENGTH], "14");
|
||||
assert!(missing_head
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap()
|
||||
.to_bytes()
|
||||
.is_empty());
|
||||
|
||||
let directory = serve_request(&service, &request(Method::GET, "/empty/"), "/empty/").await;
|
||||
assert_eq!(directory.status(), StatusCode::FORBIDDEN);
|
||||
@@ -149,6 +171,40 @@ async fn missing_files_and_disabled_directory_listing_return_normal_errors() {
|
||||
.await;
|
||||
assert_eq!(method.status(), StatusCode::METHOD_NOT_ALLOWED);
|
||||
assert_eq!(method.headers()[ALLOW], "GET, HEAD");
|
||||
assert_eq!(
|
||||
method.into_body().collect().await.unwrap().to_bytes(),
|
||||
"405 Method Not Allowed\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn internal_filesystem_diagnostics_never_appear_in_public_error_responses() {
|
||||
use std::io::ErrorKind;
|
||||
for (kind, expected_status, expected_text) in [
|
||||
(
|
||||
ErrorKind::NotFound,
|
||||
StatusCode::NOT_FOUND,
|
||||
"404 Not Found\n",
|
||||
),
|
||||
(
|
||||
ErrorKind::PermissionDenied,
|
||||
StatusCode::FORBIDDEN,
|
||||
"403 Forbidden\n",
|
||||
),
|
||||
(
|
||||
ErrorKind::Other,
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"500 Internal Server Error\n",
|
||||
),
|
||||
] {
|
||||
let error = std::io::Error::from(kind);
|
||||
let response = super::response::io_response(&error);
|
||||
assert_eq!(response.status(), expected_status);
|
||||
assert_eq!(response.headers()[CACHE_CONTROL], "no-store");
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert_eq!(body.as_ref(), expected_text.as_bytes());
|
||||
assert!(!body.starts_with(b"RustProxy:"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2238,41 +2294,87 @@ async fn sendfile_auto_without_tcp_bridge_preserves_full_head_range_and_validato
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
sendfile: SendfileMode::Auto,
|
||||
..Default::default()
|
||||
}.compile().unwrap();
|
||||
}
|
||||
.compile()
|
||||
.unwrap();
|
||||
|
||||
let get = serve_request(&service, &request(Method::GET, "/large.bin"), "/large.bin").await;
|
||||
assert_eq!(get.status(), StatusCode::OK);
|
||||
assert_eq!(get.headers()[CONTENT_LENGTH].to_str().unwrap(), original.len().to_string());
|
||||
assert_eq!(
|
||||
get.headers()[CONTENT_LENGTH].to_str().unwrap(),
|
||||
original.len().to_string()
|
||||
);
|
||||
// No transport adapter here. Even if a candidate extension was produced,
|
||||
// its fallback Body must still stream the exact original representation.
|
||||
let etag = get.headers()[ETAG].clone();
|
||||
assert_eq!(get.into_body().collect().await.unwrap().to_bytes().to_vec(), original);
|
||||
assert_eq!(
|
||||
get.into_body().collect().await.unwrap().to_bytes().to_vec(),
|
||||
original
|
||||
);
|
||||
|
||||
let head = serve_request(&service, &request(Method::HEAD, "/large.bin"), "/large.bin").await;
|
||||
assert_eq!(head.status(), StatusCode::OK);
|
||||
assert_eq!(head.headers()[CONTENT_LENGTH].to_str().unwrap(), original.len().to_string());
|
||||
assert!(head.into_body().collect().await.unwrap().to_bytes().is_empty());
|
||||
assert_eq!(
|
||||
head.headers()[CONTENT_LENGTH].to_str().unwrap(),
|
||||
original.len().to_string()
|
||||
);
|
||||
assert!(head
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap()
|
||||
.to_bytes()
|
||||
.is_empty());
|
||||
|
||||
let mut range = request(Method::GET, "/large.bin");
|
||||
range.headers_mut().insert(RANGE, HeaderValue::from_static("bytes=100-199"));
|
||||
range
|
||||
.headers_mut()
|
||||
.insert(RANGE, HeaderValue::from_static("bytes=100-199"));
|
||||
let ranged = serve_request(&service, &range, "/large.bin").await;
|
||||
assert_eq!(ranged.status(), StatusCode::PARTIAL_CONTENT);
|
||||
assert_eq!(ranged.headers()[CONTENT_RANGE].to_str().unwrap(), format!("bytes 100-199/{}", original.len()));
|
||||
assert_eq!(ranged.into_body().collect().await.unwrap().to_bytes().to_vec(), original[100..200]);
|
||||
assert_eq!(
|
||||
ranged.headers()[CONTENT_RANGE].to_str().unwrap(),
|
||||
format!("bytes 100-199/{}", original.len())
|
||||
);
|
||||
assert_eq!(
|
||||
ranged
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap()
|
||||
.to_bytes()
|
||||
.to_vec(),
|
||||
original[100..200]
|
||||
);
|
||||
|
||||
let mut conditional = request(Method::GET, "/large.bin");
|
||||
conditional.headers_mut().insert(IF_NONE_MATCH, etag);
|
||||
let unchanged = serve_request(&service, &conditional, "/large.bin").await;
|
||||
assert_eq!(unchanged.status(), StatusCode::NOT_MODIFIED);
|
||||
assert!(unchanged.into_body().collect().await.unwrap().to_bytes().is_empty());
|
||||
assert!(unchanged
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap()
|
||||
.to_bytes()
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sendfile_mode_is_backward_compatible_and_rejects_unknown_values() {
|
||||
assert_eq!(SendfileMode::default(), SendfileMode::Off);
|
||||
assert_eq!(serde_json::from_str::<SendfileMode>("\"auto\"").unwrap(), SendfileMode::Auto);
|
||||
assert_eq!(serde_json::from_str::<SendfileMode>("\"off\"").unwrap(), SendfileMode::Off);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<SendfileMode>("\"auto\"").unwrap(),
|
||||
SendfileMode::Auto
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<SendfileMode>("\"off\"").unwrap(),
|
||||
SendfileMode::Off
|
||||
);
|
||||
assert!(serde_json::from_str::<SendfileMode>("\"unsafe\"").is_err());
|
||||
let legacy = serde_json::json!({"id":"legacy", "name":"Legacy", "document_root":"/tmp"});
|
||||
assert_eq!(serde_json::from_value::<Service>(legacy).unwrap().sendfile, SendfileMode::Off);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Service>(legacy).unwrap().sendfile,
|
||||
SendfileMode::Off
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user