Files
Varaverk/Plugin/unraid/include/auth.php
T
Gmer4Lfe 13de1dab82 Stop a scalar NPM response killing the request, and add certificate deletion
NPM answers a successful DELETE with the bare literal true, which vv_npm_raw() is typed to
return an array from — so deleting or toggling a proxy host has always thrown a TypeError
rather than doing the thing.
2026-08-15 15:51:29 -04:00

794 lines
39 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()
// 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];
}
// ── 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 ──────────────────────────────────────────────────────────────────
function vv_authelia_read_rules(): 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'];
// 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;
}