This commit is contained in:
Mateusz Gruszczyński committed 2026-10-27 13:22:43 +01:00
1 parent e364392659
commit f27fbf6a30
28 files changed
+2000 -60

No files matched your search

+34 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.34.15",
"version": "0.34.16",
"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,37 @@
"id"
]
},
"AuthenticationChoice": {
"type": "object",
"description": "Optional explicit provider and integration method. Absent means infer the existing active Basic/Tinyauth/Authelia/authentik/OIDC configuration for backward compatibility. The selected method must match the one enabled legacy settings object; unknown, inconsistent or unsupported pairs are rejected.",
"additionalProperties": false,
"required": ["provider", "method"],
"properties": {
"provider": {"type": "string", "enum": ["basic", "tinyauth", "authelia", "authentik", "pocket_id", "custom"]},
"method": {"type": "string", "enum": ["basic", "forward_auth", "oidc"], "description": "Basic supports basic only; Tinyauth/Authelia/authentik support forward_auth or oidc; Pocket ID/custom support oidc only."}
}
},
"OidcConfig": {
"type": "object",
"description": "Opt-in OpenID Connect Authorization Code integration method for an identity provider selected by authentication.provider. Uses issuer discovery, S256 PKCE, nonce/state cookies, JWKS RS256/ES256 ID token verification and encrypted host-only application sessions. Existing standalone oidc.enabled configurations remain supported.",
"additionalProperties": false,
"properties": {
"enabled": {"type": "boolean", "default": false},
"issuer_url": {"type": "string", "format": "uri", "description": "Exact HTTPS OIDC issuer; /.well-known/openid-configuration is discovered at this URL"},
"backend_url": {"type": "string", "format": "uri", "description": "Optional internal HTTP(S) origin for server-side discovery/token/JWKS/UserInfo requests; public issuer and JWT validation remain unchanged"},
"redirect_url": {"type": "string", "format": "uri", "description": "HTTPS URL for an exact hostname in this Vhost and path /_rustproxy/oidc/callback"},
"client_id": {"type": "string", "maxLength": 256},
"client_secret": {"type": "string", "maxLength": 2048, "description": "Confidential OIDC client secret; stored in Vhost configuration"},
"session_secret": {"type": "string", "minLength": 32, "maxLength": 4096, "description": "High-entropy secret for encrypted session and temporary state cookies"},
"scopes": {"type": "string", "default": "openid profile email"},
"allow_private": {"type": "boolean", "default": false},
"address_allowlist": {"type": "array", "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},
"session_ttl_seconds": {"type": "integer", "minimum": 60, "maximum": 86400, "default": 3600}
}
},
"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.",
@@ -560,6 +591,8 @@
"$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."
},
"oidc": {"$ref": "#/components/schemas/OidcConfig"},
"authentication": {"$ref": "#/components/schemas/AuthenticationChoice", "description": "Optional provider/method selector. Set to null or omit for legacy Vhost data. It does not replace the existing provider-specific settings objects."},
"cache": {
"type": "object",
"properties": {
+80 -10
View File
@@ -10,7 +10,7 @@ function basicAuthEditor(parent, settings = {}) {
row.prepare = async () => { if (!password.value) { if (!row.userHash) throw new Error(t("basicHashRequired")); return; } const name = username.value.trim(), secret = password.value, epoch = draftEpoch; const result = await api("/api/basic-auth/hash", { method: "POST", body: JSON.stringify({ username: name, password: secret }) }); if (epoch !== draftEpoch || !row.isConnected || password.value !== secret || username.value.trim() !== name) throw new Error(t("vhUnsaved")); row.userHash = result.password_hash; password.value = ""; password.placeholder = t("basicKeepPassword"); state.textContent = t("basicHashSaved"); };
}
for (const user of settings.users || []) add(user); parent.append(button("basicAddUser", () => { add(); changed(); })); note(parent, "basicHelp");
return { async prepare() { for (const row of rows.children) await row.prepare(); }, read: () => ({ enabled: str("auth-mode") === "basic", realm: realm.value.trim(), require_tls: secure.checked, forward_credentials: forward.checked, users: [...rows.children].map(row => ({ username: str("basic-username", row), password_hash: row.userHash })) }) };
return { async prepare() { for (const row of rows.children) await row.prepare(); }, read: () => ({ enabled: selectedAuthentication().provider === "basic", realm: realm.value.trim(), require_tls: secure.checked, forward_credentials: forward.checked, users: [...rows.children].map(row => ({ username: str("basic-username", row), password_hash: row.userHash })) }) };
}
function hostTagEditor(parent, hosts = [], onUpdate = () => { }) {
@@ -33,10 +33,29 @@ function hostTagEditor(parent, hosts = [], onUpdate = () => { }) {
return () => { if (input.value.trim()) consume(input.value); if (!values.length) throw new Error(t("vhHostRequired")); return [...values]; };
}
const authMethodsByProvider = {
none: [], basic: ["basic"], tinyauth: ["forward_auth", "oidc"],
authelia: ["forward_auth", "oidc"], authentik: ["forward_auth", "oidc"],
pocket_id: ["oidc"], custom: ["oidc"]
};
function inferredAuthentication(value) {
if (value.authentication?.provider && value.authentication?.method) return value.authentication;
if (value.basic_auth?.enabled) return { provider: "basic", method: "basic" };
for (const provider of ["tinyauth", "authelia", "authentik"]) {
if (value[provider]?.enabled) return { provider, method: "forward_auth" };
}
if (value.oidc?.enabled) return { provider: "custom", method: "oidc" };
return { provider: "none", method: "none" };
}
function selectedAuthentication() {
const provider = str("auth-provider"), method = str("auth-method");
return provider === "none" ? { provider, method: "none" } : { provider, method };
}
function readForwardProvider(id) {
const value = key => str(`${id}-${key}`);
return {
enabled: str("auth-mode") === id,
enabled: selectedAuthentication().provider === id && selectedAuthentication().method === "forward_auth",
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`),
@@ -344,7 +363,9 @@ 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" : value.authelia?.enabled ? "authelia" : value.authentik?.enabled ? "authentik" : "none", "select", { values: [["none", "authNone"], ["tinyauth", "authTinyauth"], ["authelia", "authAuthelia"], ["authentik", "authAuthentik"], ["basic", "authBasic"]] });
const initialAuth = inferredAuthentication(value);
const authProvider = field(g, "auth-provider", "authProvider", initialAuth.provider, "select", { values: [["none", "authNone"], ["tinyauth", "authTinyauth"], ["authelia", "authAuthelia"], ["authentik", "authAuthentik"], ["pocket_id", "authPocketId"], ["custom", "authCustomOidc"], ["basic", "authBasic"]] });
const authMethod = field(g, "auth-method", "authMethod", initialAuth.method, "select", { values: [["forward_auth", "authForwardAuth"], ["oidc", "authOidc"], ["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);
@@ -397,21 +418,63 @@ function load(value = defaults(), migrationIds = [], show = true) {
forwardEditors[id] = { body, url, login };
}
const oidc = value.oidc || {};
const oidcBody = el("section", undefined, "vh-wide auth-subcard"); g.append(oidcBody);
oidcBody.append(text("h3", "authOidc"));
const oidcPrimary = el("div", undefined, "vh-grid"); oidcBody.append(oidcPrimary);
const oidcIssuer = field(oidcPrimary, "oidc-issuer", "oidcIssuer", oidc.issuer_url || "", "url", { maxLength: 2048 });
oidcIssuer.placeholder = "https://auth.example.com";
const oidcBackend = field(oidcPrimary, "oidc-backend", "oidcBackend", oidc.backend_url || "", "url", { maxLength: 2048 });
oidcBackend.placeholder = "http://172.17.0.20:9091";
const oidcRedirect = field(oidcPrimary, "oidc-redirect", "oidcRedirect", oidc.redirect_url || (value.frontend?.hosts || []).filter(host => !host.startsWith("*.")).map(host => `https://${host}/_rustproxy/oidc/callback`)[0] || "", "url", { maxLength: 2048 });
oidcRedirect.placeholder = "https://app.example.com/_rustproxy/oidc/callback";
const oidcClient = field(oidcPrimary, "oidc-client", "oidcClient", oidc.client_id || "", "text", { maxLength: 256 });
const oidcSecret = field(oidcPrimary, "oidc-secret", "oidcSecret", oidc.client_secret || "", "password", { maxLength: 2048 });
oidcSecret.autocomplete = "new-password";
const oidcSession = field(oidcPrimary, "oidc-session", "oidcSession", oidc.session_secret || "", "password", { maxLength: 4096 });
oidcSession.autocomplete = "new-password";
const oidcScopes = field(oidcPrimary, "oidc-scopes", "oidcScopes", oidc.scopes || "openid profile email", "text", { maxLength: 1024 });
const oidcOptions = el("div", undefined, "vh-wide auth-provider-options"); oidcPrimary.append(oidcOptions);
field(oidcOptions, "oidc-private", "vhPrivate", oidc.allow_private === true, "checkbox");
note(oidcBody, "oidcHelp");
const oidcAdvanced = el("details", undefined, "vh-wide auth-advanced"); oidcAdvanced.append(text("summary", "forwardAuthAdvanced"));
const oidcAdvancedGrid = el("div", undefined, "vh-grid"); oidcAdvanced.append(oidcAdvancedGrid); oidcBody.append(oidcAdvanced);
field(oidcAdvancedGrid, "oidc-connect", "taConnect", oidc.connect_timeout_seconds ?? 5, "number", { min: 1, max: 30, required: true });
field(oidcAdvancedGrid, "oidc-timeout", "taTimeout", oidc.timeout_seconds ?? 10, "number", { min: 1, max: 60, required: true });
field(oidcAdvancedGrid, "oidc-ttl", "oidcTtl", oidc.session_ttl_seconds ?? 3600, "number", { min: 60, max: 86400, required: true });
field(oidcAdvancedGrid, "oidc-allowlist", "vhAddressAllowlist", (oidc.address_allowlist || []).join("\n"), "textarea", { rows: 2, maxLength: 8192 });
field(oidcAdvancedGrid, "oidc-ca", "vhCa", oidc.tls_ca_pem || "", "textarea", { rows: 2, maxLength: 65536 });
basicBody.append(text("h3", "authBasic"));
const basicGrid = el("div", undefined, "vh-grid"); basicBody.append(basicGrid);
basicEditor = basicAuthEditor(basicGrid, value.basic_auth || {});
const syncAuth = () => {
const tinyauthSelected = authMode.value === "tinyauth";
const methods = authMethodsByProvider[authProvider.value] || [];
const previous = authMethod.value;
authMethod.replaceChildren(...methods.map(method => {
const label = method === "forward_auth" ? "authForwardAuth" : method === "basic" ? "authBasic" : "authOidc";
const option = text("option", label); option.value = method; return option;
}));
if (methods.includes(previous)) authMethod.value = previous;
authMethod.closest("label").hidden = authProvider.value === "none";
const { provider, method } = selectedAuthentication();
const forward = method === "forward_auth";
const tinyauthSelected = provider === "tinyauth" && forward;
taBody.hidden = !tinyauthSelected;
basicBody.hidden = authMode.value !== "basic";
basicBody.hidden = provider !== "basic";
authUrl.required = loginUrl.required = tinyauthSelected;
oidcBody.hidden = method !== "oidc";
for (const input of [oidcIssuer, oidcRedirect, oidcClient, oidcSecret, oidcSession, oidcScopes]) input.required = method === "oidc";
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;
const selected = provider === id && forward;
editor.body.hidden = !selected;
editor.url.required = selected;
if (editor.login) editor.login.required = selected;
}
};
authMode.addEventListener("change", syncAuth); syncAuth();
authProvider.addEventListener("change", syncAuth);
authMethod.addEventListener("change", syncAuth);
syncAuth();
g = group("vhResponsePolicy"); const server = value.server_header, diagnostics = value.diagnostic_headers;
const serverMode = field(g, "server-mode", "serverHeader", server?.mode || "inherit", "select", { values: [["inherit", "vhInherit"], ["preserve", "serverPreserve"], ["hide", "serverHide"], ["custom", "serverCustom"]] });
const serverValue = field(g, "server-value", "serverValue", server?.value || "RustProxy", "text", { maxLength: 256 });
@@ -554,8 +617,15 @@ 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: 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") },
authentication: selectedAuthentication().provider === "none" ? null : selectedAuthentication(),
tinyauth: { enabled: selectedAuthentication().provider === "tinyauth" && selectedAuthentication().method === "forward_auth", 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"),
oidc: { enabled: selectedAuthentication().method === "oidc", issuer_url: str("oidc-issuer"), backend_url: str("oidc-backend"),
redirect_url: str("oidc-redirect"), client_id: str("oidc-client"),
client_secret: str("oidc-secret"), session_secret: str("oidc-session"), scopes: str("oidc-scopes"),
allow_private: yes("oidc-private"), address_allowlist: lines(str("oidc-allowlist")),
tls_ca_pem: nullable("oidc-ca"), connect_timeout_seconds: num("oidc-connect"),
timeout_seconds: num("oidc-timeout"), session_ttl_seconds: num("oidc-ttl") },
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") },
+12 -4
View File
@@ -386,10 +386,18 @@ function render() {
const health = el("p", t("vhHealthEmpty"), "vh-health-summary"); health.dataset.healthFor = v.id; box.append(health);
}
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.authentication?.provider) {
const providers = { basic: "authBasic", tinyauth: "authTinyauth", authelia: "authAuthelia", authentik: "authAuthentik", pocket_id: "authPocketId", custom: "authCustomOidc" };
const provider = providers[v.authentication.provider];
const method = v.authentication.method;
if (provider) policy.append(el("small", method === "basic" ? t(provider) : `${t(provider)} · ${method === "oidc" ? "OIDC" : t("authForwardAuth")}`, "pill"));
} else {
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.oidc?.enabled) policy.append(el("small", "OIDC", "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"));