v0.34.10
This commit is contained in:
1 parent
66ff9dfaa2
commit
ee54aa6875
30 files changed
+939
-68
No files matched your search
@@ -0,0 +1,182 @@
|
||||
#[tokio::test]
|
||||
async fn certificate_inspection_reads_public_san_without_private_key_or_config_write() {
|
||||
let (router, store, dir, secret, csrf) =
|
||||
fixture_with_certificate_storage(Config::default()).await;
|
||||
let certificate = rcgen::generate_simple_self_signed(vec![
|
||||
"www.example.test".into(),
|
||||
"*.example.test".into(),
|
||||
"example.test".into(),
|
||||
])
|
||||
.unwrap();
|
||||
std::fs::write(dir.path().join("certs/inspect.pem"), certificate.cert.pem()).unwrap();
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
let config_before = json_body(
|
||||
router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/config")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/certificates/inspect")
|
||||
.method("POST")
|
||||
.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#"{"cert_file":"inspect.pem"}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
json_body(response).await,
|
||||
json!({
|
||||
"cert_file": "inspect.pem",
|
||||
"covered_hosts": ["*.example.test", "example.test", "www.example.test"]
|
||||
})
|
||||
);
|
||||
let config_after = json_body(
|
||||
router
|
||||
.oneshot(
|
||||
request("/api/config")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(config_before["revision"], config_after["revision"]);
|
||||
assert_eq!(config_before["config"], config_after["config"]);
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn certificate_inspection_rejects_traversal_non_pem_and_unknown_fields() {
|
||||
let (router, store, dir, secret, csrf) =
|
||||
fixture_with_certificate_storage(Config::default()).await;
|
||||
std::fs::write(dir.path().join("certs/not-pem"), b"not a certificate").unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("certs/large.pem"),
|
||||
vec![b'x'; 128 * 1024 + 1],
|
||||
)
|
||||
.unwrap();
|
||||
for body in [
|
||||
json!({"cert_file": "../db"}),
|
||||
json!({"cert_file": "not-pem"}),
|
||||
json!({"cert_file": "large.pem"}),
|
||||
json!({"cert_file": "missing.pem"}),
|
||||
json!({"cert_file": ""}),
|
||||
json!({"cert_file": "inspect.pem", "key_file": "secret.key"}),
|
||||
] {
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/certificates/inspect")
|
||||
.method("POST")
|
||||
.header("cookie", format!("{}={secret}", auth::COOKIE_NAME))
|
||||
.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.to_string()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"{body}"
|
||||
);
|
||||
}
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn certificate_inspection_tracks_external_symlink_and_keeps_trust_checks() {
|
||||
use std::os::unix::fs::{symlink, PermissionsExt};
|
||||
let (router, store, dir, secret, csrf) =
|
||||
fixture_with_certificate_storage(Config::default()).await;
|
||||
let external = dir.path().join("external");
|
||||
std::fs::create_dir(&external).unwrap();
|
||||
let first = rcgen::generate_simple_self_signed(vec!["old.example.test".into()]).unwrap();
|
||||
let second = rcgen::generate_simple_self_signed(vec!["new.example.test".into()]).unwrap();
|
||||
std::fs::write(external.join("cert1.pem"), first.cert.pem()).unwrap();
|
||||
std::fs::write(external.join("cert2.pem"), second.cert.pem()).unwrap();
|
||||
let configured = external.join("fullchain.pem");
|
||||
let inspect = || {
|
||||
request("/api/certificates/inspect")
|
||||
.method("POST")
|
||||
.header("cookie", format!("{}={secret}", auth::COOKIE_NAME))
|
||||
.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(json!({"cert_file": configured}).to_string()))
|
||||
.unwrap()
|
||||
};
|
||||
for (filename, expected) in [
|
||||
("cert1.pem", "old.example.test"),
|
||||
("cert2.pem", "new.example.test"),
|
||||
] {
|
||||
symlink(filename, &configured).unwrap();
|
||||
let response = router.clone().oneshot(inspect()).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
json_body(response).await["covered_hosts"],
|
||||
json!([expected])
|
||||
);
|
||||
std::fs::remove_file(&configured).unwrap();
|
||||
}
|
||||
symlink("cert2.pem", &configured).unwrap();
|
||||
std::fs::set_permissions(&external, std::fs::Permissions::from_mode(0o770)).unwrap();
|
||||
assert_eq!(
|
||||
router.oneshot(inspect()).await.unwrap().status(),
|
||||
StatusCode::UNPROCESSABLE_ENTITY
|
||||
);
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn certificate_inspection_is_not_a_certificate_token_operation() {
|
||||
let (router, store, _dir, _, _) = fixture().await;
|
||||
let response = router
|
||||
.oneshot(
|
||||
request("/api/certificates/inspect")
|
||||
.method("POST")
|
||||
.header("x-rustproxy-certificate-key", "external-hook")
|
||||
.header("authorization", "Bearer external-token")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"cert_file":"test.pem"}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
assert_eq!(
|
||||
json_body(response).await,
|
||||
json!({"error": "certificate_api.invalid_route"})
|
||||
);
|
||||
for permission in ["ro", "rw"] {
|
||||
assert!(!general_api_token_allowed(
|
||||
&Method::POST,
|
||||
"/api/certificates/inspect",
|
||||
permission
|
||||
));
|
||||
}
|
||||
store.close().await;
|
||||
}
|
||||
@@ -1,3 +1,49 @@
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CertificateInspection {
|
||||
cert_file: String,
|
||||
}
|
||||
|
||||
async fn certificate_inspect(
|
||||
State(state): State<Arc<App>>,
|
||||
Json(input): Json<CertificateInspection>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let directory = state.policy.cert_dir.clone().ok_or_else(|| {
|
||||
ApiError(
|
||||
StatusCode::CONFLICT,
|
||||
"certificate storage is unavailable; configure the certificate directory".into(),
|
||||
)
|
||||
})?;
|
||||
let permit = state
|
||||
.certificate_slots
|
||||
.clone()
|
||||
.try_acquire_owned()
|
||||
.map_err(|_| {
|
||||
ApiError(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"certificate inspection queue is full".into(),
|
||||
)
|
||||
})?;
|
||||
let cert_file = input.cert_file;
|
||||
let (cert_file, metadata) = tokio::task::spawn_blocking(move || {
|
||||
let _permit = permit;
|
||||
crate::tls::certificate_metadata(&directory, &cert_file)
|
||||
.map(|metadata| (cert_file, metadata))
|
||||
})
|
||||
.await
|
||||
.map_err(|_| {
|
||||
ApiError(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"certificate inspection task failed".into(),
|
||||
)
|
||||
})?
|
||||
.map_err(|error| ApiError(StatusCode::UNPROCESSABLE_ENTITY, format!("{error:#}")))?;
|
||||
Ok(Json(json!({
|
||||
"cert_file": cert_file,
|
||||
"covered_hosts": metadata.covered_hosts,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn certificate_deploy(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
|
||||
+176
-1
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "RustProxy Administration API",
|
||||
"version": "0.34.9",
|
||||
"version": "0.34.10",
|
||||
"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. The API 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": [
|
||||
@@ -2405,6 +2405,41 @@
|
||||
"key_file"
|
||||
]
|
||||
},
|
||||
"CertificateInspection": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"cert_file"
|
||||
],
|
||||
"properties": {
|
||||
"cert_file": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096,
|
||||
"description": "Simple managed filename (up to 96 ASCII bytes) or absolute external certificate path (up to 4096 bytes). Reads only the public certificate; external paths retain root/service ownership, parent trust and file permission checks."
|
||||
}
|
||||
}
|
||||
},
|
||||
"CertificateInspectionResult": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"cert_file",
|
||||
"covered_hosts"
|
||||
],
|
||||
"properties": {
|
||||
"cert_file": {
|
||||
"type": "string"
|
||||
},
|
||||
"covered_hosts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Normalized, sorted, unique DNS SubjectAltName values from the leaf certificate. No Common Name fallback. Empty when the leaf has no DNS SAN names."
|
||||
}
|
||||
}
|
||||
},
|
||||
"CertificateUploadResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -8834,6 +8869,146 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/certificates/inspect": {
|
||||
"post": {
|
||||
"summary": "Read DNS names from a certificate file",
|
||||
"tags": [
|
||||
"Certificates"
|
||||
],
|
||||
"operationId": "post_api_certificates_inspect",
|
||||
"description": "Browser-session-only read-only inspection for the manual certificate editor. Maximum request body 32 KiB; public PEM reads are limited to 128 KiB on a bounded blocking worker. Existing managed filenames or absolute external certificate paths are accepted with the same trust, owner, symlink and permissions checks as certificate inventory. Returns DNS SAN coverage without reading a private key, staging files, changing permissions or updating configuration. General API and certificate-scoped tokens cannot use this endpoint.",
|
||||
"security": [
|
||||
{
|
||||
"AdminSession": [],
|
||||
"CsrfToken": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Origin",
|
||||
"in": "header",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Exact browser origin accepted by the panel origin policy."
|
||||
},
|
||||
{
|
||||
"name": "X-RustProxy-Request",
|
||||
"in": "header",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"1"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CertificateInspection"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Public leaf certificate DNS names",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CertificateInspectionResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid request JSON",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Administrator session required or revoked",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Host, origin or CSRF rejected; API token scope is not supported",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Certificate storage is not configured",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"413": {
|
||||
"description": "Request exceeds the 32 KiB body limit",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Invalid certificate path, PEM, file size, trust/permission policy or request fields",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Bounded certificate worker capacity exhausted",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Certificate inspection worker failed",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/certificates/upload": {
|
||||
"post": {
|
||||
"summary": "Validate and stage a PEM pair",
|
||||
|
||||
@@ -57,6 +57,7 @@ fn admin_app_javascript() -> &'static str {
|
||||
");",
|
||||
include_str!("web/app/overview-samples.js"),
|
||||
include_str!("web/app/core.js"),
|
||||
include_str!("web/app/path-permissions.js"),
|
||||
include_str!("web/app/session-resume.js"),
|
||||
include_str!("web/app/refresh.js"),
|
||||
include_str!("web/app/rate-sampling.js"),
|
||||
@@ -74,6 +75,7 @@ fn admin_app_javascript() -> &'static str {
|
||||
include_str!("web/app/web-overview.js"),
|
||||
include_str!("web/app/overview-masonry.js"),
|
||||
include_str!("web/app/config-history.js"),
|
||||
include_str!("web/app/certificate-inspection.js"),
|
||||
include_str!("web/app/runtime.js"),
|
||||
include_str!("web/app/settings.js"),
|
||||
include_str!("web/management.js"),
|
||||
@@ -168,6 +170,10 @@ pub fn router(state: Arc<App>) -> Router {
|
||||
.route("/api/docker", get(docker_status))
|
||||
.route("/api/docker/refresh", post(docker_refresh))
|
||||
.route("/api/certificates", get(certificate_inventory))
|
||||
.route(
|
||||
"/api/certificates/inspect",
|
||||
post(certificate_inspect).layer(DefaultBodyLimit::max(32 * 1024)),
|
||||
)
|
||||
.route(
|
||||
"/api/basic-auth/hash",
|
||||
post(basic_hash).layer(DefaultBodyLimit::max(4096)),
|
||||
|
||||
+10
-1
@@ -980,6 +980,10 @@ mod tests {
|
||||
"/api/certificates/upload",
|
||||
r#"{"cert_pem":"test","key_pem":"test","hosts":["app.localhost"]}"#,
|
||||
),
|
||||
(
|
||||
"/api/certificates/inspect",
|
||||
r#"{"cert_file":"example.pem"}"#,
|
||||
),
|
||||
(
|
||||
"/api/certificates/deploy",
|
||||
r#"{"certificate_id":"shared","cert_pem":"test","key_pem":"test"}"#,
|
||||
@@ -2298,6 +2302,7 @@ mod tests {
|
||||
("/api/cache/cold/cleanup", "POST"),
|
||||
("/api/cache/cold/cleanup/1", "GET"),
|
||||
("/api/certificates/upload", "POST"),
|
||||
("/api/certificates/inspect", "POST"),
|
||||
("/api/certificates/deploy", "POST"),
|
||||
("/api/certificates/reload", "POST"),
|
||||
("/api/certificates/manual-file/reload", "POST"),
|
||||
@@ -2985,7 +2990,10 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers()["content-type"], "text/css; charset=utf-8");
|
||||
assert_eq!(
|
||||
response.headers()["content-type"],
|
||||
"text/css; charset=utf-8"
|
||||
);
|
||||
assert_eq!(response.headers()["vary"], "Accept-Encoding");
|
||||
let bytes = axum::body::to_bytes(response.into_body(), css.len() + 1)
|
||||
.await
|
||||
@@ -3874,5 +3882,6 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
include!("certificate_inspection_tests.rs");
|
||||
include!("preferences_tests.rs");
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use strict";
|
||||
(() => {
|
||||
const form = $("certificate-form"), file = $("certificate-file"), source = $("certificate-file-source");
|
||||
const names = $("certificate-hosts"), button = $("certificate-inspect"), status = $("certificate-inspect-status");
|
||||
let timer = null, sequence = 0, namesRevision = 0, resetGeneration = 0, applying = false;
|
||||
let manuallyEdited = !!names.value.trim(), pending = null, lastSuccess = "";
|
||||
|
||||
const identity = () => JSON.stringify([source.value, file.value.trim(), authEpoch]);
|
||||
function validFile() {
|
||||
const value = file.value.trim();
|
||||
return source.value === "external"
|
||||
? value.startsWith("/") && value.length <= 4096 && !/[\x00-\x1f\x7f]/.test(value)
|
||||
: value.length <= 96 && value !== "." && value !== ".." && /^[A-Za-z0-9_.-]+$/.test(value);
|
||||
}
|
||||
function message(key = "", detail = "") {
|
||||
if (key && !detail) status.dataset.i18n = key;
|
||||
else delete status.dataset.i18n;
|
||||
status.textContent = key ? `${t(key)}${detail ? ` ${detail}` : ""}` : "";
|
||||
status.classList.toggle("status-error", !!detail);
|
||||
}
|
||||
function refresh() { button.disabled = !validFile() || !!pending; }
|
||||
function invalidate() {
|
||||
clearTimeout(timer); timer = null; sequence++;
|
||||
pending?.controller.abort(); pending = null; lastSuccess = "";
|
||||
refresh();
|
||||
}
|
||||
function writeNames(value) {
|
||||
names.value = value; applying = true;
|
||||
try { names.dispatchEvent(new Event("input", { bubbles: true })); }
|
||||
finally { applying = false; }
|
||||
}
|
||||
function reset({ preserveNames = false } = {}) {
|
||||
invalidate(); manuallyEdited = preserveNames && !!names.value.trim(); namesRevision++; resetGeneration++;
|
||||
message(); refresh();
|
||||
}
|
||||
async function read(force = false) {
|
||||
clearTimeout(timer); timer = null;
|
||||
if (!authenticated || !validFile()) { refresh(); return; }
|
||||
const id = identity();
|
||||
if (!force && pending?.id === id) return pending.promise;
|
||||
if (!force && lastSuccess === id) return;
|
||||
invalidate();
|
||||
const current = sequence, epoch = authEpoch, revision = namesRevision, previousNames = names.value;
|
||||
const certFile = file.value.trim(), controller = new AbortController();
|
||||
message("certificateInspectReading");
|
||||
const active = { id, controller, promise: null };
|
||||
pending = active; refresh();
|
||||
active.promise = (async () => {
|
||||
try {
|
||||
const result = await api("/api/certificates/inspect", {
|
||||
method: "POST", body: JSON.stringify({ cert_file: certFile }), signal: controller.signal,
|
||||
});
|
||||
if (current !== sequence || epoch !== authEpoch || !authenticated || identity() !== id) return;
|
||||
const hosts = Array.isArray(result.covered_hosts) ? result.covered_hosts.filter(host => typeof host === "string") : [];
|
||||
lastSuccess = id;
|
||||
const update = revision === namesRevision && names.value === previousNames && (force || !manuallyEdited);
|
||||
if (update) { writeNames(hosts.join(", ")); manuallyEdited = false; }
|
||||
message(!hosts.length ? "certificateInspectNoNames" : update ? "certificateInspectReady" : "certificateInspectKept");
|
||||
} catch (error) {
|
||||
if (current === sequence && epoch === authEpoch && authenticated && identity() === id && error.name !== "AbortError") {
|
||||
message("certificateInspectFailed", error.message);
|
||||
}
|
||||
} finally {
|
||||
if (pending === active) { pending = null; refresh(); }
|
||||
}
|
||||
})();
|
||||
return active.promise;
|
||||
}
|
||||
function schedule() {
|
||||
invalidate();
|
||||
if (!manuallyEdited && names.value) writeNames("");
|
||||
message();
|
||||
if (validFile()) timer = setTimeout(() => { void read(); }, 400);
|
||||
}
|
||||
async function beforeSave() {
|
||||
const generation = resetGeneration;
|
||||
let id;
|
||||
do { id = identity(); await read(); }
|
||||
while (authenticated && generation === resetGeneration && id !== identity());
|
||||
return generation === resetGeneration;
|
||||
}
|
||||
names.addEventListener("input", () => {
|
||||
if (applying) return;
|
||||
manuallyEdited = true; namesRevision++;
|
||||
});
|
||||
file.addEventListener("input", schedule);
|
||||
file.addEventListener("change", () => { void read(); });
|
||||
file.addEventListener("blur", () => { void read(); });
|
||||
source.addEventListener("change", schedule);
|
||||
form.addEventListener("reset", () => reset());
|
||||
button.addEventListener("click", () => { void read(true); });
|
||||
window.rustProxyCertificateInspection = { reset, schedule, read, beforeSave, refresh };
|
||||
refresh();
|
||||
})();
|
||||
@@ -0,0 +1,164 @@
|
||||
"use strict";
|
||||
(() => {
|
||||
// These are reviewable shell instructions only. The panel never executes them.
|
||||
const views = new WeakMap();
|
||||
const quote = value => `'${String(value).replaceAll("'", "'\"'\"'")}'`;
|
||||
function absolutePath(value, directory) {
|
||||
const raw = String(value || "");
|
||||
if (/[\x00-\x1f\x7f]/.test(raw)) return null;
|
||||
const path = raw.trim();
|
||||
if (!path.startsWith("/") || path.length > 4096) return null;
|
||||
if (path.split("/").some(part => part === "." || part === "..")) return null;
|
||||
if (!directory && path.endsWith("/")) return null;
|
||||
const normalized = path.replace(/\/{2,}/g, "/").replace(/\/$/, "");
|
||||
return normalized && normalized !== "/" ? normalized : null;
|
||||
}
|
||||
function commands({ kind, paths = [], user = "rustproxy" } = {}) {
|
||||
if (!["certificate", "web"].includes(kind) || !/^[A-Za-z_][A-Za-z0-9_.-]*\$?$/.test(user) || /[\x00-\x1f\x7f]/.test(user) || user.length > 64) return "";
|
||||
const selected = paths.map(path => absolutePath(path, kind === "web"));
|
||||
if (selected.length !== (kind === "web" ? 1 : 2) || selected.some(path => !path)) return "";
|
||||
const lines = ["sudo sh <<'RUSTPROXY_ACL'", "set -eu", `rp_user=${quote(user)}`];
|
||||
if (kind === "web") lines.push(`rp_root_path=${quote(selected[0])}`);
|
||||
else lines.push(`rp_cert_path=${quote(selected[0])}`, `rp_key_path=${quote(selected[1])}`);
|
||||
lines.push(
|
||||
"command -v setfacl >/dev/null || { printf '%s\\n' 'Install the acl package (setfacl) first.' >&2; exit 1; }",
|
||||
'id "$rp_user" >/dev/null',
|
||||
"",
|
||||
"rp_traverse() {",
|
||||
' [ "$1" = / ] && return 0',
|
||||
' rp_traverse "$(dirname -- "$1")" || return 1',
|
||||
' rp_real_dir=$(readlink -e -- "$1") || return 1',
|
||||
' if [ "$rp_real_dir" != "$1" ]; then rp_traverse "$rp_real_dir" || return 1; fi',
|
||||
' if ! sudo -u "$rp_user" -- test -x "$1"; then',
|
||||
' rp_access=1',
|
||||
' if sudo -u "$rp_user" -- test -r "$1"; then rp_access=$((rp_access | 4)); fi',
|
||||
' if sudo -u "$rp_user" -- test -w "$1"; then rp_access=$((rp_access | 2)); fi',
|
||||
' rp_mode=$(stat -Lc %a -- "$1") || return 1',
|
||||
' rp_mask=$(((0$rp_mode / 8 % 8) | rp_access))',
|
||||
' setfacl -n -m "u:$rp_user:$rp_access,m::$rp_mask" -- "$1" || return 1',
|
||||
" fi",
|
||||
"}",
|
||||
""
|
||||
);
|
||||
if (kind === "web") {
|
||||
const grantWebTree = [
|
||||
"set -eu",
|
||||
"rp_user=$1; shift",
|
||||
'for rp_path do',
|
||||
' [ ! -L "$rp_path" ] || continue',
|
||||
' rp_required=4',
|
||||
' if [ -d "$rp_path" ]; then rp_required=5; elif [ ! -f "$rp_path" ]; then continue; fi',
|
||||
' rp_mode=$(stat -Lc %a -- "$rp_path")',
|
||||
' rp_old_mask=$((0$rp_mode / 8 % 8))',
|
||||
' rp_mask=$((rp_old_mask | rp_required))',
|
||||
' rp_entries="u:$rp_user:$rp_required,m::$rp_mask"',
|
||||
' if [ "$rp_required" = 5 ]; then',
|
||||
' rp_acl=$(getfacl -cp -- "$rp_path")',
|
||||
' rp_default=$(printf "%s\\n" "$rp_acl" | awk -F: \'$1 == "default" && $2 == "mask" && $3 == "" {mask=substr($4,1,3)} $1 == "default" && $2 == "group" && $3 == "" {group=substr($4,1,3)} END {print (mask != "" ? mask : group)}\')',
|
||||
' rp_default_mask=$rp_old_mask',
|
||||
' if [ -n "$rp_default" ]; then',
|
||||
' rp_default_mask=0',
|
||||
' case "$rp_default" in r??) rp_default_mask=$((rp_default_mask | 4));; esac',
|
||||
' case "$rp_default" in ?w?) rp_default_mask=$((rp_default_mask | 2));; esac',
|
||||
' case "$rp_default" in ??x) rp_default_mask=$((rp_default_mask | 1));; esac',
|
||||
' fi',
|
||||
' rp_entries="$rp_entries,d:u:$rp_user:5,d:m::$((rp_default_mask | 5))"',
|
||||
" fi",
|
||||
' setfacl -n -m "$rp_entries" -- "$rp_path"',
|
||||
"done"
|
||||
].join("\n");
|
||||
lines.push(
|
||||
"command -v getfacl >/dev/null || { printf '%s\\n' 'Install the acl package (getfacl) first.' >&2; exit 1; }",
|
||||
'rp_root=$(readlink -e -- "$rp_root_path")',
|
||||
'[ "$rp_root" != / ] && [ -d "$rp_root" ] || { printf \'%s\\n\' \'Choose an existing website directory, not /.\' >&2; exit 1; }',
|
||||
'rp_traverse "$(dirname -- "$rp_root_path")"',
|
||||
'rp_traverse "$(dirname -- "$rp_root")"',
|
||||
`find -P "$rp_root" \\( -type d -o -type f \\) -exec sh -c ${quote(grantWebTree)} rustproxy-web-acl "$rp_user" {} +`,
|
||||
'sudo -u "$rp_user" -- test -r "$rp_root"',
|
||||
'sudo -u "$rp_user" -- test -x "$rp_root"'
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
'rp_cert=$(readlink -e -- "$rp_cert_path")',
|
||||
'rp_key=$(readlink -e -- "$rp_key_path")',
|
||||
'[ -f "$rp_cert" ] && [ -f "$rp_key" ] || { printf \'%s\\n\' \'Certificate and key must be existing regular files.\' >&2; exit 1; }',
|
||||
'chmod go-w -- "$rp_cert" "$rp_key"',
|
||||
'chmod o-r -- "$rp_key"',
|
||||
'rp_traverse "$(dirname -- "$rp_cert_path")"',
|
||||
'rp_traverse "$(dirname -- "$rp_key_path")"',
|
||||
'rp_traverse "$(dirname -- "$rp_cert")"',
|
||||
'rp_traverse "$(dirname -- "$rp_key")"',
|
||||
'setfacl -n -m "u:$rp_user:r--,m::r--" -- "$rp_cert" "$rp_key"',
|
||||
'sudo -u "$rp_user" -- test -r "$rp_cert_path"',
|
||||
'sudo -u "$rp_user" -- test -r "$rp_key_path"'
|
||||
);
|
||||
}
|
||||
lines.push("RUSTPROXY_ACL");
|
||||
return lines.join("\n");
|
||||
}
|
||||
function update(view) {
|
||||
const { target, options, summary, help, accountLabel, account, output, empty, copy } = view;
|
||||
target.hidden = options.enabled === false;
|
||||
summary.textContent = t("pathPermissionsOptions");
|
||||
help.textContent = t(options.kind === "web" ? "pathPermissionsWebHelp" : "pathPermissionsCertificateHelp");
|
||||
accountLabel.textContent = t("pathPermissionsUser");
|
||||
copy.textContent = t("pathPermissionsCopy");
|
||||
output.setAttribute("aria-label", t("pathPermissionsTitle"));
|
||||
output.textContent = target.hidden ? "" : commands({ ...options, user: account.value });
|
||||
output.hidden = !output.textContent;
|
||||
copy.disabled = !output.textContent;
|
||||
empty.hidden = !!output.textContent;
|
||||
empty.textContent = t(options.kind === "web" ? "pathPermissionsWebPrompt" : "pathPermissionsCertificatePrompt");
|
||||
}
|
||||
function render(target, options) {
|
||||
if (!target) return;
|
||||
let view = views.get(target);
|
||||
if (!view) {
|
||||
target.classList.add("path-permissions");
|
||||
const actions = el("div", undefined, "form-actions path-permissions-actions");
|
||||
const copy = el("button", undefined, "quiet");
|
||||
copy.type = "button";
|
||||
const details = el("details", undefined, "path-permissions-options");
|
||||
const summary = el("summary", undefined, "fine");
|
||||
const help = el("p", undefined, "fine");
|
||||
const label = el("label", undefined, "path-permissions-account"), accountLabel = el("span"), account = el("input");
|
||||
account.type = "text"; account.defaultValue = "rustproxy"; account.value = "rustproxy"; account.maxLength = 64;
|
||||
account.autocomplete = "off"; account.spellcheck = false;
|
||||
label.append(accountLabel, account);
|
||||
const output = el("pre", undefined, "metrics-example path-permissions-commands");
|
||||
output.tabIndex = 0;
|
||||
const empty = el("p", undefined, "fine");
|
||||
details.append(summary, label, help);
|
||||
actions.append(copy, details);
|
||||
target.replaceChildren(output, empty, actions);
|
||||
view = { target, options, summary, help, accountLabel, account, output, empty, copy };
|
||||
views.set(target, view);
|
||||
account.addEventListener("input", () => update(view));
|
||||
const selectOutput = () => {
|
||||
output.focus();
|
||||
const selection = window.getSelection?.();
|
||||
if (!selection) return;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(output);
|
||||
selection.removeAllRanges(); selection.addRange(range);
|
||||
};
|
||||
copy.addEventListener("click", async () => {
|
||||
try {
|
||||
if (!output.textContent) return;
|
||||
if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(output.textContent);
|
||||
else {
|
||||
selectOutput();
|
||||
if (!document.execCommand("copy")) throw new Error("clipboard unavailable");
|
||||
}
|
||||
toast(t("pathPermissionsCopied"));
|
||||
} catch {
|
||||
selectOutput();
|
||||
toast(t("pathPermissionsCopyFailed"), true);
|
||||
}
|
||||
});
|
||||
}
|
||||
view.options = options;
|
||||
update(view);
|
||||
}
|
||||
window.rustProxyPermissions = { commands, render };
|
||||
})();
|
||||
@@ -7,6 +7,8 @@ function verifyAdminModules() {
|
||||
["rustProxyTrafficPanel", "refresh"],
|
||||
["rustProxyPreferences", "load"],
|
||||
["rustProxyRefresh", "start"],
|
||||
["rustProxyPermissions", "render"],
|
||||
["rustProxyCertificateInspection", "read"],
|
||||
];
|
||||
const missing = required.find(([module, method]) => typeof window[module]?.[method] !== "function");
|
||||
if (missing) throw new Error(`Panel resources incomplete (${missing[0]}). Reload to retry.`);
|
||||
@@ -34,6 +36,12 @@ else document.addEventListener("DOMContentLoaded", startAdminPanel, { once: true
|
||||
function certificateUsesExternalFiles(cert) {
|
||||
return String(cert?.cert_file || "").startsWith("/") && String(cert?.key_file || "").startsWith("/");
|
||||
}
|
||||
function syncCertificatePermissions() {
|
||||
window.rustProxyPermissions?.render($("certificate-external-acl-example"), {
|
||||
kind: "certificate", paths: [$("certificate-file").value, $("certificate-key").value],
|
||||
enabled: $("certificate-file-source").value === "external",
|
||||
});
|
||||
}
|
||||
function syncCertificateFileSource() {
|
||||
const external = $("certificate-file-source").value === "external";
|
||||
const cert = $("certificate-file"), key = $("certificate-key");
|
||||
@@ -47,9 +55,14 @@ function syncCertificateFileSource() {
|
||||
const helpKey = external ? "certificateSourceExternalHelp" : "certificateSourceManagedHelp";
|
||||
$("certificate-file-source-help").dataset.i18n = helpKey;
|
||||
$("certificate-file-source-help").textContent = t(helpKey);
|
||||
$("certificate-external-acl-example").hidden = !external;
|
||||
syncCertificatePermissions();
|
||||
window.rustProxyCertificateInspection?.refresh();
|
||||
}
|
||||
$("certificate-file-source").addEventListener("change", syncCertificateFileSource);
|
||||
for (const id of ["certificate-file", "certificate-key"]) {
|
||||
$(id).addEventListener("input", syncCertificatePermissions);
|
||||
$(id).addEventListener("change", syncCertificatePermissions);
|
||||
}
|
||||
function renderCertificates() {
|
||||
if (!envelope) return;
|
||||
const settings = envelope.config.tls || {};
|
||||
@@ -148,7 +161,15 @@ function renderCertificates() {
|
||||
if (result.administration_restart_required) toast(t("certificateAdminRestartRequired"), true);
|
||||
} catch (error) { toast(error.message, true); } finally { busy = false; }
|
||||
}));
|
||||
buttons.append(action(t("edit"), () => { window.rustProxyCertificates?.open("existing"); $("certificate-advanced").open = true; $("certificate-id").value = cert.id; $("certificate-hosts").value = cert.hosts.join(", "); $("certificate-file-source").value = certificateUsesExternalFiles(cert) ? "external" : "managed"; syncCertificateFileSource(); $("certificate-file").value = cert.cert_file; $("certificate-key").value = cert.key_file; $("certificate-default").checked = settings.default_certificate === cert.id; }));
|
||||
buttons.append(action(t("edit"), () => {
|
||||
window.rustProxyCertificates?.open("existing"); $("certificate-advanced").open = true;
|
||||
$("certificate-id").value = cert.id; $("certificate-hosts").value = (cert.hosts || []).join(", ");
|
||||
$("certificate-file-source").value = certificateUsesExternalFiles(cert) ? "external" : "managed";
|
||||
$("certificate-file").value = cert.cert_file; $("certificate-key").value = cert.key_file;
|
||||
$("certificate-default").checked = settings.default_certificate === cert.id;
|
||||
window.rustProxyCertificateInspection?.reset({ preserveNames: true });
|
||||
syncCertificateFileSource(); window.rustProxyCertificateInspection?.schedule();
|
||||
}));
|
||||
}
|
||||
const remove = action(t("remove"), async () => { if (inUse) { toast(t("certInUse"), true); return; } if (!(await appConfirm(t("confirmDelete"), { danger: true }))) return; const next = structuredClone(envelope.config); next.tls.certificates = next.tls.certificates.filter(v => v.id !== cert.id); await saveSection("tls", next.tls); });
|
||||
remove.disabled = inUse; remove.title = inUse ? t("certInUse") : ""; buttons.append(remove);
|
||||
@@ -157,8 +178,38 @@ function renderCertificates() {
|
||||
});
|
||||
$("certificate-list").replaceChildren(...entries);
|
||||
if (!entries.length) $("certificate-list").append(el("p", t("emptyCertificates"), "empty"));
|
||||
syncCertificatePermissions();
|
||||
}
|
||||
$("certificate-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try { await withFormSubmitState(form, event.submitter, async () => { const next = structuredClone(envelope.config); next.tls = next.tls || {}; next.tls.certificates = next.tls.certificates || []; const cert = { id: $("certificate-id").value.trim(), hosts: $("certificate-hosts").value.split(",").map(v => v.trim()).filter(Boolean), cert_file: $("certificate-file").value.trim(), key_file: $("certificate-key").value.trim() }; const index = next.tls.certificates.findIndex(v => v.id === cert.id); if (index >= 0 && next.tls.certificates[index].issuer) throw fieldError($("certificate-id"), t("certManagedReadOnly")); if (index < 0) next.tls.certificates.push(cert); else { const old = next.tls.certificates[index]; const sameFiles = old.cert_file === cert.cert_file && old.key_file === cert.key_file; next.tls.certificates[index] = { ...old, ...cert, covered_hosts: sameFiles ? (old.covered_hosts || []) : [], issuer: sameFiles ? old.issuer : null }; } if ($("certificate-default").checked) next.tls.default_certificate = cert.id; else if (next.tls.default_certificate === cert.id) delete next.tls.default_certificate; if (await saveSection("tls", next.tls)) { form.reset(); syncCertificateFileSource(); window.rustProxyCertificates?.close(); } }); } catch (error) { toast(error.message, true); } });
|
||||
$("certificate-form").addEventListener("submit", async event => {
|
||||
event.preventDefault(); const form = event.currentTarget;
|
||||
if (!envelope || busy) return;
|
||||
try {
|
||||
await withFormSubmitState(form, event.submitter, async () => {
|
||||
const epoch = authEpoch;
|
||||
if (await window.rustProxyCertificateInspection?.beforeSave() === false) return;
|
||||
if (!authenticated || epoch !== authEpoch || !envelope || busy) return;
|
||||
const next = structuredClone(envelope.config);
|
||||
next.tls ||= {}; next.tls.certificates ||= [];
|
||||
const cert = {
|
||||
id: $("certificate-id").value.trim(),
|
||||
hosts: $("certificate-hosts").value.split(",").map(v => v.trim()).filter(Boolean),
|
||||
cert_file: $("certificate-file").value.trim(), key_file: $("certificate-key").value.trim(),
|
||||
};
|
||||
const index = next.tls.certificates.findIndex(v => v.id === cert.id);
|
||||
if (index >= 0 && next.tls.certificates[index].issuer) throw fieldError($("certificate-id"), t("certManagedReadOnly"));
|
||||
if (index < 0) next.tls.certificates.push(cert);
|
||||
else {
|
||||
const old = next.tls.certificates[index], sameFiles = old.cert_file === cert.cert_file && old.key_file === cert.key_file;
|
||||
next.tls.certificates[index] = { ...old, ...cert, covered_hosts: sameFiles ? (old.covered_hosts || []) : [], issuer: sameFiles ? old.issuer : null };
|
||||
}
|
||||
if ($("certificate-default").checked) next.tls.default_certificate = cert.id;
|
||||
else if (next.tls.default_certificate === cert.id) delete next.tls.default_certificate;
|
||||
if (await saveSection("tls", next.tls)) {
|
||||
form.reset(); syncCertificateFileSource(); window.rustProxyCertificates?.close();
|
||||
}
|
||||
});
|
||||
} catch (error) { toast(error.message, true); }
|
||||
});
|
||||
$("reload-certificates").addEventListener("click", async () => {
|
||||
if (!envelope || busy) return;
|
||||
try {
|
||||
@@ -331,8 +382,6 @@ function renderNetworkInterfaceCharts(interfaces, appendSample) {
|
||||
}
|
||||
}
|
||||
|
||||
// Cumulative process counters, not rate-sampler deltas. Never invent OTHER
|
||||
// or force a 100% mix if independently read totals are inconsistent.
|
||||
function cacheOutcomeMix(totals) {
|
||||
if (!Array.isArray(totals) || totals.length !== 4 ||
|
||||
totals.some(value => !Number.isSafeInteger(value) || value < 0)) return null;
|
||||
|
||||
@@ -1478,3 +1478,30 @@ html[data-table-density=ultra] :is(table th, table td) {
|
||||
min-height: var(--button-min-height);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Keep generated ACL instructions in the original metrics-example code style. */
|
||||
.path-permissions {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.path-permissions-commands {
|
||||
max-height: 180px;
|
||||
}
|
||||
|
||||
.path-permissions-actions {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.path-permissions-options>summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.path-permissions-options[open] {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.path-permissions-account {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
max-width: 260px;
|
||||
}
|
||||
@@ -553,7 +553,8 @@ p,
|
||||
.cache-operation-dialog .card,
|
||||
.file-log-search-help-popover p,
|
||||
.file-logs-table-toolbar .fine,
|
||||
.cache-maintenance-help .subheading {
|
||||
.cache-maintenance-help .subheading,
|
||||
.path-permissions p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -1941,4 +1942,4 @@ html.app-loading,
|
||||
.web-service-preset-toggles>.check {
|
||||
margin: 0 !important;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
@@ -1183,4 +1183,4 @@ html[data-dashboard-density=normal] #panel-overview .stat>span,
|
||||
.refresh-settings-grid select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
@@ -1093,14 +1093,16 @@
|
||||
</select>
|
||||
<p class="fine" id="certificate-file-source-help"
|
||||
data-i18n="certificateSourceManagedHelp"></p>
|
||||
<pre class="metrics-example" data-i18n="certificateExternalAclExample"
|
||||
hidden id="certificate-external-acl-example"></pre>
|
||||
<label data-i18n="certificateFile" for="certificate-file"></label><input
|
||||
id="certificate-file" maxlength="96" pattern="[A-Za-z0-9_.-]+"
|
||||
placeholder="app.pem" required="" />
|
||||
<label data-i18n="privateKeyFile" for="certificate-key"></label><input
|
||||
id="certificate-key" maxlength="96" pattern="[A-Za-z0-9_.-]+"
|
||||
placeholder="app.key" required="" />
|
||||
<div hidden id="certificate-external-acl-example"></div>
|
||||
<div class="form-actions"><button type="button" class="quiet"
|
||||
id="certificate-inspect" data-i18n="certificateReadNames"></button></div>
|
||||
<p class="fine" id="certificate-inspect-status" role="status" aria-live="polite"></p>
|
||||
<label class="check"><input id="certificate-default" type="checkbox" /><span
|
||||
data-i18n="defaultCertificate"></span></label>
|
||||
<div class="form-actions"><button class="primary"
|
||||
@@ -3609,6 +3611,7 @@
|
||||
required="" /></label>
|
||||
<label class="vh-wide"><span data-i18n="webServiceRoot">Document root</span><input
|
||||
id="web-service-root" maxlength="4096" placeholder="/var/www/main" required="" /></label>
|
||||
<div class="vh-wide" hidden id="web-service-permissions"></div>
|
||||
<label class="vh-wide"><span data-i18n="webServiceIndex">Index files</span><textarea
|
||||
id="web-service-index" maxlength="4096" rows="5" spellcheck="false"></textarea><small
|
||||
class="fine" data-i18n="webServiceIndexHelp">Optional ordered index list. index.php may be
|
||||
@@ -4294,4 +4297,4 @@ log_errors = On</code></pre>
|
||||
</dialog>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
@@ -2,6 +2,9 @@
|
||||
(() => {
|
||||
let editing = null, dirty = false;
|
||||
const form = $("web-service-form"), dialog = $("web-service-dialog");
|
||||
function syncRootPermissions() {
|
||||
window.rustProxyPermissions?.render($("web-service-permissions"), { kind: "web", paths: [$("web-service-root").value] });
|
||||
}
|
||||
const lines = value => value.split(/[\n,]+/).map(item => item.trim()).filter(Boolean);
|
||||
const strictLines = value => value.split(/\n+/).map(item => item.trim()).filter(Boolean);
|
||||
const mimeLines = value => Object.entries(value || {}).map(([extension, mime]) => `${extension} = ${mime}`).join("\n");
|
||||
@@ -276,6 +279,7 @@
|
||||
syncCompression();
|
||||
syncPrecompressed();
|
||||
syncHtaccess();
|
||||
syncRootPermissions();
|
||||
}
|
||||
|
||||
function updatePresetSummary() {
|
||||
@@ -335,22 +339,23 @@
|
||||
dialog.showModal();
|
||||
}
|
||||
|
||||
function clearEditor() { editing = null; dirty = false; form.reset(); if (dialog.open) dialog.close(); }
|
||||
function clearEditor() { editing = null; dirty = false; form.reset(); syncRootPermissions(); if (dialog.open) dialog.close(); }
|
||||
function suspendDraft() {
|
||||
const activeTab = $("web-service-tabs").querySelector("button.active")?.dataset.tab || "general";
|
||||
const state = { editing, activeTab, label: $("web-service-editing").textContent, controls: window.rustProxyDrafts.captureControls(form) };
|
||||
const title = $("web-service-id").value.trim() || $("web-service-name").value.trim() || editing || t("draftUntitled");
|
||||
editing = null; dirty = false; form.reset(); dialog.close(); return { title, state };
|
||||
editing = null; dirty = false; form.reset(); syncRootPermissions(); dialog.close(); return { title, state };
|
||||
}
|
||||
function restoreDraft(state) {
|
||||
const saved = state.editing ? (envelope?.config.web_services || []).find(item => item.id === state.editing) : null;
|
||||
open(saved ? structuredClone(saved) : (state.editing ? { id: state.editing } : null));
|
||||
window.rustProxyDrafts.restoreControls(form, state.controls); editing = state.editing; $("web-service-editing").textContent = state.label;
|
||||
syncPhp(); syncPrecompressed(); syncHtaccess(); updatePresetSummary(); activateTab(state.activeTab || "general"); dirty = true;
|
||||
syncPhp(); syncPrecompressed(); syncHtaccess(); syncRootPermissions(); updatePresetSummary(); activateTab(state.activeTab || "general"); dirty = true;
|
||||
}
|
||||
function closeEditor() { if (dirty) return window.rustProxyDrafts.dock("web-server"); clearEditor(); return true; }
|
||||
|
||||
function render() {
|
||||
syncRootPermissions();
|
||||
if (!envelope) return;
|
||||
const query = ($("web-service-search")?.value || "").trim().toLowerCase();
|
||||
const services = (envelope.config.web_services || []).filter(service => !query || [service.id, service.name, service.document_root].join(" ").toLowerCase().includes(query));
|
||||
@@ -530,6 +535,8 @@
|
||||
initTabs();
|
||||
form.addEventListener("input", () => { dirty = true; });
|
||||
form.addEventListener("change", () => { dirty = true; });
|
||||
$("web-service-root").addEventListener("input", syncRootPermissions);
|
||||
$("web-service-root").addEventListener("change", syncRootPermissions);
|
||||
$("web-service-php-enabled").addEventListener("change", syncPhp);
|
||||
$("web-service-php-profile").addEventListener("change", syncPhp);
|
||||
$("web-service-php-profiles").addEventListener("click", openProfiles);
|
||||
|
||||
@@ -104,7 +104,9 @@ fn read_pem_with_policy(
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.custom_flags(libc::O_NOFOLLOW);
|
||||
// Reject special files below without blocking while opening a FIFO.
|
||||
// O_NONBLOCK has no effect on ordinary certificate/key files.
|
||||
options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
|
||||
}
|
||||
let file = options
|
||||
.open(path)
|
||||
|
||||
@@ -127,3 +127,21 @@ fn external_paths_reject_writable_non_sticky_parent_directories() {
|
||||
fs::write(&cert, b"dummy").unwrap();
|
||||
assert!(configured_certificate_path(root.path(), cert.to_str().unwrap()).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn certificate_input_rejects_fifo_without_waiting_for_a_writer() {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let fifo = root.path().join("certificate.pipe");
|
||||
let path = std::ffi::CString::new(fifo.as_os_str().as_bytes()).unwrap();
|
||||
assert_eq!(unsafe { libc::mkfifo(path.as_ptr(), 0o600) }, 0);
|
||||
let (send, receive) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let _ = send.send(read_pem_with_policy(&fifo, false, false).is_err());
|
||||
});
|
||||
assert!(receive
|
||||
.recv_timeout(std::time::Duration::from_secs(2))
|
||||
.expect("certificate reads must reject a FIFO without blocking"));
|
||||
}
|
||||
Reference in new issue
Block a user