1	<?php
2	require_once __DIR__ . '/config.php';
3
4	// ── Config ────────────────────────────────────────────────────────────────────
5
6	function vv_auth_conf(): array {
7	    $v    = vv_conf_vars();
8	    $host = strtoupper(vv_detect_host());
9	    return [
10	        'npm_url'            => rtrim($v["{$host}_NPM_URL"]            ?? 'http://localhost:81', '/'),
11	        'npm_user'           => $v["{$host}_NPM_USER"]                 ?? '',
12	        'npm_pass'           => $v["{$host}_NPM_PASS"]                 ?? '',
13	        'lldap_url'          => rtrim($v["{$host}_LLDAP_URL"]          ?? 'http://localhost:17170', '/'),
14	        'lldap_user'         => $v["{$host}_LLDAP_USER"]               ?? '',
15	        'lldap_pass'         => $v["{$host}_LLDAP_PASS"]               ?? '',
16	        'authelia_config'    => $v["{$host}_AUTHELIA_CONFIG"]          ?? '/mnt/user/appdata-Fallback/Critical-Data/Authelia/configuration.yml',
17	        'authelia_container' => $v["{$host}_AUTHELIA_CONTAINER"]       ?? 'Authelia',
18	        'is_owner'           => vv_is_owner(),
19	    ];
20	}
21
22	// ── NPM ───────────────────────────────────────────────────────────────────────
23
24	function vv_npm_token(): string {
25	    if (!session_id()) session_start();
26	    $conf   = vv_auth_conf();
27	    $cached = $_SESSION['vv_npm_token']     ?? '';
28	    $expiry = $_SESSION['vv_npm_token_exp'] ?? 0;
29	    if ($cached && time() < $expiry) return $cached;
30
31	    $resp  = vv_npm_raw('POST', '/api/tokens', [
32	        'identity' => $conf['npm_user'],
33	        'secret'   => $conf['npm_pass'],
34	    ], '', $conf);
35	    $token = $resp['token'] ?? '';
36	    if ($token) {
37	        $_SESSION['vv_npm_token']     = $token;
38	        $_SESSION['vv_npm_token_exp'] = time() + 82800;
39	    }
40	    return $token;
41	}
42
43	function vv_npm_raw(string $method, string $path, array $data, string $token, array $conf = []): array {
44	    if (!$conf) $conf = vv_auth_conf();
45	    $url     = $conf['npm_url'] . $path;
46	    $headers = ['Content-Type: application/json', 'Accept: application/json'];
47	    if ($token) $headers[] = 'Authorization: Bearer ' . $token;
48
49	    $ch = curl_init($url);
50	    curl_setopt_array($ch, [
51	        CURLOPT_RETURNTRANSFER => true,
52	        CURLOPT_TIMEOUT        => 10,
53	        CURLOPT_HTTPHEADER     => $headers,
54	        CURLOPT_CUSTOMREQUEST  => $method,
55	    ]);
56	    if ($data && in_array($method, ['POST', 'PUT'], true))
57	        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
58	    $body = curl_exec($ch);
59	    curl_close($ch);
60	    return json_decode($body ?: '{}', true) ?: [];
61	}
62
63	function vv_npm_req(string $method, string $path, array $data = []): array {
64	    $token = vv_npm_token();
65	    if (!$token) return ['_err' => 'NPM auth failed — check credentials in host conf'];
66	    return vv_npm_raw($method, $path, $data, $token);
67	}
68
69	function vv_npm_list_proxies(): array {
70	    $list = vv_npm_req('GET', '/api/nginx/proxy-hosts?expand=certificate');
71	    if (!is_array($list) || isset($list['_err']))
72	        return ['ok' => false, 'error' => $list['_err'] ?? 'Invalid response from NPM'];
73	    return ['ok' => true, 'proxies' => $list];
74	}
75
76	function vv_npm_list_certs(): array {
77	    $list = vv_npm_req('GET', '/api/nginx/certificates');
78	    return is_array($list) ? $list : [];
79	}
80
81	function vv_npm_create_proxy(array $data): array {
82	    $r = vv_npm_req('POST', '/api/nginx/proxy-hosts', $data);
83	    return isset($r['id']) ? ['ok' => true, 'proxy' => $r] : ['ok' => false, 'error' => $r['error'] ?? ($r['_err'] ?? 'Create failed')];
84	}
85
86	function vv_npm_update_proxy(int $id, array $data): array {
87	    $r = vv_npm_req('PUT', "/api/nginx/proxy-hosts/$id", $data);
88	    return isset($r['id']) ? ['ok' => true, 'proxy' => $r] : ['ok' => false, 'error' => $r['error'] ?? ($r['_err'] ?? 'Update failed')];
89	}
90
91	function vv_npm_delete_proxy(int $id): array {
92	    vv_npm_req('DELETE', "/api/nginx/proxy-hosts/$id");
93	    return ['ok' => true];
94	}
95
96	function vv_npm_toggle_proxy(int $id, bool $enabled): array {
97	    vv_npm_req('POST', "/api/nginx/proxy-hosts/$id/" . ($enabled ? 'enable' : 'disable'));
98	    return ['ok' => true];
99	}
100
101	// ── lldap ─────────────────────────────────────────────────────────────────────
102
103	function vv_lldap_token(): string {
104	    if (!session_id()) session_start();
105	    $conf   = vv_auth_conf();
106	    $cached = $_SESSION['vv_lldap_token']     ?? '';
107	    $expiry = $_SESSION['vv_lldap_token_exp'] ?? 0;
108	    if ($cached && time() < $expiry) return $cached;
109
110	    $ch = curl_init($conf['lldap_url'] . '/auth/simple/login');
111	    curl_setopt_array($ch, [
112	        CURLOPT_RETURNTRANSFER => true,
113	        CURLOPT_TIMEOUT        => 10,
114	        CURLOPT_POST           => true,
115	        CURLOPT_POSTFIELDS     => json_encode(['username' => $conf['lldap_user'], 'password' => $conf['lldap_pass']]),
116	        CURLOPT_HTTPHEADER     => ['Content-Type: application/json'],
117	    ]);
118	    $body  = curl_exec($ch);
119	    curl_close($ch);
120	    $resp  = json_decode($body ?: '{}', true) ?: [];
121	    $token = $resp['token'] ?? '';
122	    if ($token) {
123	        $_SESSION['vv_lldap_token']     = $token;
124	        $_SESSION['vv_lldap_token_exp'] = time() + 3500;
125	    }
126	    return $token;
127	}
128
129	function vv_lldap_gql(string $query, array $variables = []): array {
130	    $conf  = vv_auth_conf();
131	    $token = vv_lldap_token();
132	    if (!$token) return ['errors' => [['message' => 'lldap auth failed — check credentials']]];
133
134	    $ch = curl_init($conf['lldap_url'] . '/api/graphql');
135	    curl_setopt_array($ch, [
136	        CURLOPT_RETURNTRANSFER => true,
137	        CURLOPT_TIMEOUT        => 10,
138	        CURLOPT_POST           => true,
139	        CURLOPT_POSTFIELDS     => json_encode(['query' => $query, 'variables' => $variables]),
140	        CURLOPT_HTTPHEADER     => ['Content-Type: application/json', 'Authorization: Bearer ' . $token],
141	    ]);
142	    $body = curl_exec($ch);
143	    curl_close($ch);
144	    return json_decode($body ?: '{}', true) ?: [];
145	}
146
147	function vv_lldap_list_users(): array {
148	    $r = vv_lldap_gql('query { users { id displayName email creationDate groups { id displayName } } }');
149	    if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Query failed'];
150	    return ['ok' => true, 'users' => $r['data']['users'] ?? []];
151	}
152
153	function vv_lldap_list_groups(): array {
154	    $r = vv_lldap_gql('query { groups { id displayName users { id displayName } } }');
155	    if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Query failed'];
156	    return ['ok' => true, 'groups' => $r['data']['groups'] ?? []];
157	}
158
159	function vv_lldap_create_user(string $id, string $email, string $displayName, string $password): array {
160	    $r = vv_lldap_gql(
161	        'mutation CreateUser($user: CreateUserInput!) { createUser(user: $user) { id displayName email } }',
162	        ['user' => ['id' => $id, 'email' => $email, 'displayName' => $displayName]]
163	    );
164	    if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Create failed'];
165	    if ($password) vv_lldap_set_password($id, $password);
166	    return ['ok' => true, 'user' => $r['data']['createUser'] ?? []];
167	}
168
169	function vv_lldap_update_user(string $id, string $email, string $displayName): array {
170	    $r = vv_lldap_gql(
171	        'mutation UpdateUser($user: UpdateUserInput!) { updateUser(user: $user) { ok } }',
172	        ['user' => ['id' => $id, 'email' => $email, 'displayName' => $displayName]]
173	    );
174	    if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Update failed'];
175	    return ['ok' => true];
176	}
177
178	function vv_lldap_delete_user(string $id): array {
179	    $r = vv_lldap_gql(
180	        'mutation DeleteUser($userId: String!) { deleteUser(userId: $userId) { ok } }',
181	        ['userId' => $id]
182	    );
183	    if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Delete failed'];
184	    return ['ok' => true];
185	}
186
187	function vv_lldap_set_password(string $userId, string $password): array {
188	    $conf = vv_auth_conf();
189	    $token = vv_lldap_token();
190	    if (!$token) return ['ok' => false, 'error' => 'lldap auth failed'];
191
192	    $ch = curl_init($conf['lldap_url'] . '/auth/admin/resetPassword');
193	    curl_setopt_array($ch, [
194	        CURLOPT_RETURNTRANSFER => true,
195	        CURLOPT_TIMEOUT        => 10,
196	        CURLOPT_POST           => true,
197	        CURLOPT_POSTFIELDS     => json_encode(['userId' => $userId, 'password' => $password]),
198	        CURLOPT_HTTPHEADER     => ['Content-Type: application/json', 'Authorization: Bearer ' . $token],
199	    ]);
200	    $body = curl_exec($ch);
201	    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
202	    curl_close($ch);
203	    if ($code >= 200 && $code < 300) return ['ok' => true];
204	    $err = json_decode($body ?: '{}', true)['message'] ?? "HTTP $code";
205	    return ['ok' => false, 'error' => $err];
206	}
207
208	function vv_lldap_create_group(string $name): array {
209	    $r = vv_lldap_gql(
210	        'mutation CreateGroup($name: String!) { createGroup(name: $name) { id displayName } }',
211	        ['name' => $name]
212	    );
213	    if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Create failed'];
214	    return ['ok' => true, 'group' => $r['data']['createGroup'] ?? []];
215	}
216
217	function vv_lldap_delete_group(int $id): array {
218	    $r = vv_lldap_gql(
219	        'mutation DeleteGroup($groupId: Int!) { deleteGroup(groupId: $groupId) { ok } }',
220	        ['groupId' => $id]
221	    );
222	    if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Delete failed'];
223	    return ['ok' => true];
224	}
225
226	function vv_lldap_add_to_group(string $userId, int $groupId): array {
227	    $r = vv_lldap_gql(
228	        'mutation AddUserToGroup($userId: String!, $groupId: Int!) { addUserToGroup(userId: $userId, groupId: $groupId) { ok } }',
229	        ['userId' => $userId, 'groupId' => $groupId]
230	    );
231	    if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Failed'];
232	    return ['ok' => true];
233	}
234
235	function vv_lldap_remove_from_group(string $userId, int $groupId): array {
236	    $r = vv_lldap_gql(
237	        'mutation RemoveUserFromGroup($userId: String!, $groupId: Int!) { removeUserFromGroup(userId: $userId, groupId: $groupId) { ok } }',
238	        ['userId' => $userId, 'groupId' => $groupId]
239	    );
240	    if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Failed'];
241	    return ['ok' => true];
242	}
243
244	// ── Authelia ──────────────────────────────────────────────────────────────────
245
246	function vv_authelia_read_rules(): array {
247	    $conf = vv_auth_conf();
248	    $file = $conf['authelia_config'];
249	    if (!file_exists($file)) return ['ok' => false, 'error' => 'Config not found: ' . $file];
250
251	    $content = file_get_contents($file);
252	    if ($content === false) return ['ok' => false, 'error' => 'Cannot read config file'];
253
254	    // Extract default_policy (strip inline comments)
255	    $defaultPolicy = 'deny';
256	    if (preg_match('/^[ \t]+default_policy:[ \t]+([a-z_]+)/m', $content, $m))
257	        $defaultPolicy = $m[1];
258
259	    // Extract the indented block under access_control:
260	    if (!preg_match('/^access_control:[ \t]*\n((?:[ \t][^\n]*\n?)*)/m', $content, $m))
261	        return ['ok' => false, 'error' => 'access_control section not found'];
262
263	    $acBlock = $m[1];
264
265	    // Extract the indented block under rules: (3+ space indent = rule list items)
266	    if (!preg_match('/^  rules:[ \t]*\n((?:[ \t]{3,}[^\n]*\n?)*)/m', $acBlock, $m))
267	        return ['ok' => true, 'default_policy' => $defaultPolicy, 'rules' => []];
268
269	    // Split into individual rule chunks at "    - " (indent-4 rule starts)
270	    $chunks = preg_split('/(?=^    - )/m', $m[1]);
271	    $rules  = [];
272	    foreach ($chunks as $chunk) {
273	        if (!preg_match('/^    - /', $chunk)) continue;
274	        $rule = vv_authelia_parse_rule_chunk($chunk);
275	        if (!empty($rule)) $rules[] = $rule;
276	    }
277
278	    return ['ok' => true, 'default_policy' => $defaultPolicy, 'rules' => $rules];
279	}
280
281	function vv_authelia_parse_rule_chunk(string $chunk): array {
282	    $rule  = [];
283	    $field = null;
284	    $list  = [];
285
286	    $save = function () use (&$rule, &$field, &$list) {
287	        if ($field === null) return;
288	        if (!empty($list))
289	            $rule[$field] = count($list) === 1 ? $list[0] : $list;
290	        $field = null;
291	        $list  = [];
292	    };
293
294	    foreach (explode("\n", $chunk) as $line) {
295	        $raw    = rtrim($line);
296	        $trim   = trim($raw);
297	        if ($trim === '' || preg_match('/^#+/', $trim)) continue;
298	        $indent = strlen($raw) - strlen(ltrim($raw, ' '));
299
300	        // indent=4, starts with "- " → first field of this rule block
301	        if ($indent === 4 && str_starts_with($trim, '- ')) {
302	            $rest = ltrim(substr($trim, 2));
303	            if (preg_match('/^([a-z_]+):[ \t]*(.*)$/', $rest, $m)) {
304	                $save();
305	                $field = $m[1];
306	                $val   = trim($m[2]);
307	                if ($val !== '' && !str_starts_with($val, '#')) {
308	                    $rule[$field] = vv_authelia_unquote($val);
309	                    $field = null;
310	                }
311	            }
312	            continue;
313	        }
314
315	        // indent=6 → named field (scalar or list header)
316	        if ($indent === 6 && preg_match('/^([a-z_]+):[ \t]*(.*)$/', $trim, $m)) {
317	            $save();
318	            $field = $m[1];
319	            $val   = trim($m[2]);
320	            if ($val !== '' && !str_starts_with($val, '#')) {
321	                $rule[$field] = vv_authelia_unquote($val);
322	                $field = null;
323	            }
324	            continue;
325	        }
326
327	        // indent=8, starts with "- " → list item under current field
328	        if ($indent === 8 && str_starts_with($trim, '- ')) {
329	            $list[] = vv_authelia_parse_list_item(trim(substr($trim, 2)));
330	        }
331	    }
332	    $save();
333	    return $rule;
334	}
335
336	// Strip surrounding quotes and inline comments from a YAML scalar.
337	function vv_authelia_unquote(string $val): string {
338	    $val = trim($val);
339	    $val = preg_replace('/\s+#[^"\']*$/', '', $val); // strip trailing comment
340	    if (preg_match('/^(["\'])(.+)\1$/', $val, $m)) return $m[2];
341	    return $val;
342	}
343
344	// Parse a YAML list item: flow sequence ['group:name'] or plain/quoted scalar.
345	function vv_authelia_parse_list_item(string $val): string {
346	    $val = trim($val);
347	    // Flow sequence: ['value'] or ["value"] or [value]
348	    if (preg_match('/^\[[\'""]?([^\]\'""]+)[\'""]?\]$/', $val, $m)) return trim($m[1]);
349	    return vv_authelia_unquote($val);
350	}
351
352	function vv_authelia_write_rules(array $rules, string $defaultPolicy): array {
353	    $conf = vv_auth_conf();
354	    $file = $conf['authelia_config'];
355	    if (!file_exists($file)) return ['ok' => false, 'error' => 'Config not found: ' . $file];
356
357	    $content = file_get_contents($file);
358	    if ($content === false) return ['ok' => false, 'error' => 'Cannot read config file'];
359
360	    // Build the new access_control block
361	    $block  = "access_control:\n";
362	    $block .= "  default_policy: $defaultPolicy\n";
363	    $block .= "  rules:\n";
364
365	    // Preferred field output order
366	    $fieldOrder = ['domain', 'policy', 'subject', 'networks', 'resources'];
367
368	    foreach ($rules as $rule) {
369	        $keys  = array_merge(
370	            array_filter($fieldOrder, fn($k) => array_key_exists($k, $rule)),
371	            array_diff(array_keys($rule), $fieldOrder)
372	        );
373	        $first = true;
374	        foreach ($keys as $key) {
375	            if (!array_key_exists($key, $rule)) continue;
376	            $val    = $rule[$key];
377	            $prefix = $first ? '    - ' : '      ';
378	            $first  = false;
379
380	            // domain, subject, resources, networks → always output as list
381	            $isList = in_array($key, ['domain', 'subject', 'resources', 'networks'], true);
382	            if ($isList) {
383	                $items  = is_array($val) ? $val : [$val];
384	                $block .= $prefix . $key . ":\n";
385	                foreach ($items as $item) {
386	                    $out    = $key === 'subject'
387	                        ? "['" . $item . "']"
388	                        : vv_authelia_yaml_scalar((string) $item);
389	                    $block .= '        - ' . $out . "\n";
390	                }
391	            } else {
392	                $block .= $prefix . $key . ': ' . vv_authelia_yaml_scalar((string) $val) . "\n";
393	            }
394	        }
395	    }
396
397	    // Replace existing access_control: block (from its line to next top-level key or EOF)
398	    $pattern = '/^access_control:[ \t]*\n(?:[ \t][^\n]*\n?)*/m';
399	    $new     = preg_match($pattern, $content)
400	        ? preg_replace($pattern, $block, $content, 1)
401	        : rtrim($content) . "\n\n" . $block;
402
403	    if ($new === null) return ['ok' => false, 'error' => 'Regex replace failed'];
404
405	    $tmp = $file . '.vv.tmp';
406	    if (file_put_contents($tmp, $new) === false) return ['ok' => false, 'error' => 'Write failed'];
407	    if (!rename($tmp, $file)) { @unlink($tmp); return ['ok' => false, 'error' => 'Atomic rename failed']; }
408
409	    shell_exec('docker restart ' . escapeshellarg($conf['authelia_container']) . ' >/dev/null 2>&1 &');
410	    return ['ok' => true];
411	}
412
413	// Quote a YAML scalar value if it contains characters that require quoting.
414	function vv_authelia_yaml_scalar(string $val): string {
415	    if ($val === '' || preg_match('/[:#\[\]{},|>&*?!%@`\'"]/', $val) || preg_match('/^\s|\s$/', $val))
416	        return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $val) . '"';
417	    return $val;
418	}
419

<?php
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:81', '/'),
        '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(),
    ];
}

// ── 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);
    curl_close($ch);
    return json_decode($body ?: '{}', true) ?: [];
}

function vv_npm_req(string $method, string $path, array $data = []): array {
    $token = vv_npm_token();
    if (!$token) return ['_err' => 'NPM auth failed — check credentials in host conf'];
    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];
}

function vv_npm_list_certs(): array {
    $list = vv_npm_req('GET', '/api/nginx/certificates');
    return is_array($list) ? $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);
    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();
    $token = vv_lldap_token();
    if (!$token) return ['errors' => [['message' => 'lldap auth failed — check credentials']]];

    $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();
    $token = vv_lldap_token();
    if (!$token) return ['ok' => false, 'error' => 'lldap auth failed'];

    $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;
}


ok, so now .confs are correct. it got so messed up last night. i think we should scan all scripts and make sure all vars in .confs are there and correct not stale


Configure boot partition USB installation process


474:    HOST1_NETWORK_WATCHDOG_NPM_URL="https://gmer4lfe.com"


Bash

grep -n "NPM_URL\|NPM_USER\|NPM_PASS\|LLDAP\|AUTHELIA" /boot/config/plugins/varaverk/Configurations/host1.conf

Full host1 auth var check


grep -n "NPM\|LLDAP\|AUTHELIA" /boot/config/plugins/varaverk/Configurations/host.conf.template

Check template for auth vars


51:#   NETWORK WATCHDOG        DDNS domain, NPM URL for connectivity checks
352:    HOSTN_NETWORK_WATCHDOG_NPM_URL=""           # e.g. https://myserver.com


The entire auth stack credentials section is missing from HOST1 — and from the template. The agent only scanned `.sh` files and missed that `auth.php` uses all of them via `{$host}_*` expansion. Let me look at the end of host1.conf and the host2 auth section to get the right format.


630	# Must be on cache pool — array disks too slow for active transcode writes.
631	    HOST1_TRANSCODE_SSD="/mnt/cache/Temp_Storage/Emby/Transcodes/"
632
633	# Media servers sharing the ramdisk transcode space on HOST1.
634	# Format: "ContainerName|URL|APIKey|Type" — Type: emby | jellyfin | plex
635	# Entries with placeholder API keys are skipped automatically.
636	# ⚠ Tdarr does NOT belong here — keep Tdarr on SSD, not ramdisk.
637	    HOST1_TRANSCODE_SERVERS=(
638	        "${HOST1_EMBY_CONTAINER}|${HOST1_EMBY_URL}|${HOST1_EMBY_API_KEY}|emby"
639	        "${HOST1_JELLYFIN_CONTAINER}|${HOST1_JELLYFIN_URL}|${HOST1_JELLYFIN_API_KEY}|jellyfin"
640	    )
641
642	# ==============================================================================================
643	# ── MONITORS ──────────────────────────────────────────────────────────────────────────────────
644	# ==============================================================================================
645
646	# ━━━ Certificate Monitor ━━━
647	# Domains checked via direct openssl connection — not relying on NPM's certificate state.
648	# Checks the actual certificate served, not what NPM thinks it has.
649	# Thresholds (CERT_WARN_DAYS, CERT_CRIT_DAYS) defined in master.conf.
650	    HOST1_CERT_MONITOR_DOMAINS=(
651	        "Gmer4Lfe.com"
652	        "Gmer4Lfe.us"
653	    )
654
655	# ━━━ SMART Health ━━━
656	# Drives skipped in SMART attribute monitoring — hardware is server-specific.
657	# Thresholds read from dynamix.cfg at runtime — fallbacks in master.conf.
658	    HOST1_SMART_IGNORE_DRIVES=(
659	        "sda"                       # boot USB — SMART not meaningful on flash drives
660	    )
661
662	# ━━━ ZFS Report ━━━
663	# Pools excluded from the weekly ZFS health report — reduces noise from single-disk array pools.
664	# These are individual array disks formatted as ZFS — converting to XFS over time via unBalance.
665	# Pool health thresholds defined in master.conf.
666	    HOST1_ZFS_REPORT_IGNORE_POOLS=(
667	        "disk5"
668	        "disk6"
669	        "disk8"
670	        "disk9"
671	        "disk10"
672	    )
673
674	# ==============================================================================================
675	# ── RESOURCE MANAGER ──────────────────────────────────────────────────────────────────────────
676	# ==============================================================================================
677	# Containers to manage under pressure — see master.conf RW_CRITICAL_CONTAINERS for exclusions.
678
679	# docker pause at medium pressure (RAM < RW_RAM_MEDIUM_GB or load > medium threshold)
680	# Suspended in-place — instant to pause/unpause, no state lost, no restart delay.
681	    HOST1_RW_PAUSE_CONTAINERS=(
682	        "Huntarr"                       # arr search automation — safe to suspend
683	        "Cleanuparr"                    # download cleanup — safe to suspend
684	        "Healarr"                       # arr health checks — safe to suspend
685	        "Soularr"                       # Slskd automation — background only
686	        "ChannelTube"                   # YouTube archiver — background only
687	        "Pinchflat"                     # YouTube archiver — background only
688	    )
689

# Must be on cache pool — array disks too slow for active transcode writes.
    HOST1_TRANSCODE_SSD="/mnt/cache/Temp_Storage/Emby/Transcodes/"

# Media servers sharing the ramdisk transcode space on HOST1.
# Format: "ContainerName|URL|APIKey|Type" — Type: emby | jellyfin | plex
# Entries with placeholder API keys are skipped automatically.
# ⚠ Tdarr does NOT belong here — keep Tdarr on SSD, not ramdisk.
    HOST1_TRANSCODE_SERVERS=(
        "${HOST1_EMBY_CONTAINER}|${HOST1_EMBY_URL}|${HOST1_EMBY_API_KEY}|emby"
        "${HOST1_JELLYFIN_CONTAINER}|${HOST1_JELLYFIN_URL}|${HOST1_JELLYFIN_API_KEY}|jellyfin"
    )

# ==============================================================================================
# ── MONITORS ──────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================

# ━━━ Certificate Monitor ━━━
# Domains checked via direct openssl connection — not relying on NPM's certificate state.
# Checks the actual certificate served, not what NPM thinks it has.
# Thresholds (CERT_WARN_DAYS, CERT_CRIT_DAYS) defined in master.conf.
    HOST1_CERT_MONITOR_DOMAINS=(
        "Gmer4Lfe.com"
        "Gmer4Lfe.us"
    )

# ━━━ SMART Health ━━━
# Drives skipped in SMART attribute monitoring — hardware is server-specific.
# Thresholds read from dynamix.cfg at runtime — fallbacks in master.conf.
    HOST1_SMART_IGNORE_DRIVES=(
        "sda"                       # boot USB — SMART not meaningful on flash drives
    )

# ━━━ ZFS Report ━━━
# Pools excluded from the weekly ZFS health report — reduces noise from single-disk array pools.
# These are individual array disks formatted as ZFS — converting to XFS over time via unBalance.
# Pool health thresholds defined in master.conf.
    HOST1_ZFS_REPORT_IGNORE_POOLS=(
        "disk5"
        "disk6"
        "disk8"
        "disk9"
        "disk10"
    )

# ==============================================================================================
# ── RESOURCE MANAGER ──────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Containers to manage under pressure — see master.conf RW_CRITICAL_CONTAINERS for exclusions.

# docker pause at medium pressure (RAM < RW_RAM_MEDIUM_GB or load > medium threshold)
# Suspended in-place — instant to pause/unpause, no state lost, no restart delay.
    HOST1_RW_PAUSE_CONTAINERS=(
        "Huntarr"                       # arr search automation — safe to suspend
        "Cleanuparr"                    # download cleanup — safe to suspend
        "Healarr"                       # arr health checks — safe to suspend
        "Soularr"                       # Slskd automation — background only
        "ChannelTube"                   # YouTube archiver — background only
        "Pinchflat"                     # YouTube archiver — background only
    )


638	    HOST2_RW_STOP_CONTAINERS=(
639	        # fill in when HOST2 is back online
640	    )
641
642	# ==============================================================================================
643	# ── AUTH STACK ────────────────────────────────────────────────────────────────────────────────
644	# ==============================================================================================
645	# Credentials for the Varaverk Auth Stack page (NPM, lldap, Authelia).
646	# Fill in when HOST2 is back online.
647
648	# ━━━ NginxProxyManager ━━━
649	    HOST2_NPM_URL="http://localhost:81"
650	    HOST2_NPM_USER=""                           # NPM admin email
651	    HOST2_NPM_PASS=""                           # NPM admin password
652
653	# ━━━ lldap ━━━
654	    HOST2_LLDAP_URL="http://localhost:17170"
655	    HOST2_LLDAP_USER="admin"                    # lldap admin username
656	    HOST2_LLDAP_PASS=""                         # lldap admin password
657
658	# ━━━ Authelia ━━━
659	    HOST2_AUTHELIA_CONFIG="/mnt/user/appdata-Fallback/Critical-Data/Authelia/configuration.yml"
660	    HOST2_AUTHELIA_CONTAINER="Authelia"
661
662	# ==============================================================================================
663	# ──────────────────────── End Of HOST2 Variables ──────────────────────────────────────────────
664	# ==============================================================================================

    HOST2_RW_STOP_CONTAINERS=(
        # fill in when HOST2 is back online
    )

# ==============================================================================================
# ── AUTH STACK ────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Credentials for the Varaverk Auth Stack page (NPM, lldap, Authelia).
# Fill in when HOST2 is back online.

# ━━━ NginxProxyManager ━━━
    HOST2_NPM_URL="http://localhost:81"
    HOST2_NPM_USER=""                           # NPM admin email
    HOST2_NPM_PASS=""                           # NPM admin password

# ━━━ lldap ━━━
    HOST2_LLDAP_URL="http://localhost:17170"
    HOST2_LLDAP_USER="admin"                    # lldap admin username
    HOST2_LLDAP_PASS=""                         # lldap admin password

# ━━━ Authelia ━━━
    HOST2_AUTHELIA_CONFIG="/mnt/user/appdata-Fallback/Critical-Data/Authelia/configuration.yml"
    HOST2_AUTHELIA_CONTAINER="Authelia"

# ==============================================================================================
# ──────────────────────── End Of HOST2 Variables ──────────────────────────────────────────────
# ==============================================================================================


689
690	# docker stop at hard pressure (RAM < RW_RAM_HARD_GB)
691	# Full stop — these are optional/heavy services that free significant RAM when stopped.
692	# resource_watchdog.sh restarts them when pressure fully clears (RAM >= RW_RAM_RECOVER_GB).
693	    HOST1_RW_STOP_CONTAINERS=(
694	        "LocalAI"                       # GPU/CPU heavy — largest RAM consumer when idle
695	        "7DaysToDie"                    # game server — optional
696	        "V-Rising"                      # game server — optional
697	        "Code-Server"                   # IDE — not needed during pressure events
698	    )
699
700	# ==============================================================================================
701	# ── SYSTEM WATCHDOG ───────────────────────────────────────────────────────────────────────────
702	# ==============================================================================================
703	# Per-host check toggles and NIC config for system_watchdog.sh.
704	# Aliased by detect_hosts() — script uses unprefixed SYS_WATCHDOG_* names.
705	# HOST1: TR1950X 128GB — full media server, active transcoding, ZFS cache pools.
706	#
707	# Three-tier response — all critical checks enabled by default on HOST1:
708	#   Tier 1 (bypass strikes, reboot now):  docker daemon, rootfs full, kernel oops, FD, /boot
709	#   Tier 2 (bypass strikes with OOM):     RAM critical + OOM kills in cycle
710	#   Tier 3 (standard strike system):      everything else
711	#
712	# RAM tiers, OOM limits, and reboot loop settings in master.conf System Watchdog section.
713
714	# ━━━ Primary NIC ━━━
715	# Network interface for NIC state check — verify with: ip link show | grep "^[0-9]"
716	# Common values: eth0, bond0, br0, eno1
717	    HOST1_SYS_WATCHDOG_NIC="eth0"
718
719	# ━━━ Tier 1 — Critical Checks ━━━
720	# These bypass the strike system — a single hit triggers immediate reboot.
721	# Disabling any of these is not recommended — they protect against acute system failure.
722
723	# Docker daemon unresponsive → try restart, reboot if restart fails.
724	# Without a working daemon docker_watchdog.sh is blind and containers cannot be managed.
725	    HOST1_SYS_WATCHDOG_CHECK_DOCKER_DAEMON=true
726
727	# rootfs at critical threshold (ROOTFS_CRITICAL_PCT=99) → reboot immediately.
728	# At 99% rootfs writes fail silently — logs stop, Docker errors out, SSH may stop working.

# docker stop at hard pressure (RAM < RW_RAM_HARD_GB)
# Full stop — these are optional/heavy services that free significant RAM when stopped.
# resource_watchdog.sh restarts them when pressure fully clears (RAM >= RW_RAM_RECOVER_GB).
    HOST1_RW_STOP_CONTAINERS=(
        "LocalAI"                       # GPU/CPU heavy — largest RAM consumer when idle
        "7DaysToDie"                    # game server — optional
        "V-Rising"                      # game server — optional
        "Code-Server"                   # IDE — not needed during pressure events
    )

# ==============================================================================================
# ── SYSTEM WATCHDOG ───────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Per-host check toggles and NIC config for system_watchdog.sh.
# Aliased by detect_hosts() — script uses unprefixed SYS_WATCHDOG_* names.
# HOST1: TR1950X 128GB — full media server, active transcoding, ZFS cache pools.
#
# Three-tier response — all critical checks enabled by default on HOST1:
#   Tier 1 (bypass strikes, reboot now):  docker daemon, rootfs full, kernel oops, FD, /boot
#   Tier 2 (bypass strikes with OOM):     RAM critical + OOM kills in cycle
#   Tier 3 (standard strike system):      everything else
#
# RAM tiers, OOM limits, and reboot loop settings in master.conf System Watchdog section.

# ━━━ Primary NIC ━━━
# Network interface for NIC state check — verify with: ip link show | grep "^[0-9]"
# Common values: eth0, bond0, br0, eno1
    HOST1_SYS_WATCHDOG_NIC="eth0"

# ━━━ Tier 1 — Critical Checks ━━━
# These bypass the strike system — a single hit triggers immediate reboot.
# Disabling any of these is not recommended — they protect against acute system failure.

# Docker daemon unresponsive → try restart, reboot if restart fails.
# Without a working daemon docker_watchdog.sh is blind and containers cannot be managed.
    HOST1_SYS_WATCHDOG_CHECK_DOCKER_DAEMON=true

# rootfs at critical threshold (ROOTFS_CRITICAL_PCT=99) → reboot immediately.
# At 99% rootfs writes fail silently — logs stop, Docker errors out, SSH may stop working.


Find end of host1.conf


# Zombie process count above SYS_WATCHDOG_ZOMBIE_LIMIT (50).
# Large zombie counts indicate serious process management failure — something is stuck.
    HOST1_SYS_WATCHDOG_CHECK_ZOMBIES=true

# Check docker_watchdog.sh persistent skip list — required containers on skip list.
# Cross-watchdog coordination: if docker_watchdog gave up, system_watchdog escalates.
# ENABLED — HOST1 fully built and operational, skip list is meaningful.
    HOST1_SYS_WATCHDOG_CHECK_CONTAINERS=true

# /tmp filesystem usage above SYS_WATCHDOG_TMP_PCT with auto-clear attempt.
# Script tries to clear aged /tmp files first — only strikes if clear fails.
# Lock files, rsync temp files, and Docker ops use /tmp — 100% means lock failures.
    HOST1_SYS_WATCHDOG_CHECK_TMP=true

# Array disk error count delta in /proc/mdstat — accumulating errors = disk failing now.
# Triggers on SYS_WATCHDOG_MDSTAT_ERROR_LIMIT new errors in one cycle.
    HOST1_SYS_WATCHDOG_CHECK_MDSTAT=true

# Primary NIC operstate — detects NIC going down (physical or driver failure).
# Uses HOST1_SYS_WATCHDOG_NIC above. Strike system — brief flaps don't trigger reboot.
    HOST1_SYS_WATCHDOG_CHECK_NETWORK=true

# sshd running check — attempts restart before escalating.
# sshd down = no remote access. Script tries rc.sshd start, notifies, strikes on failure.
    HOST1_SYS_WATCHDOG_CHECK_SSHD=true

# Runaway process detection — single process above SYS_WATCHDOG_RUNAWAY_CPU_PCT sustained.
# DISABLED — Tdarr encoding and Emby transcoding legitimately peg CPU for extended periods.
# Enable only if HOST1 has no CPU-intensive workloads.
    HOST1_SYS_WATCHDOG_CHECK_RUNAWAY=false


HOST1 is entirely missing the auth stack section — and it's the auth source of truth. The `auth.php` fallback for NPM URL is port 81, which would silently hit the wrong port for HOST1 (should be 7818). I also need to add this to the template.


grep -n "AUTHELIA_CONFIG\|appdata.*Authelia\|Authelia.*appdata" /boot/config/plugins/varaverk/Configurations/host1.conf /boot/config/plugins/varaverk/Watchdogs/System/system_watchdog.sh /boot/config/plugins/varaverk/Fallback/fallback.sh 2>/dev/null | head -20

Check if Authelia config path is referenced elsewhere


(Bash completed with no output)


Find end of template


    HOSTN_SYS_WATCHDOG_CHECK_TMP=true
    HOSTN_SYS_WATCHDOG_CHECK_MDSTAT=true
    HOSTN_SYS_WATCHDOG_CHECK_NETWORK=true
    HOSTN_SYS_WATCHDOG_CHECK_SSHD=true
    HOSTN_SYS_WATCHDOG_CHECK_RUNAWAY=false


Now I'll add the auth stack section to all three — host1.conf, host2.conf (fix the comment since it's valid config not placeholder), and the template.


Edit

# Runaway process detection — single process above SYS_WATCHDOG_RUNAWAY_CPU_PCT sustained.
# DISABLED — Tdarr encoding and Emby transcoding legitimately peg CPU for extended periods.
# Enable only if HOST1 has no CPU-intensive workloads.
    HOST1_SYS_WATCHDOG_CHECK_RUNAWAY=false

# Runaway process detection — single process above SYS_WATCHDOG_RUNAWAY_CPU_PCT sustained.
# DISABLED — Tdarr encoding and Emby transcoding legitimately peg CPU for extended periods.
# Enable only if HOST1 has no CPU-intensive workloads.
    HOST1_SYS_WATCHDOG_CHECK_RUNAWAY=false

# ==============================================================================================
# ── AUTH STACK ────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Credentials for the Varaverk Auth Stack page (NPM, lldap, Authelia).
# HOST1 is the auth source of truth — these are the live production credentials.

# ━━━ NginxProxyManager ━━━
# Admin API runs on 7818 (not 81 — 81 is the partnership WebUI port).
    HOST1_NPM_URL="http://localhost:7818"
    HOST1_NPM_USER=""                           # NPM admin email
    HOST1_NPM_PASS=""                           # NPM admin password

# ━━━ lldap ━━━
    HOST1_LLDAP_URL="http://localhost:17170"
    HOST1_LLDAP_USER="admin"                    # lldap admin username
    HOST1_LLDAP_PASS=""                         # lldap admin password

# ━━━ Authelia ━━━
    HOST1_AUTHELIA_CONFIG="/mnt/user/appdata/Authelia/configuration.yml"
    HOST1_AUTHELIA_CONTAINER="Authelia"

# ==============================================================================================
# ──────────────────────── End Of HOST1 Variables ──────────────────────────────────────────────
# ==============================================================================================


The file /boot/config/plugins/varaverk/Configurations/host1.conf has been updated successfully. (file state is current in your context — no need to Read it back)

# Runaway process detection — single process above SYS_WATCHDOG_RUNAWAY_CPU_PCT sustained.
# DISABLED — Tdarr encoding and Emby transcoding legitimately peg CPU for extended periods.
# Enable only if HOST1 has no CPU-intensive workloads.
    HOST1_SYS_WATCHDOG_CHECK_RUNAWAY=false

# ==============================================================================================
# ── AUTH STACK ────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Credentials for the Varaverk Auth Stack page (NPM, lldap, Authelia).
# HOST1 is the auth source of truth — these are the live production credentials.

# ━━━ NginxProxyManager ━━━
# Admin API runs on 7818 (not 81 — 81 is the partnership WebUI port).
    HOST1_NPM_URL="http://localhost:7818"
    HOST1_NPM_USER=""                           # NPM admin email
    HOST1_NPM_PASS=""                           # NPM admin password

# ━━━ lldap ━━━
    HOST1_LLDAP_URL="http://localhost:17170"
    HOST1_LLDAP_USER="admin"                    # lldap admin username
    HOST1_LLDAP_PASS=""                         # lldap admin password

# ━━━ Authelia ━━━
    HOST1_AUTHELIA_CONFIG="/mnt/user/appdata/Authelia/configuration.yml"
    HOST1_AUTHELIA_CONTAINER="Authelia"

# ==============================================================================================
# ──────────────────────── End Of HOST1 Variables ──────────────────────────────────────────────
# ==============================================================================================

+# ── AUTH STACK ────────────────────────────────────────────────────────────────────────────────

+# Credentials for the Varaverk Auth Stack page (NPM, lldap, Authelia).

+# HOST1 is the auth source of truth — these are the live production credentials.

+# Admin API runs on 7818 (not 81 — 81 is the partnership WebUI port).

+    HOST1_NPM_URL="http://localhost:7818"

+    HOST1_NPM_USER=""                           # NPM admin email

+    HOST1_NPM_PASS=""                           # NPM admin password

+    HOST1_LLDAP_URL="http://localhost:17170"

+    HOST1_LLDAP_USER="admin"                    # lldap admin username

+    HOST1_LLDAP_PASS=""                         # lldap admin password

+    HOST1_AUTHELIA_CONFIG="/mnt/user/appdata/Authelia/configuration.yml"

+    HOST1_AUTHELIA_CONTAINER="Authelia"

+# ──────────────────────── End Of HOST1 Variables ──────────────────────────────────────────────


Now clean up host2.conf — the comment "fill in when HOST2 is back online" is misleading since the section is valid config, just with empty credentials.


# ==============================================================================================
# ── AUTH STACK ────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Credentials for the Varaverk Auth Stack page (NPM, lldap, Authelia).
# Fill in when HOST2 is back online.

# ━━━ NginxProxyManager ━━━
    HOST2_NPM_URL="http://localhost:81"

# ==============================================================================================
# ── AUTH STACK ────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Credentials for the Varaverk Auth Stack page (NPM, lldap, Authelia).
# Credentials empty — fill in when HOST2 is back online.

# ━━━ NginxProxyManager ━━━
# Admin API runs on 7818 (not 81 — 81 is the partnership WebUI port).
    HOST2_NPM_URL="http://localhost:7818"


The file /boot/config/plugins/varaverk/Configurations/host2.conf has been updated successfully. (file state is current in your context — no need to Read it back)

# ==============================================================================================
# ── AUTH STACK ────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Credentials for the Varaverk Auth Stack page (NPM, lldap, Authelia).
# Fill in when HOST2 is back online.

# ━━━ NginxProxyManager ━━━
    HOST2_NPM_URL="http://localhost:81"

# ==============================================================================================
# ── AUTH STACK ────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Credentials for the Varaverk Auth Stack page (NPM, lldap, Authelia).
# Credentials empty — fill in when HOST2 is back online.

# ━━━ NginxProxyManager ━━━
# Admin API runs on 7818 (not 81 — 81 is the partnership WebUI port).
    HOST2_NPM_URL="http://localhost:7818"

+# Credentials empty — fill in when HOST2 is back online.

-    HOST2_NPM_URL="http://localhost:81"

+    HOST2_NPM_URL="http://localhost:7818"

HOST2_NPM_USER=""                           # NPM admin email

HOST2_NPM_PASS=""                           # NPM admin password


Now add the auth stack section to the template.


340	    declare -A HOSTN_WATCHDOG_DEPENDENCIES=(
341	        # ["Authelia"]="Mariadb Redis-Authelia"
342	    )
343
344	# Per-container appdata growth suppress ceilings in MB.
345	    declare -A HOSTN_WATCHDOG_APPDATA_SIZES=(
346	        # ["Tdarr"]="25600"
347	    )
348
349	# ━━━ Network Watchdog ━━━
350	    HOSTN_NETWORK_WATCHDOG_DDNS_DOMAIN=""       # e.g. myserver.com
351	    HOSTN_NETWORK_WATCHDOG_DDNS_CONTAINER=""    # e.g. MyServer.com
352	    HOSTN_NETWORK_WATCHDOG_NPM_URL=""           # e.g. https://myserver.com
353
354	# ━━━ Docker Network Connect ━━━
355	    HOSTN_NETWORK_CONNECT_CONTAINERS=(
356	        # "memcached"
357	    )
358
359	    HOSTN_NETWORK_CONNECT_NETWORKS=(
360	        # "high-availability"
361	    )
362
363	# ==============================================================================================
364	# ── MEDIA ─────────────────────────────────────────────────────────────────────────────────────
365	# ==============================================================================================
366
367	# ━━━ Media Permissions ━━━
368	    HOSTN_MEDIA_PERMISSION_SHARES=(
369	        # /mnt/user/Movies

    declare -A HOSTN_WATCHDOG_DEPENDENCIES=(
        # ["Authelia"]="Mariadb Redis-Authelia"
    )

# Per-container appdata growth suppress ceilings in MB.
    declare -A HOSTN_WATCHDOG_APPDATA_SIZES=(
        # ["Tdarr"]="25600"
    )

# ━━━ Network Watchdog ━━━
    HOSTN_NETWORK_WATCHDOG_DDNS_DOMAIN=""       # e.g. myserver.com
    HOSTN_NETWORK_WATCHDOG_DDNS_CONTAINER=""    # e.g. MyServer.com
    HOSTN_NETWORK_WATCHDOG_NPM_URL=""           # e.g. https://myserver.com

# ━━━ Docker Network Connect ━━━
    HOSTN_NETWORK_CONNECT_CONTAINERS=(
        # "memcached"
    )

    HOSTN_NETWORK_CONNECT_NETWORKS=(
        # "high-availability"
    )

# ==============================================================================================
# ── MEDIA ─────────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================

# ━━━ Media Permissions ━━━
    HOSTN_MEDIA_PERMISSION_SHARES=(
        # /mnt/user/Movies


Bash

grep -n "SYS_WATCHDOG_CHECK_RUNAWAY\|End Of HOST\|^# ====" /boot/config/plugins/varaverk/Configurations/host.conf.template | tail -10

Find end section markers in template


388:# ==============================================================================================
437:# ==============================================================================================
439:# ==============================================================================================
450:# ==============================================================================================
452:# ==============================================================================================
469:# ==============================================================================================
471:# ==============================================================================================
482:# ==============================================================================================
484:# ==============================================================================================
505:    HOSTN_SYS_WATCHDOG_CHECK_RUNAWAY=false


Edit

    HOSTN_SYS_WATCHDOG_CHECK_RUNAWAY=false

# ==============================================================================================
# ── AUTH STACK ────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Credentials for the Varaverk Auth Stack page (NPM, lldap, Authelia).

# ━━━ NginxProxyManager ━━━
# Admin API runs on 7818 (not 81 — 81 is the partnership WebUI port).
    HOSTN_NPM_URL="http://localhost:7818"
    HOSTN_NPM_USER=""                           # NPM admin email
    HOSTN_NPM_PASS=""                           # NPM admin password

# ━━━ lldap ━━━
    HOSTN_LLDAP_URL="http://localhost:17170"
    HOSTN_LLDAP_USER="admin"                    # lldap admin username
    HOSTN_LLDAP_PASS=""                         # lldap admin password

# ━━━ Authelia ━━━
    HOSTN_AUTHELIA_CONFIG="/mnt/user/appdata/Authelia/configuration.yml"
    HOSTN_AUTHELIA_CONTAINER="Authelia"

# ==============================================================================================
# ──────────────────────── End Of HOSTn Variables ──────────────────────────────────────────────
# ==============================================================================================


The file /boot/config/plugins/varaverk/Configurations/host.conf.template has been updated successfully. (file state is current in your context — no need to Read it back)

    HOSTN_SYS_WATCHDOG_CHECK_RUNAWAY=false

# ==============================================================================================
# ── AUTH STACK ────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Credentials for the Varaverk Auth Stack page (NPM, lldap, Authelia).

# ━━━ NginxProxyManager ━━━
# Admin API runs on 7818 (not 81 — 81 is the partnership WebUI port).
    HOSTN_NPM_URL="http://localhost:7818"
    HOSTN_NPM_USER=""                           # NPM admin email
    HOSTN_NPM_PASS=""                           # NPM admin password

# ━━━ lldap ━━━
    HOSTN_LLDAP_URL="http://localhost:17170"
    HOSTN_LLDAP_USER="admin"                    # lldap admin username
    HOSTN_LLDAP_PASS=""                         # lldap admin password

# ━━━ Authelia ━━━
    HOSTN_AUTHELIA_CONFIG="/mnt/user/appdata/Authelia/configuration.yml"
    HOSTN_AUTHELIA_CONTAINER="Authelia"

# ==============================================================================================
# ──────────────────────── End Of HOSTn Variables ──────────────────────────────────────────────
# ==============================================================================================

+# ── AUTH STACK ────────────────────────────────────────────────────────────────────────────────

+# Credentials for the Varaverk Auth Stack page (NPM, lldap, Authelia).

+# Admin API runs on 7818 (not 81 — 81 is the partnership WebUI port).

+    HOSTN_NPM_URL="http://localhost:7818"

+    HOSTN_NPM_USER=""                           # NPM admin email

+    HOSTN_NPM_PASS=""                           # NPM admin password

+    HOSTN_LLDAP_URL="http://localhost:17170"

+    HOSTN_LLDAP_USER="admin"                    # lldap admin username

+    HOSTN_LLDAP_PASS=""                         # lldap admin password

+    HOSTN_AUTHELIA_CONFIG="/mnt/user/appdata/Authelia/configuration.yml"

+    HOSTN_AUTHELIA_CONTAINER="Authelia"

+# ──────────────────────── End Of HOSTn Variables ──────────────────────────────────────────────


Three files updated. Summary of what was actually wrong:

- **HOST1 was missing the entire `── AUTH STACK` section** — `auth.php` uses `{$host}_NPM_URL`, `{$host}_NPM_USER`, etc. via dynamic expansion, so HOST1 was falling through to the PHP defaults (port 81 instead of 7818, empty credentials for lldap/Authelia). The Auth tab would have silently failed on HOST1.
- **HOST2's NPM_URL** was set to port 81 (wrong) — corrected to 7818.
- **Template** was also missing the whole section, so any new host set up via the wizard would start without auth stack vars.

The agent's "no issues" verdict was wrong because it only grepped `.sh` files and missed `auth.php`. You'll still need to fill in `HOST1_NPM_USER`, `HOST1_NPM_PASS`, `HOST1_LLDAP_PASS` with the real credentials — and confirm the Authelia config path is right for your setup.


so a missunderstanding last night from what i asked to do caused some issues that have gitea push new templates to replace my .confs. all should vbe good now. but lets double check that i fixed the issue, what happened was i have .conf template under deployment and in configuations. it was always in config, but when we stated to work on initial startup, for some reason it got put there and was the current. i asked to have them only in config and make sure all scripts are pointing to the correct path, wich should just be the loader and stuff. now i want to make sure initial run, from the start of the install works as expected


Let me check the current state of both locations and trace every reference to make sure it's all pointing to `Configurations/` only.


Bash

find /boot/config/plugins/varaverk -name "*.template" -o -name "*.conf.template" 2>/dev/null | sort

Find all template files in the repo


find /boot/config/plugins/varaverk -type d -name "deployment" -o -type d -name "Deployment" 2>/dev/null

Find any deployment directories


/boot/config/plugins/varaverk/Configurations/host.conf.template
/boot/config/plugins/varaverk/Configurations/master.conf.template


List deployment directory contents


conf_upgrade.sh*
deploy.sh*


grep -rn "Deployment\|deployment" /boot/config/plugins/varaverk/ --include="*.sh" --include="*.php" --include="*.plg" --include="*.cfg" 2>/dev/null | grep -v ".git" | grep -v "Binary"

Find all references to Deployment in scripts and configs


/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:1369:    # Emby admin provisioning — runs after container deployment (deploy step not yet built)
/boot/config/plugins/varaverk/Plugin/unraid/user_script_plug-in.sh:859:# Deployment mechanism for the ecosystem. Push from VS Code → Gitea → run on both servers.
/boot/config/plugins/varaverk/Plugin/unraid/Partnership/containers.sh:10:#   - Docker container deployment from Unraid CA XML templates
/boot/config/plugins/varaverk/Plugin/unraid/Partnership/containers.sh:166:        warn "  $name deployment failed — check $MIRROR manually"
/boot/config/plugins/varaverk/Plugin/unraid/api/rsync_win_arrays.php:13:                'Deployment', 'State_Files', 'data', 'Old_Arch_Still_Works', 'Kernel'];
/boot/config/plugins/varaverk/Plugin/unraid/include/unraid_api.php:242:// to hostn.conf, then run Deployment/deploy.sh. No schema work needed.
/boot/config/plugins/varaverk/Deployment/deploy.sh:6:# Direct call (interactive):  ! bash Deployment/deploy.sh
/boot/config/plugins/varaverk/Deployment/deploy.sh:18:UPGRADE="$PROD_ROOT/Deployment/conf_upgrade.sh"


so a missunderstanding last night from what i asked to do caused some issues that have gitea push new templates to replace my .confs. all should vbe good now. but lets double check that i fixed the is…


grep -rn "conf\.template\|\.conf\.template\|master\.conf\.template\|host\.conf\.template" /boot/config/plugins/varaverk/ --include="*.sh" --include="*.php" --include="*.plg" 2>/dev/null | grep -v ".git"

Find all references to conf templates


1	#!/bin/bash
2	# ==============================================================================================
3	# deploy.sh — Sync dev working tree → prod runtime for Plugin PHP and conf files.
4	# ==============================================================================================
5	#
6	# Direct call (interactive):  ! bash Deployment/deploy.sh
7	#   Deploys both Plugin and Configurations with full output.
8	#
9	# Hook call (Claude Code PostToolUse):
10	#   Reads the tool JSON from stdin, deploys only the component that was actually changed.
11	#   Plugin/  → rsync --delete into prod Plugin/unraid/
12	#   Configurations/ → conf_upgrade.sh (adds new keys, preserves prod values)
13	#
14	# ==============================================================================================
15
16	DEV_ROOT="/mnt/cloud-storage/Important Shit/Git/Development/Varaverk"
17	PROD_ROOT="/boot/config/plugins/varaverk"
18	UPGRADE="$PROD_ROOT/Deployment/conf_upgrade.sh"
19	LOG="/tmp/vv_deploy.log"
20
21	# ── Deploy functions ──────────────────────────────────────────────────────────
22
23	deploy_plugin() {
24	    rsync -a --delete --exclude='.git' \
25	        "$DEV_ROOT/Plugin/unraid/" \
26	        "$PROD_ROOT/Plugin/unraid/"
27	}
28
29	deploy_conf() {
30	    for conf in master.conf host1.conf host2.conf; do
31	        tmpl="$DEV_ROOT/Configurations/$conf"
32	        target="$PROD_ROOT/Configurations/$conf"
33	        [[ -f "$tmpl" && -f "$target" ]] && \
34	            "$UPGRADE" --template "$tmpl" --target "$target"
35	    done
36	}
37
38	# ── Entry point ───────────────────────────────────────────────────────────────
39
40	# Read stdin (non-blocking — empty when called directly or via '!')
41	STDIN=$(cat 2>/dev/null || true)
42
43	# Extract file_path from hook JSON if present
44	FILE=$(python3 -c "
45	import json, sys
46	try:
47	    d = json.loads(sys.stdin.read())
48	    print(d.get('tool_input', {}).get('file_path', ''))
49	except:
50	    print('')
51	" <<< "$STDIN" 2>/dev/null || echo "")
52
53	if [[ -z "$FILE" ]]; then
54	    # Direct call (interactive or via !) — deploy everything with output
55	    echo "=== deploy: dev → prod ==="
56	    echo ""
57	    echo "── Plugin ──"
58	    deploy_plugin && echo "  rsync done"
59	    echo ""
60	    echo "── Configurations ──"
61	    deploy_conf
62	    echo ""
63	    echo "=== done ==="
64	    exit 0
65	fi
66
67	# Hook call — deploy only the affected component, log silently
68	{
69	    echo "[$(date '+%H:%M:%S')] hook: $FILE"
70
71	    if [[ "$FILE" == "$DEV_ROOT/Plugin/"* ]]; then
72	        deploy_plugin && echo "  plugin synced"
73	    fi
74
75	    if [[ "$FILE" == "$DEV_ROOT/Configurations/"* ]]; then
76	        deploy_conf && echo "  conf upgraded"
77	    fi
78	} >> "$LOG" 2>&1
79
80	exit 0
81

#!/bin/bash
# ==============================================================================================
# deploy.sh — Sync dev working tree → prod runtime for Plugin PHP and conf files.
# ==============================================================================================
#
# Direct call (interactive):  ! bash Deployment/deploy.sh
#   Deploys both Plugin and Configurations with full output.
#
# Hook call (Claude Code PostToolUse):
#   Reads the tool JSON from stdin, deploys only the component that was actually changed.
#   Plugin/  → rsync --delete into prod Plugin/unraid/
#   Configurations/ → conf_upgrade.sh (adds new keys, preserves prod values)
#
# ==============================================================================================

DEV_ROOT="/mnt/cloud-storage/Important Shit/Git/Development/Varaverk"
PROD_ROOT="/boot/config/plugins/varaverk"
UPGRADE="$PROD_ROOT/Deployment/conf_upgrade.sh"
LOG="/tmp/vv_deploy.log"

# ── Deploy functions ──────────────────────────────────────────────────────────

deploy_plugin() {
    rsync -a --delete --exclude='.git' \
        "$DEV_ROOT/Plugin/unraid/" \
        "$PROD_ROOT/Plugin/unraid/"
}

deploy_conf() {
    for conf in master.conf host1.conf host2.conf; do
        tmpl="$DEV_ROOT/Configurations/$conf"
        target="$PROD_ROOT/Configurations/$conf"
        [[ -f "$tmpl" && -f "$target" ]] && \
            "$UPGRADE" --template "$tmpl" --target "$target"
    done
}

# ── Entry point ───────────────────────────────────────────────────────────────

# Read stdin (non-blocking — empty when called directly or via '!')
STDIN=$(cat 2>/dev/null || true)

# Extract file_path from hook JSON if present
FILE=$(python3 -c "
import json, sys
try:
    d = json.loads(sys.stdin.read())
    print(d.get('tool_input', {}).get('file_path', ''))
except:
    print('')
" <<< "$STDIN" 2>/dev/null || echo "")

if [[ -z "$FILE" ]]; then
    # Direct call (interactive or via !) — deploy everything with output
    echo "=== deploy: dev → prod ==="
    echo ""
    echo "── Plugin ──"
    deploy_plugin && echo "  rsync done"
    echo ""
    echo "── Configurations ──"
    deploy_conf
    echo ""
    echo "=== done ==="
    exit 0
fi

# Hook call — deploy only the affected component, log silently
{
    echo "[$(date '+%H:%M:%S')] hook: $FILE"

    if [[ "$FILE" == "$DEV_ROOT/Plugin/"* ]]; then
        deploy_plugin && echo "  plugin synced"
    fi

    if [[ "$FILE" == "$DEV_ROOT/Configurations/"* ]]; then
        deploy_conf && echo "  conf upgraded"
    fi
} >> "$LOG" 2>&1

exit 0


/boot/config/plugins/varaverk/Plugin/varaverk.plg:198:if [[ ! -f "$CONF_DIR/master.conf" && -f "$CONF_DIR/master.conf.template" ]]; then
/boot/config/plugins/varaverk/Plugin/varaverk.plg:199:    cp "$CONF_DIR/master.conf.template" "$CONF_DIR/master.conf"
/boot/config/plugins/varaverk/Plugin/unraid/api/setup.php:140:        $template = @file_get_contents(CONF_DIR . '/host.conf.template') ?: '';
/boot/config/plugins/varaverk/Plugin/unraid/api/setup.php:232:    $template = @file_get_contents(CONF_DIR . '/host.conf.template') ?: '';
/boot/config/plugins/varaverk/Deployment/conf_upgrade.sh:56:# conf_upgrade.sh --template Configurations/master.conf.template --target Configurations/master.conf --dry-run
/boot/config/plugins/varaverk/Deployment/conf_upgrade.sh:59:# conf_upgrade.sh --template Configurations/master.conf.template --target Configurations/master.conf --backup
/boot/config/plugins/varaverk/Deployment/conf_upgrade.sh:62:# conf_upgrade.sh --template Configurations/master.conf.template --target Configurations/master.conf


Templates are correctly only in `Configurations/` and all references point there. The `Deployment/` dir is just `deploy.sh` + `conf_upgrade.sh` — the dev→prod sync tools, unrelated to the install path. Let me now trace the full initial install flow end-to-end.


1	#!/bin/bash
2	# ==============================================================================================
3	# ================================= CONFIGURATION LOADER =======================================
4	# ==============================================================================================
5	# Single entry point for all configuration sourcing across the ecosystem.
6	# Every script sources this file instead of sourcing master.conf files directly.
7	#
8	# ── HOW IT WORKS ──────────────────────────────────────────────────────────────────────────────
9	# 1. Detects OS platform → PLATFORM=unraid|truenas|unknown; exports SCRIPTS_DIR
10	# 2. Sources master.conf (shared config — hostnames, thresholds, toggles, profiles, job lists)
11	# 3. Auto-discovers and sources all host*.conf files in the same directory
12	#    Each host conf extends the shared profile arrays and adds host-specific credentials
13	# 4. Sources common.sh (shared functions — detect_hosts, logging, notifications etc.)
14	# 5. Sources Plugin/<platform>/adapter.sh (platform_*() functions for OS-specific ops)
15	#
16	# ── WHY THIS EXISTS ───────────────────────────────────────────────────────────────────────────
17	# Without this loader every script had to explicitly source each conf file:
18	#   source master.conf
19	#   source host1.conf
20	#   source host2.conf
21	#   source common.sh
22	#
23	# Adding a new server meant updating every script.
24	# With this loader — add host3.conf to the git repo and every server
25	# auto-discovers it on next git pull. Zero script changes required. Ever.
26	#
27	# ── ADDING A NEW SERVER ───────────────────────────────────────────────────────────────────────
28	# 1. Create Configurations/host3.conf following the same structure as HOST1/HOST2
29	# 2. Commit and push to git repo
30	# 3. All servers pull it automatically — no other changes needed
31	#
32	# ── USAGE IN SCRIPTS ──────────────────────────────────────────────────────────────────────────
33	# Replace the three source lines at the top of every script with:
34	#
35	#   SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
36	#   source "$SCRIPT_DIR/../load_config.sh"
37	#
38	# Scripts in subdirectories (Rsync/, Docker_Essentials/ etc.) use ../ to reach root.
39	# Scripts in root directory use ./ instead:
40	#
41	#   SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
42	#   source "$SCRIPT_DIR/load_config.sh"
43	#
44	# ── SPARSE CHECKOUT NOTE ──────────────────────────────────────────────────────────────────────
45	# Sparse checkout controls which host*.conf files each server receives.
46	# HOST1 only pulls Configurations/host1.conf — never HOST2's credentials.
47	# HOST2 only pulls Configurations/host2.conf — never HOST1's credentials.
48	# This loader sources whatever conf files ARE present — sparse checkout handles the rest.
49	# Both servers pull all non-credential conf files (Configurations/master.conf, common.sh, load_config.sh).
50	#
51	# ==============================================================================================
52
53	# ━━━ Locate config root ━━━
54	# load_config.sh always lives in the repo root.
55	# Scripts call it from subdirectories using ../ — resolve to the actual root.
56	    LOAD_CONFIG_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
57
58	# ━━━ Platform detection ━━━
59	# PLATFORM drives the adapter layer — Plugin/<platform>/adapter.sh
60	# Each platform adapter provides the same function API; scripts stay OS-agnostic.
61	    if   [[ -f /etc/unraid-version ]]; then  PLATFORM="unraid"
62	    elif [[ -f /etc/truenas ]];        then  PLATFORM="truenas"
63	    else                                     PLATFORM="unknown"
64	    fi
65	    export PLATFORM SCRIPTS_DIR="$LOAD_CONFIG_DIR"
66
67	# ━━━ Source shared config ━━━
68	# master.conf must be sourced first — it declares the shared PROFILE_* arrays
69	# that Host confs extend. Sourcing host confs before master.conf would fail.
70	    if [[ ! -f "$LOAD_CONFIG_DIR/Configurations/master.conf" ]]; then
71	        echo "[FATAL] master.conf not found at $LOAD_CONFIG_DIR/Configurations/master.conf" >&2
72	        echo "[FATAL] Check TARGET_DIR and git pull status" >&2
73	        exit 1
74	    fi
75	    source "$LOAD_CONFIG_DIR/Configurations/master.conf"
76
77	# ━━━ Auto-discover and source all host*.conf files ━━━
78	# Sorted for consistent load order — HOST1 before HOST2 before HOST3 etc.
79	# Each host conf extends the shared PROFILE_* arrays and adds host-specific vars.
80	# Missing files are silently skipped — sparse checkout intentionally withholds some.
81	# At least one host conf must be present or the ecosystem has no identity to work with.
82	    _host_confs_loaded=0
83	    declare -A _disk_conf_basenames=()
84
85	    # Use a sorted array glob — avoids word-splitting on paths with spaces
86	    while IFS= read -r _conf; do
87	        [[ -f "$_conf" ]] || continue
88	        source "$_conf"
89	        (( _host_confs_loaded++ ))
90	        _disk_conf_basenames["$(basename "$_conf")"]=1
91	        [[ "${ENABLE_LOGGING:-false}" == "true" ]] && \
92	            echo "[LOG] Loaded host config: $(basename "$_conf")" >&2
93	    done < <(printf '%s\n' "$LOAD_CONFIG_DIR/Configurations"/host*.conf 2>/dev/null | sort)
94
95	    if [[ "$_host_confs_loaded" -eq 0 ]]; then
96	        echo "[FATAL] No host*.conf files found in $LOAD_CONFIG_DIR" >&2
97	        echo "[FATAL] At least one host conf required — check git pull and sparse checkout" >&2
98	        exit 1
99	    fi
100
101	# ━━━ Source partner confs from /tmp cache ━━━
102	# conf_sync.sh pulls partner host*.conf files into /tmp/.vv/config/cached/.confs/
103	# on array start and after any conf save. Sourcing them here makes partner vars
104	# (HOST2_*, HOST3_*, …) available without committing credentials to the git repo
105	# or violating sparse checkout — partner confs live in RAM only, cleared on reboot.
106	# Confs already loaded from disk are skipped — disk copy is authoritative.
107	    _VV_CONF_CACHE="/tmp/.vv/config/cached/.confs"
108	    if [[ -d "$_VV_CONF_CACHE" ]]; then
109	        while IFS= read -r _conf; do
110	            [[ -f "$_conf" ]] || continue
111	            _conf_base="$(basename "$_conf")"
112	            # Skip if already sourced from disk
113	            [[ -n "${_disk_conf_basenames[$_conf_base]:-}" ]] && continue
114	            source "$_conf"
115	            [[ "${ENABLE_LOGGING:-false}" == "true" ]] && \
116	                echo "[LOG] Loaded cached partner config: $_conf_base" >&2
117	        done < <(printf '%s\n' "$_VV_CONF_CACHE"/host*.conf 2>/dev/null | sort)
118	    fi
119	    unset _VV_CONF_CACHE _conf_base _disk_conf_basenames
120
121	# ━━━ Source shared functions ━━━
122	# common.sh sourced last — it calls detect_hosts() which needs HOST* vars to be set.
123	    if [[ ! -f "$LOAD_CONFIG_DIR/common.sh" ]]; then
124	        echo "[FATAL] common.sh not found at $LOAD_CONFIG_DIR/common.sh" >&2
125	        exit 1
126	    fi
127	    source "$LOAD_CONFIG_DIR/common.sh"
128
129	# ━━━ Source platform adapter ━━━
130	# Provides platform_*() functions used by common.sh and scripts.
131	# Guard lets the ecosystem run before Plugin/<platform>/adapter.sh exists.
132	    _adapter="$LOAD_CONFIG_DIR/Plugin/$PLATFORM/adapter.sh"
133	    [[ -f "$_adapter" ]] && source "$_adapter"
134
135	# ━━━ Derived path constants ━━━
136	# Centralised here so every script that sources load_config.sh has them without
137	# re-deriving from SCRIPTS_DIR or hardcoding /var/log or /tmp paths inline.
138	    CONF_DIR="${SCRIPTS_DIR}/Configurations"
139	    LOG_DIR="/var/log/varaverk"
140	    VV_CACHE_DIR="/tmp/vv_cache"
141	    export CONF_DIR LOG_DIR VV_CACHE_DIR
142
143	# ━━━ Cleanup ━━━
144	    unset _conf _host_confs_loaded _adapter LOAD_CONFIG_DIR

#!/bin/bash
# ==============================================================================================
# ================================= CONFIGURATION LOADER =======================================
# ==============================================================================================
# Single entry point for all configuration sourcing across the ecosystem.
# Every script sources this file instead of sourcing master.conf files directly.
#
# ── HOW IT WORKS ──────────────────────────────────────────────────────────────────────────────
# 1. Detects OS platform → PLATFORM=unraid|truenas|unknown; exports SCRIPTS_DIR
# 2. Sources master.conf (shared config — hostnames, thresholds, toggles, profiles, job lists)
# 3. Auto-discovers and sources all host*.conf files in the same directory
#    Each host conf extends the shared profile arrays and adds host-specific credentials
# 4. Sources common.sh (shared functions — detect_hosts, logging, notifications etc.)
# 5. Sources Plugin/<platform>/adapter.sh (platform_*() functions for OS-specific ops)
#
# ── WHY THIS EXISTS ───────────────────────────────────────────────────────────────────────────
# Without this loader every script had to explicitly source each conf file:
#   source master.conf
#   source host1.conf
#   source host2.conf
#   source common.sh
#
# Adding a new server meant updating every script.
# With this loader — add host3.conf to the git repo and every server
# auto-discovers it on next git pull. Zero script changes required. Ever.
#
# ── ADDING A NEW SERVER ───────────────────────────────────────────────────────────────────────
# 1. Create Configurations/host3.conf following the same structure as HOST1/HOST2
# 2. Commit and push to git repo
# 3. All servers pull it automatically — no other changes needed
#
# ── USAGE IN SCRIPTS ──────────────────────────────────────────────────────────────────────────
# Replace the three source lines at the top of every script with:
#
#   SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
#   source "$SCRIPT_DIR/../load_config.sh"
#
# Scripts in subdirectories (Rsync/, Docker_Essentials/ etc.) use ../ to reach root.
# Scripts in root directory use ./ instead:
#
#   SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
#   source "$SCRIPT_DIR/load_config.sh"
#
# ── SPARSE CHECKOUT NOTE ──────────────────────────────────────────────────────────────────────
# Sparse checkout controls which host*.conf files each server receives.
# HOST1 only pulls Configurations/host1.conf — never HOST2's credentials.
# HOST2 only pulls Configurations/host2.conf — never HOST1's credentials.
# This loader sources whatever conf files ARE present — sparse checkout handles the rest.
# Both servers pull all non-credential conf files (Configurations/master.conf, common.sh, load_config.sh).
#
# ==============================================================================================

# ━━━ Locate config root ━━━
# load_config.sh always lives in the repo root.
# Scripts call it from subdirectories using ../ — resolve to the actual root.
    LOAD_CONFIG_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

# ━━━ Platform detection ━━━
# PLATFORM drives the adapter layer — Plugin/<platform>/adapter.sh
# Each platform adapter provides the same function API; scripts stay OS-agnostic.
    if   [[ -f /etc/unraid-version ]]; then  PLATFORM="unraid"
    elif [[ -f /etc/truenas ]];        then  PLATFORM="truenas"
    else                                     PLATFORM="unknown"
    fi
    export PLATFORM SCRIPTS_DIR="$LOAD_CONFIG_DIR"

# ━━━ Source shared config ━━━
# master.conf must be sourced first — it declares the shared PROFILE_* arrays
# that Host confs extend. Sourcing host confs before master.conf would fail.
    if [[ ! -f "$LOAD_CONFIG_DIR/Configurations/master.conf" ]]; then
        echo "[FATAL] master.conf not found at $LOAD_CONFIG_DIR/Configurations/master.conf" >&2
        echo "[FATAL] Check TARGET_DIR and git pull status" >&2
        exit 1
    fi
    source "$LOAD_CONFIG_DIR/Configurations/master.conf"

# ━━━ Auto-discover and source all host*.conf files ━━━
# Sorted for consistent load order — HOST1 before HOST2 before HOST3 etc.
# Each host conf extends the shared PROFILE_* arrays and adds host-specific vars.
# Missing files are silently skipped — sparse checkout intentionally withholds some.
# At least one host conf must be present or the ecosystem has no identity to work with.
    _host_confs_loaded=0
    declare -A _disk_conf_basenames=()

    # Use a sorted array glob — avoids word-splitting on paths with spaces
    while IFS= read -r _conf; do
        [[ -f "$_conf" ]] || continue
        source "$_conf"
        (( _host_confs_loaded++ ))
        _disk_conf_basenames["$(basename "$_conf")"]=1
        [[ "${ENABLE_LOGGING:-false}" == "true" ]] && \
            echo "[LOG] Loaded host config: $(basename "$_conf")" >&2
    done < <(printf '%s\n' "$LOAD_CONFIG_DIR/Configurations"/host*.conf 2>/dev/null | sort)

    if [[ "$_host_confs_loaded" -eq 0 ]]; then
        echo "[FATAL] No host*.conf files found in $LOAD_CONFIG_DIR" >&2
        echo "[FATAL] At least one host conf required — check git pull and sparse checkout" >&2
        exit 1
    fi

# ━━━ Source partner confs from /tmp cache ━━━
# conf_sync.sh pulls partner host*.conf files into /tmp/.vv/config/cached/.confs/
# on array start and after any conf save. Sourcing them here makes partner vars
# (HOST2_*, HOST3_*, …) available without committing credentials to the git repo
# or violating sparse checkout — partner confs live in RAM only, cleared on reboot.
# Confs already loaded from disk are skipped — disk copy is authoritative.
    _VV_CONF_CACHE="/tmp/.vv/config/cached/.confs"
    if [[ -d "$_VV_CONF_CACHE" ]]; then
        while IFS= read -r _conf; do
            [[ -f "$_conf" ]] || continue
            _conf_base="$(basename "$_conf")"
            # Skip if already sourced from disk
            [[ -n "${_disk_conf_basenames[$_conf_base]:-}" ]] && continue
            source "$_conf"
            [[ "${ENABLE_LOGGING:-false}" == "true" ]] && \
                echo "[LOG] Loaded cached partner config: $_conf_base" >&2
        done < <(printf '%s\n' "$_VV_CONF_CACHE"/host*.conf 2>/dev/null | sort)
    fi
    unset _VV_CONF_CACHE _conf_base _disk_conf_basenames

# ━━━ Source shared functions ━━━
# common.sh sourced last — it calls detect_hosts() which needs HOST* vars to be set.
    if [[ ! -f "$LOAD_CONFIG_DIR/common.sh" ]]; then
        echo "[FATAL] common.sh not found at $LOAD_CONFIG_DIR/common.sh" >&2
        exit 1
    fi
    source "$LOAD_CONFIG_DIR/common.sh"

# ━━━ Source platform adapter ━━━
# Provides platform_*() functions used by common.sh and scripts.
# Guard lets the ecosystem run before Plugin/<platform>/adapter.sh exists.
    _adapter="$LOAD_CONFIG_DIR/Plugin/$PLATFORM/adapter.sh"
    [[ -f "$_adapter" ]] && source "$_adapter"

# ━━━ Derived path constants ━━━
# Centralised here so every script that sources load_config.sh has them without
# re-deriving from SCRIPTS_DIR or hardcoding /var/log or /tmp paths inline.
    CONF_DIR="${SCRIPTS_DIR}/Configurations"
    LOG_DIR="/var/log/varaverk"
    VV_CACHE_DIR="/tmp/vv_cache"
    export CONF_DIR LOG_DIR VV_CACHE_DIR

# ━━━ Cleanup ━━━
    unset _conf _host_confs_loaded _adapter LOAD_CONFIG_DIR