diff --git a/Deployment/master.conf.template b/Deployment/master.conf.template
index dc3b34f..949a155 100644
--- a/Deployment/master.conf.template
+++ b/Deployment/master.conf.template
@@ -1439,6 +1439,23 @@
# Defined per host in host*.conf — Emby container names and keys differ per server:
# HOST1_TRANSCODE_SERVERS / HOST2_TRANSCODE_SERVERS
+# ==============================================================================================
+# ── AUTH STACK ────────────────────────────────────────────────────────────────────────────────
+# ==============================================================================================
+
+# ━━━ Auth Stack ━━━
+# Which identity stack this mesh runs behind its protected hostnames. The Auth tab reads this to
+# decide which panels to draw and which endpoints to call — it is a routing switch, not a
+# migration. Changing it does not move users, groups or rules between stacks; stand the new one
+# up first, then point this at it.
+#
+# Mesh-wide rather than per host: auth is the one service the partnership treats as shared, owned
+# by the mesh owner and consumed by everyone else, so two nodes disagreeing about which stack is
+# in force would mean two different answers to "who is this person".
+# "authelia_lldap" — Authelia for access rules, lldap for users and groups. Fully implemented.
+# "authentik" — single stack for both. NOT IMPLEMENTED YET; the tab says so instead of drawing panels that cannot work.
+ AUTH_STACK="authelia_lldap"
+
# ==============================================================================================
# ── MONITORS ──────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
@@ -1706,12 +1723,12 @@
# earned it — narration for months before anything is allowed near a decision.
# ━━━ AI Master Switch ━━━
-# Fail-closed: anything other than the literal "true" means off.
# Which node runs the model, the retrieval index and the bug store. Every other node borrows it
# over the mesh, so a box without a GPU still gets the assistant — it just does not get the AI tab.
# Defaults to host1 when unset or malformed: whoever builds the mesh is host1.
AI_OWNER_HOST="host1"
+# Fail-closed: anything other than the literal "true" means off.
AI_ENABLED=false
AI_CONNECT_TIMEOUT=5 # seconds — probe when resolving which node has Ollama
AI_REQUEST_TIMEOUT=240 # seconds — must clear a cold model load
diff --git a/Plugin/unraid/api/auth.php b/Plugin/unraid/api/auth.php
index e754a9c..b73fe43 100644
--- a/Plugin/unraid/api/auth.php
+++ b/Plugin/unraid/api/auth.php
@@ -93,8 +93,44 @@
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/auth.php';
+// Which panel each action belongs to. AUTH_STACK decides which panels the tab draws, and this is
+// the same decision applied to the endpoint — a tab left open from before a switch would otherwise
+// keep writing to the stack that is no longer in force, which on this page means editing the
+// directory or the rules of a system nobody is authenticating against any more.
+//
+// Proxies and certs are Nginx Proxy Manager's, not the identity stack's, so they are listed under
+// panels every stack carries rather than gated to one.
+const VV_AUTH_ACTION_PANEL = [
+ // GET
+ 'npm_proxies' => 'proxies', 'npm_certs' => 'proxies',
+ 'lldap_users' => 'users', 'lldap_groups' => 'users', 'lldap_avatar' => 'users',
+ 'authelia_rules' => 'acl',
+ // POST
+ 'npm_create' => 'proxies', 'npm_update' => 'proxies',
+ 'npm_delete' => 'proxies', 'npm_toggle' => 'proxies',
+ 'lldap_create_user' => 'users', 'lldap_update_user' => 'users', 'lldap_delete_user' => 'users',
+ 'lldap_set_password' => 'users', 'lldap_set_avatar' => 'users', 'lldap_remove_avatar' => 'users',
+ 'lldap_create_group' => 'users', 'lldap_delete_group' => 'users', 'lldap_rename_group' => 'users',
+ 'lldap_add_to_group' => 'users', 'lldap_remove_from_group' => 'users',
+ 'authelia_save' => 'acl',
+];
+
+function vv_auth_action_allowed(string $action): bool {
+ $panel = VV_AUTH_ACTION_PANEL[$action] ?? null;
+ // Unmapped actions are left to the existing "Unknown action" answer rather than being refused
+ // here, so a new action is never silently blocked by a table someone forgot to extend.
+ return $panel === null || vv_auth_panel_on($panel);
+}
+
+function vv_auth_action_refusal(string $action): array {
+ $d = vv_auth_stack_def();
+ return ['ok' => false, 'error' => 'AUTH_STACK is "' . vv_auth_stack() . '" (' . $d['label']
+ . '), which does not serve this request. Reload the Auth tab.'];
+}
+
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$action = $_GET['action'] ?? '';
+ if (!vv_auth_action_allowed($action)) { echo json_encode(vv_auth_action_refusal($action)); exit; }
// The one route here that does not answer in JSON — it streams the stored JPEG so the page can
// point an at it, rather than carrying 470 KB of base64 through the user list on every
@@ -133,6 +169,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
}
$action = trim($_POST['action'] ?? '');
+if (!vv_auth_action_allowed($action)) { echo json_encode(vv_auth_action_refusal($action)); exit; }
$result = match ($action) {
// NPM
diff --git a/Plugin/unraid/include/auth.php b/Plugin/unraid/include/auth.php
index 595fcc7..9a861e8 100644
--- a/Plugin/unraid/include/auth.php
+++ b/Plugin/unraid/include/auth.php
@@ -98,6 +98,57 @@ function vv_auth_conf(): array {
];
}
+// ── Which stack ───────────────────────────────────────────────────────────────
+
+// The stacks this page knows how to drive, and how far it can drive each one. Declared rather
+// than inferred so the tab can offer a stack it cannot yet operate and say exactly that, instead
+// of drawing panels that call endpoints with nothing behind them — which is how a switch ends up
+// looking like a feature while reading nothing.
+const VV_AUTH_STACKS = [
+ 'authelia_lldap' => [
+ 'label' => 'Authelia + lldap',
+ 'ready' => true,
+ 'panels' => ['proxies', 'users', 'acl', 'certs'],
+ 'summary' => 'Authelia holds the access rules, lldap holds users and groups, '
+ . 'Nginx Proxy Manager holds the hostnames.',
+ ],
+ 'authentik' => [
+ 'label' => 'Authentik',
+ 'ready' => false,
+ // Proxies and certs are NPM's, not the identity stack's, so they keep working whichever
+ // stack is selected. Users and access control are the two this page cannot draw yet.
+ 'panels' => ['proxies', 'certs'],
+ 'summary' => 'One stack for identity and access. Varaverk can still manage the proxy '
+ . 'hosts and certificates, but not Authentik users, groups or policies yet.',
+ 'needs' => 'An API token and base URL in host*.conf, then user, group and policy '
+ . 'calls against Authentik\'s REST API to sit behind the same page.',
+ ],
+];
+
+// Falls back rather than failing: an unrecognised value means someone typed a stack name into the
+// conf, and answering with a blank tab helps nobody. The working stack is the safe answer, and
+// vv_auth_stack_valid() is what the page uses to say the value was not understood.
+function vv_auth_stack(): string {
+ $v = trim(vv_conf_vars()['AUTH_STACK'] ?? '');
+ return isset(VV_AUTH_STACKS[$v]) ? $v : 'authelia_lldap';
+}
+
+function vv_auth_stack_valid(): bool {
+ $v = trim(vv_conf_vars()['AUTH_STACK'] ?? '');
+ return $v === '' || isset(VV_AUTH_STACKS[$v]);
+}
+
+function vv_auth_stack_def(): array {
+ return VV_AUTH_STACKS[vv_auth_stack()];
+}
+
+// One question, asked the same way by the page and by the endpoint. The page uses it to decide
+// what to draw; api/auth.php uses it to refuse an action belonging to a stack that is not the one
+// in force, so a stale tab left open across a switch cannot write to the wrong directory.
+function vv_auth_panel_on(string $panel): bool {
+ return in_array($panel, vv_auth_stack_def()['panels'], true);
+}
+
// ── Credential state ──────────────────────────────────────────────────────────
// Three different failures arrive at a token fetch as the same empty string: the credential was
diff --git a/Plugin/unraid/include/confform.php b/Plugin/unraid/include/confform.php
index 97b1e61..5242f4a 100644
--- a/Plugin/unraid/include/confform.php
+++ b/Plugin/unraid/include/confform.php
@@ -128,7 +128,7 @@ const VV_UI_SECTION_SURFACES = [
// is what its Certs panel reports. The credentials in particular belong on the page that fails
// without them: a blank NPM_USER surfaces there as a refused login, and the fix is two panels
// away rather than in a hundred-and-twenty-field catch-all.
- ['match' => 'NginxProxyManager|lldap|Authelia|Certificate Monitor', 'tab' => 'Auth',
+ ['match' => 'Auth Stack|NginxProxyManager|lldap|Authelia|Certificate Monitor', 'tab' => 'Auth',
'route' => 'Auth tab → Auth settings'],
// Everything that is not structurally excluded. The catch-all exists so no ordinary setting
diff --git a/Plugin/unraid/pages/auth.php b/Plugin/unraid/pages/auth.php
index a2dd468..d68b3b9 100644
--- a/Plugin/unraid/pages/auth.php
+++ b/Plugin/unraid/pages/auth.php
@@ -37,6 +37,12 @@
// LLDAP user and group management
// Authelia access-control rules and default policy
//
+// STACK SWITCHING
+// AUTH_STACK in master.conf decides which panels exist. Proxies and Certs belong to Nginx
+// Proxy Manager and are drawn for every stack; Users and Access Control are the identity
+// stack's and are drawn only for one that Varaverk can drive. api/auth.php applies the same
+// rule to every action, so a tab left open across a switch cannot write to the old stack.
+//
// DEPENDS ON
// include/auth.php required directly for initial render
// api/auth.php mutations
@@ -60,6 +66,17 @@ require_once dirname(__DIR__) . '/include/ai_chat.php';
.vv-au-btn.green{ border-color:#1a3a1a;background:#0d1f0d;color:#4caf50; }
.vv-au-btn:disabled { opacity:.4;cursor:default; }
+/* ── Stack indicator ─────────────────────────────────────────────────────── */
+/* Which stack the tab is driving. Always visible, because every panel below it means something
+ different depending on this, and it is a conf value nothing else on the page reveals. */
+.vv-au-stackchip{ font-size:10px;padding:2px 8px;border-radius:3px;background:#12181f;
+ border:1px solid #1e2a38;color:#5c9fd4;white-space:nowrap; }
+.vv-au-stacknote{ font-size:11px;color:#997;background:#1a1400;border:1px solid #3a2d00;
+ border-radius:4px;padding:8px 12px;margin-bottom:10px;line-height:1.55; }
+.vv-au-stacknote b { color:#ffb74d;display:block;margin-bottom:2px; }
+.vv-au-stacknote.bad { background:#1a0d0d;border-color:#3a1a1a;color:#a77; }
+.vv-au-stacknote.bad b { color:#ef5350; }
+
/* ── Panels ──────────────────────────────────────────────────────────────── */
.vv-au-panel { display:none; }
.vv-au-panel.active { display:block; }
@@ -231,19 +248,45 @@ require_once dirname(__DIR__) . '/include/ai_chat.php';
'Proxies', 'users' => 'Users & Groups',
+ 'acl' => 'Access Control', 'certs' => 'Certs'];
?>