This commit is contained in:
Mateusz Gruszczyński committed 2026-10-03 18:53:51 +02:00
1 parent d3f0f060bf
commit 2e4aa4df4d
33 files changed
+3704 -3566

No files matched your search

+103 -2
View File
@@ -1447,6 +1447,13 @@ struct ResponseSection {
compression: crate::compression::Settings,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct LoggingSection {
logging: crate::logging::Settings,
error_log: crate::logging::ErrorLogSettings,
}
fn invalid_section(section: &str, error: serde_json::Error) -> ApiError {
ApiError(
StatusCode::BAD_REQUEST,
@@ -1502,8 +1509,17 @@ async fn update_section(
serde_json::from_value(value).map_err(|e| invalid_section(&section, e))?
}
"logging" => {
config.logging =
serde_json::from_value(value).map_err(|e| invalid_section(&section, e))?
if value.get("logging").is_some() || value.get("error_log").is_some() {
let part: LoggingSection =
serde_json::from_value(value).map_err(|e| invalid_section(&section, e))?;
config.logging = part.logging;
config.error_log = part.error_log;
} else {
// Backward compatibility for clients that still send only the
// historical access-logging object to /api/config/logging.
config.logging =
serde_json::from_value(value).map_err(|e| invalid_section(&section, e))?;
}
}
"forwarding" => {
config.forwarding =
@@ -2387,6 +2403,91 @@ mod tests {
store.close().await;
}
#[tokio::test]
async fn logging_section_persists_error_log_settings_atomically() {
let (router, store, dir, secret, csrf) = fixture().await;
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
let logging = Config::default().logging;
let mut error_log = Config::default().error_log;
error_log.enabled = true;
error_log.path = "/tmp/rustproxy-error.log".into();
let response = router
.clone()
.oneshot(
request("/api/config/logging")
.method("PUT")
.header("content-type", "application/json")
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.body(Body::from(
serde_json::to_vec(&json!({
"expected_revision": 0,
"value": {
"logging": logging,
"error_log": error_log,
},
}))
.unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let updated = json_body(response).await;
assert_eq!(updated["revision"], 1);
assert_eq!(updated["config"]["error_log"]["enabled"], true);
assert_eq!(updated["config"]["error_log"]["path"], "/tmp/rustproxy-error.log");
let configuration = router
.oneshot(
request("/api/config")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let persisted = json_body(configuration).await;
assert_eq!(persisted["config"]["error_log"]["enabled"], true);
assert_eq!(persisted["config"]["error_log"]["path"], "/tmp/rustproxy-error.log");
drop(router);
store.close().await;
let reopened = Store::open(&dir.path().join("db"), &Config::default())
.await
.unwrap();
let persisted = reopened.load().await.unwrap();
assert_eq!(persisted.revision, 1);
assert!(persisted.config.error_log.enabled);
assert_eq!(persisted.config.error_log.path, "/tmp/rustproxy-error.log");
reopened.close().await;
}
#[tokio::test]
async fn error_log_is_not_a_standalone_configuration_section() {
let (router, store, _dir, secret, csrf) = fixture().await;
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
let response = router
.oneshot(
request("/api/config/error_log")
.method("PUT")
.header("content-type", "application/json")
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.body(Body::from(r#"{"expected_revision":0,"value":{}}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
store.close().await;
}
#[tokio::test]
async fn default_pages_section_accepts_the_standard_value_payload() {
let (router, store, _dir, secret, csrf) = fixture().await;
+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.24.9",
"version": "0.24.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."
},
"servers": [
+11 -8
View File
@@ -778,7 +778,7 @@ function renderSettings() {
if (!settingsDirty.has("response-form")) {
const server = config.server_header || {}, compression = config.compression || {}, diagnostics = config.diagnostic_headers || {};
$("expose-request-id").checked = !!diagnostics.request_id;
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.24.9";
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.24.10";
for (const name of ["gzip", "br", "zstd", "deflate"]) $(`compress-${name}`).checked = !!compression[name];
$("compress-min").value = compression.min_bytes ?? 1024; syncServerMode();
}
@@ -821,23 +821,23 @@ function renderSettings() {
updateTransformResourceSizing();
}
if (!settingsDirty.has("logging-form")) {
const l = config.logging || {}, file = l.file || {}, logstash = l.logstash || {}, loki = l.loki || {}, format = l.format || {}, errorLog = config.error_log || {};
const l = config.logging || {}, file = l.file || {}, logstash = l.logstash || {}, loki = l.loki || {}, format = l.format || {};
$("log-file-enabled").checked = !!file.enabled; $("log-file-per-vhost").checked = !!file.per_vhost; $("log-file-path").value = file.path || "/var/log/rustproxy/access.jsonl"; $("log-file-directory").value = file.directory || "/var/log/rustproxy/vhosts"; syncLoggingFileMode();
$("log-logstash-enabled").checked = !!logstash.enabled; $("log-logstash-address").value = logstash.address || "127.0.0.1:5044";
$("log-loki-enabled").checked = !!loki.enabled; $("log-loki-url").value = loki.url || "http://127.0.0.1:3100/loki/api/v1/push"; $("log-loki-token").value = loki.bearer_token_env || "";
$("log-loki-labels").value = JSON.stringify(loki.labels || { job: "rustproxy" }, null, 2); $("log-format-fields").value = JSON.stringify(format.fields || {}, null, 2); $("log-format-static").value = JSON.stringify(format.static_fields || {}, null, 2);
$("log-format-preset").value = detectLoggingPreset(format.fields || {});
}
if (!settingsDirty.has("error-log-form")) {
const errorLog = config.error_log || {};
$("error-log-enabled").checked = !!errorLog.enabled;
$("error-log-path").value = errorLog.path || "/var/log/rustproxy/error.log";
$("error-log-format").value = ["JSON", "RAW"].includes(errorLog.format) ? errorLog.format : "JSON";
syncErrorLogMode();
}
const sink = latestStatus?.logging?.sinks, errorSink = latestStatus?.logging?.error_file;
if (sink) {
let status = `${t("loggingQueue")}: ${fmt(sink.queue_dropped)} ${t("loggingDropped")} / ${fmt(sink.sent)} ${t("loggingSent")} / ${fmt(sink.errors)} ${t("errors")} · ${t("loggingCapacity")}: ${fmt(sink.queue_capacity)}`;
if (errorSink) status += ` · ${t("errorLogTitle")}: ${fmt(errorSink.queue_dropped)} ${t("loggingDropped")} / ${fmt(errorSink.written)} ${t("loggingSent")} / ${fmt(errorSink.errors)} ${t("errors")}`;
$("logging-status").textContent = status;
}
$("logging-status").textContent = sink ? `${t("loggingQueue")}: ${fmt(sink.queue_dropped)} ${t("loggingDropped")} / ${fmt(sink.sent)} ${t("loggingSent")} / ${fmt(sink.errors)} ${t("errors")} · ${t("loggingCapacity")}: ${fmt(sink.queue_capacity)}` : "";
$("error-log-status").textContent = errorSink ? `${t("loggingQueue")}: ${fmt(errorSink.queue_dropped)} ${t("loggingDropped")} / ${fmt(errorSink.written)} ${t("loggingSent")} / ${fmt(errorSink.errors)} ${t("errors")} · ${t("loggingCapacity")}: ${fmt(errorSink.queue_capacity)}` : "";
if (!settingsDirty.has("forwarding-form")) { $("trusted-proxies").value = (config.forwarding?.trusted_proxies || []).join("\n"); $("trusted-proto").checked = !!config.forwarding?.trust_forwarded_proto; }
if (!settingsDirty.has("headers-form")) { for (const direction of ["request", "response"]) renderHeaderRules(direction, config.headers?.[direction] || []); }
}
@@ -1120,6 +1120,7 @@ function applyLoggingPreset(name) {
$("log-format-fields").value = JSON.stringify(LOGGING_PRESETS[name], null, 2);
setSettingsDirty("logging-form");
}
const selectLoggingSection = next => ({ logging: next.logging, error_log: next.error_log });
bindSettingsForm("logging-form", next => {
const parseObject = id => parseJsonObjectValue($(id).value, $(id));
next.logging = {
@@ -1128,12 +1129,14 @@ bindSettingsForm("logging-form", next => {
loki: { enabled: $("log-loki-enabled").checked, url: $("log-loki-url").value.trim(), bearer_token_env: $("log-loki-token").value.trim() || null, labels: parseObject("log-loki-labels") },
format: { fields: parseObject("log-format-fields"), static_fields: parseObject("log-format-static") }
};
}, "logging", selectLoggingSection);
bindSettingsForm("error-log-form", next => {
next.error_log = {
enabled: $("error-log-enabled").checked,
path: $("error-log-path").value.trim(),
format: $("error-log-format").value
};
}, "logging");
}, "logging", selectLoggingSection);
bindSettingsForm("forwarding-form", next => { next.forwarding = { trusted_proxies: $("trusted-proxies").value.split(/[\n,]/).map(s => s.trim()).filter(Boolean), trust_forwarded_proto: $("trusted-proto").checked }; }, "forwarding");
bindSettingsForm("headers-form", next => { next.headers = { request: collectHeaderRules("request"), response: collectHeaderRules("response") }; }, "headers");
$("log-format-preset").addEventListener("change", event => applyLoggingPreset(event.target.value));
+32 -21
View File
@@ -479,17 +479,22 @@
by interface</span><b id="network-interface-chart-value">-</b></div>
<div class="network-interface-charts" id="network-interface-charts"></div>
</div>
<details class="mini-live-chart mini-live-chart-collapsible">
<summary class="mini-live-chart-head"><span
data-i18n="responseLatencyHistory">Response time history</span><b
id="latency-chart-value">-</b></summary>
<div class="mini-live-chart">
<div class="mini-live-chart-head"><span
data-i18n="responseLatencyHistory">Response time history</span><span
class="mini-live-chart-actions"><details class="metric-info">
<summary data-i18n-aria="responseLatencyInfo" aria-label="Response time information">i</summary>
<div class="metric-info-popover">
<p class="fine" data-i18n="responseLatencyHint">The value is the cumulative mean duration of completed HTTP exchanges since process start. The chart shows successive samples of that mean; it is not a percentile or the latency of one request.</p>
</div>
</details><b id="latency-chart-value">-</b></span></div>
<svg aria-label="Mean response latency history"
data-i18n-aria="responseLatencyHistory" role="img" viewbox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line" id="latency-sparkline" points="0,55 300,55">
</polyline>
</svg>
</details>
</div>
</div>
</article>
</div>
@@ -1391,7 +1396,7 @@
<option data-i18n="serverCustom" value="custom">Replace / add custom value</option>
</select>
<label data-i18n="serverValue" for="server-value">Custom Server value</label><input
id="server-value" maxlength="256" placeholder="RustProxy/v0.24.9"
id="server-value" maxlength="256" placeholder="RustProxy/v0.24.10"
spellcheck="false" />
<h3 class="subheading" data-i18n="diagnosticHeaders">Diagnostic headers</h3>
<div class="switch-grid">
@@ -1798,20 +1803,6 @@
<label><span data-i18n="loggingLabels">Labels (JSON object)</span><textarea
id="log-loki-labels" rows="4" spellcheck="false"></textarea></label>
</article>
<article class="card">
<h3 class="subheading" data-i18n="errorLogTitle">Error file</h3>
<label class="check"><input id="error-log-enabled" type="checkbox" /><span
data-i18n="enabled">Enabled</span></label>
<label><span data-i18n="errorLogPath">Error log path</span><input
id="error-log-path" maxlength="1024"
placeholder="/var/log/rustproxy/error.log" /></label>
<label><span data-i18n="errorLogFormat">Format</span><select
id="error-log-format">
<option value="JSON">JSON</option>
<option value="RAW">RAW</option>
</select></label>
<p class="fine" data-i18n="errorLogHelp">Only ERROR events are written. Rotation is handled by system logrotate.</p>
</article>
</div>
<h3 class="subheading" data-i18n="loggingFormat">Configurable JSON format</h3>
<p class="fine" data-i18n="loggingFormatHelp">Map output names to source fields. Leave
@@ -1849,6 +1840,26 @@
type="submit">Validate, save
and apply</button></div>
</form>
<form class="card edit-form" id="error-log-form">
<div class="card-heading">
<h2 data-i18n="errorLogTitle">Error file</h2><span class="pill live" data-i18n="hotApply">LIVE APPLY</span>
</div>
<label class="check"><input id="error-log-enabled" type="checkbox" /><span
data-i18n="enabled">Enabled</span></label>
<div class="field-grid">
<label><span data-i18n="errorLogPath">Error log path</span><input
id="error-log-path" maxlength="1024"
placeholder="/var/log/rustproxy/error.log" /></label>
<label><span data-i18n="errorLogFormat">Format</span><select
id="error-log-format">
<option value="JSON">JSON</option>
<option value="RAW">RAW</option>
</select></label>
</div>
<p class="fine" id="error-log-status" role="status"></p>
<div class="form-actions"><button class="primary" data-i18n="apply"
type="submit">Validate, save and apply</button></div>
</form>
</div>
<div aria-labelledby="settings-tab-prometheus" class="settings-page"
data-settings-page="prometheus" hidden="" id="settings-page-prometheus" role="tabpanel">
@@ -2112,7 +2123,7 @@
</section>
</div>
</section>
<footer class="page-footer"><span>RustProxy <b>0.24.9</b></span><span id="footer-status">API - /
<footer class="page-footer"><span>RustProxy <b>0.24.10</b></span><span id="footer-status">API - /
dropped: 0</span></footer>
</div>
</main>
+12 -29
View File
@@ -1758,6 +1758,14 @@ progress {
text-align: right
}
.mini-live-chart-actions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 7px;
min-width: 0
}
.mini-live-chart svg {
display: block;
width: 100%;
@@ -3773,6 +3781,10 @@ body[data-density="dense"] .stat strong {
max-width: none
}
.settings-page>.edit-form + .edit-form {
margin-top: var(--ui-gap)
}
.settings-page>.card {
padding: var(--ui-card)
}
@@ -6359,31 +6371,6 @@ body[data-density="compact"] .page-footer {
}
}
.mini-live-chart-collapsible>summary {
cursor: pointer;
list-style: none;
user-select: none
}
.mini-live-chart-collapsible>summary::-webkit-details-marker {
display: none
}
.mini-live-chart-collapsible>summary::before {
content: "▸";
flex: 0 0 auto;
color: var(--muted);
margin-right: 1px
}
.mini-live-chart-collapsible[open]>summary::before {
content: "▾"
}
.mini-live-chart-collapsible>summary>span {
flex: 1 1 auto
}
.network-interface-charts {
gap: 8px;
padding-right: 0
@@ -6552,10 +6539,6 @@ body[data-density="compact"] .page-footer {
}
}
.mini-live-chart-collapsible:not([open])>summary {
margin-bottom: 0
}
.docker-route-grid {
grid-template-columns: minmax(190px, 1fr) minmax(250px, 1.35fr) minmax(210px, 1fr) minmax(120px, .65fr)
}
+1 -1
View File
@@ -600,7 +600,7 @@
authMode.addEventListener("change", syncAuth); syncAuth();
g = group("vhResponsePolicy"); const server = value.server_header, diagnostics = value.diagnostic_headers;
const serverMode = field(g, "server-mode", "serverHeader", server?.mode || "inherit", "select", { values: [["inherit", "vhInherit"], ["preserve", "serverPreserve"], ["hide", "serverHide"], ["custom", "serverCustom"]] });
const serverValue = field(g, "server-value", "serverValue", server?.value || "RustProxy/v0.24.9", "text", { maxLength: 256 });
const serverValue = field(g, "server-value", "serverValue", server?.value || "RustProxy/v0.24.10", "text", { maxLength: 256 });
const syncServer = () => { serverValue.disabled = serverMode.value !== "custom"; serverValue.required = !serverValue.disabled; }; serverMode.addEventListener("change", syncServer); syncServer();
const inheritDiagnostics = field(g, "diag-inherit", "diagInherit", !diagnostics, "checkbox"), diagInputs = [];
for (const [name, key] of [["request_id", "diagRequestId"]]) diagInputs.push(field(g, `diag-${name}`, key, diagnostics?.[name] ?? envelope?.config.diagnostic_headers?.[name], "checkbox"));
+2 -2
View File
@@ -41,7 +41,7 @@ use tracing_subscriber::prelude::*;
#[command(
name = "rust-proxy",
version,
about = "RustProxy - independent Rust reverse proxy 0.24.9"
about = "RustProxy - independent Rust reverse proxy 0.24.10"
)]
struct Cli {
#[arg(
@@ -1515,7 +1515,7 @@ async fn live_logs_command(dir: PathBuf, addr: SocketAddr) -> Result<()> {
}
});
let interactive = io::stdin().is_terminal() && io::stdout().is_terminal();
println!("RustProxy live logs 0.24.9 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
println!("RustProxy live logs 0.24.10 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
if interactive { print!("> "); io::stdout().flush()?; }
let mut filters = LiveLogFilters::default();
let mut history = std::collections::BTreeMap::<u64, serde_json::Value>::new();