Tell the three auth failures apart, and give the Auth tab a settings card

Not set, rejected and unreachable all reached the page as one message about checking
credentials, which sends you to a password when the field is simply empty — as both of
HOST1's were, with no card on the page to fill them in from.
This commit is contained in:
Gmer4Lfe
2026-08-14 23:14:33 -04:00
parent 2bda4cdaf8
commit 9eef5b50e6
4 changed files with 133 additions and 9 deletions
+72 -6
View File
@@ -45,14 +45,20 @@
// //
// Auth failure is reported, not retried into a lockout. // Auth failure is reported, not retried into a lockout.
// A failed token fetch returns an _err string immediately. Nothing loops on bad // A failed token fetch returns an _err string immediately. Nothing loops on bad
// credentials against a service that may rate-limit or lock the account. // credentials against a service that may rate-limit or lock the account. A blank
// credential is caught before the request rather than sent as a guess.
//
// The three auth failures are told apart.
// Not set, rejected, and unreachable all reach a caller as an empty token and need
// three different fixes. vv_auth_creds_missing() and vv_auth_token_err() name which.
// //
// Every remote call has a timeout, and every function returns a structured result — // Every remote call has a timeout, and every function returns a structured result —
// ['ok' => bool] or an _err key — so no caller has to distinguish an exception from a // ['ok' => bool] or an _err key — so no caller has to distinguish an exception from a
// legitimately empty list. // legitimately empty list.
// //
// EXPORTS // EXPORTS
// Config vv_auth_conf() // Config vv_auth_conf(), vv_auth_creds_missing(), vv_auth_token_err(),
// vv_auth_last_transport()
// NPM vv_npm_list_proxies(), vv_npm_list_certs(), vv_npm_create_proxy(), // NPM vv_npm_list_proxies(), vv_npm_list_certs(), vv_npm_create_proxy(),
// vv_npm_update_proxy(), vv_npm_delete_proxy(), vv_npm_toggle_proxy() // vv_npm_update_proxy(), vv_npm_delete_proxy(), vv_npm_toggle_proxy()
// LLDAP vv_lldap_list_users(), vv_lldap_list_groups(), vv_lldap_create_user(), // LLDAP vv_lldap_list_users(), vv_lldap_list_groups(), vv_lldap_create_user(),
@@ -92,6 +98,48 @@ function vv_auth_conf(): array {
]; ];
} }
// ── Credential state ──────────────────────────────────────────────────────────
// Three different failures arrive at a token fetch as the same empty string: the credential was
// never filled in, the service rejected it, or the service is not answering. They need three
// different actions, and "check credentials" sends someone to look at a password that is fine
// while the container is down — or at a container that is fine while the field is empty.
//
// Blank is checked first and without a request, because there is nothing to ask: a login with an
// empty identity is a guess against a service that may rate-limit or lock the account, and
// vv_npm_raw() would report its 401 as if a real password had been rejected.
function vv_auth_creds_missing(string $svc): string {
$conf = vv_auth_conf();
$h = strtoupper(vv_detect_host());
if ($svc === 'npm')
return ($conf['npm_user'] === '' || $conf['npm_pass'] === '')
? "NPM credentials are not set — {$h}_NPM_USER / {$h}_NPM_PASS are empty. Fill them in Auth settings, below."
: '';
return ($conf['lldap_user'] === '' || $conf['lldap_pass'] === '')
? "lldap credentials are not set — {$h}_LLDAP_USER / {$h}_LLDAP_PASS are empty. Fill them in Auth settings, below."
: '';
}
// The transport result of the last auth-stack curl, so a caller holding an empty token can say
// which of the two remaining failures it was. Static rather than returned through every signature
// because the token functions return a plain string and always have; widening them would touch
// every call site to carry a value only the failure path reads.
function vv_auth_last_transport(?array $set = null): array {
static $last = ['errno' => 0, 'error' => '', 'code' => 0];
if ($set !== null) $last = $set;
return $last;
}
function vv_auth_token_err(string $svc, string $url): string {
$t = vv_auth_last_transport();
$name = $svc === 'npm' ? 'NPM' : 'lldap';
if ($t['errno'])
return "$name unreachable at $url" . ($t['error'] ?: 'connection failed');
$h = strtoupper(vv_detect_host());
$k = $svc === 'npm' ? "{$h}_NPM_USER / {$h}_NPM_PASS" : "{$h}_LLDAP_USER / {$h}_LLDAP_PASS";
return "$name rejected the login — check $k in Auth settings, below.";
}
// ── NPM ─────────────────────────────────────────────────────────────────────── // ── NPM ───────────────────────────────────────────────────────────────────────
function vv_npm_token(): string { function vv_npm_token(): string {
@@ -129,13 +177,19 @@ function vv_npm_raw(string $method, string $path, array $data, string $token, ar
if ($data && in_array($method, ['POST', 'PUT'], true)) if ($data && in_array($method, ['POST', 'PUT'], true))
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data)); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
$body = curl_exec($ch); $body = curl_exec($ch);
vv_auth_last_transport([
'errno' => curl_errno($ch),
'error' => curl_error($ch),
'code' => (int) curl_getinfo($ch, CURLINFO_HTTP_CODE),
]);
curl_close($ch); curl_close($ch);
return json_decode($body ?: '{}', true) ?: []; return json_decode($body ?: '{}', true) ?: [];
} }
function vv_npm_req(string $method, string $path, array $data = []): array { function vv_npm_req(string $method, string $path, array $data = []): array {
if ($miss = vv_auth_creds_missing('npm')) return ['_err' => $miss];
$token = vv_npm_token(); $token = vv_npm_token();
if (!$token) return ['_err' => 'NPM auth failed — check credentials in host conf']; if (!$token) return ['_err' => vv_auth_token_err('npm', vv_auth_conf()['npm_url'])];
return vv_npm_raw($method, $path, $data, $token); return vv_npm_raw($method, $path, $data, $token);
} }
@@ -146,9 +200,14 @@ function vv_npm_list_proxies(): array {
return ['ok' => true, 'proxies' => $list]; return ['ok' => true, 'proxies' => $list];
} }
// Returns a list, always. An auth failure arrives here as a map with an _err key, and the
// is_array() check passed it straight through as if it were the certificates — the caller then
// held an object where it expected an array and lost .find() on it. The contract is a list, so a
// failure is an empty one; vv_npm_list_proxies() runs on the same page and reports the reason.
function vv_npm_list_certs(): array { function vv_npm_list_certs(): array {
$list = vv_npm_req('GET', '/api/nginx/certificates'); $list = vv_npm_req('GET', '/api/nginx/certificates');
return is_array($list) ? $list : []; if (!is_array($list) || isset($list['_err'])) return [];
return array_values($list);
} }
function vv_npm_create_proxy(array $data): array { function vv_npm_create_proxy(array $data): array {
@@ -189,6 +248,11 @@ function vv_lldap_token(): string {
CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
]); ]);
$body = curl_exec($ch); $body = curl_exec($ch);
vv_auth_last_transport([
'errno' => curl_errno($ch),
'error' => curl_error($ch),
'code' => (int) curl_getinfo($ch, CURLINFO_HTTP_CODE),
]);
curl_close($ch); curl_close($ch);
$resp = json_decode($body ?: '{}', true) ?: []; $resp = json_decode($body ?: '{}', true) ?: [];
$token = $resp['token'] ?? ''; $token = $resp['token'] ?? '';
@@ -201,8 +265,9 @@ function vv_lldap_token(): string {
function vv_lldap_gql(string $query, array $variables = []): array { function vv_lldap_gql(string $query, array $variables = []): array {
$conf = vv_auth_conf(); $conf = vv_auth_conf();
if ($miss = vv_auth_creds_missing('lldap')) return ['errors' => [['message' => $miss]]];
$token = vv_lldap_token(); $token = vv_lldap_token();
if (!$token) return ['errors' => [['message' => 'lldap auth failed — check credentials']]]; if (!$token) return ['errors' => [['message' => vv_auth_token_err('lldap', $conf['lldap_url'])]]];
$ch = curl_init($conf['lldap_url'] . '/api/graphql'); $ch = curl_init($conf['lldap_url'] . '/api/graphql');
curl_setopt_array($ch, [ curl_setopt_array($ch, [
@@ -259,8 +324,9 @@ function vv_lldap_delete_user(string $id): array {
function vv_lldap_set_password(string $userId, string $password): array { function vv_lldap_set_password(string $userId, string $password): array {
$conf = vv_auth_conf(); $conf = vv_auth_conf();
if ($miss = vv_auth_creds_missing('lldap')) return ['ok' => false, 'error' => $miss];
$token = vv_lldap_token(); $token = vv_lldap_token();
if (!$token) return ['ok' => false, 'error' => 'lldap auth failed']; if (!$token) return ['ok' => false, 'error' => vv_auth_token_err('lldap', $conf['lldap_url'])];
$ch = curl_init($conf['lldap_url'] . '/auth/admin/resetPassword'); $ch = curl_init($conf['lldap_url'] . '/auth/admin/resetPassword');
curl_setopt_array($ch, [ curl_setopt_array($ch, [
+6
View File
@@ -124,6 +124,12 @@ const VV_UI_SECTION_SURFACES = [
'route' => 'Media Stack tab → Media settings'], 'route' => 'Media Stack tab → Media settings'],
['match' => 'docker', 'tab' => 'Docker', ['match' => 'docker', 'tab' => 'Docker',
'route' => 'Docker tab → Docker settings'], 'route' => 'Docker tab → Docker settings'],
// NPM, lldap and Authelia are the three services the Auth tab drives, and Certificate Monitor
// 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',
'route' => 'Auth tab → Auth settings'],
// Everything that is not structurally excluded. The catch-all exists so no ordinary setting // Everything that is not structurally excluded. The catch-all exists so no ordinary setting
// is reachable only by editing a file — a settings page whose answer to a third of the conf // is reachable only by editing a file — a settings page whose answer to a third of the conf
+41
View File
@@ -41,6 +41,9 @@
// include/auth.php required directly for initial render // include/auth.php required directly for initial render
// api/auth.php mutations // api/auth.php mutations
// api/cert.php certificate status // api/cert.php certificate status
// api/confform.php inline conf edits → include/confui.php
require_once dirname(__DIR__) . '/include/confui.php';
require_once dirname(__DIR__) . '/include/ai_chat.php';
?> ?>
<style> <style>
/* ── Toolbar ─────────────────────────────────────────────────────────────── */ /* ── Toolbar ─────────────────────────────────────────────────────────────── */
@@ -271,6 +274,44 @@ $isOwner = vv_is_owner();
<div class="vv-au-modal" id="vv-au-modal"></div> <div class="vv-au-modal" id="vv-au-modal"></div>
</div> </div>
<?php if (vv_ai_ui_on()): ?>
<div class="vv-card" id="vv-au-ai-card" style="margin-top:12px;">
<?php
// The factory, the profile registry and the store are three separate emits and none implies
// the others — omitting any renders a chat that looks complete and dies on the first click.
vv_ai_profiles_script();
vv_ai_chat_store_script();
vv_ai_chat_assets();
// Scoped to the tab. This page's vocabulary is the part of the stack least likely to be in
// anyone's head — a proxy host, a forward target, an Authelia policy and an LDAP group are four
// different objects that all end up deciding whether one person can open one URL, and the
// question is nearly always "which of these is stopping me".
vv_ai_chat_markup('vv-au-ai', [
'profile' => 'varaverk',
'compact' => true,
'title' => 'Assistant',
'scopeLabel' => 'Auth',
'empty' => 'Ask about a proxy host, a rule, a group, or why a login is being refused.',
'placeholder' => 'Ask about what is on this page…',
]); ?>
</div>
<?php endif; ?>
<?php
// The three services this page drives, plus the cert thresholds behind the Certs panel. They had
// no card at all until now, so the credentials that make the whole page work were reachable only
// from the Settings catch-all or over SSH — and an empty NPM_USER renders here as a failed login,
// which sends you looking for a password rather than a blank field.
//
// The match mirrors VV_UI_SECTION_SURFACES in confform.php and has to keep mirroring it: that
// constant is what tells the assistant where to send someone, and this is what the page actually
// draws. The two disagreeing means being given directions to a card that is not there.
//
// Both passwords render masked and are logged by name only — vv_conf_key_is_secret() matches
// PASS, so the same key cannot be redacted in the audit log and legible in the form.
vv_conf_ui_card('vv-cf-auth', 'NginxProxyManager|lldap|Authelia|Certificate Monitor', 'Auth settings');
?>
<script> <script>
(function () { (function () {
'use strict'; 'use strict';
+14 -3
View File
@@ -44,7 +44,10 @@ Saved into `host1.conf`, which does not need to be opened by hand.
## Authelia ## Authelia
Route: Settings tab → All settings → *Authelia* Reachable from:
- Auth tab → Auth settings → *Authelia*
- Settings tab → All settings → *Authelia*
Saved into `host1.conf`, which does not need to be opened by hand. Saved into `host1.conf`, which does not need to be opened by hand.
@@ -84,6 +87,7 @@ Reachable from:
- Scheduler tab → **Cert Monitor** → Config → *Certificate Monitor* - Scheduler tab → **Cert Monitor** → Config → *Certificate Monitor*
- Scheduler tab → **Weekly Health Digest** → Config → *Certificate Monitor* - Scheduler tab → **Weekly Health Digest** → Config → *Certificate Monitor*
- Auth tab → Auth settings → *Certificate Monitor*
- Settings tab → All settings → *Certificate Monitor* - Settings tab → All settings → *Certificate Monitor*
Saved into `host1.conf`, which does not need to be opened by hand. Saved into `host1.conf`, which does not need to be opened by hand.
@@ -418,7 +422,10 @@ Saved into `host1.conf`, which does not need to be opened by hand.
## NginxProxyManager ## NginxProxyManager
Route: Settings tab → All settings → *NginxProxyManager* Reachable from:
- Auth tab → Auth settings → *NginxProxyManager*
- Settings tab → All settings → *NginxProxyManager*
Saved into `host1.conf`, which does not need to be opened by hand. Saved into `host1.conf`, which does not need to be opened by hand.
@@ -714,7 +721,10 @@ Saved into `host1.conf`, which does not need to be opened by hand.
## lldap ## lldap
Route: Settings tab → All settings → *lldap* Reachable from:
- Auth tab → Auth settings → *lldap*
- Settings tab → All settings → *lldap*
Saved into `host1.conf`, which does not need to be opened by hand. Saved into `host1.conf`, which does not need to be opened by hand.
@@ -1178,6 +1188,7 @@ Reachable from:
- Scheduler tab → **Cert Monitor** → Config → *Certificate Monitor* - Scheduler tab → **Cert Monitor** → Config → *Certificate Monitor*
- Scheduler tab → **Weekly Health Digest** → Config → *Certificate Monitor* - Scheduler tab → **Weekly Health Digest** → Config → *Certificate Monitor*
- Auth tab → Auth settings → *Certificate Monitor*
- Settings tab → All settings → *Certificate Monitor* - Settings tab → All settings → *Certificate Monitor*
Saved into `master.conf`, which does not need to be opened by hand. Saved into `master.conf`, which does not need to be opened by hand.