This commit is contained in:
Mateusz Gruszczyński committed 2026-10-27 12:18:20 +01:00
1 parent 62b7574553
commit e364392659
47 files changed
+2164 -257

No files matched your search

+27 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.34.14",
"version": "0.34.15",
"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. The API also permits explicitly scoped GeneralApiBearer tokens on a narrow route allowlist, with RO/RW and source CIDR checks; API-token management and Prometheus-token management are browser-session-only."
},
"servers": [
@@ -439,6 +439,24 @@
"id"
]
},
"ForwardAuthConfig": {
"type": "object",
"description": "Per-Vhost Authelia or authentik verification. Disabled by default. All other settings are optional and use RustProxy defaults; when enabled, url must identify an internal HTTP(S) origin (or expected verifier path). Authelia additionally requires a public HTTPS login_url. External addresses retain normal safe upstream validation, with TLS verification always enabled for HTTPS.",
"additionalProperties": false,
"properties": {
"enabled": {"type": "boolean", "default": false},
"url": {"type": "string", "maxLength": 2048, "description": "Internal service/outpost origin or the provider's verifier endpoint"},
"login_url": {"type": "string", "maxLength": 2048, "description": "Required public HTTPS portal URL for Authelia; ignored for authentik"},
"allow_private": {"type": "boolean", "default": false},
"address_allowlist": {"type": "array", "maxItems": 32, "items": {"type": "string"}},
"tls_ca_pem": {"type": "string", "nullable": true},
"connect_timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 30, "default": 5},
"timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 60, "default": 10},
"request_headers": {"type": "array", "maxItems": 32, "items": {"type": "string", "maxLength": 128}, "description": "Only cookie, authorization, user-agent, accept, accept-language and x-api-key are allowed"},
"response_headers": {"type": "array", "maxItems": 32, "items": {"type": "string", "maxLength": 128}, "description": "Allowed Remote-*, X-Auth-Request-* and X-Authentik-* identity headers; total injected identity data capped at 8 KiB"},
"redirect_forbidden": {"type": "boolean", "default": false, "description": "Authelia only: accept trusted portal Location on explicit 403 if provided"}
}
},
"Vhost": {
"type": "object",
"properties": {
@@ -534,6 +552,14 @@
"properties": {},
"additionalProperties": true
},
"authelia": {
"$ref": "#/components/schemas/ForwardAuthConfig",
"description": "Authelia ForwardAuth. Requires a public HTTPS login_url and /api/authz/forward-auth verifier. Disabled by default; mutually exclusive with basic_auth, tinyauth and authentik."
},
"authentik": {
"$ref": "#/components/schemas/ForwardAuthConfig",
"description": "authentik single-application forward auth. The URL is the internal outpost origin; RustProxy proxies its reserved path on the protected host. Disabled by default; mutually exclusive with basic_auth, tinyauth and authelia."
},
"cache": {
"type": "object",
"properties": {
+2
View File
@@ -41,6 +41,8 @@ fn status_payload(state: &App) -> Value {
"security_headers",
"http3",
"tinyauth",
"authelia_forward_auth",
"authentik_forward_auth",
"trusted_forwarding",
"named_listeners",
"stream_proxy",
+10
View File
@@ -500,6 +500,16 @@ main {
font-size: 12px;
}
.auth-provider-options {
display: flex;
flex-wrap: wrap;
gap: 7px 18px;
}
.auth-provider-options>.vh-check {
flex: 1 1 250px;
}
.auth-advanced {
border-top: 1px solid var(--line);
padding-top: 4px;
@@ -4,7 +4,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>502 - Tinyauth connection error - RustProxy</title>
<title>{{STATUS}} - {{PROVIDER}} {{ERROR}} - RustProxy</title>
<style>
body {
margin: 12vh auto;
@@ -29,8 +29,9 @@
<body>
<main>
<h1>502 - Tinyauth connection error</h1>
<p>Unable to connect to the Tinyauth authentication service.</p>
<h1>{{STATUS}} - {{PROVIDER}} {{ERROR}}</h1>
<p>{{MESSAGE}}</p>
<p>Access to the protected application is temporarily unavailable.</p>
</main>
</body>
-37
View File
@@ -1,37 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>504 - Tinyauth timeout - RustProxy</title>
<style>
body {
margin: 12vh auto;
padding: 24px;
max-width: 620px;
background: #131615;
color: #e5e9e6;
font: 15px/1.6 system-ui, sans-serif
}
h1 {
font-size: 24px;
margin: 0 0 8px
}
p {
margin: 0;
color: #b9c3bc
}
</style>
</head>
<body>
<main>
<h1>504 - Tinyauth timeout</h1>
<p>The Tinyauth authentication service did not respond in time.</p>
</main>
</body>
</html>
@@ -1,37 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>502 - Tinyauth verification error - RustProxy</title>
<style>
body {
margin: 12vh auto;
padding: 24px;
max-width: 620px;
background: #131615;
color: #e5e9e6;
font: 15px/1.6 system-ui, sans-serif
}
h1 {
font-size: 24px;
margin: 0 0 8px
}
p {
margin: 0;
color: #b9c3bc
}
</style>
</head>
<body>
<main>
<h1>502 - Tinyauth verification error</h1>
<p>The Tinyauth authentication service returned an invalid response.</p>
</main>
</body>
</html>
+57 -9
View File
@@ -33,6 +33,18 @@ function hostTagEditor(parent, hosts = [], onUpdate = () => { }) {
return () => { if (input.value.trim()) consume(input.value); if (!values.length) throw new Error(t("vhHostRequired")); return [...values]; };
}
function readForwardProvider(id) {
const value = key => str(`${id}-${key}`);
return {
enabled: str("auth-mode") === id,
url: value("url"), login_url: id === "authelia" ? value("login") : "",
allow_private: yes(`${id}-private`), redirect_forbidden: yes(`${id}-redirect-forbidden`),
connect_timeout_seconds: num(`${id}-connect`), timeout_seconds: num(`${id}-timeout`),
request_headers: lines(value("request-headers")), response_headers: lines(value("response-headers")),
address_allowlist: lines(value("allowlist")), tls_ca_pem: nullable(`${id}-ca`),
};
}
function load(value = defaults(), migrationIds = [], show = true) {
draftEpoch++; stagedCertificates = []; uploading = false; backendHealthEditorControl = null;
editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true;
@@ -332,19 +344,18 @@ function load(value = defaults(), migrationIds = [], show = true) {
const syncTlsPolicy = () => { for (const input of [tls12, tls13, tlsCiphers, tlsPqc]) input.disabled = !customTlsPolicy.checked; };
customTlsPolicy.addEventListener("change", syncTlsPolicy); syncTlsPolicy(); note(g, "vhTlsOverrideHelp"); note(g, "tlsPqcVhostHelp");
g = group("vhTinyauth"); const auth = value.tinyauth || {};
const authMode = field(g, "auth-mode", "authMode", value.basic_auth?.enabled ? "basic" : auth.enabled ? "tinyauth" : "none", "select", { values: [["none", "authNone"], ["tinyauth", "authTinyauth"], ["basic", "authBasic"]] });
const authMode = field(g, "auth-mode", "authMode", value.basic_auth?.enabled ? "basic" : auth.enabled ? "tinyauth" : value.authelia?.enabled ? "authelia" : value.authentik?.enabled ? "authentik" : "none", "select", { values: [["none", "authNone"], ["tinyauth", "authTinyauth"], ["authelia", "authAuthelia"], ["authentik", "authAuthentik"], ["basic", "authBasic"]] });
note(g, "authModeHelp");
const taBody = el("section", undefined, "vh-wide auth-subcard"), basicBody = el("section", undefined, "vh-wide auth-subcard"); g.append(taBody, basicBody);
taBody.append(text("h3", "authTinyauth"));
const taPrimary = el("div", undefined, "vh-grid"); taBody.append(taPrimary);
const authEnable = field(taPrimary, "auth-enabled", "taEnable", auth.enabled, "checkbox");
const authUrl = field(taPrimary, "auth-url", "taUrl", auth.url || "", "url", { maxLength: 2048 });
const loginUrl = field(taPrimary, "auth-login", "taLogin", auth.login_url || "", "url", { maxLength: 2048 });
authUrl.placeholder = "http://10.87.7.99:3000"; loginUrl.placeholder = "https://auth.example.com/login";
field(taPrimary, "auth-private", "vhPrivate", auth.allow_private, "checkbox");
field(taPrimary, "auth-redirect-forbidden", "taRedirectForbidden", auth.redirect_forbidden !== false, "checkbox");
authEnable.closest("label").hidden = true;
const taOptions = el("div", undefined, "vh-wide auth-provider-options"); taPrimary.append(taOptions);
field(taOptions, "auth-private", "vhPrivate", auth.allow_private, "checkbox");
field(taOptions, "auth-redirect-forbidden", "taRedirectForbidden", auth.redirect_forbidden !== false, "checkbox");
note(taBody, "taHelp");
const taAdvanced = el("details", undefined, "vh-wide auth-advanced"); taAdvanced.append(text("summary", "taAdvanced"));
@@ -356,14 +367,49 @@ function load(value = defaults(), migrationIds = [], show = true) {
field(taAdvancedGrid, "auth-allowlist", "vhAddressAllowlist", (auth.address_allowlist || []).join("\n"), "textarea", { rows: 2, maxLength: 8192 });
field(taAdvancedGrid, "auth-ca", "vhCa", auth.tls_ca_pem || "", "textarea", { rows: 2, maxLength: 65536 });
const forwardEditors = {};
for (const id of ["authelia", "authentik"]) {
const current = value[id] || {}, title = id === "authelia" ? "authAuthelia" : "authAuthentik";
const body = el("section", undefined, "vh-wide auth-subcard"); g.append(body);
body.append(text("h3", title));
const primary = el("div", undefined, "vh-grid"); body.append(primary);
const url = field(primary, `${id}-url`, "forwardAuthUrl", current.url || "", "url", { maxLength: 2048 });
url.placeholder = id === "authelia" ? "http://authelia:9091" : "http://authentik:9000";
let login = null;
if (id === "authelia") {
login = field(primary, `${id}-login`, "forwardAuthLogin", current.login_url || "", "url", { maxLength: 2048 });
login.placeholder = "https://auth.example.com";
}
const options = el("div", undefined, "vh-wide auth-provider-options"); primary.append(options);
field(options, `${id}-private`, "vhPrivate", current.allow_private, "checkbox");
field(options, `${id}-redirect-forbidden`, "taRedirectForbidden", current.redirect_forbidden === true, "checkbox");
note(body, id === "authelia" ? "autheliaHelp" : "authentikHelp");
const advanced = el("details", undefined, "vh-wide auth-advanced"); advanced.append(text("summary", "forwardAuthAdvanced"));
const details = el("div", undefined, "vh-grid"); advanced.append(details); body.append(advanced);
field(details, `${id}-connect`, "taConnect", current.connect_timeout_seconds ?? 5, "number", { required: true, min: 1, max: 30 });
field(details, `${id}-timeout`, "taTimeout", current.timeout_seconds ?? 10, "number", { required: true, min: 1, max: 60 });
field(details, `${id}-request-headers`, "taRequestHeaders", (current.request_headers || ["cookie", "authorization", "user-agent", "accept", "accept-language"]).join("\n"), "textarea", { rows: 3, maxLength: 8192 });
const autheliaHeaders = ["remote-user", "remote-groups", "remote-name", "remote-email"];
const authentikHeaders = ["x-authentik-username", "x-authentik-email", "x-authentik-name", "x-authentik-groups", "x-authentik-uid", "x-authentik-entitlements"];
field(details, `${id}-response-headers`, "taResponseHeaders", (current.response_headers || [...autheliaHeaders, ...authentikHeaders]).join("\n"), "textarea", { rows: 4, maxLength: 8192 });
field(details, `${id}-allowlist`, "vhAddressAllowlist", (current.address_allowlist || []).join("\n"), "textarea", { rows: 2, maxLength: 8192 });
field(details, `${id}-ca`, "vhCa", current.tls_ca_pem || "", "textarea", { rows: 2, maxLength: 65536 });
forwardEditors[id] = { body, url, login };
}
basicBody.append(text("h3", "authBasic"));
const basicGrid = el("div", undefined, "vh-grid"); basicBody.append(basicGrid);
basicEditor = basicAuthEditor(basicGrid, value.basic_auth || {});
const syncAuth = () => {
authEnable.checked = authMode.value === "tinyauth";
taBody.hidden = !authEnable.checked;
const tinyauthSelected = authMode.value === "tinyauth";
taBody.hidden = !tinyauthSelected;
basicBody.hidden = authMode.value !== "basic";
authUrl.required = loginUrl.required = authEnable.checked;
authUrl.required = loginUrl.required = tinyauthSelected;
for (const [id, editor] of Object.entries(forwardEditors)) {
editor.body.hidden = id !== authMode.value;
editor.url.required = id === authMode.value;
if (editor.login) editor.login.required = id === authMode.value;
}
};
authMode.addEventListener("change", syncAuth); syncAuth();
g = group("vhResponsePolicy"); const server = value.server_header, diagnostics = value.diagnostic_headers;
@@ -508,7 +554,9 @@ function collect() {
content_type_policy: { enabled: yes("content-type-block-enabled"), blocked: lines(str("content-type-block-list")).map(value => value.toLowerCase()) },
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,
tinyauth: { enabled: str("auth-mode") === "tinyauth", 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") },
authelia: readForwardProvider("authelia"), authentik: readForwardProvider("authentik"),
backends: members, routes: paths, acl, headers,
load_balancing: {
algorithm: str("algorithm"), slow_start_seconds: num("slow-start"), keepalive: { enabled: yes("keepalive"), idle_timeout_seconds: num("idle-timeout"), max_idle_per_host: num("max-idle"), tcp_keepalive_seconds: num("tcp-keepalive"), pool_recycle_requests: num("pool-recycle-requests"), pool_recycle_seconds: num("pool-recycle-seconds"), http1_max_buffer_bytes: num("http1-max-buffer") },
retry: { max_retries: num("retry-max"), retry_connect_errors: yes("retry-connect"), retry_timeouts: yes("retry-timeouts"), retry_statuses: str("retry-statuses").split(/[\s,]+/).filter(Boolean).map(value => Number(value)) },
+2 -3
View File
@@ -1,5 +1,3 @@
// Shared, anchored Vhost popover for backend quick-view and upstream HTTP
// protocol observations. Both consume existing live status without extra probes.
let activeVhostBackendPopup = null;
function closeVhostBackendPopup(returnFocus = false) {
const current = activeVhostBackendPopup;
@@ -390,6 +388,8 @@ function render() {
const policy = el("div", undefined, "vhost-policy-pills");
if (v.basic_auth?.enabled) policy.append(el("small", t("authBasic"), "pill"));
if (v.tinyauth?.enabled) policy.append(el("small", "Tinyauth", "pill"));
if (v.authelia?.enabled) policy.append(el("small", "Authelia", "pill"));
if (v.authentik?.enabled) policy.append(el("small", "authentik", "pill"));
if (v.rate_limit?.enabled) policy.append(el("small", t("vhRateEnabled"), "pill"));
if (v.acl_profile_id) policy.append(el("small", `${t("vhAclProfile")}: ${v.acl_profile_id}`, "pill"));
if (v.force_ssl) policy.append(el("small", t("vhForceSsl"), "pill"));
@@ -573,7 +573,6 @@ form.addEventListener("submit", async event => {
const value = collect(), next = structuredClone(envelope.config); next.vhosts ||= [];
const debugIssues = debugOriginIssues(value.backends); if (debugIssues.length && !(await appConfirm(`${t("vhDebugOriginMismatch")}\n\n${debugIssues.map(member => member.id).join(", ")}`, { danger: true }))) return;
const oldVhost = (envelope.config.vhosts || []).find(v => v.id === editing);
// A previous acceptance must not silently authorize new hostnames or a different certificate.
const certificateScope = item => JSON.stringify({
certificate: item?.certificate_id || null,
hosts: [...(item?.frontend?.hosts || [])].map(v => v.toLowerCase().replace(/\.$/, "")).sort(),