// POST action=npm_update id, data= // 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=, 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); } // One period of the history card: the rolled-up percentage, the drawable series, and how much of // the window has actually been observed. All three come off the same buckets, so the number and // the graph beside it can never disagree. function vv_uptime_period(array $buckets, int $n, string $unit): array { $series = vv_auth_uptime_series($buckets, $n, $unit); return [ 'pct' => vv_auth_uptime_window($buckets, $n), 'series' => $series, 'have' => count(array_filter($series, fn($v) => $v !== null)), 'want' => $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 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, // The history card below the table. One aligned series per period rather than the // raw buckets: the client would otherwise have to re-derive calendar keys to know // which of thirty slots a given day belongs in, and there would then be two // implementations of that rule in two languages. // // `have` is what actually exists, so the card can say "collecting — 2 of 30 days" // instead of printing a percentage computed from two days as though it were a // month. The store began 2026-08-15; every window longer than a day is partial // for a while, and a confident figure over a short sample is the one thing this // card must not do. 'hist' => [ 'h24' => vv_uptime_period($r['hours'] ?? [], 24, 'hour'), 'd7' => vv_uptime_period($r['days'] ?? [], 7, 'day'), 'd30' => vv_uptime_period($r['days'] ?? [], 30, 'day'), 'm12' => vv_uptime_period($r['months'] ?? [], 12, 'month'), ], ]; } 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);