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(), ]; } // ── 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 { $r = vv_lldap_gql('query { users { id displayName email creationDate groups { id displayName } } }'); if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Query failed']; return ['ok' => true, 'users' => $r['data']['users'] ?? []]; } 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): array { $r = vv_lldap_gql( 'mutation CreateUser($user: CreateUserInput!) { createUser(user: $user) { id displayName email } }', ['user' => ['id' => $id, 'email' => $email, 'displayName' => $displayName]] ); 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'] ?? []]; } function vv_lldap_update_user(string $id, string $email, string $displayName): array { $r = vv_lldap_gql( 'mutation UpdateUser($user: UpdateUserInput!) { updateUser(user: $user) { ok } }', ['user' => ['id' => $id, 'email' => $email, 'displayName' => $displayName]] ); 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'] ?? []]; } 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; }