This commit is contained in:
Mateusz Gruszczyński committed 2026-10-04 22:53:33 +02:00
1 parent 174389e5b1
commit e6604e41fc
73 files changed
+1240 -201

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.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
View File
@@ -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"
}
}
}
}
}
}
}
}
}
+2
View File
@@ -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))
+31
View File
@@ -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>,
+1
View File
@@ -252,6 +252,7 @@
for path in [
"/api/status",
"/api/config",
"/api/config/history",
"/api/backends",
"/api/docker",
"/api/traffic",
+107
View File
@@ -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());
+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.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();
}
+26 -4
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.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>
+170
View File
@@ -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; }
}
+24 -4
View File
@@ -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")}`;
+5 -1
View File
@@ -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,
+6
View File
@@ -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,
+22
View File
@@ -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 {
+33 -4
View File
@@ -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?;
+96
View File
@@ -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,
}))
}
}
+22
View File
@@ -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();
+2
View File
@@ -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),
+1
View File
@@ -235,6 +235,7 @@ fn source_value_name(name: &str) -> bool {
| "transform_effective_enabled"
| "backend"
| "id"
| "request_id"
| "time_ms"
| "method"
| "host"
+1
View File
@@ -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(),
+1 -1
View File
@@ -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
View File
@@ -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();
+2
View File
@@ -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(),
+1
View File
@@ -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(),
+19 -2
View File
@@ -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(
+4
View File
@@ -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
View File
@@ -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
+18 -3
View File
@@ -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
View File
@@ -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);
}
}
+65
View File
@@ -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.
+24
View File
@@ -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
View File
@@ -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"
+1 -1
View File
@@ -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());
+1 -1
View File
@@ -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()
}
+1 -1
View File
@@ -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]