Files
Varaverk/Plugin/unraid/include/auth.php
T

1691 lines
90 KiB
PHP

<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// The auth-stack control layer. Drives the three services behind every protected hostname:
// Nginx Proxy Manager (proxy hosts and certificates), LLDAP (users and groups), and
// Authelia (access-control rules). Read and write.
//
// OPERATIONAL MODEL
// The only include/ file that routinely mutates external state. Everything else here
// reports; this one creates users, rewrites proxy hosts, edits Authelia's YAML, and
// restarts the Authelia container. Treat every function below as load-bearing.
//
// DESIGN PRINCIPLES
// Credentials come from conf, never from the page.
// NPM and LLDAP credentials are read from host*.conf. The browser never sees them and
// never supplies them.
//
// Tokens are cached per session, not per request.
// NPM and LLDAP tokens are held in $_SESSION with a 23-hour expiry, so a page that
// makes twelve calls authenticates once. Expiry is checked before reuse.
//
// Authelia is edited as text, not parsed and re-emitted.
// Only the access_control block is rewritten, in place. Round-tripping the whole YAML
// through a parser would silently reformat and drop comments from a file that is
// hand-maintained and synced between hosts.
//
// The owner host is the source of truth for auth config.
// Changes are made here and reach the partner through Critical-Data sync, not by
// writing to two hosts from the browser.
//
// OPERATIONAL SAFEGUARDS
// The Authelia config write is atomic and reversible up to the last step.
// Existence check → read → regex replace → write .vv.tmp → rename() into place. A
// failure at any stage returns an error and leaves the original untouched; a failed
// rename unlinks the temp file rather than leaving it beside the real config.
//
// A missing config file is refused, never created.
// Both the read and write paths return 'Config not found' rather than writing a fresh
// file. Creating one would hand Authelia a config with no rules and a default policy —
// an accidental open door. See HOST*_AUTHELIA_CONFIG below.
//
// The container restart is shell-escaped.
// The container name comes from conf and is passed through escapeshellarg(), so a
// malformed conf value cannot become a command.
//
// Auth failure is reported, not retried into a lockout.
// 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. 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 —
// ['ok' => bool] or an _err key — so no caller has to distinguish an exception from a
// legitimately empty list.
//
// EXPORTS
// 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(),
// vv_npm_update_proxy(), vv_npm_delete_proxy(), vv_npm_toggle_proxy()
// Diagnosis vv_npm_why(), vv_npm_why_findings(), vv_auth_uptime_window(), vv_auth_db_file(),
// vv_auth_tcp_probe(), vv_auth_http_probe(), vv_auth_container_for()
// — read-only. The only group here that changes nothing.
// LLDAP vv_lldap_list_users(), vv_lldap_list_groups(), vv_lldap_create_user(),
// vv_lldap_update_user(), vv_lldap_delete_user(), vv_lldap_set_password(),
// vv_lldap_create_group(), vv_lldap_delete_group(),
// vv_lldap_add_to_group(), vv_lldap_remove_from_group()
// Authelia vv_authelia_read_rules(), vv_authelia_write_rules()
//
// CONFIGURATION
// HOST*_NPM_URL admin API — port 7818. Port 81 is the partnership WebUI port
// (HOST*_PARTNERSHIP_AUTH_WEBUIS), not the API. Easy to confuse.
// HOST*_NPM_USER / _NPM_PASS
// HOST*_LLDAP_URL / _LLDAP_USER / _LLDAP_PASS
// HOST*_AUTHELIA_CONFIG path to configuration.yml. Lives in the Critical-Data share so
// it is covered by the 30-minute auth sync — not under
// /mnt/user/appdata, which is not synced.
// HOST*_AUTHELIA_CONTAINER restarted after a successful rules write
// ═══════════════════════════════════════════════════════════════════════════════════════════════
require_once __DIR__ . '/config.php';
// ── Config ────────────────────────────────────────────────────────────────────
function vv_auth_conf(): array {
$v = vv_conf_vars();
$host = strtoupper(vv_detect_host());
return [
'npm_url' => rtrim($v["{$host}_NPM_URL"] ?? 'http://localhost:7818', '/'),
'npm_user' => $v["{$host}_NPM_USER"] ?? '',
'npm_pass' => $v["{$host}_NPM_PASS"] ?? '',
'lldap_url' => rtrim($v["{$host}_LLDAP_URL"] ?? 'http://localhost:17170', '/'),
'lldap_user' => $v["{$host}_LLDAP_USER"] ?? '',
'lldap_pass' => $v["{$host}_LLDAP_PASS"] ?? '',
'authelia_config' => $v["{$host}_AUTHELIA_CONFIG"] ?? '/mnt/user/appdata-Fallback/Critical-Data/Authelia/configuration.yml',
'authelia_container' => $v["{$host}_AUTHELIA_CONTAINER"] ?? 'Authelia',
'is_owner' => vv_is_owner(),
];
}
// ── 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
// 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 ───────────────────────────────────────────────────────────────────────
function vv_npm_token(): string {
if (!session_id()) session_start();
$conf = vv_auth_conf();
$cached = $_SESSION['vv_npm_token'] ?? '';
$expiry = $_SESSION['vv_npm_token_exp'] ?? 0;
if ($cached && time() < $expiry) return $cached;
$resp = vv_npm_raw('POST', '/api/tokens', [
'identity' => $conf['npm_user'],
'secret' => $conf['npm_pass'],
], '', $conf);
$token = $resp['token'] ?? '';
if ($token) {
$_SESSION['vv_npm_token'] = $token;
$_SESSION['vv_npm_token_exp'] = time() + 82800;
}
return $token;
}
function vv_npm_raw(string $method, string $path, array $data, string $token, array $conf = []): array {
if (!$conf) $conf = vv_auth_conf();
$url = $conf['npm_url'] . $path;
$headers = ['Content-Type: application/json', 'Accept: application/json'];
if ($token) $headers[] = 'Authorization: Bearer ' . $token;
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_CUSTOMREQUEST => $method,
]);
if ($data && in_array($method, ['POST', 'PUT'], true))
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
$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);
// NPM answers a successful DELETE with the bare JSON literal `true`, not an object. This
// function is declared `: array`, so decoding that and returning it threw a TypeError and
// killed the request — which is what deleting a certificate did, and what deleting or toggling
// a proxy host has always done, since those three are the only callers whose endpoint answers
// with a scalar. Wrapped rather than returned raw, so every caller still gets an array and the
// outcome is readable as ['result' => true].
$decoded = json_decode($body ?: '{}', true);
if (is_array($decoded)) return $decoded;
if ($decoded === null) return [];
return ['result' => $decoded];
}
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();
if (!$token) return ['_err' => vv_auth_token_err('npm', vv_auth_conf()['npm_url'])];
return vv_npm_raw($method, $path, $data, $token);
}
function vv_npm_list_proxies(): array {
$list = vv_npm_req('GET', '/api/nginx/proxy-hosts?expand=certificate');
if (!is_array($list) || isset($list['_err']))
return ['ok' => false, 'error' => $list['_err'] ?? 'Invalid response from NPM'];
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 {
$list = vv_npm_req('GET', '/api/nginx/certificates');
if (!is_array($list) || isset($list['_err'])) return [];
return array_values($list);
}
// Deleting a certificate is not undoable — the private key goes with it and a replacement means a
// fresh issuance against Let's Encrypt's rate limits. The caller is expected to have checked that
// no proxy host still points at it; NPM will happily remove one that is in use and leave the host
// serving nothing.
function vv_npm_delete_cert(int $id): array {
$r = vv_npm_req('DELETE', "/api/nginx/certificates/$id");
if (is_array($r) && isset($r['_err'])) return ['ok' => false, 'error' => $r['_err']];
// NPM answers `true` for a successful delete and an error object otherwise.
if (is_array($r) && isset($r['error']))
return ['ok' => false, 'error' => $r['error']['message'] ?? 'Delete failed'];
return ['ok' => true];
}
// Which proxy hosts reference a certificate. The guard that belongs with the delete above, so a
// caller cannot forget to ask the question.
function vv_npm_cert_users(int $id): array {
$p = vv_npm_list_proxies();
if (!($p['ok'] ?? false)) return [];
$out = [];
foreach ($p['proxies'] as $h)
if ((int) ($h['certificate_id'] ?? 0) === $id) $out[] = $h;
return $out;
}
function vv_npm_create_proxy(array $data): array {
$r = vv_npm_req('POST', '/api/nginx/proxy-hosts', $data);
return isset($r['id']) ? ['ok' => true, 'proxy' => $r] : ['ok' => false, 'error' => $r['error'] ?? ($r['_err'] ?? 'Create failed')];
}
function vv_npm_update_proxy(int $id, array $data): array {
$r = vv_npm_req('PUT', "/api/nginx/proxy-hosts/$id", $data);
return isset($r['id']) ? ['ok' => true, 'proxy' => $r] : ['ok' => false, 'error' => $r['error'] ?? ($r['_err'] ?? 'Update failed')];
}
function vv_npm_delete_proxy(int $id): array {
vv_npm_req('DELETE', "/api/nginx/proxy-hosts/$id");
return ['ok' => true];
}
function vv_npm_toggle_proxy(int $id, bool $enabled): array {
vv_npm_req('POST', "/api/nginx/proxy-hosts/$id/" . ($enabled ? 'enable' : 'disable'));
return ['ok' => true];
}
// ── Why is this host not at 100% ──────────────────────────────────────────────
// The one implementation of the uptime windowing rule. Buckets are keyed by time, so "the last N"
// is a key sort rather than an assumption that every period produced a sample — a pass that did not
// run leaves no bucket at all rather than a zero, and averaging over a count would read a probe
// outage as a service outage. Tools/uptime_probe.php and api/auth.php both defer to this.
function vv_auth_uptime_window(array $buckets, int $n): ?float {
if (!$buckets) return null;
krsort($buckets);
$u = $t = 0;
foreach (array_slice($buckets, 0, $n, true) as $b) { $u += $b['u'] ?? 0; $t += $b['t'] ?? 0; }
return $t > 0 ? round($u / $t * 100, 2) : null;
}
// The same buckets as a drawable series: one entry per period on the calendar, oldest first, null
// where nothing was recorded. Aligned to the clock rather than packed, because the gap is the
// information — a domain probed for two of the last thirty days must read as twenty-eight unknowns
// and not as a solid bar chart that happens to be short.
//
// Keys are built with mktime rather than strtotime("-N month"), which resolves relative to today's
// day-of-month and skips a month entirely when run on the 31st.
function vv_auth_uptime_series(array $buckets, int $n, string $unit, ?int $now = null): array {
$now = $now ?? time();
[$Y, $M, $D, $H] = [(int) date('Y', $now), (int) date('n', $now),
(int) date('j', $now), (int) date('G', $now)];
$out = [];
for ($i = $n - 1; $i >= 0; $i--) {
// mktime normalises underflow, so hour -3 is 21:00 the previous day and month 0 is December.
[$fmt, $ts] = match ($unit) {
'hour' => ['YmdH', mktime($H - $i, 0, 0, $M, $D, $Y)],
'month' => ['Ym', mktime(0, 0, 0, $M - $i, 1, $Y)],
default => ['Ymd', mktime(0, 0, 0, $M, $D - $i, $Y)],
};
$b = $buckets[date($fmt, $ts)] ?? null;
$out[] = ($b && ($b['t'] ?? 0) > 0) ? round($b['u'] / $b['t'] * 100, 2) : null;
}
return $out;
}
function vv_auth_db_file(string $name): string {
return rtrim(defined('DB_DIR') ? DB_DIR : (DATA_DIR . '/db'), '/') . '/' . $name;
}
// One TCP connect, timed. The cheapest question that separates "the application is broken" from
// "nothing is there at all", and the one the proxy itself cannot answer — NPM reports a 502 for a
// refused connection, a closed port and a hung process alike.
function vv_auth_tcp_probe(string $host, int $port, int $timeout = 4): array {
if ($host === '' || $port <= 0) return ['ok' => false, 'err' => 'no forward target configured'];
$t0 = microtime(true);
$errno = 0; $errstr = '';
// @ because a refused connection and an unresolvable name are both expected answers here, and
// a warning raised into the JSON body would corrupt the response this is reported in.
$fp = @fsockopen($host, $port, $errno, $errstr, $timeout);
$ms = (int) round((microtime(true) - $t0) * 1000);
if ($fp === false) return ['ok' => false, 'ms' => $ms, 'err' => $errstr ?: ('errno ' . $errno)];
fclose($fp);
return ['ok' => true, 'ms' => $ms];
}
// A HEAD against a URL, reporting the same three things for every probe so the caller can compare
// the front door and the back door without special-casing either.
//
// $verify is the whole point of the second call this makes: an identical request that succeeds only
// with verification off says the certificate is the fault and the service behind it is fine, which
// is otherwise indistinguishable from the site being down.
function vv_auth_http_probe(string $url, bool $verify, int $timeout = 5): array {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_NOBODY => true,
CURLOPT_FOLLOWLOCATION => false, // a redirect to the auth portal is an answer, not a step
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CONNECTTIMEOUT => min($timeout, 4),
// Marked as the monitor so this cannot land in the access log as a real request and inflate
// the very traffic figures shown beside it. Same string npm_access_stats.php drops.
CURLOPT_USERAGENT => 'Varaverk-Uptime/1.0',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_SSL_VERIFYPEER => $verify,
CURLOPT_SSL_VERIFYHOST => $verify ? 2 : 0,
]);
curl_exec($ch);
$r = ['code' => (int) curl_getinfo($ch, CURLINFO_HTTP_CODE),
'ms' => (int) round(curl_getinfo($ch, CURLINFO_TOTAL_TIME) * 1000),
'err' => curl_error($ch)];
curl_close($ch);
return $r;
}
// Which container is behind a forward target, if any. Matched on the name first and the container
// IP second, because both forms are in use here — some hosts forward to a container name on a
// custom network and some to an address on br0.
//
// Returns null rather than guessing. A forward target that is another machine entirely is a normal
// configuration, and reporting "no container" for it is correct, not a failure to find one.
function vv_auth_container_for(string $fwdHost): ?array {
if ($fwdHost === '') return null;
require_once __DIR__ . '/docker.php';
$all = vv_dk_inspect_all();
if (!$all) return null;
$hit = function (string $name, array $c, string $how): array {
$nets = $c['networks'] ?? [];
return ['name' => $name, 'running' => $c['running'], 'status' => $c['status'], 'match' => $how,
'networks' => $nets, 'ip' => $nets ? reset($nets) : '',
'network' => $nets ? (string) array_key_first($nets) : ''];
};
$needle = strtolower($fwdHost);
foreach ($all as $name => $c) if (strtolower($name) === $needle) return $hit($name, $c, 'name');
foreach ($all as $name => $c)
foreach ($c['networks'] ?? [] as $ip) if ($ip === $fwdHost) return $hit($name, $c, 'ip');
return null;
}
// Where this process should actually knock, which is not always what the proxy host says.
//
// Most forward targets here are container names on a user-defined docker network. Those names are
// resolved by docker's embedded DNS, which only the containers on that network can see — NPM
// resolves NextCloud perfectly and PHP running on the host cannot resolve it at all. A check that
// treated its own resolution failure as evidence would report every one of them as dead, which is
// the exact false alarm this whole dialog exists to stop someone chasing.
//
// So an unresolvable name that matches a running container is redirected to that container's
// address, and the substitution is reported rather than hidden — the reader needs to know which
// address the result below actually describes.
function vv_auth_probe_target(string $fwdHost, ?array $container): array {
if ($fwdHost === '' || filter_var($fwdHost, FILTER_VALIDATE_IP)) return ['host' => $fwdHost, 'note' => ''];
// gethostbyname() hands back its input unchanged when it cannot resolve — the documented way it
// fails, and the reason this is a comparison rather than a truthiness test.
if (@gethostbyname($fwdHost) !== $fwdHost) return ['host' => $fwdHost, 'note' => ''];
if ($container && ($container['ip'] ?? '') !== '')
return ['host' => $container['ip'],
'note' => $fwdHost . ' is a docker name that only resolves on the ' . ($container['network'] ?: 'proxy')
. ' network, so this checked the container address ' . $container['ip'] . ' instead.'];
return ['host' => $fwdHost,
'note' => $fwdHost . ' does not resolve from this host and matches no container here, so the direct '
. 'check below could not be made. What the proxy itself reports is the reliable part.'];
}
// Everything known about why one proxy host is not at 100%, gathered in one pass.
//
// The Proxies tab shows an uptime percentage and nothing about what is behind it, and the causes
// look identical from the row: the application is down, its container is not running, the
// certificate stopped validating, or the proxy reaches the application perfectly and the
// application is the thing returning 5xx. Those are four different jobs and the row cannot tell
// them apart, so every low figure has so far meant opening NPM, then Docker, then a terminal.
//
// Three of the four sources are already on disk — the probe history, the access-log totals, NPM's
// own record. The fourth is the part no stored figure can answer: what happens right now, asked
// separately of the front door and the back. A host that answers on 10.0.0.5:8096 but fails through
// https://name/ is a proxy or certificate fault; one that fails both is the service itself.
//
// Read-only by construction. Every call below is a GET, a HEAD, a TCP connect or a file read —
// nothing here restarts, rewrites or retries anything, because the value of a diagnosis is that it
// can be run on a host that is limping without being the thing that finishes it off.
function vv_npm_why(int $id): array {
$p = vv_npm_list_proxies();
if (!($p['ok'] ?? false)) return ['ok' => false, 'error' => $p['error'] ?? 'Could not read proxy hosts from NPM'];
$host = null;
foreach ($p['proxies'] as $h) if ((int) ($h['id'] ?? 0) === $id) { $host = $h; break; }
if (!$host) return ['ok' => false, 'error' => "No proxy host with id $id — the list may be stale, reload the tab."];
$adv = trim((string) ($host['advanced_config'] ?? ''));
$guarded = str_contains($adv, 'auth_request');
$fwdHost = trim((string) ($host['forward_host'] ?? ''));
$fwdPort = (int) ($host['forward_port'] ?? 0);
$fwdScheme = (string) ($host['forward_scheme'] ?? 'http');
$enabled = ($host['enabled'] ?? true) ? true : false;
// Wildcards are excluded for the same reason the probe excludes them: *.example.com is not a
// hostname anything can connect to, so a live check against it would report a fault that only
// describes the check.
$domains = [];
foreach ($host['domain_names'] ?? [] as $d) {
$d = strtolower(trim((string) $d));
if ($d !== '' && !str_contains($d, '*')) $domains[] = $d;
}
$out = [
'ok' => true,
'id' => $id,
'enabled' => $enabled,
'domains' => $host['domain_names'] ?? [],
'forward' => $fwdScheme . '://' . $fwdHost . ':' . $fwdPort,
'guarded' => $guarded,
'checked' => time(),
];
// ── Certificate ──
// Expiry is the single most common reason a host that worked for months stops, and the row
// cannot show it because the row is about uptime.
$cert = null;
$certId = (int) ($host['certificate_id'] ?? 0);
if ($certId > 0) {
foreach (vv_npm_list_certs() as $c) {
if ((int) ($c['id'] ?? 0) !== $certId) continue;
$exp = strtotime((string) ($c['expires_on'] ?? '')) ?: null;
$cert = [
'name' => $c['nice_name'] ?? implode(', ', $c['domain_names'] ?? []),
'provider' => $c['provider'] ?? '',
'expires' => $exp,
'days_left' => $exp ? (int) floor(($exp - time()) / 86400) : null,
];
break;
}
}
$out['cert'] = $cert;
// ── Recorded history ──
$u = is_file(vv_auth_db_file('uptime.json'))
? (json_decode((string) @file_get_contents(vv_auth_db_file('uptime.json')), true) ?: []) : [];
$recs = [];
foreach ($domains as $d) {
$r = $u['domains'][$d] ?? null;
if (!$r) continue;
// The hours that actually lost something, rather than all 48. "Every hour lost two samples"
// and "one hour lost forty" are the same daily percentage and completely different faults,
// and this is the only place that distinction survives.
$bad = [];
$hours = $r['hours'] ?? [];
krsort($hours);
foreach (array_slice($hours, 0, 24, true) as $k => $b) {
$t = $b['t'] ?? 0; $up = $b['u'] ?? 0;
if ($t > 0 && $up < $t) $bad[] = ['hour' => $k, 'up' => $up, 'total' => $t];
}
$recs[$d] = [
'state' => $r['state'] ?? null,
'h1' => vv_auth_uptime_window($r['hours'] ?? [], 1),
'h24' => vv_auth_uptime_window($r['hours'] ?? [], 24),
'd30' => vv_auth_uptime_window($r['days'] ?? [], 30),
'last_code' => $r['last_code'] ?? null,
'last_detail' => $r['last_detail'] ?? null,
'last_change' => $r['last_change'] ?? null,
'last_ms' => $r['last_ms'] ?? null,
'checks' => $r['checks'] ?? 0,
// Newest first — a flap is read backwards from now, not forwards from whenever the
// record happens to start.
'events' => array_slice(array_reverse($r['events'] ?? []), 0, 8),
'bad_hours' => $bad,
];
}
$out['history'] = $recs;
// ── Access-log totals ──
$a = is_file(vv_auth_db_file('npm_access.json'))
? (json_decode((string) @file_get_contents(vv_auth_db_file('npm_access.json')), true) ?: []) : [];
$out['traffic'] = $a['hosts'][(string) $id] ?? null;
// ── Live, right now ──
$cont = vv_auth_container_for($fwdHost);
$target = vv_auth_probe_target($fwdHost, $cont);
$out['upstream'] = [
'host' => $fwdHost,
'port' => $fwdPort,
'probed' => $target['host'],
'note' => $target['note'],
'container' => $cont,
'tcp' => vv_auth_tcp_probe($target['host'], $fwdPort),
];
// Only worth asking once something is listening — an HTTP probe of a closed port re-reports the
// TCP failure in a less specific form.
if ($out['upstream']['tcp']['ok'] ?? false) {
// Verification off deliberately: this is an internal hop to an address on this machine's own
// network, usually plain HTTP and usually a self-signed certificate when it is not. The
// question here is whether the application answers, and the certificate question is asked
// at the front door where it actually applies.
$out['upstream']['http'] = vv_auth_http_probe($fwdScheme . '://' . $target['host'] . ':' . $fwdPort . '/', false);
}
// Bounded. A host carrying a dozen names would otherwise turn one button press into a dozen
// sequential TLS handshakes, and the first few answer the question.
$out['live'] = [];
foreach (array_slice($domains, 0, 4) as $d) {
$r = vv_auth_http_probe('https://' . $d . '/', true);
// The second call is the diagnosis, not a retry: succeeding here after failing above is
// what proves the certificate rather than the service.
if ($r['err'] !== '' && preg_match('/certificat|SSL|TLS/i', $r['err'])) {
$r['insecure'] = vv_auth_http_probe('https://' . $d . '/', false);
}
$out['live'][$d] = $r;
}
$out['findings'] = vv_npm_why_findings($out);
return $out;
}
// The deterministic half of the answer, written as sentences rather than codes.
//
// Separate from the gathering so it can be read, argued with and corrected on its own — and so the
// dialog has something certain to show whether or not there is a model on this node to interpret
// it. Most low figures on this installation have one of these causes, and none of them needs a
// language model to reach.
//
// Ordered most decisive first: the caller shows them in order and the first line is meant to be the
// answer. Each entry is ['level' => bad|warn|info, 'text' => …].
function vv_npm_why_findings(array $w): array {
$f = [];
$up = $w['upstream'] ?? [];
$tcp = $up['tcp'] ?? [];
$cont = $up['container'] ?? null;
// Whether the front door is failing *now*, decided once. Several readings below change meaning
// entirely on it — "the application answers directly" is a useful clue during an outage and a
// false alarm when the site is simply working, and an earlier draft said the second as if it
// were the first.
$frontBad = false;
foreach ($w['live'] ?? [] as $r)
if (($r['code'] ?? 0) === 0 || ($r['code'] ?? 0) >= 500) $frontBad = true;
if (!($w['enabled'] ?? true))
$f[] = ['level' => 'bad', 'text' => 'This host is disabled in NPM, so nothing is being served for it. The probe still counts it as unreachable.'];
if ($cont && !($cont['running'] ?? true))
$f[] = ['level' => 'bad', 'text' => 'Container ' . $cont['name'] . ' is ' . ($cont['status'] ?? 'not running')
. ' — nothing can answer on ' . ($up['host'] ?? '') . ':' . ($up['port'] ?? '') . '.'];
// The direct check, stated as what it is. Whether it could be made at all is reported first,
// because a check that did not happen must never be read as a check that failed — see
// vv_auth_probe_target(). Most forward targets on this machine are docker names this process
// cannot resolve, and an earlier draft of this reported every one of them as a dead service.
$reached = ($up['probed'] ?? '') !== '' && (($tcp['ok'] ?? false) || !str_contains((string) ($tcp['err'] ?? ''), 'getaddrinfo'));
if (!$reached) {
$f[] = ['level' => 'info', 'text' => ($up['note'] ?: 'The forward target could not be resolved from here, so no direct check was made.')
. ' Nothing below is evidence that the service is down.'];
} elseif (!($tcp['ok'] ?? false)) {
$f[] = ['level' => 'bad', 'text' => 'Nothing is listening on ' . ($up['probed'] ?? '') . ':' . ($up['port'] ?? '')
. ' — ' . ($tcp['err'] ?? 'no reason given')
. ($cont && ($cont['running'] ?? false)
? '. Container ' . $cont['name'] . ' is running, so the container is up and the application inside it is not serving that port.'
: '. The proxy has nothing to forward to.')];
} else {
$uh = $up['http'] ?? null;
if ($uh && $uh['code'] >= 500)
$f[] = ['level' => 'bad', 'text' => 'The service is listening on ' . ($up['probed'] ?? '') . ':' . ($up['port'] ?? '')
. ' and answered HTTP ' . $uh['code'] . ' itself. The proxy is forwarding correctly — the fault is inside the application.'];
elseif ($uh && $uh['code'] === 0 && ($uh['err'] ?? '') !== '')
$f[] = ['level' => 'warn', 'text' => 'The port on ' . ($up['probed'] ?? '') . ' is open but nothing came back over it (' . $uh['err']
. '). Something is holding the socket without serving — which is what the proxy sees as a timeout.'];
elseif ($uh && $frontBad)
$f[] = ['level' => 'info', 'text' => 'The service answers directly on ' . ($up['probed'] ?? '') . ':' . ($up['port'] ?? '')
. ' with HTTP ' . $uh['code'] . ', so whatever is failing sits between the proxy and it, not in the application.'];
}
// The certificate, from both directions: what NPM says about its expiry, and what a live
// handshake actually did. Either can be the fault on its own — a cert with weeks left still
// fails if the chain it is serving is wrong.
$c = $w['cert'] ?? null;
if ($c && $c['days_left'] !== null) {
if ($c['days_left'] < 0)
$f[] = ['level' => 'bad', 'text' => 'The certificate expired ' . abs($c['days_left']) . ' days ago. Every HTTPS request to this host fails verification.'];
elseif ($c['days_left'] <= 14)
$f[] = ['level' => 'warn', 'text' => 'The certificate expires in ' . $c['days_left'] . ' days — check the Certs tab for whether renewal is running.'];
}
foreach ($w['live'] ?? [] as $dom => $r) {
if (isset($r['insecure']) && $r['insecure']['code'] > 0 && $r['insecure']['code'] < 500) {
$f[] = ['level' => 'bad', 'text' => $dom . ' answers normally when certificate verification is turned off. '
. 'The service is up and the certificate is what is failing: ' . $r['err']];
} elseif ($r['code'] === 0 && ($r['err'] ?? '') !== '') {
$f[] = ['level' => 'bad', 'text' => $dom . ' did not answer just now — ' . $r['err']];
} elseif ($r['code'] === 502 || $r['code'] === 504) {
// NPM's own verdict, and the one piece of evidence that is always authoritative: it is
// the component that actually has to reach the upstream. Paired with a direct probe
// that succeeded, it stops being "the app is down" and becomes a routing problem —
// the proxy and the application are on networks that cannot see each other.
$ok = ($up['http']['code'] ?? 0) > 0 && ($up['http']['code'] ?? 0) < 500;
$f[] = ['level' => 'bad', 'text' => $dom . ' answered HTTP ' . $r['code'] . ' through the proxy — NPM could not reach '
. ($up['host'] ?? '') . ':' . ($up['port'] ?? '') . '.'
. ($ok ? ' It answers fine when asked directly, so the two are not on a network that can see each other,'
. ' or the forward host is written in a form NPM cannot resolve.' : '')];
} elseif ($r['code'] >= 500) {
$f[] = ['level' => 'bad', 'text' => $dom . ' answered HTTP ' . $r['code'] . ' through the proxy.'];
}
}
// Traffic, read against whether the host is guarded. An unguarded host that is nothing but 4xx
// is broken; a guarded one that is nothing but 4xx is usually Authelia doing its job to
// unauthenticated callers, and calling that a fault would send someone to fix what is working.
$t = $w['traffic'] ?? null;
if ($t && ($t['requests'] ?? 0) > 0) {
$req = (int) $t['requests'];
$s5 = (int) ($t['s5xx'] ?? 0);
$s4 = (int) ($t['s4xx'] ?? 0);
if ($s5 / $req >= 0.5)
$f[] = ['level' => 'bad', 'text' => round($s5 / $req * 100) . '% of all logged requests to this host are 5xx — this has been failing for real users, not just the probe.'];
if ($s4 / $req >= 0.9)
$f[] = $w['guarded']
? ['level' => 'info', 'text' => round($s4 / $req * 100) . '% of requests are 4xx, which on a host behind auth_request is usually Authelia refusing unauthenticated callers rather than a fault.']
: ['level' => 'warn', 'text' => round($s4 / $req * 100) . '% of requests are 4xx and nothing is guarding this host, so callers are being refused by the application itself.'];
}
// Shape of the loss. Same percentage, two entirely different problems, and this is the only
// reading that separates them.
foreach ($w['history'] ?? [] as $dom => $h) {
$ev = $h['events'] ?? [];
// Over what period, not just how many. Four state changes is flapping if they were this
// afternoon and completely unremarkable if they were spread across a month, and a count on
// its own cannot tell those apart — the store keeps the last twenty however old they are.
$span = count($ev) >= 2 ? ((int) ($ev[0]['ts'] ?? 0) - (int) ($ev[count($ev) - 1]['ts'] ?? 0)) : 0;
$spanTxt = $span >= 172800 ? 'over ' . round($span / 86400) . ' days'
: ($span >= 7200 ? 'over ' . round($span / 3600) . ' hours' : 'within the hour');
if (count($ev) >= 4)
$f[] = ['level' => 'warn', 'text' => $dom . ' changed state ' . count($ev) . ' times ' . $spanTxt
. ' — this is flapping rather than one clean outage, so look for something restarting on a cycle.'];
elseif (count($h['bad_hours'] ?? []) === 1 && ($h['state'] ?? '') === 'up')
$f[] = ['level' => 'info', 'text' => $dom . ' lost samples in one hour only (' . $h['bad_hours'][0]['up'] . '/' . $h['bad_hours'][0]['total']
. ' at ' . substr((string) $h['bad_hours'][0]['hour'], 8, 2) . ':00) and has been up since. A single event, already over.'];
}
if (!$f)
$f[] = ['level' => 'info', 'text' => 'Nothing is failing right now — the service answers, the certificate verifies and the proxy is forwarding. Whatever cost this host its uptime is in the history below and has already ended.'];
return $f;
}
// ── lldap ─────────────────────────────────────────────────────────────────────
function vv_lldap_token(): string {
if (!session_id()) session_start();
$conf = vv_auth_conf();
$cached = $_SESSION['vv_lldap_token'] ?? '';
$expiry = $_SESSION['vv_lldap_token_exp'] ?? 0;
if ($cached && time() < $expiry) return $cached;
$ch = curl_init($conf['lldap_url'] . '/auth/simple/login');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['username' => $conf['lldap_user'], 'password' => $conf['lldap_pass']]),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
]);
$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);
$resp = json_decode($body ?: '{}', true) ?: [];
$token = $resp['token'] ?? '';
if ($token) {
$_SESSION['vv_lldap_token'] = $token;
$_SESSION['vv_lldap_token_exp'] = time() + 3500;
}
return $token;
}
function vv_lldap_gql(string $query, array $variables = []): array {
$conf = vv_auth_conf();
if ($miss = vv_auth_creds_missing('lldap')) return ['errors' => [['message' => $miss]]];
$token = vv_lldap_token();
if (!$token) return ['errors' => [['message' => vv_auth_token_err('lldap', $conf['lldap_url'])]]];
$ch = curl_init($conf['lldap_url'] . '/api/graphql');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['query' => $query, 'variables' => $variables]),
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $token],
]);
$body = curl_exec($ch);
curl_close($ch);
return json_decode($body ?: '{}', true) ?: [];
}
function vv_lldap_list_users(): array {
// firstName/lastName/uuid were never requested, so the page could not show or edit them —
// 31 of the 33 users here have them set and none of it was reachable without opening lldap's
// own WebUI.
//
// The avatar itself is deliberately NOT in this query. It is a base64 JPEG stored inline, and
// the six that exist here come to 470 KB — a third of a megabyte added to every load of the
// tab, re-fetched on every refresh, to draw six thumbnails. The attribute *names* are enough
// to know who has one, and the bytes are fetched per user by vv_lldap_avatar() through an
// endpoint the browser can cache like any other image.
$r = vv_lldap_gql('query { users { id displayName email firstName lastName uuid creationDate '
. 'groups { id displayName } attributes { name } } }');
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Query failed'];
$users = $r['data']['users'] ?? [];
foreach ($users as &$u) {
$names = array_column($u['attributes'] ?? [], 'name');
$u['has_avatar'] = in_array('avatar', $names, true);
// Sent to the browser as a flag, not a list. Nothing on the page reads the attribute names
// and shipping 33 copies of the same nine strings is pure weight.
unset($u['attributes']);
}
unset($u);
return ['ok' => true, 'users' => $users];
}
// Raw JPEG bytes for one user, or '' when they have no avatar. Returned as bytes rather than
// base64 because the only caller streams it to an <img>, and re-encoding it to hand the browser
// something it would immediately decode again is a third of a megabyte of nothing.
function vv_lldap_avatar(string $userId): string {
$r = vv_lldap_gql('query Avatar($id: String!) { user(userId: $id) { avatar } }', ['id' => $userId]);
if (isset($r['errors'])) return '';
$b64 = $r['data']['user']['avatar'] ?? '';
if (!is_string($b64) || $b64 === '') return '';
$raw = base64_decode($b64, true);
return ($raw !== false && vv_lldap_is_jpeg($raw)) ? $raw : '';
}
// lldap types this attribute JPEG_PHOTO and rejects anything else, so the check happens here where
// the answer can name the problem. A rejection from the server arrives as a generic GraphQL error
// several layers from the file the operator picked.
function vv_lldap_is_jpeg(string $raw): bool {
return strlen($raw) > 3 && substr($raw, 0, 3) === "\xFF\xD8\xFF";
}
// One megabyte of JPEG, decoded. The browser resizes before upload so nothing near this should
// arrive; the cap is here because this value is stored inline in the directory and read back on
// every user query, and an unbounded one would be paid for on every page load forever.
const VV_LLDAP_AVATAR_MAX = 1048576;
function vv_lldap_set_avatar(string $userId, string $b64): array {
$b64 = preg_replace('#^data:image/[a-z+]+;base64,#i', '', trim($b64));
$raw = base64_decode($b64, true);
if ($raw === false || $raw === '') return ['ok' => false, 'error' => 'Image data could not be decoded'];
if (!vv_lldap_is_jpeg($raw)) return ['ok' => false, 'error' => 'lldap stores avatars as JPEG only — that file is not one'];
if (strlen($raw) > VV_LLDAP_AVATAR_MAX)
return ['ok' => false, 'error' => 'Image is ' . round(strlen($raw) / 1024) . ' KB; the limit is '
. round(VV_LLDAP_AVATAR_MAX / 1024) . ' KB'];
$r = vv_lldap_gql('mutation SetAvatar($user: UpdateUserInput!) { updateUser(user: $user) { ok } }',
['user' => ['id' => $userId, 'avatar' => base64_encode($raw)]]);
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Avatar update failed'];
return ['ok' => true, 'bytes' => strlen($raw)];
}
// Cleared through removeAttributes rather than by setting avatar to an empty string: lldap treats
// an empty avatar as a value to validate, and it is not a JPEG.
function vv_lldap_remove_avatar(string $userId): array {
$r = vv_lldap_gql('mutation ClearAvatar($user: UpdateUserInput!) { updateUser(user: $user) { ok } }',
['user' => ['id' => $userId, 'removeAttributes' => ['avatar']]]);
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Avatar removal failed'];
return ['ok' => true];
}
function vv_lldap_list_groups(): array {
$r = vv_lldap_gql('query { groups { id displayName users { id displayName } } }');
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Query failed'];
return ['ok' => true, 'groups' => $r['data']['groups'] ?? []];
}
function vv_lldap_create_user(string $id, string $email, string $displayName, string $password,
string $firstName = '', string $lastName = ''): array {
$user = ['id' => $id, 'email' => $email, 'displayName' => $displayName];
// Omitted when blank rather than sent as "". lldap distinguishes the two, and an empty string
// creates the attribute holding nothing, which then shows as set everywhere that tests for it.
if ($firstName !== '') $user['firstName'] = $firstName;
if ($lastName !== '') $user['lastName'] = $lastName;
$r = vv_lldap_gql(
'mutation CreateUser($user: CreateUserInput!) { createUser(user: $user) { id displayName email } }',
['user' => $user]
);
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Create failed'];
if ($password) vv_lldap_set_password($id, $password);
return ['ok' => true, 'user' => $r['data']['createUser'] ?? []];
}
// $firstName/$lastName are nullable on purpose: null means "the form did not offer this field, so
// leave it alone", '' means "the operator cleared it". Passing '' for an absent field would erase
// a name that 31 of the 33 users here have set.
function vv_lldap_update_user(string $id, string $email, string $displayName,
?string $firstName = null, ?string $lastName = null): array {
$user = ['id' => $id, 'email' => $email, 'displayName' => $displayName];
$remove = [];
foreach (['firstName' => $firstName, 'lastName' => $lastName] as $k => $v) {
if ($v === null) continue;
if ($v === '') $remove[] = $k === 'firstName' ? 'first_name' : 'last_name';
else $user[$k] = $v;
}
// Clearing goes through removeAttributes — setting the field to "" leaves the attribute in
// place holding an empty string, which is a different thing to lldap and to anything reading
// the directory over LDAP.
if ($remove) $user['removeAttributes'] = $remove;
$r = vv_lldap_gql(
'mutation UpdateUser($user: UpdateUserInput!) { updateUser(user: $user) { ok } }',
['user' => $user]
);
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Update failed'];
return ['ok' => true];
}
function vv_lldap_delete_user(string $id): array {
$r = vv_lldap_gql(
'mutation DeleteUser($userId: String!) { deleteUser(userId: $userId) { ok } }',
['userId' => $id]
);
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Delete failed'];
return ['ok' => true];
}
function vv_lldap_set_password(string $userId, string $password): array {
$conf = vv_auth_conf();
if ($miss = vv_auth_creds_missing('lldap')) return ['ok' => false, 'error' => $miss];
$token = vv_lldap_token();
if (!$token) return ['ok' => false, 'error' => vv_auth_token_err('lldap', $conf['lldap_url'])];
$ch = curl_init($conf['lldap_url'] . '/auth/admin/resetPassword');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['userId' => $userId, 'password' => $password]),
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $token],
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code >= 200 && $code < 300) return ['ok' => true];
$err = json_decode($body ?: '{}', true)['message'] ?? "HTTP $code";
return ['ok' => false, 'error' => $err];
}
function vv_lldap_create_group(string $name): array {
$r = vv_lldap_gql(
'mutation CreateGroup($name: String!) { createGroup(name: $name) { id displayName } }',
['name' => $name]
);
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Create failed'];
return ['ok' => true, 'group' => $r['data']['createGroup'] ?? []];
}
// The only editable field a group has. Without it the sole way to correct a group's name was to
// delete it and make a new one — which drops every member, and on this directory those group names
// are what the Authelia rules match on, so the rule would keep naming a group that no longer
// exists and quietly stop admitting anyone.
function vv_lldap_rename_group(int $id, string $displayName): array {
$r = vv_lldap_gql(
'mutation UpdateGroup($group: UpdateGroupInput!) { updateGroup(group: $group) { ok } }',
['group' => ['id' => $id, 'displayName' => $displayName]]
);
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Rename failed'];
return ['ok' => true];
}
function vv_lldap_delete_group(int $id): array {
$r = vv_lldap_gql(
'mutation DeleteGroup($groupId: Int!) { deleteGroup(groupId: $groupId) { ok } }',
['groupId' => $id]
);
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Delete failed'];
return ['ok' => true];
}
function vv_lldap_add_to_group(string $userId, int $groupId): array {
$r = vv_lldap_gql(
'mutation AddUserToGroup($userId: String!, $groupId: Int!) { addUserToGroup(userId: $userId, groupId: $groupId) { ok } }',
['userId' => $userId, 'groupId' => $groupId]
);
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Failed'];
return ['ok' => true];
}
function vv_lldap_remove_from_group(string $userId, int $groupId): array {
$r = vv_lldap_gql(
'mutation RemoveUserFromGroup($userId: String!, $groupId: Int!) { removeUserFromGroup(userId: $userId, groupId: $groupId) { ok } }',
['userId' => $userId, 'groupId' => $groupId]
);
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Failed'];
return ['ok' => true];
}
// ── Authelia ──────────────────────────────────────────────────────────────────
// $file overrides the configured path. There is more than one Authelia on this machine — the
// primary serves the .com names and Authelia-Secondary serves the .us ones — and conf names only
// the primary, so anything reasoning about a specific domain has to be able to read the instance
// that domain actually talks to. Defaults to the configured one, so every existing caller and the
// whole editing path are unchanged.
function vv_authelia_read_rules(?string $file = null): array {
$conf = vv_auth_conf();
$file = $file ?: $conf['authelia_config'];
if (!file_exists($file)) return ['ok' => false, 'error' => 'Config not found: ' . $file];
$content = file_get_contents($file);
if ($content === false) return ['ok' => false, 'error' => 'Cannot read config file'];
// Extract default_policy, and keep any trailing comment rather than dropping it. The line in
// this config reads "default_policy: bypass #deny" — a note about what it used to be, or is
// meant to become, on the single most consequential setting in the file. The block is
// re-emitted on save, so anything not carried here is deleted by the next save.
$defaultPolicy = 'deny';
$defaultNote = '';
if (preg_match('/^[ \t]+default_policy:[ \t]+([a-z_]+)[ \t]*(#[^\n]*)?/m', $content, $m)) {
$defaultPolicy = $m[1];
$defaultNote = trim($m[2] ?? '');
}
// Extract the indented block under access_control:
if (!preg_match('/^access_control:[ \t]*\n((?:[ \t][^\n]*\n?)*)/m', $content, $m))
return ['ok' => false, 'error' => 'access_control section not found'];
$acBlock = $m[1];
// Extract the indented block under rules: (3+ space indent = rule list items)
if (!preg_match('/^ rules:[ \t]*\n((?:[ \t]{3,}[^\n]*\n?)*)/m', $acBlock, $m))
return ['ok' => true, 'default_policy' => $defaultPolicy, 'rules' => [],
'default_note' => $defaultNote];
// Walked rather than preg_split, so the comment lines above each rule can be attached to it.
//
// This block is rebuilt from the parsed model on every save, so anything the parser drops is
// deleted the next time anyone touches this page — and the parser dropped every comment. The
// five rules here are labelled ## Media_Users_users, ## Admin Only, ## super_users,
// ## power_users and ## Home_users, which is the only thing in the file that says what a rule
// is *for*: the rule itself is thirteen hostnames and a group id. Saving once erased all five.
//
// A lookahead split cannot do this, because the comment above rule N lands at the end of rule
// N-1's chunk (or before the first chunk entirely), so it would be attributed to the wrong
// rule or lost with the preamble.
$lines = explode("\n", $m[1]);
$starts = [];
foreach ($lines as $i => $l) if (preg_match('/^ - /', $l)) $starts[] = $i;
$rules = [];
foreach ($starts as $n => $s) {
// Contiguous comment lines immediately above this rule, in file order. A blank line or
// any content ends the run — a comment separated from the rule by a blank belongs to the
// block, not to the rule.
$label = [];
for ($j = $s - 1; $j >= 0; $j--) {
if (!preg_match('/^\s*#/', $lines[$j])) break;
array_unshift($label, trim($lines[$j]));
}
$end = $starts[$n + 1] ?? count($lines);
$chunk = implode("\n", array_slice($lines, $s, $end - $s));
$rule = vv_authelia_parse_rule_chunk($chunk);
if (empty($rule)) continue;
// Underscore-prefixed so it cannot collide with an Authelia field name, and so the writer
// can tell presentation from configuration when it decides what to emit as YAML.
if ($label) $rule['_label'] = $label;
$rules[] = $rule;
}
return ['ok' => true, 'default_policy' => $defaultPolicy, 'rules' => $rules,
'default_note' => $defaultNote];
}
function vv_authelia_parse_rule_chunk(string $chunk): array {
$rule = [];
$field = null;
$list = [];
$save = function () use (&$rule, &$field, &$list) {
if ($field === null) return;
if (!empty($list))
$rule[$field] = count($list) === 1 ? $list[0] : $list;
$field = null;
$list = [];
};
foreach (explode("\n", $chunk) as $line) {
$raw = rtrim($line);
$trim = trim($raw);
if ($trim === '' || preg_match('/^#+/', $trim)) continue;
$indent = strlen($raw) - strlen(ltrim($raw, ' '));
// indent=4, starts with "- " → first field of this rule block
if ($indent === 4 && str_starts_with($trim, '- ')) {
$rest = ltrim(substr($trim, 2));
if (preg_match('/^([a-z_]+):[ \t]*(.*)$/', $rest, $m)) {
$save();
$field = $m[1];
$val = trim($m[2]);
if ($val !== '' && !str_starts_with($val, '#')) {
$rule[$field] = vv_authelia_unquote($val);
$field = null;
}
}
continue;
}
// indent=6 → named field (scalar or list header)
if ($indent === 6 && preg_match('/^([a-z_]+):[ \t]*(.*)$/', $trim, $m)) {
$save();
$field = $m[1];
$val = trim($m[2]);
if ($val !== '' && !str_starts_with($val, '#')) {
$rule[$field] = vv_authelia_unquote($val);
$field = null;
}
continue;
}
// indent=8, starts with "- " → list item under current field
if ($indent === 8 && str_starts_with($trim, '- ')) {
$list[] = vv_authelia_parse_list_item(trim(substr($trim, 2)));
}
}
$save();
return $rule;
}
// Strip surrounding quotes and inline comments from a YAML scalar.
function vv_authelia_unquote(string $val): string {
$val = trim($val);
$val = preg_replace('/\s+#[^"\']*$/', '', $val); // strip trailing comment
if (preg_match('/^(["\'])(.+)\1$/', $val, $m)) return $m[2];
return $val;
}
// Parse a YAML list item: flow sequence ['group:name'] or plain/quoted scalar.
function vv_authelia_parse_list_item(string $val): string {
$val = trim($val);
// Flow sequence: ['value'] or ["value"] or [value]
if (preg_match('/^\[[\'""]?([^\]\'""]+)[\'""]?\]$/', $val, $m)) return trim($m[1]);
return vv_authelia_unquote($val);
}
function vv_authelia_write_rules(array $rules, string $defaultPolicy, string $defaultNote = ''): array {
$conf = vv_auth_conf();
$file = $conf['authelia_config'];
if (!file_exists($file)) return ['ok' => false, 'error' => 'Config not found: ' . $file];
$content = file_get_contents($file);
if ($content === false) return ['ok' => false, 'error' => 'Cannot read config file'];
// Build the new access_control block
$block = "access_control:\n";
$block .= " default_policy: $defaultPolicy" . ($defaultNote !== '' ? ' ' . $defaultNote : '') . "\n";
$block .= " rules:\n";
// Preferred field output order
$fieldOrder = ['domain', 'policy', 'subject', 'networks', 'resources'];
foreach ($rules as $rule) {
// The labels the operator wrote above this rule, put back before it. Emitted here rather
// than inside the field loop because they are not a field — they carry no indent-4 dash
// and must land above the rule, not inside it.
foreach ((array) ($rule['_label'] ?? []) as $lbl) {
$lbl = trim((string) $lbl);
if ($lbl === '') continue;
// Forced back into comment form. This string reaches here from the browser, and a
// label that lost its # would be spliced into the config as YAML.
if ($lbl[0] !== '#') $lbl = '# ' . $lbl;
// One line only — a newline here would end the comment and start config.
$block .= ' ' . str_replace(["\r", "\n"], ' ', $lbl) . "\n";
}
$keys = array_merge(
array_filter($fieldOrder, fn($k) => array_key_exists($k, $rule)),
array_diff(array_keys($rule), $fieldOrder)
);
// Presentation, already emitted above. Left in the key list it would be written out as a
// YAML field named _label, which Authelia would reject on load.
$keys = array_filter($keys, fn($k) => $k !== '_label');
$first = true;
foreach ($keys as $key) {
if (!array_key_exists($key, $rule)) continue;
$val = $rule[$key];
$prefix = $first ? ' - ' : ' ';
$first = false;
// domain, subject, resources, networks → always output as list
$isList = in_array($key, ['domain', 'subject', 'resources', 'networks'], true);
if ($isList) {
$items = is_array($val) ? $val : [$val];
$block .= $prefix . $key . ":\n";
foreach ($items as $item) {
$out = $key === 'subject'
? "['" . $item . "']"
: vv_authelia_yaml_scalar((string) $item);
$block .= ' - ' . $out . "\n";
}
} else {
$block .= $prefix . $key . ': ' . vv_authelia_yaml_scalar((string) $val) . "\n";
}
}
}
// Replace existing access_control: block (from its line to next top-level key or EOF)
$pattern = '/^access_control:[ \t]*\n(?:[ \t][^\n]*\n?)*/m';
$new = preg_match($pattern, $content)
? preg_replace($pattern, $block, $content, 1)
: rtrim($content) . "\n\n" . $block;
if ($new === null) return ['ok' => false, 'error' => 'Regex replace failed'];
$tmp = $file . '.vv.tmp';
if (file_put_contents($tmp, $new) === false) return ['ok' => false, 'error' => 'Write failed'];
if (!rename($tmp, $file)) { @unlink($tmp); return ['ok' => false, 'error' => 'Atomic rename failed']; }
shell_exec('docker restart ' . escapeshellarg($conf['authelia_container']) . ' >/dev/null 2>&1 &');
return ['ok' => true];
}
// Quote a YAML scalar value if it contains characters that require quoting.
function vv_authelia_yaml_scalar(string $val): string {
if ($val === '' || preg_match('/[:#\[\]{},|>&*?!%@`\'"]/', $val) || preg_match('/^\s|\s$/', $val))
return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $val) . '"';
return $val;
}
// ── Access simulation ─────────────────────────────────────────────────────────
//
// Whether one person can open one URL is decided by three objects that no single card shows: the
// NPM host (does it hand the request to Authelia at all), the Authelia rule list (which rule wins,
// in file order), and the LDAP group membership (does the winning rule's subject include them).
// Any one of the three can be the reason a login is refused — or worse, not asked for — and the
// only way to find out has been to read three configs and reason about them by hand.
//
// This walks it the way Authelia does and reports every step, so the answer is checkable rather
// than asserted. It changes nothing: every function below reads.
// Which Authelia a proxy host actually talks to, read out of its own nginx block rather than
// assumed from conf.
//
// This machine runs two — Authelia for the .com names and Authelia-Secondary for the .us ones —
// and HOST1_AUTHELIA_CONFIG names only the first. Evaluating a .us domain against the primary's
// rules would produce a confident, wrong answer for six live hostnames, so the instance is taken
// from the `set $upstream_authelia http://NAME:PORT` line that decides it in production.
function vv_authelia_instance_for(array $proxyHost): array {
$adv = (string) ($proxyHost['advanced_config'] ?? '');
if (!str_contains($adv, 'auth_request'))
return ['guarded' => false, 'container' => '', 'config' => '', 'source' => 'none'];
$container = '';
if (preg_match('#set\s+\$upstream_authelia\s+https?://([A-Za-z0-9._-]+):(\d+)#', $adv, $m))
$container = $m[1];
$conf = vv_auth_conf();
// The configured instance is matched by name rather than assumed, so the tab's own editing
// target is identified as such and anything else is reported as the separate instance it is.
if ($container !== '' && strcasecmp($container, (string) ($conf['authelia_container'] ?? '')) === 0)
return ['guarded' => true, 'container' => $container, 'config' => $conf['authelia_config'],
'source' => 'conf', 'is_configured' => true];
$path = $container !== '' ? vv_authelia_config_for_container($container) : '';
return ['guarded' => true, 'container' => $container, 'config' => $path,
'source' => $path !== '' ? 'docker' : 'unknown', 'is_configured' => false];
}
// A container's configuration.yml, found through its own /config bind mount. Nothing hardcodes a
// path: a second instance added later is picked up because it is mounted the same way, which is the
// same reason the rest of this plugin reads its host list from NPM rather than from conf.
function vv_authelia_config_for_container(string $name): string {
static $cache = [];
if (isset($cache[$name])) return $cache[$name];
$cache[$name] = '';
require_once __DIR__ . '/docker.php';
$all = vv_dk_inspect_all();
foreach ($all as $cn => $c) {
if (strcasecmp($cn, $name) !== 0) continue;
foreach ($c['mounts'] ?? [] as $m) {
if (($m['dst'] ?? '') !== '/config') continue;
$p = rtrim((string) $m['src'], '/') . '/configuration.yml';
if (is_file($p)) $cache[$name] = $p;
}
}
return $cache[$name];
}
// Does an Authelia domain pattern match this hostname? Authelia accepts an exact name and a single
// leading wildcard label; nothing else here uses regex domains, so nothing else is claimed.
function vv_authelia_domain_matches(string $pattern, string $domain): bool {
$pattern = strtolower(trim($pattern));
$domain = strtolower(trim($domain));
if ($pattern === '' || $domain === '') return false;
if ($pattern === $domain) return true;
if (str_starts_with($pattern, '*.')) {
$suffix = substr($pattern, 1); // ".example.com"
// One label only, matching Authelia: *.example.com covers a.example.com and not a.b.example.com.
return str_ends_with($domain, $suffix)
&& !str_contains(substr($domain, 0, -strlen($suffix)), '.')
&& substr($domain, 0, -strlen($suffix)) !== '';
}
return false;
}
// Whether a rule's subject admits this user. Authelia's shape is a list of subjects OR'd together,
// where an element that is itself a list is AND'd — so [[a,b],c] means "(a and b) or c".
//
// A rule with no subject at all applies to everyone, which is the case that silently shadows every
// specific rule below it. Returned as a reason string as well as a verdict, because "the rule was
// skipped" and "the rule matched and denied" look identical in a result and mean opposite things.
function vv_authelia_subject_matches($subject, string $uid, array $groups): array {
if ($subject === null || $subject === '' || $subject === [])
return ['match' => true, 'why' => 'no subject — applies to everyone'];
$groupsLc = array_map('strtolower', $groups);
$one = function (string $s) use ($uid, $groupsLc): bool {
$s = trim($s);
if (str_starts_with($s, 'group:')) return in_array(strtolower(substr($s, 6)), $groupsLc, true);
if (str_starts_with($s, 'user:')) return strcasecmp(substr($s, 5), $uid) === 0;
// An unprefixed subject is a username in Authelia's schema.
return strcasecmp($s, $uid) === 0;
};
$alternatives = is_array($subject) ? $subject : [$subject];
foreach ($alternatives as $alt) {
if (is_array($alt)) {
$all = true;
foreach ($alt as $part) if (!$one((string) $part)) { $all = false; break; }
if ($all) return ['match' => true, 'why' => 'matches all of ' . implode(' + ', array_map('strval', $alt))];
} elseif ($one((string) $alt)) {
return ['match' => true, 'why' => 'matches ' . $alt];
}
}
$flat = [];
foreach ($alternatives as $alt) $flat[] = is_array($alt) ? '(' . implode(' + ', array_map('strval', $alt)) . ')' : (string) $alt;
return ['match' => false, 'why' => 'not ' . implode(' or ', $flat)];
}
// Walk the rules in file order and stop at the first that matches on every axis, which is exactly
// what Authelia does. Every rule considered is reported with why it did or did not apply — the
// trace is the point, because "which rule won" is rarely the surprising part. "Which rule you
// thought would win and why it was skipped" is.
function vv_authelia_evaluate(array $rules, string $defaultPolicy, string $domain, string $path,
string $uid, array $groups): array {
$trace = [];
foreach ($rules as $i => $r) {
$doms = is_array($r['domain'] ?? '') ? $r['domain'] : [$r['domain'] ?? ''];
$domHit = false;
foreach ($doms as $d) if (vv_authelia_domain_matches((string) $d, $domain)) { $domHit = true; break; }
$label = trim(preg_replace('/^#+\s*/', '', implode(' ', $r['_label'] ?? []))) ?: ('rule ' . ($i + 1));
if (!$domHit) { $trace[] = ['n' => $i + 1, 'label' => $label, 'applied' => false, 'skip' => 'domain', 'why' => 'domain not listed']; continue; }
// Resources is a path regex. A rule carrying one only applies to the paths it names, so a
// rule that looks like it covers a host may cover one directory of it.
$res = $r['resources'] ?? null;
if ($res !== null && $res !== '' && $res !== []) {
$list = is_array($res) ? $res : [$res];
$hit = false;
foreach ($list as $rx) {
// Delimited and error-suppressed: this pattern comes from a hand-edited file, and a
// malformed one must report as "did not match" rather than raising a warning into
// the answer.
if (@preg_match('#' . str_replace('#', '\#', (string) $rx) . '#', $path)) { $hit = true; break; }
}
if (!$hit) {
$trace[] = ['n' => $i + 1, 'label' => $label, 'applied' => false, 'skip' => 'path',
'why' => 'domain matches but the path ' . $path . ' is outside its resources pattern'];
continue;
}
}
$sub = vv_authelia_subject_matches($r['subject'] ?? null, $uid, $groups);
if (!$sub['match']) {
// Recorded as a subject skip specifically. "No rule mentioned this host" and "a rule
// for this host stepped over this person" both end at the default policy and are
// completely different facts about the configuration.
$trace[] = ['n' => $i + 1, 'label' => $label, 'applied' => false, 'skip' => 'subject',
'policy' => $r['policy'] ?? 'deny',
'why' => 'domain matches but the user is ' . $sub['why']];
continue;
}
$trace[] = ['n' => $i + 1, 'label' => $label, 'applied' => true,
'why' => 'domain matches and the user ' . $sub['why'],
'policy' => $r['policy'] ?? 'deny'];
return ['policy' => $r['policy'] ?? 'deny', 'matched' => $i + 1, 'matched_label' => $label, 'trace' => $trace];
}
return ['policy' => $defaultPolicy, 'matched' => null, 'matched_label' => '', 'trace' => $trace];
}
// The whole question, end to end: can this user open this URL, and what decided it.
function vv_auth_access_check(string $domain, string $uid, string $path = '/'): array {
$domain = strtolower(trim($domain));
$path = $path === '' ? '/' : $path;
if ($domain === '') return ['ok' => false, 'error' => 'No domain given'];
$p = vv_npm_list_proxies();
if (!($p['ok'] ?? false)) return ['ok' => false, 'error' => $p['error'] ?? 'Could not read proxy hosts'];
$host = null;
foreach ($p['proxies'] as $h)
foreach ($h['domain_names'] ?? [] as $d)
if (vv_authelia_domain_matches((string) $d, $domain) || strtolower((string) $d) === $domain) { $host = $h; break 2; }
$out = ['ok' => true, 'domain' => $domain, 'path' => $path, 'uid' => $uid, 'findings' => []];
if (!$host) {
$out['findings'][] = ['level' => 'warn', 'text' => 'No NPM proxy host serves ' . $domain
. ', so nothing reaches Authelia for it and no rule about it has any effect.'];
$out['served'] = false;
return $out;
}
$out['served'] = true;
$out['enabled'] = ($host['enabled'] ?? true) ? true : false;
$out['forward'] = ($host['forward_scheme'] ?? 'http') . '://' . ($host['forward_host'] ?? '') . ':' . ($host['forward_port'] ?? '');
// The user's groups, which is the half of the answer that lives in a different system entirely.
// Taken from the user record rather than by walking every group, because that record carries
// both facts this needs — whether the person exists and what they belong to. Asking the group
// list instead would make "in no groups" and "no such person" the same empty result.
$groups = [];
$known = false;
if ($uid !== '') {
$ul = vv_lldap_list_users();
foreach (($ul['ok'] ?? false) ? ($ul['users'] ?? []) : [] as $u) {
if (strcasecmp((string) ($u['id'] ?? ''), $uid) !== 0) continue;
$known = true;
$groups = array_values(array_filter(array_column($u['groups'] ?? [], 'displayName')));
break;
}
}
$out['groups'] = $groups;
$out['user_known'] = $known;
$inst = vv_authelia_instance_for($host);
$out['authelia'] = $inst;
if (!$inst['guarded']) {
$out['policy'] = 'bypass';
$out['findings'][] = ['level' => 'warn', 'text' => 'This host has no auth_request block, so the request never reaches Authelia. '
. 'Anyone who can resolve ' . $domain . ' gets through to the application, whatever the rules say.'];
return $out;
}
if ($inst['config'] === '' || !is_file($inst['config'])) {
$out['findings'][] = ['level' => 'warn', 'text' => 'This host sends its authentication to ' . ($inst['container'] ?: 'an unnamed instance')
. ', whose configuration could not be located, so the decision below cannot be worked out.'];
return $out;
}
$r = vv_authelia_read_rules($inst['config']);
if (!($r['ok'] ?? false)) { $out['findings'][] = ['level' => 'warn', 'text' => $r['error'] ?? 'Rules unreadable']; return $out; }
$ev = vv_authelia_evaluate($r['rules'] ?? [], $r['default_policy'] ?? 'deny', $domain, $path, $uid, $groups);
$out['policy'] = $ev['policy'];
$out['matched'] = $ev['matched'];
$out['matched_label'] = $ev['matched_label'];
$out['trace'] = $ev['trace'];
$out['default_policy'] = $r['default_policy'] ?? 'deny';
$out['findings'] = array_merge($out['findings'], vv_auth_access_findings($out, $inst));
return $out;
}
function vv_auth_access_findings(array $o, array $inst): array {
$f = [];
// The one that cannot be seen from any single page. A rule list that is not the one being
// edited on this tab is a rule list nobody is maintaining on purpose.
if (!($inst['is_configured'] ?? false))
$f[] = ['level' => 'info', 'text' => 'Decided by ' . ($inst['container'] ?: 'a second instance')
. ', which is not the Authelia this tab edits. Its rules are in ' . ($inst['config'] ?: 'a config that was not found')
. ' and nothing on this page changes them.'];
if (!($o['enabled'] ?? true))
$f[] = ['level' => 'warn', 'text' => 'The proxy host is disabled in NPM, so nothing is served here at all right now.'];
if ($o['uid'] !== '' && !($o['user_known'] ?? false))
$f[] = ['level' => 'warn', 'text' => 'No user with the id "' . $o['uid'] . '" exists in the directory, so this is the answer for a name that cannot log in.'];
elseif ($o['uid'] !== '' && !$o['groups'])
$f[] = ['level' => 'info', 'text' => $o['uid'] . ' is in no groups, so every rule with a group subject skips them.'];
$policy = $o['policy'] ?? '';
if ($o['matched'] === null) {
// Two different facts end at the same default policy, and conflating them was the first
// version of this: a host no rule mentions, and a host whose rule stepped over this
// particular person. The second is the more pointed one — the rule exists, it was written
// for this host, and the default let them past it anyway.
$stepped = [];
foreach ($o['trace'] ?? [] as $t) if (($t['skip'] ?? '') === 'subject') $stepped[] = $t;
if ($stepped && $policy === 'bypass') {
$t = $stepped[0];
$f[] = ['level' => 'bad', 'text' => 'Rule ' . $t['n'] . ' (' . $t['label'] . ') covers ' . $o['domain']
. ' but does not apply to this user — ' . $t['why'] . '. No later rule matches either, so the default policy takes over, '
. 'and the default here is bypass. The rule written to protect this host lets everyone it does not name straight through.'];
// Stated because the answer is different for a caller who is not logged in at all, and
// an operator reading "bypass" would otherwise reasonably conclude the host is open to
// the internet. Authelia treats an anonymous request against a rule carrying a subject
// as a potential match and sends them to the portal first; this simulation answers for
// someone who has already authenticated as this user.
$f[] = ['level' => 'info', 'text' => 'This is the answer for a caller already logged in as ' . ($o['uid'] ?: 'someone')
. '. Authelia handles an anonymous caller differently — a rule carrying a subject makes it send them to the login portal first — '
. 'so this is an authenticated user reaching something not meant for them, not an open door to the internet.'];
} elseif ($policy === 'bypass') {
$f[] = ['level' => 'bad', 'text' => 'No rule mentions ' . $o['domain'] . ' at all, so it falls to the default policy, which is bypass — '
. 'the request goes to Authelia and Authelia waves it through. This host is behind an auth_request block that never refuses anyone.'];
} else {
$f[] = ['level' => 'info', 'text' => 'No rule matches ' . $o['domain'] . ', so the default policy of ' . $policy . ' applies.'];
}
} else {
$f[] = ['level' => $policy === 'deny' ? 'warn' : 'info',
'text' => 'Rule ' . $o['matched'] . ' (' . $o['matched_label'] . ') is the first one that applies, and its policy is ' . $policy . '.'];
}
if ($policy === 'bypass' || $policy === '')
$f[] = ['level' => 'info', 'text' => ($o['uid'] !== '' ? $o['uid'] : 'Anyone') . ' reaches ' . $o['domain']
. ' without being asked to authenticate. Whether that is right depends on whether the application behind it has its own login.'];
elseif ($policy === 'deny')
$f[] = ['level' => 'warn', 'text' => ($o['uid'] !== '' ? $o['uid'] : 'This caller') . ' is refused before reaching the application.'];
else
$f[] = ['level' => 'info', 'text' => ($o['uid'] !== '' ? $o['uid'] : 'A caller') . ' is asked to log in (' . $policy . ') and then reaches the application.'];
return $f;
}
// ── Certificate renewal triage ────────────────────────────────────────────────
//
// Why renewals failed, from certbot's own logs. cert_history.sh counts failures by noticing an
// expiry in the past; this reads the reason. See Tools/cert_triage.php for the full note.
// The categories renewal failures actually fall into here, in the order a reader should meet them:
// causes before consequences. Each pattern is anchored on the string certbot itself emits, so a
// category matching is evidence rather than inference.
//
// 'root' marks a cause worth acting on directly. Rate limiting is deliberately not one — it is
// what happens after something else has been failing, and treating it as the problem sends people
// to wait out a timer instead of fixing the DNS record that burned it.
const VV_CERT_TRIAGE_PATTERNS = [
['id' => 'no-dns-record', 'root' => true,
'rx' => '/DNS problem: NXDOMAIN looking up [A-Z]+ for ([A-Za-z0-9._-]+)/',
'what' => 'the hostname has no DNS record at all'],
['id' => 'no-a-record', 'root' => true,
'rx' => '/no valid A records found for ([A-Za-z0-9._-]+)/',
'what' => 'the hostname resolves but has no address record Let\'s Encrypt can reach'],
['id' => 'challenge-unreachable', 'root' => true,
'rx' => '/Timeout during connect[^\n]*|Fetching http:\/\/([A-Za-z0-9._-]+)\/\.well-known[^\n]*Timeout/',
'what' => 'the HTTP-01 challenge could not be fetched — port 80 is not reaching this proxy'],
['id' => 'caa-forbids', 'root' => true,
'rx' => '/CAA record for ([A-Za-z0-9._-]+) prevents issuance/',
'what' => 'a CAA record on the domain forbids Let\'s Encrypt from issuing'],
['id' => 'revoke-expired', 'root' => false,
'rx' => '/Unable to revoke :: Certificate is expired/',
'what' => 'a revoke was attempted on a certificate that had already expired'],
// Last, and marked as a consequence. 2079 of these on this installation, every one of them
// downstream of the three hostnames above.
['id' => 'rate-limited', 'root' => false,
'rx' => '/urn:ietf:params:acme:error:rateLimited/',
'what' => 'Let\'s Encrypt refused the request because too many were made too recently'],
];
// The rotated certbot logs, newest first, ordered by their rotation suffix.
//
// Never by mtime. Every one of these files carries the same mtime on this machine — they live in
// the Critical-Data share and are written as a set by the sync, so the filesystem says all
// thousand were modified in the same minute. Sorting by mtime picks an arbitrary sample from
// anywhere in the history and presents it as the current state.
function vv_cert_log_files(int $limit): array {
$dir = trim((string) (vv_conf_vars()['CERT_TRIAGE_LOG_DIR'] ?? ''));
if ($dir === '') {
require_once __DIR__ . '/docker.php';
foreach (vv_dk_inspect_all() as $name => $c) {
if (stripos($name, 'nginx') === false && stripos($name, 'npm') === false) continue;
foreach ($c['mounts'] ?? [] as $m) {
if (($m['dst'] ?? '') !== '/config' && ($m['dst'] ?? '') !== '/data') continue;
$p = rtrim((string) $m['src'], '/') . '/log';
if (is_dir($p)) { $dir = $p; break 2; }
}
}
}
if ($dir === '' || !is_dir($dir)) return ['dir' => $dir, 'files' => []];
$found = glob($dir . '/letsencrypt.log*') ?: [];
$rank = [];
foreach ($found as $f) {
// letsencrypt.log is the live one and sorts ahead of every numbered rotation.
$n = preg_match('/\.log\.(\d+)$/', $f, $m) ? (int) $m[1] : -1;
$rank[$f] = $n;
}
asort($rank);
return ['dir' => $dir, 'files' => array_slice(array_keys($rank), 0, max(1, $limit))];
}
// The tail of a file, without reading the whole thing. These run to a megabyte each and the
// interesting part of a certbot run is always at the end.
function vv_cert_log_tail(string $path, int $maxBytes): string {
$size = @filesize($path);
if ($size === false) return '';
$fh = @fopen($path, 'rb');
if (!$fh) return '';
if ($size > $maxBytes) @fseek($fh, $size - $maxBytes);
$data = (string) @stream_get_contents($fh);
fclose($fh);
return $data;
}
function vv_cert_triage(int $filesOverride = 0): array {
$v = vv_conf_vars();
$lim = $filesOverride > 0 ? $filesOverride : max(1, (int) ($v['CERT_TRIAGE_FILES'] ?? 40));
$max = max(4096, (int) ($v['CERT_TRIAGE_MAX_BYTES'] ?? 262144));
$found = vv_cert_log_files($lim);
if (!$found['files'])
return ['ok' => false, 'error' => 'No certbot logs found'
. ($found['dir'] !== '' ? ' in ' . $found['dir'] : ' — set CERT_TRIAGE_LOG_DIR')];
// Counted per run, not per line. One log file is one certbot invocation, and a single failed
// run writes its reason several times over — in the ACME response, in the traceback, and again
// in certbot's own ERROR summary. Counting lines therefore reports one failure as three and
// makes the categories incomparable with each other, because the noisier reasons repeat more.
// "12 of 40 runs failed for no DNS record" is a number that means something.
$counts = $doms = [];
$runs = $failedRuns = $unclassified = 0;
foreach ($found['files'] as $f) {
$text = vv_cert_log_tail($f, $max);
if ($text === '') continue;
$runs++;
$hitAny = false;
foreach (VV_CERT_TRIAGE_PATTERNS as $p) {
if (!preg_match_all($p['rx'], $text, $m, PREG_SET_ORDER)) continue;
$hitAny = true;
$counts[$p['id']] = ($counts[$p['id']] ?? 0) + 1;
foreach ($m as $hit)
// Only some patterns capture a hostname; the others are about the run, not a name.
if (isset($hit[1]) && $hit[1] !== '') $doms[$p['id']][strtolower($hit[1])] = true;
}
if ($hitAny) $failedRuns++;
// A run that errored and matched nothing known. Reported rather than dropped: an error
// certbot starts emitting after this was written has to show up as something, and a total
// that quietly shrinks is how a new failure mode stays invisible.
if (!$hitAny && preg_match('/:ERROR:certbot/', $text)) { $unclassified++; $failedRuns++; }
}
$cats = [];
foreach (VV_CERT_TRIAGE_PATTERNS as $p) {
if (empty($counts[$p['id']])) continue;
$cats[] = ['id' => $p['id'], 'root' => $p['root'], 'what' => $p['what'],
'count' => $counts[$p['id']], 'domains' => array_keys($doms[$p['id']] ?? [])];
}
return ['ok' => true, 'dir' => $found['dir'], 'files_read' => $runs,
'total' => $failedRuns, 'unclassified' => $unclassified,
'categories' => $cats, 'reading' => vv_cert_triage_reading($cats)];
}
// The causal reading, which is the part a category count cannot give. Written as sentences because
// the relationship between these categories is the whole finding: one of them is nearly always
// downstream of another, and a list sorted by count puts the consequence at the top.
function vv_cert_triage_reading(array $cats): array {
if (!$cats) return [];
$by = [];
foreach ($cats as $c) $by[$c['id']] = $c;
$roots = array_values(array_filter($cats, fn($c) => $c['root']));
$out = [];
if ($roots) {
$names = [];
foreach ($roots as $r) foreach ($r['domains'] as $d) $names[$d] = true;
$n = count($roots);
$out[] = 'Root cause: ' . $n . ' kind' . ($n === 1 ? '' : 's') . ' of failure that '
. ($n === 1 ? 'is' : 'are') . ' nobody else\'s consequence'
. ($names ? ', affecting ' . implode(', ', array_slice(array_keys($names), 0, 8))
. (count($names) > 8 ? ' and ' . (count($names) - 8) . ' more' : '') : '') . '.';
}
if (isset($by['rate-limited'])) {
$out[] = $roots
? 'The ' . $by['rate-limited']['count'] . ' rate-limit refusals are downstream of that: '
. 'certbot retried the failing names often enough to exhaust the allowance, which then '
. 'fails renewals for domains that have nothing wrong with them. Fixing the names above '
. 'is what clears it — waiting out the limit only restarts the cycle.'
: 'Rate limiting is the only category present, with no failing name behind it. That points '
. 'at renewal being attempted far too often rather than at any one domain.';
}
if (isset($by['revoke-expired']))
$out[] = 'The revoke failures are harmless in themselves — a certificate that already expired '
. 'cannot be revoked, and does not need to be. They indicate cleanup running against '
. 'certificates that were already dead.';
if (!$roots && !isset($by['rate-limited']))
$out[] = 'Nothing here is a standing cause — these are individual failures rather than a pattern.';
return $out;
}