This commit is contained in:
Mateusz Gruszczyński committed 2026-10-04 23:13:27 +02:00
1 parent e6604e41fc
commit 1dea7f55c8
49 files changed
+4499 -4025

No files matched your search

+1
View File
@@ -72,6 +72,7 @@ static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
("docs/V0.24.8_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.24.8_AUDIT.md"))),
("docs/V0.25.0_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.25.0_AUDIT.md"))),
("docs/V0.25.1_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.25.1_AUDIT.md"))),
("docs/V0.25.4_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.25.4_AUDIT.md"))),
("docs/V0.25.3_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.25.3_AUDIT.md"))),
("docs/V0.25.2_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.25.2_AUDIT.md"))),
("docs/V0.25.0_SECURITY_PREPROD_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.25.0_SECURITY_PREPROD_AUDIT.md"))),
+171 -2
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.25.3",
"version": "0.25.4",
"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": [
@@ -5046,7 +5046,7 @@
}
}
},
"description": "Sets an HttpOnly SameSite=Strict session cookie. Browser sessions use the configured 7–30 day absolute lifetime. Use the panel sign-in page. No bearer token or URL token is accepted.",
"description": "Sets an HttpOnly SameSite=Strict session cookie. Browser sessions use the configured 7\u201330 day absolute lifetime. Use the panel sign-in page. No bearer token or URL token is accepted.",
"security": [],
"parameters": [
{
@@ -9179,6 +9179,175 @@
}
}
}
},
"/api/config/history/restore": {
"post": {
"summary": "Restore a retained configuration revision as a new revision",
"description": "Loads one retained configuration document, validates it through the normal full configuration update path, and saves it as a new revision. expected_revision must match the current configuration revision; the selected source revision must be older. Existing history is preserved.",
"tags": [
"Configuration"
],
"operationId": "post_api_config_history_restore",
"parameters": [
{
"name": "Origin",
"in": "header",
"required": true,
"schema": {
"type": "string"
},
"description": "Exact http://HOST:ADMIN_PORT. Set automatically by same-origin browser POST."
},
{
"name": "X-RustProxy-Request",
"in": "header",
"required": true,
"schema": {
"type": "string",
"enum": [
"1"
]
}
}
],
"security": [
{
"AdminSession": [],
"CsrfToken": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": false,
"required": [
"revision",
"expected_revision"
],
"properties": {
"revision": {
"type": "integer",
"format": "int64",
"minimum": 0,
"description": "Older retained revision to restore."
},
"expected_revision": {
"type": "integer",
"format": "int64",
"minimum": 0,
"description": "Current revision observed by the caller."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Configuration restored and published as a new revision",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Envelope"
}
}
}
},
"400": {
"description": "Invalid restore request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Authentication required or revoked",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Host, origin or CSRF validation failed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "Requested revision is not retained",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "Configuration changed; reload before restoring",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"413": {
"description": "Request exceeds the endpoint body limit",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"422": {
"description": "Restored configuration failed current validation",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"429": {
"description": "Bounded worker/session/rate limit",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"503": {
"description": "Control-plane storage/worker unavailable",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
}
}
}
+4
View File
@@ -339,6 +339,10 @@ pub fn router(state: Arc<App>) -> Router {
.route("/api/cache/cold/cleanup/{id}", get(cache_cold_cleanup_job))
.route("/api/config", get(configuration).merge(put(update)))
.route("/api/config/history", get(config_history))
.route(
"/api/config/history/restore",
post(restore_config_history).layer(DefaultBodyLimit::max(4096)),
)
.route("/api/config/{section}", put(update_section))
.route("/api/logs", get(logs))
.route("/api/live-logs", get(live_logs_stream))
+43
View File
@@ -342,6 +342,13 @@ struct ConfigHistoryQuery {
limit: Option<usize>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ConfigHistoryRestore {
revision: u64,
expected_revision: u64,
}
async fn config_history(
State(state): State<Arc<App>>,
Query(query): Query<ConfigHistoryQuery>,
@@ -363,6 +370,42 @@ async fn config_history(
.map_err(auth::internal)?;
Ok(Json(json!({"revisions": revisions})))
}
async fn restore_config_history(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
Json(input): Json<ConfigHistoryRestore>,
) -> Result<Json<crate::config::Envelope>, ApiError> {
let current_revision = state.config.load().envelope.revision;
if current_revision != input.expected_revision {
return Err(ApiError(
StatusCode::CONFLICT,
"configuration changed; reload before restoring".into(),
));
}
if input.revision >= current_revision {
return Err(ApiError(
StatusCode::BAD_REQUEST,
"only an older configuration revision can be restored".into(),
));
}
let detail = state
.store
.configuration_revision(input.revision)
.await
.map_err(auth::internal)?
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "configuration revision not found".into()))?;
state
.restore_as(
Update {
expected_revision: input.expected_revision,
config: detail.config,
},
session.username,
input.revision,
)
.await
.map(Json)
}
async fn update(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
+1
View File
@@ -312,6 +312,7 @@
for (path, method) in [
("/api/config", "PUT"),
("/api/config/cache", "PUT"),
("/api/config/history/restore", "POST"),
("/api/acme/issue", "POST"),
("/api/acme/renew", "POST"),
("/api/docker/refresh", "POST"),
+82
View File
@@ -55,6 +55,7 @@
"cookie"
);
assert!(spec["paths"].get("/api/config").is_some());
assert!(spec["paths"].get("/api/config/history/restore").is_some());
let response = router
.clone()
.oneshot(
@@ -170,3 +171,84 @@
}
store.close().await;
}
#[tokio::test]
async fn retained_configuration_revision_can_be_restored_as_a_new_revision() {
let (router, store, _dir, secret, csrf) = fixture().await;
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
let mut first = Config::default();
first.server_header.value = "first".into();
let response = router
.clone()
.oneshot(
request("/api/config")
.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(
json!({"expected_revision":0,"config":first}).to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(json_body(response).await["revision"], 1);
let mut second = Config::default();
second.server_header.value = "second".into();
let response = router
.clone()
.oneshot(
request("/api/config")
.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(
json!({"expected_revision":1,"config":second}).to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(json_body(response).await["revision"], 2);
let response = router
.clone()
.oneshot(
request("/api/config/history/restore")
.method("POST")
.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(
json!({"revision":1,"expected_revision":2}).to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let restored = json_body(response).await;
assert_eq!(restored["revision"], 3);
assert_eq!(restored["config"]["server_header"]["value"], "first");
let audit = store.recent_audit().await.unwrap();
assert_eq!(audit[0].actor, "owner");
assert_eq!(audit[0].action, "config.restore");
assert_eq!(audit[0].detail, "source_revision=1 revision=3");
let detail = store.configuration_revision(3).await.unwrap().unwrap();
assert_eq!(detail.config.server_header.value, "first");
assert_eq!(detail.previous_config.unwrap().server_header.value, "second");
store.close().await;
}
+32 -1
View File
@@ -1,4 +1,4 @@
let configHistoryEntries = [];
let configHistoryEntries = [], configHistorySelectedRevision = null;
function historyDate(value, options = {}) {
try { return new Intl.DateTimeFormat(locale(), options).format(new Date(Number(value) * 1000)); }
catch { return new Date(Number(value) * 1000).toLocaleString(); }
@@ -13,6 +13,12 @@ function historyValue(value) {
const text = JSON.stringify(value, null, 2);
return text.length > 6000 ? `${text.slice(0, 6000)}\n...` : text;
}
function syncConfigHistoryRestoreButton() {
const button = $("config-history-restore"), revision = Number(configHistorySelectedRevision);
if (!button) return;
button.hidden = configHistorySelectedRevision === null;
button.disabled = busy || !envelope || !Number.isSafeInteger(revision) || revision >= Number(envelope.revision);
}
function configDiff(before, after, path = "$", output = [], depth = 0) {
if (output.length >= 200) return output;
if (Object.is(before, after)) return output;
@@ -29,8 +35,10 @@ function configDiff(before, after, path = "$", output = [], depth = 0) {
function renderConfigRevisionDetail(detail) {
const entry = detail?.revision;
if (!entry) return;
configHistorySelectedRevision = Number(entry.revision);
$("config-history-detail-title").textContent = `${t("configHistoryRevision")} ${entry.revision}`;
$("config-history-detail-meta").textContent = `${historyDate(entry.time, { dateStyle: "medium", timeStyle: "short" })} · ${entry.actor}`;
syncConfigHistoryRestoreButton();
document.querySelectorAll(".config-history-revision").forEach(button => button.classList.toggle("active", Number(button.dataset.revision) === Number(entry.revision)));
const diff = configDiff(entry.previous_config, entry.config);
const box = $("config-history-diff"); box.replaceChildren();
@@ -95,7 +103,29 @@ function renderConfigHistory() {
}
if (!months.length) calendar.append(el("p", t("configHistoryEmpty"), "empty"));
}
async function restoreConfigRevision() {
const revision = Number(configHistorySelectedRevision);
if (!envelope || busy || !Number.isSafeInteger(revision) || revision >= Number(envelope.revision)) return;
const expectedRevision = Number(envelope.revision);
const message = t("configHistoryRestoreConfirm").replace("{revision}", revision).replace("{current}", expectedRevision);
if (!(await appConfirm(message, { danger: true, confirmLabel: t("configHistoryRestoreAction") }))) return;
const button = $("config-history-restore"), epoch = authEpoch;
busy = true; syncConfigHistoryRestoreButton();
try {
const value = await api("/api/config/history/restore", { method: "POST", body: JSON.stringify({ revision, expected_revision: expectedRevision }) });
if (!authenticated || epoch !== authEpoch) return;
envelope = value;
await refreshCertificateInventory();
if (!authenticated || epoch !== authEpoch) return;
renderConfig();
toast(t("configHistoryRestored").replace("{revision}", revision).replace("{new_revision}", value.revision));
await refresh(true);
if (authenticated && epoch === authEpoch) await loadConfigHistory();
} catch (error) { toast(error.message, true); if (error.status === 401) lock(); }
finally { busy = false; if (button?.isConnected) syncConfigHistoryRestoreButton(); }
}
async function loadConfigHistory() {
configHistorySelectedRevision = null; syncConfigHistoryRestoreButton();
$("config-history-summary").textContent = t("configHistoryLoading");
try {
const result = await api("/api/config/history?limit=512"); configHistoryEntries = result.revisions || []; renderConfigHistory();
@@ -105,3 +135,4 @@ async function loadConfigHistory() {
$("config-history-open").addEventListener("click", () => { const dialog = $("config-history-dialog"); if (!dialog.open) dialog.showModal(); void loadConfigHistory(); });
$("config-history-close").addEventListener("click", () => $("config-history-dialog").close());
$("config-history-reload").addEventListener("click", () => void loadConfigHistory());
$("config-history-restore").addEventListener("click", () => void restoreConfigRevision());
+1 -1
View File
@@ -6,7 +6,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.25.3";
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.25.4";
for (const name of ["gzip", "br", "zstd", "deflate"]) $(`compress-${name}`).checked = !!compression[name];
$("compress-min").value = compression.min_bytes ?? 1024; syncServerMode();
}
+3 -3
View File
@@ -1472,7 +1472,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.25.3"
id="server-value" maxlength="256" placeholder="RustProxy/v0.25.4"
spellcheck="false" />
<h3 class="subheading" data-i18n="diagnosticHeaders">Diagnostic headers</h3>
<div class="switch-grid">
@@ -2218,7 +2218,7 @@
</section>
</div>
</section>
<footer class="page-footer"><span>RustProxy <b>0.25.3</b></span><span id="footer-status">API - /
<footer class="page-footer"><span>RustProxy <b>0.25.4</b></span><span id="footer-status">API - /
dropped: 0</span></footer>
</div>
</main>
@@ -2370,7 +2370,7 @@
<aside class="card config-history-detail">
<div class="config-history-detail-head">
<div><span class="small-label" data-i18n="configHistoryChanges">CHANGES</span><h3 id="config-history-detail-title">-</h3></div>
<span class="fine" id="config-history-detail-meta"></span>
<div class="config-history-detail-actions"><span class="fine" id="config-history-detail-meta"></span><button class="outline" data-i18n="configHistoryRestoreAction" id="config-history-restore" type="button" hidden disabled>Restore revision</button></div>
</div>
<div class="config-history-revisions" id="config-history-revisions"></div>
<div class="config-history-diff" id="config-history-diff"><p class="empty" data-i18n="configHistorySelectDay">Select a day.</p></div>
+14
View File
@@ -7734,6 +7734,18 @@ button.config-history-day.is-selected {
margin: 3px 0 0;
}
.config-history-detail-actions {
display: flex;
flex-direction: column;
gap: 7px;
align-items: flex-end;
text-align: right;
}
.config-history-detail-actions button {
white-space: nowrap;
}
.config-history-revisions {
display: flex;
flex-wrap: wrap;
@@ -7788,4 +7800,6 @@ button.config-history-day.is-selected {
.config-history-layout { grid-template-columns: 1fr; overflow: auto; }
.config-history-browser, .config-history-detail { overflow: visible; }
.config-history-change-values { grid-template-columns: 1fr; }
.config-history-detail-head { flex-direction: column; }
.config-history-detail-actions { align-items: flex-start; text-align: left; }
}