Not set, rejected and unreachable all reached the page as one message about checking credentials, which sends you to a password when the field is simply empty — as both of HOST1's were, with no card on the page to fill them in from.
558 lines
26 KiB
PHP
558 lines
26 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(),
|
|
];
|
|
}
|
|
|
|
// ── 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 (strip inline comments)
|
|
$defaultPolicy = 'deny';
|
|
if (preg_match('/^[ \t]+default_policy:[ \t]+([a-z_]+)/m', $content, $m))
|
|
$defaultPolicy = $m[1];
|
|
|
|
// 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' => []];
|
|
|
|
// Split into individual rule chunks at " - " (indent-4 rule starts)
|
|
$chunks = preg_split('/(?=^ - )/m', $m[1]);
|
|
$rules = [];
|
|
foreach ($chunks as $chunk) {
|
|
if (!preg_match('/^ - /', $chunk)) continue;
|
|
$rule = vv_authelia_parse_rule_chunk($chunk);
|
|
if (!empty($rule)) $rules[] = $rule;
|
|
}
|
|
|
|
return ['ok' => true, 'default_policy' => $defaultPolicy, 'rules' => $rules];
|
|
}
|
|
|
|
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): 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\n";
|
|
$block .= " rules:\n";
|
|
|
|
// Preferred field output order
|
|
$fieldOrder = ['domain', 'policy', 'subject', 'networks', 'resources'];
|
|
|
|
foreach ($rules as $rule) {
|
|
$keys = array_merge(
|
|
array_filter($fieldOrder, fn($k) => array_key_exists($k, $rule)),
|
|
array_diff(array_keys($rule), $fieldOrder)
|
|
);
|
|
$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;
|
|
}
|