Files
Varaverk/Plugin/unraid/include/auth.php
T
Gmer4Lfe 8157673291 Add AUTH_STACK so the Auth tab follows the stack in force
Authentik is the likely destination and the page had Authelia and lldap wired in at every
level, so the seam goes in now: the panels and every endpoint action route off one conf value,
and a stack that cannot be driven yet says so rather than drawing controls with nothing behind.
2026-08-15 15:27:50 -04:00

760 lines
37 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);
return json_decode($body ?: '{}', true) ?: [];
}
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);
}
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;
}