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,