Files
Varaverk/Plugin/unraid/api/auth.php
T
Gmer4Lfe 99b58c0c4f Make the Auth tab explain a number instead of only showing it
A low uptime figure, a refused login and a certificate that stopped
renewing all looked the same from the row: a number, with the reason
split across NPM, an Authelia config and the directory.

The why-check goes and looks — TCP to the forward target, HTTP through
the proxy, a second handshake with verification off to tell a broken
certificate from a broken service. Forward hosts are docker names that
only resolve on NPM's network, so an unresolvable one is redirected to
the container address and the substitution is reported; a check that
could not be made must never read as a check that failed.

The access simulator walks the rules the way Authelia does and shows the
ones it stepped over, reading whichever instance the chosen host points
at rather than the one conf names — there are two here.

Cert triage counts runs rather than log lines and orders by rotation
suffix rather than mtime, both of which change the answer.
2026-08-16 01:59:01 -04:00

273 lines
16 KiB
PHP

<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Auth stack endpoint. The single URL behind the auth tab, covering all three services it
// manages: Nginx Proxy Manager proxy hosts and certificates, lldap users and groups, and
// Authelia access rules.
//
// OPERATIONAL MODEL
// Read and write share one URL, split on HTTP method. GET serves the five read actions and
// is always safe. POST carries an action naming exactly one library call. Anything that is
// neither GET nor POST is refused with 405 before a parameter is read.
//
// Three services, one endpoint, because they are one subject. The auth stack is HOST1's
// source of truth for identity, and a change in one service usually accompanies a change in
// another — a new lldap user is only useful once an Authelia rule and an NPM host exist for
// it. Splitting them into three endpoints would split one page's work across three files
// with three copies of the same dispatch.
//
// DESIGN PRINCIPLES
// Both dispatches are closed match expressions.
// Every action maps to one named library call, and an unrecognised action falls to a
// default arm that returns an error. No part of the request is ever used to construct a
// function name, so the action list is the complete set of things this endpoint can do.
//
// The endpoint holds no auth logic of its own.
// Token acquisition, API dialects, config parsing and the Authelia container restart
// all live in include/auth.php. This file is dispatch and nothing else, which is what
// keeps the credential handling in one auditable place.
//
// Structured payloads arrive as JSON in a form field.
// Proxy definitions and rule sets are nested, so they are passed as encoded JSON rather
// than flattened into form keys — a rule set does not survive form encoding intact.
//
// OPERATIONAL SAFEGUARDS
// Wrong method is refused with a status code, not just a body, so a mistaken caller fails
// visibly rather than parsing an error object as data.
//
// Every parameter is optional and typed at the call site.
// (int) casts on ids, ?? '' on strings, ?? '0' === '1' on flags. A malformed POST
// reaches the library as zeros and empty strings — which the library rejects — rather
// than raising undefined-index warnings into the JSON body and corrupting the response.
//
// Malformed JSON degrades to an empty structure.
// json_decode with ?: [] on both data and rules. A truncated payload becomes an empty
// set the library refuses, not a partial one it might act on.
//
// Credentials pass through, and are never returned.
// lldap_set_password and lldap_create_user accept a password and hand it straight to
// the library. No action in either dispatch returns a stored credential, and nothing
// here writes one to a log.
//
// The destructive actions are POST-only by construction.
// Delete of a proxy, a user, or a group exists only in the POST match. The GET arm has
// five read actions and no others, so no link or prefetch can reach a delete.
//
// Authelia rule writes are atomic and refuse to create.
// The library writes .vv.tmp and renames, and returns 'Config not found' rather than
// authoring a fresh config — a config it created would carry no rules and a default
// policy, which is an accidental open door.
//
// The POST actions are CSRF-guarded by the platform, not by this file.
// Unraid's auto_prepend (webGui/local_prepend.php) validates a token on every POST and
// terminates the request before any code here runs. That guard is the reason every
// mutating action lives in the POST arm — this is the highest-value endpoint in the
// plugin to reach, since it can create a user and open a proxy host. The five GET
// actions are reads and are deliberately outside it. See README-unraid.md.
//
// REQUEST
// GET ?action=npm_proxies | npm_certs | npm_stats | npm_uptime | lldap_users | lldap_groups
// | authelia_rules
// GET ?action=npm_why id
// GET ?action=access_check domain, uid, path
// POST action=npm_create data=<JSON>
// POST action=npm_update id, data=<JSON>
// POST action=npm_delete id
// POST action=npm_toggle id, enabled=0|1
// POST action=lldap_create_user uid, email, display_name, password
// POST action=lldap_update_user uid, email, display_name
// POST action=lldap_delete_user uid
// POST action=lldap_set_password uid, password
// POST action=lldap_create_group name
// POST action=lldap_delete_group id
// POST action=lldap_add_to_group uid, gid
// POST action=lldap_remove_from_group uid, gid
// POST action=authelia_save rules=<JSON>, default_policy
//
// RESPONSE
// Whatever the invoked library call returns — ['ok' => bool] with a payload or an error,
// or an _err key on a failed remote call. npm_certs is wrapped as {"ok":true,"certs":[…]}.
// {"ok":false,"error":"Unknown action: …"} for anything outside the lists above.
//
// DEPENDS ON
// include/auth.php vv_npm_*(), vv_lldap_*(), vv_authelia_read_rules(),
// vv_authelia_write_rules()
// ═══════════════════════════════════════════════════════════════════════════════════════════════
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', 'npm_stats' => 'proxies', 'npm_uptime' => 'proxies',
'npm_why' => 'proxies',
'lldap_users' => 'users', 'lldap_groups' => 'users', 'lldap_avatar' => 'users',
'authelia_rules' => 'acl', 'access_check' => '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',
];
// The windowing rule lives in include/auth.php and is shared with Tools/uptime_probe.php. It was
// three separate copies of the same six lines, which is three places for the definition of "the
// last 24 hours" to drift apart while every one of them keeps returning a plausible number.
function vv_uptime_window_api(array $buckets, int $n): ?float {
return vv_auth_uptime_window($buckets, $n);
}
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 <img> at it, rather than carrying 470 KB of base64 through the user list on every
// load. Handled before the match so the Content-Type set above is replaced rather than sent
// alongside image bytes.
if ($action === 'lldap_avatar') {
$raw = vv_lldap_avatar((string) ($_GET['uid'] ?? ''));
if ($raw === '') { header('Content-Type: application/json'); http_response_code(404);
echo json_encode(['ok' => false, 'error' => 'No avatar']); exit; }
header('Content-Type: image/jpeg');
header('Content-Length: ' . strlen($raw));
// Private, because this is a photograph of a person behind an authenticated admin page,
// and must not be held by anything between here and the browser. Short, because the
// operator changing an avatar expects to see it change.
header('Cache-Control: private, max-age=60');
echo $raw;
exit;
}
// Per-host request and byte totals, written by Tools/npm_access_stats.sh. Served rather than
// computed: the logs behind these numbers are 475 MB and reading them is a scheduled job, not
// something a page load can do.
if ($action === 'npm_stats') {
$f = rtrim(defined('DB_DIR') ? DB_DIR : (DATA_DIR . '/db'), '/') . '/npm_access.json';
$s = is_file($f) ? (json_decode((string) @file_get_contents($f), true) ?: []) : [];
echo json_encode(['ok' => true, 'hosts' => $s['hosts'] ?? [],
'last_pass' => $s['last_pass'] ?? null]);
exit;
}
// Per-domain uptime, written by Tools/uptime_probe.sh every minute. Keyed by hostname rather
// than proxy id, because that is what was probed.
if ($action === 'npm_uptime') {
$f = rtrim(defined('DB_DIR') ? DB_DIR : (DATA_DIR . '/db'), '/') . '/uptime.json';
$u = is_file($f) ? (json_decode((string) @file_get_contents($f), true) ?: []) : [];
$out = [];
foreach ($u['domains'] ?? [] as $dom => $r) {
// Only what the row draws. The hourly and daily buckets are dozens of entries per
// domain and the page shows three percentages and a strip.
$out[$dom] = [
'state' => $r['state'] ?? null,
'samples' => array_slice($r['samples'] ?? [], -60),
'h1' => vv_uptime_window_api($r['hours'] ?? [], 1),
'h24' => vv_uptime_window_api($r['hours'] ?? [], 24),
'd30' => vv_uptime_window_api($r['days'] ?? [], 30),
'last_change' => $r['last_change'] ?? null,
'last_detail' => $r['last_detail'] ?? null,
'last_ms' => $r['last_ms'] ?? null,
];
}
echo json_encode(['ok' => true, 'domains' => $out, 'last_pass' => $u['last_pass'] ?? null]);
exit;
}
// Why one host is not at 100%. The only read here that goes and looks rather than serving a
// stored figure: it opens a socket to the forward target, asks the domain itself, and inspects
// the containers. Slow by the standards of this file — several seconds — which is why it is one
// host on demand and never part of the list load.
//
// A read, so it stays in the GET arm with the other reads. It is worth being explicit that this
// is safe to leave outside the CSRF guard: every call it makes is a GET, a HEAD, a TCP connect
// or a file read, so the worst a forged request achieves is making this machine look at itself.
if ($action === 'npm_why') {
echo json_encode(vv_npm_why((int) ($_GET['id'] ?? 0)));
exit;
}
// Can this user open this URL, and what decided it. Reads NPM, the Authelia instance that this
// particular host talks to, and the directory — the three places the answer is split across.
if ($action === 'access_check') {
echo json_encode(vv_auth_access_check((string) ($_GET['domain'] ?? ''),
(string) ($_GET['uid'] ?? ''),
(string) ($_GET['path'] ?? '/')));
exit;
}
$result = match ($action) {
'npm_proxies' => vv_npm_list_proxies(),
'npm_certs' => ['ok' => true, 'certs' => vv_npm_list_certs()],
'lldap_users' => vv_lldap_list_users(),
'lldap_groups' => vv_lldap_list_groups(),
'authelia_rules' => vv_authelia_read_rules(),
default => ['ok' => false, 'error' => 'Unknown action: ' . $action],
};
echo json_encode($result);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['ok' => false, 'error' => 'GET or POST only']);
exit;
}
$action = trim($_POST['action'] ?? '');
if (!vv_auth_action_allowed($action)) { echo json_encode(vv_auth_action_refusal($action)); exit; }
$result = match ($action) {
// NPM
'npm_create' => vv_npm_create_proxy(json_decode($_POST['data'] ?? '{}', true) ?: []),
'npm_update' => vv_npm_update_proxy((int)($_POST['id'] ?? 0), json_decode($_POST['data'] ?? '{}', true) ?: []),
'npm_delete' => vv_npm_delete_proxy((int)($_POST['id'] ?? 0)),
'npm_toggle' => vv_npm_toggle_proxy((int)($_POST['id'] ?? 0), ($_POST['enabled'] ?? '0') === '1'),
// lldap
'lldap_create_user' => vv_lldap_create_user($_POST['uid'] ?? '', $_POST['email'] ?? '', $_POST['display_name'] ?? '', $_POST['password'] ?? '',
$_POST['first_name'] ?? '', $_POST['last_name'] ?? ''),
// isset, not ??'' — the update helper reads null as "not offered" and '' as "cleared", and
// collapsing the two here would erase a first name every time a form omitted the field.
'lldap_update_user' => vv_lldap_update_user($_POST['uid'] ?? '', $_POST['email'] ?? '', $_POST['display_name'] ?? '',
isset($_POST['first_name']) ? (string) $_POST['first_name'] : null,
isset($_POST['last_name']) ? (string) $_POST['last_name'] : null),
'lldap_set_avatar' => vv_lldap_set_avatar($_POST['uid'] ?? '', $_POST['avatar'] ?? ''),
'lldap_remove_avatar' => vv_lldap_remove_avatar($_POST['uid'] ?? ''),
'lldap_rename_group' => vv_lldap_rename_group((int)($_POST['id'] ?? 0), $_POST['name'] ?? ''),
'lldap_delete_user' => vv_lldap_delete_user($_POST['uid'] ?? ''),
'lldap_set_password' => vv_lldap_set_password($_POST['uid'] ?? '', $_POST['password'] ?? ''),
'lldap_create_group' => vv_lldap_create_group($_POST['name'] ?? ''),
'lldap_delete_group' => vv_lldap_delete_group((int)($_POST['id'] ?? 0)),
'lldap_add_to_group' => vv_lldap_add_to_group($_POST['uid'] ?? '', (int)($_POST['gid'] ?? 0)),
'lldap_remove_from_group' => vv_lldap_remove_from_group($_POST['uid'] ?? '', (int)($_POST['gid'] ?? 0)),
// Authelia
'authelia_save' => vv_authelia_write_rules(json_decode($_POST['rules'] ?? '[]', true) ?: [],
$_POST['default_policy'] ?? 'deny',
$_POST['default_note'] ?? ''),
default => ['ok' => false, 'error' => 'Unknown action: ' . $action],
};
echo json_encode($result);