302 lines
18 KiB
PHP
302 lines
18 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);
|
|
}
|
|
|
|
// 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 <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,
|
|
// 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);
|