v0.25.3
This commit is contained in:
1 parent
174389e5b1
commit
e6604e41fc
73 files changed
+1240
-201
No files matched your search
@@ -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.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"))),
|
||||
("docs/VHOSTS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/VHOSTS.md"))),
|
||||
|
||||
+164
-4
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "RustProxy Administration API",
|
||||
"version": "0.25.2",
|
||||
"version": "0.25.3",
|
||||
"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": [
|
||||
@@ -300,6 +300,17 @@
|
||||
],
|
||||
"nullable": true
|
||||
},
|
||||
"acl_profile_id": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Optional reusable ACL profile; profile and inline location policies are cumulative."
|
||||
},
|
||||
"country_access": {
|
||||
"$ref": "#/components/schemas/CountryAccess"
|
||||
},
|
||||
"method_policy": {
|
||||
"$ref": "#/components/schemas/MethodPolicy"
|
||||
},
|
||||
"headers": {
|
||||
"$ref": "#/components/schemas/HeaderRules"
|
||||
},
|
||||
@@ -420,6 +431,9 @@
|
||||
],
|
||||
"nullable": true
|
||||
},
|
||||
"method_policy": {
|
||||
"$ref": "#/components/schemas/MethodPolicy"
|
||||
},
|
||||
"basic_auth": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
@@ -1230,6 +1244,56 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"MethodPolicy": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"off",
|
||||
"allow",
|
||||
"block"
|
||||
],
|
||||
"default": "off"
|
||||
},
|
||||
"methods": {
|
||||
"type": "array",
|
||||
"maxItems": 32,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"maxLength": 32
|
||||
},
|
||||
"default": []
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"description": "HTTP method policy. allow permits only listed methods; block rejects listed methods."
|
||||
},
|
||||
"CountryAccess": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"off",
|
||||
"block",
|
||||
"allow"
|
||||
],
|
||||
"default": "off"
|
||||
},
|
||||
"countries": {
|
||||
"type": "array",
|
||||
"maxItems": 128,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z]{2}$"
|
||||
},
|
||||
"default": []
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"description": "Country access policy evaluated against the verified effective client IP."
|
||||
},
|
||||
"AclProfile": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1248,9 +1312,7 @@
|
||||
"nullable": true
|
||||
},
|
||||
"country_access": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": true
|
||||
"$ref": "#/components/schemas/CountryAccess"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
@@ -9019,6 +9081,104 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/config/history": {
|
||||
"get": {
|
||||
"summary": "Browse retained manual configuration revisions",
|
||||
"description": "Returns up to 512 retained revision metadata entries, or one revision with its previous retained configuration for diff rendering.",
|
||||
"tags": [
|
||||
"Configuration"
|
||||
],
|
||||
"operationId": "get_api_config_history",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "revision",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": 0
|
||||
},
|
||||
"description": "Return one retained revision and its previous retained configuration."
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 512,
|
||||
"default": 512
|
||||
},
|
||||
"description": "Maximum metadata entries when revision is omitted."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Revision history or revision detail",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Requested revision is not retained",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ fn admin_app_javascript() -> &'static str {
|
||||
static ASSET: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
|
||||
[
|
||||
include_str!("web/app/core.js"),
|
||||
include_str!("web/app/config-history.js"),
|
||||
include_str!("web/app/runtime.js"),
|
||||
include_str!("web/app/settings.js"),
|
||||
].join("\n")
|
||||
@@ -337,6 +338,7 @@ 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/{section}", put(update_section))
|
||||
.route("/api/logs", get(logs))
|
||||
.route("/api/live-logs", get(live_logs_stream))
|
||||
|
||||
@@ -60,6 +60,9 @@ fn status_payload(state: &App) -> Value {
|
||||
"custom_pages",
|
||||
"rate_limit",
|
||||
"country_access",
|
||||
"method_policy",
|
||||
"location_access_policy",
|
||||
"configuration_history",
|
||||
"cloudflare_client_ip",
|
||||
"server_timing",
|
||||
"static_cookie_cache",
|
||||
@@ -332,6 +335,34 @@ async fn certificate_inventory(State(state): State<Arc<App>>) -> Json<Value> {
|
||||
async fn configuration(State(state): State<Arc<App>>) -> Json<crate::config::Envelope> {
|
||||
Json(state.config.load().envelope.clone())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ConfigHistoryQuery {
|
||||
revision: Option<u64>,
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
async fn config_history(
|
||||
State(state): State<Arc<App>>,
|
||||
Query(query): Query<ConfigHistoryQuery>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
if let Some(revision) = query.revision {
|
||||
let detail = state
|
||||
.store
|
||||
.configuration_revision(revision)
|
||||
.await
|
||||
.map_err(auth::internal)?
|
||||
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "configuration revision not found".into()))?;
|
||||
return Ok(Json(json!({"revision": detail})));
|
||||
}
|
||||
let limit = query.limit.unwrap_or(512).clamp(1, 512);
|
||||
let revisions = state
|
||||
.store
|
||||
.configuration_history(limit)
|
||||
.await
|
||||
.map_err(auth::internal)?;
|
||||
Ok(Json(json!({"revisions": revisions})))
|
||||
}
|
||||
async fn update(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
|
||||
@@ -252,6 +252,7 @@
|
||||
for path in [
|
||||
"/api/status",
|
||||
"/api/config",
|
||||
"/api/config/history",
|
||||
"/api/backends",
|
||||
"/api/docker",
|
||||
"/api/traffic",
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
let configHistoryEntries = [];
|
||||
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(); }
|
||||
}
|
||||
function historyDayKey(value) {
|
||||
const date = new Date(Number(value) * 1000);
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
function historyMonthKey(value) { return historyDayKey(value).slice(0, 7); }
|
||||
function historyValue(value) {
|
||||
if (value === undefined) return t("configHistoryMissing");
|
||||
const text = JSON.stringify(value, null, 2);
|
||||
return text.length > 6000 ? `${text.slice(0, 6000)}\n...` : text;
|
||||
}
|
||||
function configDiff(before, after, path = "$", output = [], depth = 0) {
|
||||
if (output.length >= 200) return output;
|
||||
if (Object.is(before, after)) return output;
|
||||
const beforeObject = before && typeof before === "object" && !Array.isArray(before);
|
||||
const afterObject = after && typeof after === "object" && !Array.isArray(after);
|
||||
if (beforeObject && afterObject && depth < 10) {
|
||||
const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort();
|
||||
for (const key of keys) configDiff(before[key], after[key], `${path}.${key}`, output, depth + 1);
|
||||
return output;
|
||||
}
|
||||
if (JSON.stringify(before) !== JSON.stringify(after)) output.push({ path, before, after });
|
||||
return output;
|
||||
}
|
||||
function renderConfigRevisionDetail(detail) {
|
||||
const entry = detail?.revision;
|
||||
if (!entry) return;
|
||||
$("config-history-detail-title").textContent = `${t("configHistoryRevision")} ${entry.revision}`;
|
||||
$("config-history-detail-meta").textContent = `${historyDate(entry.time, { dateStyle: "medium", timeStyle: "short" })} · ${entry.actor}`;
|
||||
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();
|
||||
if (!entry.previous_config) { box.append(el("p", t("configHistoryBaseline"), "empty")); return; }
|
||||
if (!diff.length) { box.append(el("p", t("configHistoryNoChanges"), "empty")); return; }
|
||||
for (const change of diff) {
|
||||
const card = el("article", undefined, "config-history-change");
|
||||
card.append(el("strong", change.path, "config-history-change-path"));
|
||||
const values = el("div", undefined, "config-history-change-values");
|
||||
for (const [label, value] of [[t("configHistoryBefore"), change.before], [t("configHistoryAfter"), change.after]]) {
|
||||
const side = el("div"); side.append(el("span", label, "small-label"), el("pre", historyValue(value))); values.append(side);
|
||||
}
|
||||
card.append(values); box.append(card);
|
||||
}
|
||||
if (diff.length >= 200) box.append(el("p", t("configHistoryDiffLimit"), "fine"));
|
||||
}
|
||||
async function loadConfigRevision(revision) {
|
||||
try { renderConfigRevisionDetail(await api(`/api/config/history?revision=${encodeURIComponent(revision)}`)); }
|
||||
catch (error) { toast(error.message, true); }
|
||||
}
|
||||
function selectConfigHistoryDay(dayKey, entries) {
|
||||
document.querySelectorAll(".config-history-day").forEach(button => button.classList.toggle("is-selected", button.dataset.day === dayKey));
|
||||
const revisions = $("config-history-revisions"); revisions.replaceChildren();
|
||||
const sorted = [...entries].sort((a, b) => b.revision - a.revision);
|
||||
for (const entry of sorted) {
|
||||
const button = el("button", `${t("configHistoryRevisionShort")} ${entry.revision}`, "quiet config-history-revision");
|
||||
button.type = "button"; button.dataset.revision = entry.revision; button.title = `${entry.actor} · ${historyDate(entry.time, { dateStyle: "medium", timeStyle: "short" })}`;
|
||||
button.addEventListener("click", () => void loadConfigRevision(entry.revision)); revisions.append(button);
|
||||
}
|
||||
if (sorted[0]) void loadConfigRevision(sorted[0].revision);
|
||||
}
|
||||
function renderConfigHistory() {
|
||||
const calendar = $("config-history-calendar"); calendar.replaceChildren();
|
||||
const byMonth = new Map(), byDay = new Map();
|
||||
for (const entry of configHistoryEntries) {
|
||||
const month = historyMonthKey(entry.time), day = historyDayKey(entry.time);
|
||||
if (!byMonth.has(month)) byMonth.set(month, []); byMonth.get(month).push(entry);
|
||||
if (!byDay.has(day)) byDay.set(day, []); byDay.get(day).push(entry);
|
||||
}
|
||||
$("config-history-summary").textContent = configHistoryEntries.length ? t("configHistorySummary").replace("{count}", configHistoryEntries.length) : t("configHistoryEmpty");
|
||||
const months = [...byMonth.keys()].sort().reverse();
|
||||
for (const key of months) {
|
||||
const [yearText, monthText] = key.split("-"), year = Number(yearText), month = Number(monthText) - 1;
|
||||
const panel = el("section", undefined, "config-history-month");
|
||||
panel.append(el("h4", new Intl.DateTimeFormat(locale(), { month: "long", year: "numeric" }).format(new Date(year, month, 1))));
|
||||
const weekdays = el("div", undefined, "config-history-weekdays");
|
||||
for (let day = 0; day < 7; day++) weekdays.append(el("span", new Intl.DateTimeFormat(locale(), { weekday: "narrow" }).format(new Date(2024, 0, 7 + day))));
|
||||
panel.append(weekdays);
|
||||
const days = el("div", undefined, "config-history-days"), first = new Date(year, month, 1).getDay(), count = new Date(year, month + 1, 0).getDate();
|
||||
for (let blank = 0; blank < first; blank++) days.append(el("span", "", "config-history-day"));
|
||||
for (let day = 1; day <= count; day++) {
|
||||
const dayKey = `${yearText}-${monthText}-${String(day).padStart(2, "0")}`, entries = byDay.get(dayKey) || [];
|
||||
const node = el(entries.length ? "button" : "span", String(day), `config-history-day${entries.length ? " has-revisions" : ""}`);
|
||||
if (entries.length) {
|
||||
node.type = "button"; node.dataset.day = dayKey;
|
||||
node.append(el("small", `${entries.length} ${t("configHistoryEntries")}`, "config-history-day-count"));
|
||||
node.addEventListener("click", () => selectConfigHistoryDay(dayKey, entries));
|
||||
}
|
||||
days.append(node);
|
||||
}
|
||||
panel.append(days); calendar.append(panel);
|
||||
}
|
||||
if (!months.length) calendar.append(el("p", t("configHistoryEmpty"), "empty"));
|
||||
}
|
||||
async function loadConfigHistory() {
|
||||
$("config-history-summary").textContent = t("configHistoryLoading");
|
||||
try {
|
||||
const result = await api("/api/config/history?limit=512"); configHistoryEntries = result.revisions || []; renderConfigHistory();
|
||||
const latest = configHistoryEntries[0]; if (latest) selectConfigHistoryDay(historyDayKey(latest.time), configHistoryEntries.filter(entry => historyDayKey(entry.time) === historyDayKey(latest.time)));
|
||||
} catch (error) { $("config-history-summary").textContent = t("configHistoryFailed"); toast(error.message, true); }
|
||||
}
|
||||
$("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());
|
||||
@@ -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.2";
|
||||
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.25.3";
|
||||
for (const name of ["gzip", "br", "zstd", "deflate"]) $(`compress-${name}`).checked = !!compression[name];
|
||||
$("compress-min").value = compression.min_bytes ?? 1024; syncServerMode();
|
||||
}
|
||||
|
||||
@@ -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.2"
|
||||
id="server-value" maxlength="256" placeholder="RustProxy/v0.25.3"
|
||||
spellcheck="false" />
|
||||
<h3 class="subheading" data-i18n="diagnosticHeaders">Diagnostic headers</h3>
|
||||
<div class="switch-grid">
|
||||
@@ -2123,8 +2123,7 @@
|
||||
<div class="card-heading">
|
||||
<div><span class="small-label">DESIRED STATE / SQLITE</span>
|
||||
<h2 data-i18n="manualConfig">Manual config</h2>
|
||||
</div><button class="outline" data-i18n="export" id="export-config">Export
|
||||
JSON</button>
|
||||
</div><div class="manual-config-tools"><button class="quiet" data-i18n="configHistory" id="config-history-open" type="button">Revision history</button><button class="outline" data-i18n="export" id="export-config" type="button">Export JSON</button></div>
|
||||
</div>
|
||||
<p class="fine" data-i18n="configHelp">A valid save creates one new revision. Invalid or
|
||||
stale changes leave the running configuration untouched.</p>
|
||||
@@ -2219,7 +2218,7 @@
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
<footer class="page-footer"><span>RustProxy <b>0.25.2</b></span><span id="footer-status">API - /
|
||||
<footer class="page-footer"><span>RustProxy <b>0.25.3</b></span><span id="footer-status">API - /
|
||||
dropped: 0</span></footer>
|
||||
</div>
|
||||
</main>
|
||||
@@ -2355,6 +2354,29 @@
|
||||
<p class="empty" data-i18n="tunnelsEmpty" id="tunnels-empty">No active tunnels.</p>
|
||||
</article>
|
||||
</dialog>
|
||||
<dialog aria-labelledby="config-history-title" class="config-history-dialog" id="config-history-dialog">
|
||||
<div class="window-heading">
|
||||
<div><span class="small-label" data-i18n="configHistoryRetention">SQLITE / LAST 512 REVISIONS</span>
|
||||
<h2 data-i18n="configHistory" id="config-history-title">Revision history</h2>
|
||||
</div>
|
||||
<div class="window-tools"><button class="quiet" data-i18n="reload" id="config-history-reload" type="button">Reload</button><button class="outline" data-i18n="close" id="config-history-close" type="button">Close</button></div>
|
||||
</div>
|
||||
<div class="config-history-layout">
|
||||
<section class="card config-history-browser">
|
||||
<p class="fine" data-i18n="configHistoryHelp">Select a marked day to inspect the saved configuration revisions and their changes.</p>
|
||||
<p class="fine" id="config-history-summary" role="status"></p>
|
||||
<div class="config-history-months" id="config-history-calendar"></div>
|
||||
</section>
|
||||
<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>
|
||||
<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>
|
||||
</aside>
|
||||
</div>
|
||||
</dialog>
|
||||
<dialog aria-labelledby="audit-dialog-title" class="audit-dialog" id="audit-dialog">
|
||||
<div class="window-heading">
|
||||
<div><span class="small-label" data-i18n="auditRetention">SQLITE / LAST 100 / RETAINED 10,000</span>
|
||||
|
||||
@@ -7619,3 +7619,173 @@ body[data-density="compact"] .page-footer {
|
||||
#web-service-dialog .web-service-page {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.manual-config-tools {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.config-history-dialog {
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
padding: 0;
|
||||
width: min(1180px, calc(100vw - 24px));
|
||||
max-width: calc(100vw - 24px);
|
||||
max-height: calc(100dvh - 28px);
|
||||
overflow: hidden;
|
||||
border-radius: 7px;
|
||||
box-shadow: 0 18px 70px var(--shadow-strong);
|
||||
}
|
||||
|
||||
.config-history-dialog::backdrop {
|
||||
background: var(--shadow-strong);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.config-history-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(380px, .95fr) minmax(420px, 1.05fr);
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
max-height: calc(100dvh - 90px);
|
||||
}
|
||||
|
||||
.config-history-browser,
|
||||
.config-history-detail {
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.config-history-months {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.config-history-month {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 9px;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.config-history-month h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.config-history-weekdays,
|
||||
.config-history-days {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.config-history-weekdays span {
|
||||
color: var(--muted);
|
||||
font: 9px var(--mono);
|
||||
text-align: center;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.config-history-day {
|
||||
min-height: 34px;
|
||||
padding: 4px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font: 10px var(--mono);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
button.config-history-day.has-revisions {
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
border-color: var(--line-strong, var(--line));
|
||||
background: var(--surface, var(--bg));
|
||||
}
|
||||
|
||||
button.config-history-day.has-revisions:hover,
|
||||
button.config-history-day.is-selected {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.config-history-day-count {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
font-size: 8px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.config-history-detail-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.config-history-detail-head h3 {
|
||||
margin: 3px 0 0;
|
||||
}
|
||||
|
||||
.config-history-revisions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.config-history-revision {
|
||||
font: 10px var(--mono);
|
||||
}
|
||||
|
||||
.config-history-revision.active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.config-history-diff {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.config-history-change {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 5px;
|
||||
padding: 8px;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.config-history-change-path {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font: 10px var(--mono);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.config-history-change-values {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.config-history-change pre {
|
||||
margin: 3px 0 0;
|
||||
max-height: 160px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font: 10px var(--mono);
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.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; }
|
||||
}
|
||||
@@ -47,7 +47,7 @@
|
||||
frontend: { hosts: [], listener_ids: [] },
|
||||
source: { type: "reverse_proxy" },
|
||||
backends: [{ id: "origin-1", url: "", weight: 1, enabled: true, pool_eligible: true, allow_private: true, accept_local_listener_risk: false, preserve_host: true, protocol: "http1", tls_verify: false }],
|
||||
cache: { enabled: false }, transform: { enabled: false }, rate_limit: {}, routes: [], headers: {}, acl_profile_id: null, acl: null, tls_policy: null, proxy_timeouts: defaultProxyTimeouts()
|
||||
cache: { enabled: false }, transform: { enabled: false }, rate_limit: {}, method_policy: { mode: "off", methods: [] }, routes: [], headers: {}, acl_profile_id: null, acl: null, tls_policy: null, proxy_timeouts: defaultProxyTimeouts()
|
||||
});
|
||||
|
||||
function activateTab(name) { document.querySelectorAll("#vhost-tabs button").forEach(b => { const active = b.dataset.tab === name; b.classList.toggle("active", active); b.setAttribute("aria-pressed", String(active)); b.setAttribute("aria-selected", String(active)); b.tabIndex = active ? 0 : -1; }); container.querySelectorAll(".vh-group").forEach(g => g.hidden = g.dataset.tab !== name); }
|
||||
@@ -296,7 +296,11 @@
|
||||
source: action === "web_server" ? { type: "web_server", web_service_id: str("location-web-service", r) } : { type: "reverse_proxy" },
|
||||
backend_id: action === "proxy" ? (str("location-backend", r) || null) : null,
|
||||
enabled: yes("location-enabled", r), cache: action === "proxy" ? (str("location-cache", r) === "" ? null : str("location-cache", r) === "on") : false,
|
||||
acl: r.readAcl(), headers: r.readHeaders(),
|
||||
acl_profile_id: str("location-acl-profile", r) || null,
|
||||
acl: r.readAcl(),
|
||||
country_access: { mode: str("location-country-mode", r), countries: lines(str("location-country-list", r)).map(value => value.toUpperCase()) },
|
||||
method_policy: { mode: str("location-method-mode", r), methods: lines(str("location-method-list", r)).map(value => value.toUpperCase()) },
|
||||
headers: r.readHeaders(),
|
||||
rewrite: { mode: handled ? rewriteMode : "preserve", value: handled && rewriteMode !== "preserve" ? read("location-rewrite-value", r).value.trim() : "" },
|
||||
response: action === "return" ? { status: Number(str("location-response-status", r)), content_type: str("location-response-type", r), body: read("location-response-body", r).value } : null
|
||||
};
|
||||
@@ -328,7 +332,18 @@
|
||||
field(returnBox, "location-response-body", "locationResponseBody", value.response?.body || "", "textarea", { rows: 5, maxLength: 65536 });
|
||||
note(handlingBody, "locationRewriteHelp");
|
||||
|
||||
const extra = el("details", undefined, "vh-wide location-section"), accessBody = el("div", undefined, "vh-grid"); extra.append(text("summary", "locationAccess"), accessBody); body.append(extra); const acl = aclEditor(accessBody, value.acl, sync); row.readAcl = () => acl.read();
|
||||
const extra = el("details", undefined, "vh-wide location-section"), accessBody = el("div", undefined, "vh-grid"); extra.append(text("summary", "locationAccess"), accessBody); body.append(extra);
|
||||
const locationProfile = field(accessBody, "location-acl-profile", "locationAclProfile", value.acl_profile_id || "", "select");
|
||||
const locationNoProfile = text("option", "vhAclProfileNone"); locationNoProfile.value = "";
|
||||
locationProfile.append(locationNoProfile, ...(envelope?.config.acl_profiles || []).map(profile => { const option = el("option", profile.name || profile.id); option.value = profile.id; return option; }));
|
||||
locationProfile.value = value.acl_profile_id || "";
|
||||
field(accessBody, "location-method-mode", "locationMethodMode", value.method_policy?.mode || "off", "select", { values: [["off", "vhOff"], ["allow", "locationMethodAllow"], ["block", "locationMethodBlock"]] });
|
||||
field(accessBody, "location-method-list", "locationMethodList", (value.method_policy?.methods || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 });
|
||||
note(accessBody, "locationMethodHelp");
|
||||
field(accessBody, "location-country-mode", "locationCountryMode", value.country_access?.mode || "off", "select", { values: [["off", "vhOff"], ["block", "vhCountryBlock"], ["allow", "vhCountryAllow"]] });
|
||||
field(accessBody, "location-country-list", "locationCountryList", (value.country_access?.countries || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 });
|
||||
note(accessBody, "locationCountryHelp");
|
||||
const acl = aclEditor(accessBody, value.acl, sync); row.readAcl = () => acl.read();
|
||||
const headerBox = el("details", undefined, "vh-wide location-section location-header-editor"), headerBody = el("div", undefined, "vh-grid"); headerBox.append(text("summary", "locationHeaders"), headerBody); body.append(headerBox);
|
||||
const headerToolbar = el("div", undefined, "vh-wide location-header-toolbar"), visualPane = el("div", undefined, "vh-wide vh-grid"), jsonPane = el("div", undefined, "vh-wide vh-grid"); jsonPane.hidden = true;
|
||||
const visualButton = button("headersVisualBuilder", () => { visualPane.hidden = false; jsonPane.hidden = true; }), jsonButton = button("headersAdvancedJson", () => { visualPane.hidden = true; jsonPane.hidden = false; }), docsButton = button("documentation", () => openLocationHelp("headers"));
|
||||
@@ -381,7 +396,12 @@
|
||||
const replacement = read("location-rewrite-value", row).value.trim();
|
||||
previewValues.rewrite.textContent = `${t(rewriteLabels[rewriteMode.value] || "locationRewritePreserve")}${replacement && !["preserve", "strip_prefix"].includes(rewriteMode.value) ? `: ${replacement}` : ""} · ${t("locationPreviewQueryPreserved")}`;
|
||||
}
|
||||
previewValues.access.textContent = yes("acl-use", row) ? t("locationPreviewCustomAcl") : t("locationPreviewVhostAcl");
|
||||
const accessParts = [];
|
||||
const profile = str("location-acl-profile", row); if (profile) accessParts.push(`${t("locationAclProfile")}: ${profile}`);
|
||||
const methodMode = str("location-method-mode", row); if (methodMode !== "off") accessParts.push(`${t("locationMethodMode")}: ${t(methodMode === "allow" ? "locationMethodAllow" : "locationMethodBlock")} ${lines(str("location-method-list", row)).join(", ")}`);
|
||||
const countryMode = str("location-country-mode", row); if (countryMode !== "off") accessParts.push(`${t("locationCountryMode")}: ${t(countryMode === "allow" ? "vhCountryAllow" : "vhCountryBlock")} ${lines(str("location-country-list", row)).join(", ")}`);
|
||||
if (yes("acl-use", row)) accessParts.push(t("locationPreviewCustomAcl"));
|
||||
previewValues.access.textContent = accessParts.length ? accessParts.join(" · ") : t("locationPreviewVhostAcl");
|
||||
try {
|
||||
const headerValue = editor.read(), requestRules = headerValue.request?.length || 0, responseRules = headerValue.response?.length || 0;
|
||||
previewValues.headers.textContent = `${requestRules} ${t("locationPreviewRequestRules")} / ${responseRules} ${t("locationPreviewResponseRules")}`;
|
||||
|
||||
@@ -145,10 +145,13 @@
|
||||
g = group("vhSticky"); field(g, "sticky-mode", "vhStickyMode", sticky.mode || "off", "select", { values: [["off", "vhOff"], ["cookie", "vhCookie"], ["ip_hash", "vhIpHash"]] });
|
||||
field(g, "cookie-name", "vhCookieName", sticky.cookie_name || "", "text", { maxLength: 96, pattern: "[A-Za-z0-9_-]*" });
|
||||
field(g, "cookie-ttl", "vhCookieTtl", sticky.ttl_seconds ?? 3600, "number", { required: true, min: 60, max: 604800 }); note(g, "vhStickyNote");
|
||||
const rate = value.rate_limit || {}, access = rate.country_access || {};
|
||||
const rate = value.rate_limit || {}, access = rate.country_access || {}, methodPolicy = value.method_policy || {};
|
||||
g = group("vhTrafficPolicy");
|
||||
const aclProfile = field(g, "acl-profile-id", "vhAclProfile", value.acl_profile_id || "", "select");
|
||||
const noProfile = text("option", "vhAclProfileNone"); noProfile.value = ""; aclProfile.append(noProfile, ...(envelope?.config.acl_profiles || []).map(profile => { const option = el("option", profile.name || profile.id); option.value = profile.id; return option; })); aclProfile.value = value.acl_profile_id || ""; note(g, "vhAclProfileHelp");
|
||||
field(g, "method-mode", "vhMethodMode", methodPolicy.mode || "off", "select", { values: [["off", "vhOff"], ["allow", "locationMethodAllow"], ["block", "locationMethodBlock"]] });
|
||||
field(g, "method-list", "vhMethodList", (methodPolicy.methods || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 });
|
||||
note(g, "vhMethodHelp");
|
||||
field(g, "rate-enabled", "vhRateEnabled", rate.enabled === true, "checkbox");
|
||||
field(g, "rate-requests", "vhRateRequests", rate.requests ?? 120, "number", { required: true, min: 1, max: 1000000 });
|
||||
field(g, "rate-window", "vhRateWindow", rate.window_seconds ?? 60, "number", { required: true, min: 1, max: 3600 });
|
||||
@@ -309,6 +312,7 @@
|
||||
id: str("id"), enabled: yes("enabled"), source: mainSourceType === "web_server" ? { type: "web_server", web_service_id: str("source-web-service") } : { type: "reverse_proxy" }, force_ssl: yes("force-ssl"), forward_client_ip: yes("forward-client-ip"), websockets: yes("websockets"), websocket_legacy_support: yes("websockets") && yes("websocket-legacy-support"), websocket_rfc8441: yes("websockets") && ["h2", "h2h3"].includes(str("websocket-mode")), websocket_rfc9220: yes("websockets") && ["h3", "h2h3"].includes(str("websocket-mode")), http2_enabled: yes("http2-enabled"), http3_enabled: yes("http3-enabled"), cloudflare_client_ip: yes("cloudflare-client-ip"), timing_headers: yes("timing-headers"), backend_header: yes("backend-header"), backend_timing_header: yes("backend-timing-header"), basic_auth: basicEditor.read(), certificate_id: nullable("certificate-id"), tls_policy: yes("tls-policy-custom") ? { tls12: yes("tls-policy-12"), tls13: yes("tls-policy-13"), cipher_suites: lines(str("tls-policy-ciphers")) } : null, cache: collectCache(), transform: collectTransform(), pages: pagesEditor.read(), frontend: { hosts: readHosts(), listener_ids: bindingSelection }, acl_profile_id: nullable("acl-profile-id"),
|
||||
proxy_timeouts: { connect_timeout_seconds: num("proxy-connect-timeout"), response_header_timeout_seconds: num("proxy-header-timeout"), read_timeout_seconds: num("proxy-read-timeout"), send_timeout_seconds: num("proxy-send-timeout") },
|
||||
rate_limit: { enabled: yes("rate-enabled"), requests: num("rate-requests"), window_seconds: num("rate-window"), burst: num("rate-burst"), country_access: { mode: str("country-mode"), countries: lines(str("country-list")).map(value => value.toUpperCase()) }, country_limits: countryRatesReader() },
|
||||
method_policy: { mode: str("method-mode"), methods: lines(str("method-list")).map(value => value.toUpperCase()) },
|
||||
server_header: str("server-mode") === "inherit" ? null : { mode: str("server-mode"), value: str("server-value") },
|
||||
diagnostic_headers: yes("diag-inherit") ? null : { request_id: yes("diag-request_id") },
|
||||
tinyauth: { enabled: yes("auth-enabled"), url: str("auth-url"), login_url: str("auth-login"), allow_private: yes("auth-private"), redirect_forbidden: yes("auth-redirect-forbidden"), connect_timeout_seconds: num("auth-connect"), timeout_seconds: num("auth-timeout"), request_headers: lines(str("auth-request-headers")), response_headers: lines(str("auth-response-headers")), address_allowlist: lines(str("auth-allowlist")), tls_ca_pem: nullable("auth-ca") }, backends: members, routes: paths, acl, headers,
|
||||
|
||||
@@ -191,7 +191,12 @@ pub struct CompiledRoute {
|
||||
pub response: Option<crate::vhost::LocationResponse>,
|
||||
pub source: CompiledSource,
|
||||
pub backend: Option<Arc<CompiledBackend>>,
|
||||
pub acl_profile_id: Option<String>,
|
||||
pub profile_acl: Option<CompiledAcl>,
|
||||
pub profile_country_access: crate::rate_limit::CountryAccess,
|
||||
pub acl: Option<CompiledAcl>,
|
||||
pub country_access: crate::rate_limit::CountryAccess,
|
||||
pub method_policy: crate::vhost::MethodPolicy,
|
||||
pub headers: CompiledHeaders,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
@@ -214,6 +219,7 @@ pub struct CompiledFrontend {
|
||||
pub backend_header: bool,
|
||||
pub backend_timing_header: bool,
|
||||
pub rate_limit: crate::rate_limit::Settings,
|
||||
pub method_policy: crate::vhost::MethodPolicy,
|
||||
pub basic_auth: Option<crate::basic_auth::Compiled>,
|
||||
pub metrics: Arc<crate::metrics::VhostMetrics>,
|
||||
pub server_header: crate::settings::ServerHeader,
|
||||
|
||||
@@ -171,6 +171,7 @@ for host in &mut config.vhosts {
|
||||
backend_header: host.backend_header,
|
||||
backend_timing_header: host.backend_timing_header,
|
||||
rate_limit: host.rate_limit.clone(),
|
||||
method_policy: host.method_policy.clone(),
|
||||
basic_auth: host.basic_auth.compile()?,
|
||||
server_header: host
|
||||
.server_header
|
||||
@@ -226,7 +227,12 @@ for host in &mut config.vhosts {
|
||||
response: None,
|
||||
source: fallback_source,
|
||||
backend: fallback_backend,
|
||||
acl_profile_id: None,
|
||||
profile_acl: None,
|
||||
profile_country_access: crate::rate_limit::CountryAccess::default(),
|
||||
acl: None,
|
||||
country_access: crate::rate_limit::CountryAccess::default(),
|
||||
method_policy: crate::vhost::MethodPolicy::default(),
|
||||
headers: crate::headers::CompiledHeaders::default(),
|
||||
};
|
||||
for name in &host.frontend.hosts {
|
||||
@@ -282,6 +288,17 @@ for host in &mut config.vhosts {
|
||||
);
|
||||
validate_route_rewrite(route, path_regex.as_ref())?;
|
||||
validate_location_response(route.response.as_ref())?;
|
||||
let (route_profile_acl, route_profile_country_access) = match route.acl_profile_id.as_deref() {
|
||||
Some(id) => acl_profiles.get(id).cloned().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"vhost {} location {} references unknown ACL profile {}",
|
||||
host.id,
|
||||
route.id,
|
||||
id
|
||||
)
|
||||
})?,
|
||||
None => (None, crate::rate_limit::CountryAccess::default()),
|
||||
};
|
||||
let acl = route.acl.as_ref().map(Acl::compile).transpose()?;
|
||||
let headers = route.headers.compile()?;
|
||||
if route.source.is_reverse_proxy() {
|
||||
@@ -339,7 +356,12 @@ for host in &mut config.vhosts {
|
||||
response: route.response.clone(),
|
||||
source,
|
||||
backend,
|
||||
acl_profile_id: route.acl_profile_id.clone(),
|
||||
profile_acl: route_profile_acl,
|
||||
profile_country_access: route_profile_country_access,
|
||||
acl,
|
||||
country_access: route.country_access.clone(),
|
||||
method_policy: route.method_policy.clone(),
|
||||
headers,
|
||||
};
|
||||
for name in &host.frontend.hosts {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! SQLite stores desired configuration, administrator state and bounded audit history.
|
||||
//! No database call is made from the public HTTP forwarding path.
|
||||
use crate::config::{Config, Envelope};
|
||||
mod history;
|
||||
pub use history::{ConfigurationHistoryDetail, ConfigurationRevision};
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use serde::Serialize;
|
||||
use sqlx::{
|
||||
@@ -9,7 +11,7 @@ use sqlx::{
|
||||
};
|
||||
use std::{path::Path, time::Duration};
|
||||
|
||||
pub const SCHEMA_VERSION: i64 = 7;
|
||||
pub const SCHEMA_VERSION: i64 = 8;
|
||||
pub const CERTIFICATE_API_CREDENTIAL_CAPACITY: i64 = 32;
|
||||
pub const AUDIT_CAPACITY: i64 = 10_000;
|
||||
|
||||
@@ -94,11 +96,20 @@ impl Store {
|
||||
sqlx::raw_sql(include_str!("../migrations/0007_secret_store.sql"))
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::raw_sql(include_str!("../migrations/0008_config_history.sql"))
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("INSERT OR IGNORE INTO configuration(id, revision, document) VALUES(1, 0, ?)")
|
||||
.bind(serde_json::to_string(initial)?)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("PRAGMA user_version = 7")
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO configuration_history(revision, time, actor, document) SELECT revision, ?, 'migration', document FROM configuration WHERE id = 1",
|
||||
)
|
||||
.bind(crate::auth::now())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("PRAGMA user_version = 8")
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
@@ -172,22 +183,40 @@ impl Store {
|
||||
pub async fn replace_as(&self, expected: u64, config: &Config, actor: &str) -> Result<bool> {
|
||||
let expected: i64 = expected.try_into().context("revision overflow")?;
|
||||
ensure!(expected < i64::MAX, "revision overflow");
|
||||
let document = serde_json::to_string(config)?;
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let result = sqlx::query("UPDATE configuration SET revision = revision + 1, document = ? WHERE id = 1 AND revision = ?")
|
||||
.bind(serde_json::to_string(config)?).bind(expected).execute(&mut *tx).await?;
|
||||
.bind(&document).bind(expected).execute(&mut *tx).await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
let revision = expected + 1;
|
||||
sqlx::query(
|
||||
"INSERT INTO configuration_history(revision, time, actor, document) VALUES(?, ?, ?, ?)",
|
||||
)
|
||||
.bind(revision)
|
||||
.bind(crate::auth::now())
|
||||
.bind(actor)
|
||||
.bind(&document)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"DELETE FROM configuration_history WHERE revision NOT IN (SELECT revision FROM configuration_history ORDER BY revision DESC LIMIT ?)",
|
||||
)
|
||||
.bind(history::CONFIG_HISTORY_CAPACITY)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
audit_tx(
|
||||
&mut tx,
|
||||
actor,
|
||||
"config.apply",
|
||||
&format!("revision={}", expected + 1),
|
||||
&format!("revision={revision}"),
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn audit(&self, actor: &str, action: &str, detail: &str) -> Result<()> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
audit_tx(&mut tx, actor, action, detail).await?;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
use super::Store;
|
||||
use crate::config::Config;
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use serde::Serialize;
|
||||
use sqlx::Row;
|
||||
|
||||
pub(super) const CONFIG_HISTORY_CAPACITY: i64 = 512;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ConfigurationRevision {
|
||||
pub revision: u64,
|
||||
pub time: i64,
|
||||
pub actor: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ConfigurationHistoryDetail {
|
||||
pub revision: u64,
|
||||
pub time: i64,
|
||||
pub actor: String,
|
||||
pub config: Config,
|
||||
pub previous_revision: Option<u64>,
|
||||
pub previous_config: Option<Config>,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub async fn configuration_history(&self, limit: usize) -> Result<Vec<ConfigurationRevision>> {
|
||||
let limit = i64::try_from(limit.min(CONFIG_HISTORY_CAPACITY as usize))?;
|
||||
let rows = sqlx::query(
|
||||
"SELECT revision, time, actor FROM configuration_history ORDER BY revision DESC LIMIT ?",
|
||||
)
|
||||
.bind(limit)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
Ok(ConfigurationRevision {
|
||||
revision: row.try_get::<i64, _>("revision")?.try_into()?,
|
||||
time: row.try_get("time")?,
|
||||
actor: row.try_get("actor")?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn configuration_revision(
|
||||
&self,
|
||||
revision: u64,
|
||||
) -> Result<Option<ConfigurationHistoryDetail>> {
|
||||
let revision_i64: i64 = revision.try_into().context("revision overflow")?;
|
||||
let Some(row) = sqlx::query(
|
||||
"SELECT revision, time, actor, document FROM configuration_history WHERE revision = ?",
|
||||
)
|
||||
.bind(revision_i64)
|
||||
.fetch_optional(&self.pool)
|
||||
.await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let document: String = row.try_get("document")?;
|
||||
ensure!(
|
||||
document.len() <= crate::config::MAX_CONFIG_BYTES,
|
||||
"stored configuration history exceeds size limit"
|
||||
);
|
||||
let previous = sqlx::query(
|
||||
"SELECT revision, document FROM configuration_history WHERE revision < ? ORDER BY revision DESC LIMIT 1",
|
||||
)
|
||||
.bind(revision_i64)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
let (previous_revision, previous_config) = if let Some(previous) = previous {
|
||||
let previous_document: String = previous.try_get("document")?;
|
||||
ensure!(
|
||||
previous_document.len() <= crate::config::MAX_CONFIG_BYTES,
|
||||
"stored configuration history exceeds size limit"
|
||||
);
|
||||
(
|
||||
Some(previous.try_get::<i64, _>("revision")?.try_into()?),
|
||||
Some(serde_json::from_str(&previous_document).context(
|
||||
"invalid previous configuration history document",
|
||||
)?),
|
||||
)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
Ok(Some(ConfigurationHistoryDetail {
|
||||
revision: row.try_get::<i64, _>("revision")?.try_into()?,
|
||||
time: row.try_get("time")?,
|
||||
actor: row.try_get("actor")?,
|
||||
config: serde_json::from_str(&document)
|
||||
.context("invalid configuration history document")?,
|
||||
previous_revision,
|
||||
previous_config,
|
||||
}))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -36,6 +36,28 @@ mod tests {
|
||||
assert_eq!(data.config.transform.max_inflight, 64);
|
||||
reopened.close().await;
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn configuration_history_keeps_revision_documents_and_actor() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("history.sqlite3");
|
||||
let store = Store::open(&file, &Config::default()).await.unwrap();
|
||||
let mut first = Config::default();
|
||||
first.server_header.value = "first".into();
|
||||
assert!(store.replace_as(0, &first, "alice").await.unwrap());
|
||||
let mut second = first.clone();
|
||||
second.server_header.value = "second".into();
|
||||
assert!(store.replace_as(1, &second, "bob").await.unwrap());
|
||||
let history = store.configuration_history(10).await.unwrap();
|
||||
assert_eq!(history[0].revision, 2);
|
||||
assert_eq!(history[0].actor, "bob");
|
||||
assert_eq!(history[1].revision, 1);
|
||||
let detail = store.configuration_revision(2).await.unwrap().unwrap();
|
||||
assert_eq!(detail.config.server_header.value, "second");
|
||||
assert_eq!(detail.previous_revision, Some(1));
|
||||
assert_eq!(detail.previous_config.unwrap().server_header.value, "first");
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn certificate_renewal_observations_survive_reopen_in_schema_v3() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -247,6 +247,7 @@ pub struct LogEntry {
|
||||
pub transform_effective_enabled: Option<bool>,
|
||||
pub backend: Option<String>,
|
||||
pub id: u64,
|
||||
pub request_id: String,
|
||||
pub time_ms: u64,
|
||||
pub method: String,
|
||||
pub host: String,
|
||||
@@ -398,6 +399,7 @@ fn value_for(entry: &LogEntry, name: &str) -> Value {
|
||||
.map(Value::String)
|
||||
.unwrap_or(Value::Null),
|
||||
"id" => json!(entry.id),
|
||||
"request_id" => json!(&entry.request_id),
|
||||
"time_ms" => json!(entry.time_ms),
|
||||
"method" => json!(&entry.method),
|
||||
"host" => json!(&entry.host),
|
||||
|
||||
@@ -235,6 +235,7 @@ fn source_value_name(name: &str) -> bool {
|
||||
| "transform_effective_enabled"
|
||||
| "backend"
|
||||
| "id"
|
||||
| "request_id"
|
||||
| "time_ms"
|
||||
| "method"
|
||||
| "host"
|
||||
|
||||
@@ -9,6 +9,7 @@ mod tests {
|
||||
transform_effective_enabled: Some(false),
|
||||
backend: Some("origin".into()),
|
||||
id: 7,
|
||||
request_id: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(),
|
||||
time_ms: 1_700_000_000_000,
|
||||
method: "GET".into(),
|
||||
host: "app.example".into(),
|
||||
|
||||
@@ -41,7 +41,7 @@ use tracing_subscriber::prelude::*;
|
||||
#[command(
|
||||
name = "rust-proxy",
|
||||
version,
|
||||
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.25.2"
|
||||
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.25.3"
|
||||
)]
|
||||
struct Cli {
|
||||
#[arg(
|
||||
|
||||
+1
-1
@@ -226,7 +226,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.25.2 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
|
||||
println!("RustProxy live logs 0.25.3 - 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();
|
||||
|
||||
@@ -29,6 +29,7 @@ pub struct Exchange {
|
||||
pub transform_effective_enabled: Mutex<Option<bool>>,
|
||||
pub backend: Mutex<Option<Arc<crate::balance::Lease>>>,
|
||||
pub id: u64,
|
||||
pub request_id: String,
|
||||
pub method: String,
|
||||
pub host: String,
|
||||
pub path: String,
|
||||
@@ -179,6 +180,7 @@ impl Drop for Exchange {
|
||||
transform_effective_enabled,
|
||||
backend,
|
||||
id: self.id,
|
||||
request_id: self.request_id.clone(),
|
||||
time_ms: now_ms(),
|
||||
method: self.method.clone(),
|
||||
host: self.host.clone(),
|
||||
|
||||
@@ -15,6 +15,7 @@ mod tests {
|
||||
transform_effective_enabled: Mutex::new(None),
|
||||
backend: Mutex::new(None),
|
||||
id: 1,
|
||||
request_id: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(),
|
||||
method: "POST".into(),
|
||||
host: "localhost".into(),
|
||||
path: "/echo".into(),
|
||||
|
||||
@@ -120,6 +120,23 @@ fn error(status: StatusCode, message: &'static str) -> Response {
|
||||
response
|
||||
}
|
||||
|
||||
fn method_policy_denial(
|
||||
policy: &crate::vhost::MethodPolicy,
|
||||
method: &Method,
|
||||
) -> Option<Response> {
|
||||
if policy.allows(method) {
|
||||
return None;
|
||||
}
|
||||
let mut response = error(
|
||||
StatusCode::METHOD_NOT_ALLOWED,
|
||||
"RustProxy: HTTP method denied by access policy\n",
|
||||
);
|
||||
if let Some(value) = policy.allow_header() {
|
||||
response.headers_mut().insert(http::header::ALLOW, value);
|
||||
}
|
||||
Some(response)
|
||||
}
|
||||
|
||||
fn local_location_response(spec: &crate::vhost::LocationResponse, method: &Method) -> Response {
|
||||
let status = StatusCode::from_u16(spec.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
let body_allowed = !matches!(status.as_u16(), 100..=199 | 204 | 304) && *method != Method::HEAD;
|
||||
@@ -211,7 +228,7 @@ fn finish_headers(
|
||||
frontend: Option<&crate::config::CompiledFrontend>,
|
||||
local: Option<std::net::SocketAddr>,
|
||||
tls: bool,
|
||||
id: u64,
|
||||
request_id: &str,
|
||||
active: &[crate::vhost::Listener],
|
||||
) {
|
||||
if let Some(frontend) = frontend {
|
||||
@@ -224,7 +241,7 @@ fn finish_headers(
|
||||
frontend
|
||||
.map(|f| &f.diagnostic_headers)
|
||||
.unwrap_or(&snapshot.envelope.config.diagnostic_headers)
|
||||
.apply(response.headers_mut(), id);
|
||||
.apply(response.headers_mut(), request_id);
|
||||
// Authenticated content must not leak through an external shared cache either.
|
||||
if frontend.is_some_and(|f| f.tinyauth.is_some() || f.basic_auth.is_some()) {
|
||||
response.headers_mut().insert(
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
{
|
||||
// Both scopes run before DNS, TCP, TLS or reuse of an existing upstream connection.
|
||||
if !snapshot.host_allowed(&host, context.client_ip)
|
||||
|| !route
|
||||
.profile_acl
|
||||
.as_ref()
|
||||
.is_none_or(|acl| acl.allows(context.client_ip))
|
||||
|| !route
|
||||
.acl
|
||||
.as_ref()
|
||||
|
||||
+52
-16
@@ -141,6 +141,11 @@ async fn forward(
|
||||
"RustProxy: RFC 9220 WebSocket transport is not enabled for this vhost\n",
|
||||
);
|
||||
}
|
||||
if let Some(response) = frontend
|
||||
.and_then(|frontend| method_policy_denial(&frontend.method_policy, request.method()))
|
||||
{
|
||||
return response;
|
||||
}
|
||||
if !tls {
|
||||
if let Some(response) = frontend.and_then(|frontend| force_ssl_redirect(frontend, &host, request.uri())) {
|
||||
return response;
|
||||
@@ -175,16 +180,19 @@ async fn forward(
|
||||
if let Some(exchange) = request.extensions().get::<Arc<Exchange>>() {
|
||||
*exchange.client_ip.lock().unwrap_or_else(|e| e.into_inner()) = Some(context.client_ip.to_string());
|
||||
}
|
||||
// GeoIP stays off the normal request path. Resolve lazily for any vhost or
|
||||
// location policy that actually needs a country code, or for log capture.
|
||||
let capture_country = state.logs.capture_country();
|
||||
let mut country_resolved = false;
|
||||
let mut country: Option<String> = None;
|
||||
if let Some(frontend) = frontend {
|
||||
// GeoIP is deliberately off the default request path. Resolve only when
|
||||
// a country policy/rate-limit requires it or the operator explicitly
|
||||
// enables country capture for access logs + traffic analytics.
|
||||
let capture_country = state.logs.capture_country();
|
||||
let country = (capture_country
|
||||
if capture_country
|
||||
|| frontend.profile_country_access.needs_geoip()
|
||||
|| frontend.rate_limit.needs_geoip())
|
||||
.then(|| state.geoip.country(context.client_ip))
|
||||
.flatten();
|
||||
|| frontend.rate_limit.needs_geoip()
|
||||
{
|
||||
country = state.geoip.country(context.client_ip);
|
||||
country_resolved = true;
|
||||
}
|
||||
if capture_country {
|
||||
if let Some(exchange) = request.extensions().get::<Arc<Exchange>>() {
|
||||
*exchange.country.lock().unwrap_or_else(|e| e.into_inner()) = country.clone();
|
||||
@@ -246,14 +254,42 @@ async fn forward(
|
||||
return response;
|
||||
}
|
||||
let selected = snapshot.select_on(&host, &path, local.0, tls);
|
||||
if !selected
|
||||
.and_then(|r| r.acl.as_ref())
|
||||
.is_none_or(|acl| acl.allows(context.client_ip))
|
||||
{
|
||||
return error(
|
||||
StatusCode::FORBIDDEN,
|
||||
"RustProxy: denied by access policy\n",
|
||||
);
|
||||
if let Some(route) = selected {
|
||||
if let Some(response) = method_policy_denial(&route.method_policy, request.method()) {
|
||||
return response;
|
||||
}
|
||||
if !route
|
||||
.profile_acl
|
||||
.as_ref()
|
||||
.is_none_or(|acl| acl.allows(context.client_ip))
|
||||
|| !route
|
||||
.acl
|
||||
.as_ref()
|
||||
.is_none_or(|acl| acl.allows(context.client_ip))
|
||||
{
|
||||
return error(
|
||||
StatusCode::FORBIDDEN,
|
||||
"RustProxy: denied by access policy\n",
|
||||
);
|
||||
}
|
||||
if (route.profile_country_access.needs_geoip() || route.country_access.needs_geoip())
|
||||
&& !country_resolved
|
||||
{
|
||||
country = state.geoip.country(context.client_ip);
|
||||
if capture_country {
|
||||
if let Some(exchange) = request.extensions().get::<Arc<Exchange>>() {
|
||||
*exchange.country.lock().unwrap_or_else(|e| e.into_inner()) = country.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
if !route.profile_country_access.allows(country.as_deref())
|
||||
|| !route.country_access.allows(country.as_deref())
|
||||
{
|
||||
return error(
|
||||
StatusCode::FORBIDDEN,
|
||||
"RustProxy: location country access policy denied this request\n",
|
||||
);
|
||||
}
|
||||
}
|
||||
// The admin cookie is never ordinary application state. Strip it BEFORE
|
||||
// auth/cache and keep only the removed fragment locally. A verified self-admin
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
fn request_id_hash(sequence: u64) -> String {
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
let mut nonce = [0u8; 32];
|
||||
let mut rng = rand::rngs::OsRng;
|
||||
rng.fill_bytes(&mut nonce);
|
||||
let mut hash = Sha256::new();
|
||||
hash.update(sequence.to_be_bytes());
|
||||
hash.update(nonce);
|
||||
format!("{:x}", hash.finalize())
|
||||
}
|
||||
|
||||
async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next) -> Response {
|
||||
let snapshot = state.config.load_full();
|
||||
let (frontend_context, route_context) = response_context(&snapshot, &request);
|
||||
@@ -32,6 +45,7 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
|
||||
state.metrics.http3_requests.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
let id = state.metrics.requests.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
let request_id = request_id_hash(id);
|
||||
if let Some(frontend) = &frontend_context {
|
||||
frontend.metrics.requests.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
@@ -70,7 +84,7 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
|
||||
frontend_context.as_deref(),
|
||||
local,
|
||||
transport_tls,
|
||||
id,
|
||||
&request_id,
|
||||
&state.active_listeners,
|
||||
);
|
||||
return response;
|
||||
@@ -118,6 +132,7 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
|
||||
transform_effective_enabled: Mutex::new(None),
|
||||
backend: Mutex::new(None),
|
||||
id,
|
||||
request_id: request_id.clone(),
|
||||
method: request.method().to_string(),
|
||||
host,
|
||||
path: request.uri().path().chars().take(256).collect(),
|
||||
@@ -148,7 +163,7 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
|
||||
traffic: state.traffic.clone(),
|
||||
_permit: permit,
|
||||
});
|
||||
if let Ok(value) = HeaderValue::from_str(&id.to_string()) {
|
||||
if let Ok(value) = HeaderValue::from_str(&request_id) {
|
||||
request.headers_mut().insert("x-request-id", value);
|
||||
}
|
||||
request.extensions_mut().insert(exchange.clone());
|
||||
@@ -223,7 +238,7 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
|
||||
frontend_context.as_deref(),
|
||||
local,
|
||||
transport_tls,
|
||||
id,
|
||||
&request_id,
|
||||
&state.active_listeners,
|
||||
);
|
||||
if response.extensions().get::<RedirectResponse>().is_some_and(|redirect| !redirect.http3_enabled) {
|
||||
|
||||
+2
-2
@@ -364,10 +364,10 @@ pub struct DiagnosticHeaders {
|
||||
pub request_id: bool,
|
||||
}
|
||||
impl DiagnosticHeaders {
|
||||
pub fn apply(&self, headers: &mut HeaderMap, id: u64) {
|
||||
pub fn apply(&self, headers: &mut HeaderMap, request_id: &str) {
|
||||
headers.remove("x-request-id");
|
||||
if self.request_id {
|
||||
if let Ok(value) = id.to_string().parse() {
|
||||
if let Ok(value) = request_id.parse() {
|
||||
headers.insert("x-request-id", value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,59 @@ impl Default for ProxyTimeouts {
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MethodPolicyMode {
|
||||
#[default]
|
||||
Off,
|
||||
Allow,
|
||||
Block,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct MethodPolicy {
|
||||
pub mode: MethodPolicyMode,
|
||||
pub methods: Vec<String>,
|
||||
}
|
||||
impl MethodPolicy {
|
||||
pub fn validate(&mut self) -> Result<()> {
|
||||
ensure!(self.methods.len() <= 32, "method policy allows at most 32 methods");
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for method in &mut self.methods {
|
||||
*method = method.trim().to_ascii_uppercase();
|
||||
ensure!(
|
||||
!method.is_empty() && method.len() <= 32 && method.parse::<http::Method>().is_ok(),
|
||||
"invalid HTTP method in method policy"
|
||||
);
|
||||
ensure!(seen.insert(method.clone()), "duplicate HTTP method in method policy");
|
||||
}
|
||||
ensure!(
|
||||
self.mode == MethodPolicyMode::Off || !self.methods.is_empty(),
|
||||
"method allow/block policy requires at least one method"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn allows(&self, method: &http::Method) -> bool {
|
||||
let listed = self
|
||||
.methods
|
||||
.iter()
|
||||
.any(|candidate| candidate.eq_ignore_ascii_case(method.as_str()));
|
||||
match self.mode {
|
||||
MethodPolicyMode::Off => true,
|
||||
MethodPolicyMode::Allow => listed,
|
||||
MethodPolicyMode::Block => !listed,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn allow_header(&self) -> Option<http::HeaderValue> {
|
||||
(self.mode == MethodPolicyMode::Allow)
|
||||
.then(|| self.methods.join(", ").parse::<http::HeaderValue>().ok())
|
||||
.flatten()
|
||||
}
|
||||
}
|
||||
|
||||
impl ProxyTimeouts {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
for (name, value, max) in [
|
||||
@@ -204,6 +257,9 @@ pub struct Vhost {
|
||||
pub backend_timing_header: bool,
|
||||
#[serde(default)]
|
||||
pub rate_limit: crate::rate_limit::Settings,
|
||||
/// Optional allow/block policy for HTTP methods. Vhost and location policies stack.
|
||||
#[serde(default)]
|
||||
pub method_policy: MethodPolicy,
|
||||
#[serde(default)]
|
||||
pub basic_auth: crate::basic_auth::Settings,
|
||||
pub id: String,
|
||||
@@ -403,8 +459,17 @@ pub struct PathRoute {
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub cache: Option<bool>,
|
||||
/// Optional reusable ACL profile applied in addition to vhost policy.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub acl_profile_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub acl: Option<Acl>,
|
||||
/// Location-scoped country allow/block policy, stacked with vhost/profile policy.
|
||||
#[serde(default)]
|
||||
pub country_access: crate::rate_limit::CountryAccess,
|
||||
/// Location-scoped HTTP method allow/block policy, stacked with vhost policy.
|
||||
#[serde(default)]
|
||||
pub method_policy: MethodPolicy,
|
||||
#[serde(default)]
|
||||
pub headers: HeaderRules,
|
||||
/// Optional origin-path transformation. Query parameters are preserved.
|
||||
|
||||
@@ -82,6 +82,30 @@ mod default_listener_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_policy_normalizes_and_enforces_allow_and_block_modes() {
|
||||
let mut allow: MethodPolicy = serde_json::from_value(json!({
|
||||
"mode": "allow",
|
||||
"methods": ["get", "HEAD", "post"]
|
||||
}))
|
||||
.unwrap();
|
||||
allow.validate().unwrap();
|
||||
assert_eq!(allow.methods, vec!["GET", "HEAD", "POST"]);
|
||||
assert!(allow.allows(&http::Method::GET));
|
||||
assert!(!allow.allows(&http::Method::DELETE));
|
||||
assert_eq!(allow.allow_header().unwrap().to_str().unwrap(), "GET, HEAD, POST");
|
||||
|
||||
let mut block: MethodPolicy = serde_json::from_value(json!({
|
||||
"mode": "block",
|
||||
"methods": ["delete"]
|
||||
}))
|
||||
.unwrap();
|
||||
block.validate().unwrap();
|
||||
assert!(!block.allows(&http::Method::DELETE));
|
||||
assert!(block.allows(&http::Method::GET));
|
||||
assert!(block.allow_header().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn h2c_is_cleartext_only_and_defaults_off() {
|
||||
let plain: Listener =
|
||||
|
||||
+14
-1
@@ -8,7 +8,10 @@ impl Default for PathRoute {
|
||||
match_type: PathMatch::Prefix,
|
||||
enabled: true,
|
||||
cache: None,
|
||||
acl_profile_id: None,
|
||||
acl: None,
|
||||
country_access: crate::rate_limit::CountryAccess::default(),
|
||||
method_policy: MethodPolicy::default(),
|
||||
headers: HeaderRules::default(),
|
||||
rewrite: PathRewrite::default(),
|
||||
response: None,
|
||||
@@ -28,7 +31,11 @@ impl PathRoute {
|
||||
&& self.source == Source::ReverseProxy
|
||||
&& self.backend_id.is_none()
|
||||
&& self.cache.is_none()
|
||||
&& self.acl_profile_id.is_none()
|
||||
&& self.acl.is_none()
|
||||
&& self.country_access.mode == crate::rate_limit::CountryMode::Off
|
||||
&& self.country_access.countries.is_empty()
|
||||
&& self.method_policy == MethodPolicy::default()
|
||||
&& self.headers.is_empty()
|
||||
&& self.rewrite.mode == RewriteMode::Preserve
|
||||
&& self.rewrite.value.is_empty()
|
||||
@@ -67,6 +74,7 @@ impl Vhost {
|
||||
if let Some(id) = &self.acl_profile_id {
|
||||
ensure!(crate::config::valid_id(id), "invalid ACL profile id");
|
||||
}
|
||||
self.method_policy.validate()?;
|
||||
ensure!(
|
||||
!self.frontend.listener_ids.is_empty(),
|
||||
"vhost requires at least one named listener"
|
||||
@@ -111,8 +119,13 @@ impl Vhost {
|
||||
// Normalize the untouched pre-0.16 synthetic root route out of the custom
|
||||
// location list so adding/removing Custom locations cannot delete fallback.
|
||||
self.routes.retain(|route| !route.is_legacy_main_fallback());
|
||||
for route in &self.routes {
|
||||
for route in &mut self.routes {
|
||||
route.source.validate()?;
|
||||
if let Some(id) = &route.acl_profile_id {
|
||||
ensure!(crate::config::valid_id(id), "invalid location ACL profile id");
|
||||
}
|
||||
route.country_access.validate()?;
|
||||
route.method_policy.validate()?;
|
||||
ensure!(
|
||||
route.source.is_reverse_proxy() || route.backend_id.is_none(),
|
||||
"Web Server location cannot pin a reverse-proxy backend"
|
||||
|
||||
@@ -156,7 +156,7 @@ pub(super) fn fastcgi_params(
|
||||
}
|
||||
params.insert("HTTPS".into(), if context.tls { "on" } else { "off" }.into());
|
||||
params.insert("GATEWAY_INTERFACE".into(), "CGI/1.1".into());
|
||||
params.insert("SERVER_SOFTWARE".into(), "RustProxy/0.25.2".into());
|
||||
params.insert("SERVER_SOFTWARE".into(), "RustProxy/0.25.3".into());
|
||||
params.insert("REDIRECT_STATUS".into(), "200".into());
|
||||
if let Some(value) = headers.get(CONTENT_TYPE).and_then(|value| value.to_str().ok()) {
|
||||
params.insert("CONTENT_TYPE".into(), value.into());
|
||||
|
||||
@@ -180,7 +180,7 @@ pub(super) fn directory_listing(path: &str, entries: &[DirectoryEntry]) -> Vec<u
|
||||
"<a class=\"{class}\" href=\"{href}\"><span class=\"type\">{type_label}</span><span class=\"name\">{label}{slash}</span><span class=\"kind\">{kind}</span></a>"
|
||||
));
|
||||
}
|
||||
html.push_str(r#"</div></section><footer class="page-footer"><span>RustProxy <b>0.25.2</b></span><span>Web Server · Directory listing</span></footer></main></body></html>"#);
|
||||
html.push_str(r#"</div></section><footer class="page-footer"><span>RustProxy <b>0.25.3</b></span><span>Web Server · Directory listing</span></footer></main></body></html>"#);
|
||||
html.into_bytes()
|
||||
}
|
||||
|
||||
|
||||
@@ -202,7 +202,7 @@ async fn empty_index_can_go_directly_to_directory_listing() {
|
||||
let body = std::str::from_utf8(&body).unwrap();
|
||||
assert!(body.contains("file.txt"));
|
||||
assert!(body.contains("DIRECTORY LISTING"));
|
||||
assert!(body.contains("RustProxy <b>0.25.2</b>"));
|
||||
assert!(body.contains("RustProxy <b>0.25.3</b>"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in new issue
Block a user