// ── 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];

    $py  = "import yaml,json,sys\n"
         . "d=yaml.safe_load(open(sys.argv[1]))\n"
         . "ac=d.get('access_control',{})\n"
         . "print(json.dumps({'default_policy':ac.get('default_policy','deny'),'rules':ac.get('rules',[])}))\n";
    $tmp = '/tmp/vv_auth_rd_' . getmypid() . '.py';
    file_put_contents($tmp, $py);
    $out = shell_exec('python3 ' . escapeshellarg($tmp) . ' ' . escapeshellarg($file) . ' 2>/dev/null');
    @unlink($tmp);

    if (!$out) return ['ok' => false, 'error' => 'Parse failed — python3 with PyYAML required'];
    $data = json_decode(trim($out), true);
    if (!$data) return ['ok' => false, 'error' => 'Invalid YAML response'];
    return ['ok' => true, 'default_policy' => $data['default_policy'], 'rules' => $data['rules']];
}

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];

    $acJson = json_encode(['default_policy' => $defaultPolicy, 'rules' => $rules]);
    $py = <<<'PYEOF'
import yaml, json, sys, re
config_file = sys.argv[1]
new_ac = json.loads(sys.argv[2])
with open(config_file, 'r') as f:
    content = f.read()
new_block = yaml.dump({'access_control': new_ac}, default_flow_style=False, allow_unicode=True, sort_keys=False)
pattern = r'(?ms)^access_control:.*?(?=^[a-zA-Z#]|\Z)'
if re.search(pattern, content):
    content = re.sub(pattern, new_block + '\n', content)
else:
    content = content.rstrip('\n') + '\n\n' + new_block + '\n'
with open(config_file, 'w') as f:
    f.write(content)
print('ok')
PYEOF;
    $tmp = '/tmp/vv_auth_wr_' . getmypid() . '.py';
    file_put_contents($tmp, $py);
    $out = shell_exec('python3 ' . escapeshellarg($tmp) . ' ' . escapeshellarg($file) . ' ' . escapeshellarg($acJson) . ' 2>&1');
    @unlink($tmp);

    if (trim($out) !== 'ok') return ['ok' => false, 'error' => 'Write failed: ' . trim($out)];
    shell_exec('docker restart ' . escapeshellarg($conf['authelia_container']) . ' >/dev/null 2>&1 &');
    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;
}

-         . "d=yaml.safe_load(open(sys.argv[1]))\n"

-         . "ac=d.get('access_control',{})\n"

-         . "print(json.dumps({'default_policy':ac.get('default_policy','deny'),'rules':ac.get('rules',[])}))\n";

-    $tmp = '/tmp/vv_auth_rd_' . getmypid() . '.py';

-    $out = shell_exec('python3 ' . escapeshellarg($tmp) . ' ' . escapeshellarg($file) . ' 2>/dev/null');

+    $content = file_get_contents($file);

+    if ($content === false) return ['ok' => false, 'error' => 'Cannot read config file'];

-    if (!$out) return ['ok' => false, 'error' => 'Parse failed — python3 with PyYAML required'];

-    $data = json_decode(trim($out), true);

-    if (!$data) return ['ok' => false, 'error' => 'Invalid YAML response'];

-    return ['ok' => true, 'default_policy' => $data['default_policy'], 'rules' => $data['rules']];

+    // Extract default_policy (strip inline comments)

+    if (preg_match('/^[ \t]+default_policy:[ \t]+([a-z_]+)/m', $content, $m))

+    // Extract the indented block under access_control:

+        return ['ok' => false, 'error' => 'access_control section not found'];

+    // Extract the indented block under rules: (3+ space indent = rule list items)

+        return ['ok' => true, 'default_policy' => $defaultPolicy, 'rules' => []];

+    // Split into individual rule chunks at "    - " (indent-4 rule starts)

+        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 {

+    $save = function () use (&$rule, &$field, &$list) {

+            $rule[$field] = count($list) === 1 ? $list[0] : $list;

+    foreach (explode("\n", $chunk) as $line) {

+        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));

+                    $rule[$field] = vv_authelia_unquote($val);

+        // indent=6 → named field (scalar or list header)

+                $rule[$field] = vv_authelia_unquote($val);

+        // 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)));

+// Strip surrounding quotes and inline comments from a YAML scalar.

+function vv_authelia_unquote(string $val): string {

+    $val = preg_replace('/\s+#[^"\']*$/', '', $val); // strip trailing comment

+// Parse a YAML list item: flow sequence ['group:name'] or plain/quoted scalar.

+function vv_authelia_parse_list_item(string $val): string {

+    // Flow sequence: ['value'] or ["value"] or [value]

-    $acJson = json_encode(['default_policy' => $defaultPolicy, 'rules' => $rules]);

-new_block = yaml.dump({'access_control': new_ac}, default_flow_style=False, allow_unicode=True, sort_keys=False)

-    content = re.sub(pattern, new_block + '\n', content)

-    content = content.rstrip('\n') + '\n\n' + new_block + '\n'

-    $tmp = '/tmp/vv_auth_wr_' . getmypid() . '.py';

-    $out = shell_exec('python3 ' . escapeshellarg($tmp) . ' ' . escapeshellarg($file) . ' ' . escapeshellarg($acJson) . ' 2>&1');

+    $content = file_get_contents($file);

-    if (trim($out) !== 'ok') return ['ok' => false, 'error' => 'Write failed: ' . trim($out)];

+    // Build the new access_control block

+    $block .= "  default_policy: $defaultPolicy\n";

+    $fieldOrder = ['domain', 'policy', 'subject', 'networks', 'resources'];

+            array_filter($fieldOrder, fn($k) => array_key_exists($k, $rule)),

+            array_diff(array_keys($rule), $fieldOrder)

+            if (!array_key_exists($key, $rule)) continue;

+            // domain, subject, resources, networks → always output as list

+            $isList = in_array($key, ['domain', 'subject', 'resources', 'networks'], true);

+                $items  = is_array($val) ? $val : [$val];

+                $block .= $prefix . $key . ":\n";

+                foreach ($items as $item) {

+                    $out    = $key === 'subject'

+                        : vv_authelia_yaml_scalar((string) $item);

+                $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)

+    $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'];

+    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']; }

+// Quote a YAML scalar value if it contains characters that require quoting.

+function vv_authelia_yaml_scalar(string $val): string {


Parse failed — python3 with PyYAML required


Conversation compacted


This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.

Summary:
1. Primary Request and Intent:
   The user (gmer4lfe) is working on the Varaverk plugin — a self-healing two-server Unraid home media ecosystem. The session covered several requests:
   - Fix the NPM auth failure on the Varaverk auth page ("NPM auth failed — check credentials in host conf")
   - Address context loss in Claude sessions (work "disappearing")
   - Create a CLAUDE.md for the project that auto-loads on Unraid reboots
   - Fix the full auth page stack: host1.conf credentials, lldap Users/Groups queries, Authelia ACL parsing
   - The most recent error: "Parse failed — python3 with PyYAML required" from the Authelia ACL tab

2. Key Technical Concepts:
   - Varaverk three-file conf system: `master.conf` (shared) + `host1.conf` / `host2.conf` (per-host credentials)
   - `vv_conf_vars()` in config.php: parses conf files with regex, handles `\

 → `

 via `str_replace('\\, ', ...)`
   - NPM (Nginx Proxy Manager) admin API on port **7818** (not 81 — 81 is the partnership WebUI port)
   - lldap GraphQL API: query fields are `users` and `groups` (not `listUsers`/`listGroups`); no `changeUserPassword` mutation; password reset via REST `/auth/admin/resetPassword`
   - Authelia `access_control` YAML section parsing — currently broken because python3/PyYAML not available on Unraid; needs pure PHP replacement
   - CLAUDE.md for persistent session context; `/root` is RAM on Unraid, wiped on every reboot
   - `Tools/claude_startup.sh` runs at array start via `ARRAY_START_SCRIPTS`, symlinks persistent Claude data

3. Files and Code Sections:

   - **`/boot/config/plugins/varaverk/Configurations/host1.conf`**
     - Was missing the entire AUTH STACK section (HOST1_NPM_URL/USER/PASS, HOST1_LLDAP_URL/USER/PASS, HOST1_AUTHELIA_CONFIG/CONTAINER)
     - Added before the "End Of HOST1 Variables" line:
     ```bash
     # ━━━ NginxProxyManager ━━━
         HOST1_NPM_URL="http://localhost:7818"
         HOST1_NPM_USER="failedproxy@gmail.com"
         HOST1_NPM_PASS="183134\$eanHess"
     # ━━━ lldap ━━━
         HOST1_LLDAP_URL="http://localhost:17170"
         HOST1_LLDAP_USER="admin"
         HOST1_LLDAP_PASS="183134\$eanHess"
     # ━━━ Authelia ━━━
         HOST1_AUTHELIA_CONFIG="/mnt/user/appdata-Fallback/Critical-Data/Authelia/configuration.yml"
         HOST1_AUTHELIA_CONTAINER="Authelia"
     ```
     - Note: `

 in password escaped as `\

 because bash double-quoted strings expand `$var`; config.php's `vv_conf_vars()` strips the backslash back to `

   - **`/boot/config/plugins/varaverk/Plugin/unraid/include/auth.php`**
     - Critical file: all NPM, lldap, and Authelia integration logic
     - **Fix 1**: `vv_npm_token()` — removed `'expiry' => '1d'` from POST body (this NPM version rejects unknown fields with HTTP 400 before checking credentials)
     - **Fix 2**: `vv_lldap_list_users()` — changed query from `listUsers` to `users`, data path from `$r['data']['listUsers']` to `$r['data']['users']`
     - **Fix 3**: `vv_lldap_list_groups()` — changed query from `listGroups` to `groups`, data path from `$r['data']['listGroups']` to `$r['data']['groups']`
     - **Fix 4**: `vv_lldap_set_password()` — replaced non-existent `changeUserPassword` GraphQL mutation with REST call:
     ```php
     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];
     }
     ```
     - **Still broken**: `vv_authelia_read_rules()` and `vv_authelia_write_rules()` use python3 + PyYAML which are not available on Unraid. Current broken code:
     ```php
     function vv_authelia_read_rules(): array {
         // ... runs python3 script to parse YAML ...
         if (!$out) return ['ok' => false, 'error' => 'Parse failed — python3 with PyYAML required'];
     }
     ```

   - **`/boot/config/plugins/varaverk/CLAUDE.md`** (newly created)
     - Comprehensive project context: working rules, architecture, conf system, orchestrator schedule, rsync toggle state, fallback rules, port notes, known gaps
     - Lives on `/boot` (USB flash, survives reboots)
     - Key port note added: NPM admin API is port 7818, not 81

   - **`/boot/config/plugins/varaverk/Tools/claude_startup.sh`**
     - Updated to symlink `/root/CLAUDE.md` → `/boot/config/plugins/varaverk/CLAUDE.md` on every array start
     - Added block after claude binary symlink:
     ```bash
     CLAUDE_MD="/boot/config/plugins/varaverk/CLAUDE.md"
     if [[ -f "$CLAUDE_MD" ]]; then
         ln -sfn "$CLAUDE_MD" /root/CLAUDE.md
         _log "CLAUDE.md      → $CLAUDE_MD"
     else
         _warn "CLAUDE.md not found at $CLAUDE_MD — skipping symlink"
     fi
     ```

   - **`/mnt/user/appdata-Fallback/Critical-Data/Authelia/configuration.yml`** (read only)
     - `access_control:` begins at line 66
     - Structure observed:
     ```yaml
     access_control:
       default_policy: bypass #deny
       rules:
         - domain:
             - jfin.gmer4lfe.com
           resources:
             - "^/web.*"
           subject:
             - ['group:media']
           policy: one_factor
         - domain:
             - "npm.gmer4lfe.com"
             - "npm2.gmer4lfe.com"
           subject:
             - ['group:services_admin']
           policy: one_factor
     ```
     - Key complexities: inline comments after values (`bypass #deny`), inline list syntax for subjects (`['group:media']`), both quoted and unquoted domain strings, multi-item domain and subject lists

   - **`/boot/config/plugins/varaverk/Plugin/unraid/include/config.php`** (read only)
     - `vv_conf_vars()` parses conf files with regex and `str_replace('\\, ', ...)`
     - `vv_detect_host()` matches hostname against HOST1/HOST2 in master.conf
     - `CONF_DIR = SCRIPTS_DIR . '/Configurations'` where SCRIPTS_DIR comes from varaverk.cfg

   - **`/boot/config/plugins/varaverk/Deployment/deploy.sh`** (read only)
     - Hook fires on Edit/Write from `.claude/settings.json`
     - Only acts on files under `DEV_ROOT="/mnt/cloud-storage/Important Shit/Git/Development/Varaverk"` — our edits to PROD_ROOT are not affected by the hook

4. Errors and fixes:
   - **NPM auth failed (round 1)**: host1.conf missing entire AUTH STACK section. Fixed by adding section with HOST1_NPM_URL/USER/PASS/lldap/Authelia vars. Initially set wrong port (81) and wrong email.
   - **NPM auth failed (round 2)**: Port was wrong. Memory file `project_watchdog_health_urls.md` had note: "NPM admin port confirmed by user: **7818** (not 81)". Fixed HOST1_NPM_URL to port 7818.
   - **NPM auth failed (round 3)**: Two issues found via live curl testing:
     - `expiry` field in POST body caused HTTP 400 "data must NOT have additional properties" — this NPM version rejects unknown fields before checking credentials. Removed `'expiry' => '1d'` from `vv_npm_token()`.
     - Email was wrong: user typed `failedproxy2gmail.com`, I stored `failedproxy2@gmail.com`. Queried NPM's SQLite database directly: `sqlite3 .../database.sqlite "SELECT id, name, email FROM user;"` → correct email is `failedproxy@gmail.com`. Fixed.
   - **lldap Users error**: "Unknown field 'listUsers' on type 'Query'". Queried schema — correct field is `users`. Fixed query and data path in `vv_lldap_list_users()`.
   - **lldap Groups error**: "Unknown field 'listGroups' on type 'Query'". Same issue — correct field is `groups`. Fixed in `vv_lldap_list_groups()`.
   - **lldap password mutation missing**: `changeUserPassword` doesn't exist in mutations list. Replaced with REST endpoint `/auth/admin/resetPassword` — tested, returns HTTP 200.
   - **Authelia ACL parse error**: "Parse failed — python3 with PyYAML required". python3, PHP yaml extension, and yq are all unavailable on Unraid. Not yet fixed — in progress.

5. Problem Solving:
   - Established that `\

 escaping in bash conf files is correctly handled by `vv_conf_vars()`'s `str_replace('\\, ', ...)`
   - Confirmed lldap's GraphQL schema by introspection queries
   - Confirmed password reset endpoint works via curl test (HTTP 200)
   - Found NPM SQLite database at `/mnt/docker-unraid/appdata-Fallback/Critical-Data/NginxProxyManager/database.sqlite`
   - Confirmed deploy hook only fires for files under the stale dev folder path, so edits to prod files are not overwritten

6. All user messages:
   - "NPM auth failed — check credentials in host conf" (first occurrence)
   - "whats causing this... i do a lot of work and a 1/4 of it dissapears"
   - "yes, do that. and lets make sure our claud script loads it on an unraid reboot"
   - "ok so lets fix the auth page and master and hosts.conf..."
   - "npm, failedproxy2gmail.com 183134$eanHess / lldap 183134$eanHess"
   - "NPM auth failed — check credentials in host conf" (second occurrence)
   - "NPM auth failed — check credentials in host conf" (third occurrence)
   - "Users / Unknown field 'listUsers' on type 'Query' / Groups / Unknown field 'listGroups' on type 'Query'"
   - "Parse failed — python3 with PyYAML required"

7. Pending Tasks:
   - **Rewrite `vv_authelia_read_rules()` and `vv_authelia_write_rules()` in pure PHP** — no python3, no yaml extension, no yq available on Unraid. Must parse the Authelia `access_control` YAML block including: inline comments, `['group:name']` inline list syntax for subjects, quoted/unquoted strings, multi-item lists.

8. Current Work:
   The immediately preceding work was investigating the "Parse failed — python3 with PyYAML required" error from the Authelia ACL tab. Confirmed no YAML tooling is available on Unraid (python3 not found, PHP yaml extension not loaded, yq not found). Read the actual Authelia config to understand the YAML structure at `/mnt/user/appdata-Fallback/Critical-Data/Authelia/configuration.yml`. The `access_control` block starts at line 66 with complex YAML including inline list subjects like `['group:media']` and inline comments. The two broken functions in `auth.php` are `vv_authelia_read_rules()` and `vv_authelia_write_rules()`.

9. Optional Next Step:
   Rewrite `vv_authelia_read_rules()` and `vv_authelia_write_rules()` in pure PHP — no external dependencies. The user's last error was "Parse failed — python3 with PyYAML required" which comes directly from these two functions. The implementation must handle the observed YAML structure: `access_control:` block extraction, `default_policy:` parsing (stripping inline comments like `bypass #deny`), rules as a list with `domain:` (scalar or multi-item list), `policy:` (scalar), `subject:` (list of inline lists `['group:name']`), `resources:` and `networks:` (string lists). The writer must serialize back to YAML and replace the existing block in the file without corrupting the rest of the config.

If you need specific details from before compaction (like exact code snippets, error messages, or content you generated), read the full transcript at: /root/.claude/projects/-root/d950f3bd-e034-44cb-a540-cdd63727516c.jsonl
Continue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, do not preface with "I'll continue" or similar. Pick up the last task as if the break never happened.


# Varaverk — Claude Code Context

## Working Rules (read first)

- **Workspace is always** `/boot/config/plugins/varaverk` — every edit goes here.
- **Never touch** `/mnt/user/Important Shit/Git/Development/Varaverk` — stale dev folder, ignore it.
- **No Co-Authored-By** in commit messages unless explicitly asked.
- **No comments** unless the WHY is genuinely non-obvious.
- The `.plg` symlinks the installed plugin location directly to this workspace — one copy, no drift.

---

## Project: What Varaverk Is

Self-healing, self-maintaining, mutually-redundant two-server Unraid home media ecosystem.
One codebase runs on both servers. No primary/standby — both run independently and cover each other.

**HOST1 — unRAID-Gmer4Lfe** (`gmer4lfe@gmail.com`)
- Hardware: Threadripper 1950X, 128 GB RAM, ZFS cache pools
- Domain: Gmer4Lfe.com
- Runs: full arr stack (Sonarr/Radarr/Lidarr), auth stack (source of truth), Emby primary

**HOST2 — unRAID-Jayred365**
- Hardware: Intel i5 10th gen, 64 GB RAM
- Domain: Gmer4Lfe.us
- Status: being rebuilt — most host2.conf sections scaffolded, not yet fully online

Networking between hosts: Tailscale mesh. No hardcoded IPs — hostnames resolve via Tailscale.

---

## Configuration System (three-file model)

Every script sources all three at startup:

```
master.conf   ← shared: thresholds, toggles, profiles, orchestrator job lists
host1.conf    ← HOST1 credentials, shares, container names, keys
host2.conf    ← HOST2 credentials, shares, container names, keys
```

Sparse checkout (git) means each server only pulls its own `host*.conf`.
HOST1 never sees HOST2 credentials and vice versa.

**Rule:** thresholds/toggles → `master.conf`; credentials/paths/container names → `host*.conf`.

`detect_hosts()` in `common.sh` matches `$(hostname)` against `HOST1`/`HOST2` in `master.conf`
and sets `MY_ID` / `REMOTE_ID` for the rest of the script.

---

## Platform Adapter Layer

`Plugin/unraid/adapter.sh` isolates all OS-specific calls.
Scripts never branch on OS directly — always call adapter functions.
This is intentional architecture — don't bypass it.

---

## Key Paths

| Path | Purpose |
|------|---------|
| `master.conf` | Shared config — all thresholds, toggles, profiles |
| `host1.conf` / `host2.conf` | Per-host credentials, shares, container lists |
| `common.sh` | Shared functions — `detect_hosts()`, `log()`, `notify()`, etc. |
| `load_config.sh` | Sources all three conf files + common.sh |
| `State_Files/` | Runtime state (watchdogs, fallback, transcode) — survives reboots |
| `data/` | Historical logs and stats |
| `Plugin/unraid/` | Unraid WebGUI plugin (PHP pages, API endpoints, adapter) |
| `Orchestrators/` | Top-level schedulers (array_started, daily, weekly, watchdog) |
| `Watchdogs/` | docker_watchdog, system_watchdog, resource_watchdog, stability |
| `Fallback/` | Mutual container failover logic |
| `Rsync/` | rsync.sh + profile system |
| `Media/` | Arr cleanup, discovery, permissions, play state sync |
| `Tools/` | Manual one-off tools including `claude_startup.sh` |

---

## Orchestrator Schedule

| When | What |
|------|------|
| Array start | `Orchestrators/array_started.sh` → runs `ARRAY_START_SCRIPTS` |
| Every minute | `watchdog_orchestrator.sh` → resource → docker → system → stability watchdogs |
| Every 30 min | `critical_sync_maintenance.sh` → downloaders_reset, play_state_sync, critical rsync |
| Every 4 hours | `intermediate_sync_maintenance.sh` → arr_sync, arrs_failed_stalled_recovery |
| Daily 1am | `daily_sync_maintenance.sh` → git pull, permissions, cleaners, arr cleanup, docker updates |
| Sunday 2:30am | `weekly_sync_maintenance.sh` → full Emby + Critical-Data sync, weekly restarts |
| Sunday 3am+ | `monthly_maintenance.sh` (self-gated on 30-day uptime) → ZFS scrub, SMART tests |
| Sunday 7am | `sunday_morning_coffee_report.sh` → ZFS, SMART, certs, backup verify, bandwidth, Emby report |

---

## Rsync Toggle State (current)

```bash
RSYNC_ENABLED=true
CRITICAL_RSYNC_ENABLED=true
INTERMEDIATE_RSYNC_ENABLED=true
DAILY_RSYNC_ENABLED=false        # HOST2 rebuild in progress — re-enable when ready
WEEKLY_RSYNC_ENABLED=true
FALLBACK_RSYNC_ENABLED=true
```

---

## Fallback System

`fallback.sh` runs continuously from array start.
States: `NORMAL | FALLBACK | NO_INTERNET | DARK`

DDNS rules are absolute:
- Internet loss → stop own DDNS immediately
- Failover → start remote's DDNS as Tier 1 first
- Handback → stop remote DDNS → rsync → start containers → start local DDNS last

Tier delays before activating higher tiers are in `host*.conf` (`HOST1_TIER*_DELAY`, `HOST2_TIER*_DELAY`).

---

## Port Notes

- **NPM admin API (`HOST1_NPM_URL`)** — port **7818**. Port 81 is the partnership WebUI port (`HOST1_PARTNERSHIP_AUTH_WEBUIS`), not the API. Easy to confuse.
- **HOST1_NETWORK_WATCHDOG_NPM_URL** — external HTTPS domain, completely separate from the admin API.

## Known Gaps / Active Work

- HOST2 NPM/lldap credentials (`HOST2_NPM_USER`, `HOST2_NPM_PASS`, `HOST2_LLDAP_PASS`) are empty in `host2.conf` — fill in when HOST2 is back online.
- `PARTNERSHIP_ENABLED=false` — not yet active.
- `FALLBACK_ENABLED=true` — fallback is running.
- `DAILY_RSYNC_ENABLED=false` — paused during HOST2 rebuild.

---

## Claude Code Persistence on Unraid

`/root` is a RAM filesystem — wiped on every reboot.
`Tools/claude_startup.sh` runs at array start (via `ARRAY_START_SCRIPTS`) and:
- Symlinks `/root/.claude` → `/mnt/user/appdata/claude-code/.claude`
- Symlinks `/root/.local/share/claude` → `/mnt/user/appdata/claude-code/local/share/claude`
- Symlinks `/root/CLAUDE.md` → `/boot/config/plugins/varaverk/CLAUDE.md` (this file)

This file lives on `/boot` (USB flash) and is always available regardless of array state.

---

## Commit Style

Plain, concise messages. No Co-Authored-By trailers. No bullet-point summaries in the body.
One sentence on the why, not the what.


#!/bin/bash
# ==============================================================================================
# ================================= Claude Code Startup ========================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Restores Claude Code's persistent data after an Unraid reboot and launches Claude.
# Unraid's root filesystem lives in RAM — /root/.claude and /root/.local are wiped on
# every reboot. This script symlinks both directories back to persistent appdata storage
# before launching Claude, so memory, sessions, and settings survive across reboots.
#
# On first run with no existing persistent data, migrates from the current live locations:
#   /root/.claude              → PERSIST_DIR/.claude            (memory, sessions, settings)
#   /root/.local/share/claude  → PERSIST_DIR/local/share/claude (installed binaries)
# Subsequent runs skip the migration and only create the symlinks.
#
# Standalone script — no common.sh dependency. Safe to run directly from terminal
# or from array_started.sh.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# claude_startup.sh
#     Set up persistent symlinks only — default, used by array_started.sh on boot.
#
# claude_startup.sh --launch
#     Set up persistent symlinks and launch Claude interactively.
#
# ==============================================================================================

PERSIST_DIR="/mnt/user/appdata/claude-code"
CLAUDE_DATA="$PERSIST_DIR/.claude"
CLAUDE_BIN="$PERSIST_DIR/local/share/claude"

LAUNCH=false
[[ "$1" == "--launch" ]] && LAUNCH=true

# Standalone — no common.sh dependency
_log()  { echo "  ✅  $*"; }
_warn() { echo "  ⚠   $*"; }
_err()  { echo "  ❌  $*" >&2; }

echo ""
echo "━━━ Claude Code Startup ━━━"
echo ""

# ── Array must be mounted ─────────────────────────────────────────────────────────────────────
if ! mountpoint -q /mnt/user 2>/dev/null; then
    _err "Array not mounted — /mnt/user not available"
    exit 1
fi

# ── Create persistent dirs ────────────────────────────────────────────────────────────────────
mkdir -p "$CLAUDE_DATA" "$CLAUDE_BIN"

# ── Migrate .claude on first run ──────────────────────────────────────────────────────────────
if [[ ! -L /root/.claude && -d /root/.claude ]]; then
    _warn "First run — migrating /root/.claude → $CLAUDE_DATA"
    cp -a /root/.claude/. "$CLAUDE_DATA/"
    rm -rf /root/.claude
    _log "Migrated .claude (memory, sessions, settings)"
elif [[ -z "$(ls -A "$CLAUDE_DATA" 2>/dev/null)" && -d /root/.claude ]]; then
    _warn "Persistent storage empty — copying current .claude data"
    cp -a /root/.claude/. "$CLAUDE_DATA/"
    _log "Copied .claude data to persistent storage"
fi

# ── Migrate Claude binaries on first run ──────────────────────────────────────────────────────
if [[ ! -L /root/.local/share/claude && -d /root/.local/share/claude ]]; then
    _warn "First run — migrating Claude binaries → $CLAUDE_BIN"
    cp -a /root/.local/share/claude/. "$CLAUDE_BIN/"
    _log "Migrated Claude binaries"
fi

# ── Create symlinks ───────────────────────────────────────────────────────────────────────────
# Remove any real directories first — ln -sfn silently creates inside a dir instead of
# replacing it, which produces a circular symlink on subsequent runs after migration.
mkdir -p /root/.local/share /root/.local/bin

[[ -d /root/.claude && ! -L /root/.claude ]] && rm -rf /root/.claude
ln -sfn "$CLAUDE_DATA" /root/.claude
_log ".claude        → $CLAUDE_DATA"

[[ -d /root/.local/share/claude && ! -L /root/.local/share/claude ]] && rm -rf /root/.local/share/claude
ln -sfn "$CLAUDE_BIN" /root/.local/share/claude
_log "claude binary  → $CLAUDE_BIN"

# ── Symlink CLAUDE.md ─────────────────────────────────────────────────────────────────────────
# Lives on /boot so it survives reboots without appdata. Symlinked into /root so Claude
# picks it up automatically from the working directory on every session.
CLAUDE_MD="/boot/config/plugins/varaverk/CLAUDE.md"
if [[ -f "$CLAUDE_MD" ]]; then
    ln -sfn "$CLAUDE_MD" /root/CLAUDE.md
    _log "CLAUDE.md      → $CLAUDE_MD"
else
    _warn "CLAUDE.md not found at $CLAUDE_MD — skipping symlink"
fi

# ── Point the claude binary at the latest installed version ───────────────────────────────────
LATEST=$(ls "$CLAUDE_BIN/versions/" 2>/dev/null | sort -V | tail -1)
if [[ -z "$LATEST" ]]; then
    _err "No Claude versions found in $CLAUDE_BIN/versions/"
    _err "Install Claude Code first:  npm install -g @anthropic-ai/claude-code"
    exit 1
fi

ln -sfn "$CLAUDE_BIN/versions/$LATEST" /root/.local/bin/claude
_log "claude v$LATEST ready"

echo ""

# ── Setup-only mode (used by array_started.sh or other callers) ─────────────────────────────────
if [[ "$LAUNCH" == false ]]; then
    _log "Setup complete — run 'claude' to start"
    echo ""
    exit 0
fi

# ── Launch ────────────────────────────────────────────────────────────────────────────────────
export PATH="$HOME/.local/bin:$PATH"
exec claude


---
name: project-watchdog-health-urls
description: Planned improvement — add HTTP health check URLs for auth stack containers in HOST1_WATCHDOG_CONTAINER_URLS
metadata:
  node_type: memory
  type: project
  originSessionId: 6cd2156e-fb44-400a-86c7-5844179fe511
---

Add health check URLs for critical containers that are currently only checked via `docker ps` (alive but hung won't be caught).

**Why:** A hung-but-not-crashed NPM/Authelia fails all traffic silently with no watchdog trigger. Currently only Emby has a health URL.

**How to apply:** When user is ready, add to host1.conf:
- Define `HOST1_NPM_ADMIN_URL="http://localhost:7818"` near the NPM identity section (no existing URL var for admin panel — `HOST1_NETWORK_WATCHDOG_NPM_URL` is the external domain, different thing)
- Add to `HOST1_WATCHDOG_CONTAINER_URLS`: NPM, Authelia (9091), Authelia-Secondary (9092), Lldap-Gmer4Lfe (17170) — confirm ports before adding
- Pattern: define URL var, reference it in the array (same as HOST1_EMBY_URL pattern)

NPM admin port confirmed by user: **7818** (not 81 — that's the partnership WebUI port in HOST1_PARTNERSHIP_AUTH_WEBUIS)


Good. Let me verify the edit landed correctly by reading the Authelia section:


Bash

grep -n "vv_authelia\|python3\|PyYAML" /boot/config/plugins/varaverk/Plugin/unraid/include/auth.php

Check for remaining python3 references and confirm new function names


246:function vv_authelia_read_rules(): array {
274:        $rule = vv_authelia_parse_rule_chunk($chunk);
281:function vv_authelia_parse_rule_chunk(string $chunk): array {
308:                    $rule[$field] = vv_authelia_unquote($val);
321:                $rule[$field] = vv_authelia_unquote($val);
329:            $list[] = vv_authelia_parse_list_item(trim(substr($trim, 2)));
337:function vv_authelia_unquote(string $val): string {
345:function vv_authelia_parse_list_item(string $val): string {
349:    return vv_authelia_unquote($val);
352:function vv_authelia_write_rules(array $rules, string $defaultPolicy): array {
388:                        : vv_authelia_yaml_scalar((string) $item);
392:                $block .= $prefix . $key . ': ' . vv_authelia_yaml_scalar((string) $val) . "\n";
414:function vv_authelia_yaml_scalar(string $val): string {


No more `python3` or `PyYAML` in that file. Let me do a quick smoke test — run the parser against the actual Authelia config to confirm it returns clean data:


php -r "
require '/boot/config/plugins/varaverk/Plugin/unraid/include/auth.php';
\$result = vv_authelia_read_rules();
echo json_encode(\$result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
" 2>&1

Smoke test the new pure-PHP Authelia reader


group:media

group:services_admin

group:super_users

group:power_users

group:home

group:media

group:services_admin

group:super_users

group:power_users

group:home


Parser works. All 5 rules parsed correctly — domains, subjects (flow sequences stripped to plain strings), and policy values. The `default_policy: bypass` inline comment was correctly stripped.

Now let me verify the write path doesn't corrupt the file by doing a round-trip dry run:


php -r "
require '/boot/config/plugins/varaverk/Plugin/unraid/include/auth.php';

// Read
\$r = vv_authelia_read_rules();
if (!\$r['ok']) { echo 'READ FAILED: ' . \$r['error']; exit; }

// Simulate what write would generate (without actually writing)
\$rules         = \$r['rules'];
\$defaultPolicy = \$r['default_policy'];
\$fieldOrder    = ['domain', 'policy', 'subject', 'networks', 'resources'];
\$block         = \"access_control:\n  default_policy: \$defaultPolicy\n  rules:\n\";
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;
        \$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\";
        }
    }
}
echo \$block;
" 2>&1

Dry-run write to verify YAML output format


access_control:
  default_policy: bypass
  rules:
    - domain:
        - jfin.gmer4lfe.com
      policy: one_factor
      subject:
        - ['group:media']
      resources:
        - "^/web.*"
    - domain:
        - npm.gmer4lfe.com
        - npm2.gmer4lfe.com
        - main.gmer4lfe.com
        - code.gmer4lfe.com
        - code2.gmer4lfe.com
        - lldap.gmer4lfe.com
        - lldap2.gmer4lfe.com
        - qbitt.gmer4lfe.com
        - sab.gmer4lfe.com
        - speed.gmer4lfe.com
        - tdar.gmer4lfe.com
        - uptime.gmer4lfe.com
        - 2.gmer4lfe.com
        - vaults.gmer4lfe.com
      policy: one_factor
      subject:
        - ['group:services_admin']
    - domain:
        - huntar.gmer4lfe.com
        - jplist.gmer4lfe.com
        - prowlar.gmer4lfe.com
        - lidar.gmer4lfe.com
        - radar.gmer4lfe.com
        - readar.gmer4lfe.com
        - sonar.gmer4lfe.com
        - pflat.gmer4lfe.com
        - pics.gmer4lfe.com
        - tfsp.gmer4lfe.com
        - tfb.gmer4lfe.com
        - tf.gmer4lfe.com
        - ytube.gmer4lfe.com
      policy: one_factor
      subject:
        - ['group:super_users']
    - domain:
        - huntar.gmer4lfe.com
        - jplist.gmer4lfe.com
        - prowlar.gmer4lfe.com
        - lidar.gmer4lfe.com
        - radar.gmer4lfe.com
        - readar.gmer4lfe.com
        - sonar.gmer4lfe.com
        - pflat.gmer4lfe.com
        - pics.gmer4lfe.com
        - tfsp.gmer4lfe.com
        - tfb.gmer4lfe.com
        - tf.gmer4lfe.com
        - ytube.gmer4lfe.com
      policy: one_factor
      subject:
        - ['group:power_users']
    - domain:
        - slist.gmer4lfe.com
        - degoog.gmer4lfe.com
      policy: one_factor
      subject:
        - ['group:home']


Round-trip is clean. Valid Authelia YAML with `['group:...']` subjects, quoted regex resources, consistent indentation.

The Authelia ACL tab should now work end-to-end — `vv_authelia_read_rules()` parses the config with pure PHP (no python3), and `vv_authelia_write_rules()` serializes cleanly back to Authelia-compatible YAML. Try the ACL tab now.


certs tab can we have this communicate with npm and auto find


Let me read the current certs tab and the NPM API functions to understand what's already there.


grep -n "cert\|Cert\|CERT\|ssl\|SSL" /boot/config/plugins/varaverk/Plugin/unraid/include/auth.php | head -60

Find cert-related functions in auth.php


70:    $list = vv_npm_req('GET', '/api/nginx/proxy-hosts?expand=certificate');
76:function vv_npm_list_certs(): array {
77:    $list = vv_npm_req('GET', '/api/nginx/certificates');


grep -n "cert\|Cert\|CERT\|ssl\|SSL\|npm" /boot/config/plugins/varaverk/Plugin/unraid/pages/auth.php | head -80

Find cert-related sections in auth page


29:.vv-au-badge.ssl   { background:#0d1f0d;border:1px solid #1a3a1a;color:#4caf50; }
30:.vv-au-badge.nossl { background:#111;border:1px solid #222;color:#444; }
115:/* ── Certs panel ─────────────────────────────────────────────────────────── */
116:.vv-au-cert-grid { display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:10px; }
117:.vv-au-cert-days { font-size:28px;font-weight:700;line-height:1;margin:6px 0 2px; }
118:.vv-au-cert-bar  { height:3px;border-radius:2px;background:#1a1a1a;overflow:hidden;margin-top:8px; }
119:.vv-au-cert-fill { height:100%;border-radius:2px;transition:width .3s; }
132:  <button class="vv-au-tab" data-tab="certs">Certs</button>
146:        Domain	Forward	SSL	Status
213:
214:

216:
217:    <button class="vv-au-btn prim" id="vv-au-cert-run">Run now</button>
219:

222:   { _certs = r.certs || []; certsLoaded = true; _check(); });
336:  _get('npm_proxies', r => {
352:    const hasSsl   = p.certificate_id && p.certificate_id !== '0';
353:    const sslBadge = hasSsl
354:      ? `<span class="vv-au-badge ssl">SSL</span>`
355:      : `<span class="vv-au-badge nossl">none</span>`;
360:      ${sslBadge}
374:  const certOptions = _certs.map(c =>
375:    `<option value="${c.id}"${p && p.certificate_id == c.id ? ' selected' : ''}>${_esc(c.nice_name || c.domain_names?.join(', '))}</option>`
402:      SSL Certificate
403:      <select class="vv-au-select" id="pm-cert">
405:        ${certOptions}
409:
410:      Force SSL
443:      certificate_id:        parseInt(document.getElementById('pm-cert').value) || 0,
444:      ssl_forced:            document.getElementById('pm-ssl-forced').classList.contains('on'),
463:    if (p) _post({ action:'npm_update', id: p.id, data: JSON.stringify(data) }, done);
464:    else   _post({ action:'npm_create',             data: JSON.stringify(data) }, done);
483:    _post({ action:'npm_toggle', id, enabled: was?'0':'1' }, r => {
497:    _post({ action:'npm_delete', id }, r => { if (r.ok) _loadProxies(); });
937:// ── Certs ─────────────────────────────────────────────────────────────────────
938:function _certBadgeCls(s) {
939:  return ({OK:'ssl', WARN:'one_factor', CRIT:'deny', FAIL:'deny'})[s] || 'nossl';
941:function _certBadgeTxt(s) {
944:function _certDayColor(days, warn, crit) {
948:function _certRel(ts) {
957:function _renderCerts(data) {
958:  const grid = document.getElementById('vv-au-cert-grid');
959:  const ts   = document.getElementById('vv-au-cert-ts');
960:  const cfg  = document.getElementById('vv-au-cert-cfg');
963:  ts.textContent  = data.checked_at ? 'Last checked: ' + _certRel(data.checked_at) : 'Not yet checked';
968:    grid.innerHTML = '
No domains configured — add HOST*_CERT_MONITOR_DOMAINS to host.conf
';
975:    const col    = _certDayColor(days, warn, crit);
980:
${days != null ? days : '—'}

982:      ${_certBadgeTxt(s)}
984:      ${days != null ? `<div class="vv-au-cert-bar"><div class="vv-au-cert-fill" style="width:${barPct}%;background:${col};"></div></div>` : ''}
989:function _loadCerts() {
990:  const grid = document.getElementById('vv-au-cert-grid');
992:  fetch(CERT_API)
994:    .then(d => { if (d.ok) _renderCerts(d); })
995:    .catch(() => { grid.innerHTML = '
Failed to load cert data
'; });
998:document.getElementById('vv-au-cert-run').addEventListener('click', function() {
1000:  const log = document.getElementById('vv-au-cert-log');
1004:  fetch(CERT_API, { method: 'POST', body: fd })
1008:      if (d.data) _renderCerts(d.data);


213
214

215

216
217	    <button class="vv-au-btn prim" id="vv-au-cert-run">Run now</button>
218

219

220
Loading…

221

222

225

226

227
228
229

230

231

232
233

    <button class="vv-au-btn prim" id="vv-au-cert-run">Run now</button>

Loading…


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

    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;


937	// ── Certs ─────────────────────────────────────────────────────────────────────
938	function _certBadgeCls(s) {
939	  return ({OK:'ssl', WARN:'one_factor', CRIT:'deny', FAIL:'deny'})[s] || 'nossl';
940	}
941	function _certBadgeTxt(s) {
942	  return ({OK:'healthy', WARN:'warning', CRIT:'critical', FAIL:'failed', UNKN:'not checked'})[s] || s;
943	}
944	function _certDayColor(days, warn, crit) {
945	  if (days == null) return '#3a3a3a';
946	  return days <= crit ? '#ef5350' : days <= warn ? '#ffb74d' : '#4caf50';
947	}
948	function _certRel(ts) {
949	  if (!ts) return '—';
950	  const d = Math.floor(Date.now()/1000) - ts;
951	  if (d < 60)    return 'just now';
952	  if (d < 3600)  return Math.floor(d/60) + 'm ago';
953	  if (d < 86400) return Math.floor(d/3600) + 'h ago';
954	  return Math.floor(d/86400) + 'd ago';
955	}
956
957	function _renderCerts(data) {
958	  const grid = document.getElementById('vv-au-cert-grid');
959	  const ts   = document.getElementById('vv-au-cert-ts');
960	  const cfg  = document.getElementById('vv-au-cert-cfg');
961	  const warn = data.warn_days || 30, crit = data.crit_days || 7;
962
963	  ts.textContent  = data.checked_at ? 'Last checked: ' + _certRel(data.checked_at) : 'Not yet checked';
964	  cfg.textContent = `Warn: ${warn}d · Crit: ${crit}d`;
965
966	  const domains = data.domains || [];
967	  if (!domains.length) {
968	    grid.innerHTML = '
No domains configured — add HOST*_CERT_MONITOR_DOMAINS to host.conf
';
969	    return;
970	  }
971
972	  grid.innerHTML = domains.map(d => {
973	    const s      = d.status || 'UNKN';
974	    const days   = d.days;
975	    const col    = _certDayColor(days, warn, crit);
976	    const barPct = days != null ? Math.min(Math.round(days/90*100), 100) : 0;
977	    const expStr = d.expires ? 'Expires ' + d.expires : (s === 'UNKN' ? 'Not yet checked' : '');
978	    return `

979
${_esc(d.domain)}

980
${days != null ? days : '—'}

981
${days != null ? 'days remaining' : ''}

982	      ${_certBadgeTxt(s)}
983
${_esc(expStr)}

984	      ${days != null ? `<div class="vv-au-cert-bar"><div class="vv-au-cert-fill" style="width:${barPct}%;background:${col};"></div></div>` : ''}
985
`;
986	  }).join('');
987	}
988
989	function _loadCerts() {
990	  const grid = document.getElementById('vv-au-cert-grid');
991	  grid.innerHTML = '
Loading…
';
992	  fetch(CERT_API)
993	    .then(r => r.json())
994	    .then(d => { if (d.ok) _renderCerts(d); })
995	    .catch(() => { grid.innerHTML = '
Failed to load cert data
'; });
996	}
997
998	document.getElementById('vv-au-cert-run').addEventListener('click', function() {
999	  const btn = this;
1000	  const log = document.getElementById('vv-au-cert-log');
1001	  btn.disabled = true; btn.textContent = 'Checking…';
1002	  log.style.display = 'none'; log.textContent = '';
1003	  const fd = new FormData(); fd.append('action', 'run');
1004	  fetch(CERT_API, { method: 'POST', body: fd })
1005	    .then(r => r.json())
1006	    .then(d => {
1007	      btn.disabled = false; btn.textContent = 'Run now';
1008	      if (d.data) _renderCerts(d.data);
1009	      if (d.output && d.output.length) {
1010	        log.style.display = 'block';
1011	        log.textContent = d.output.join('\n').replace(/\x1b\[[0-9;]*m/g, '');
1012	      }
1013	    })
1014	    .catch(() => { btn.disabled = false; btn.textContent = 'Run now'; });
1015	});
1016

// ── Certs ─────────────────────────────────────────────────────────────────────
function _certBadgeCls(s) {
  return ({OK:'ssl', WARN:'one_factor', CRIT:'deny', FAIL:'deny'})[s] || 'nossl';
}
function _certBadgeTxt(s) {
  return ({OK:'healthy', WARN:'warning', CRIT:'critical', FAIL:'failed', UNKN:'not checked'})[s] || s;
}
function _certDayColor(days, warn, crit) {
  if (days == null) return '#3a3a3a';
  return days <= crit ? '#ef5350' : days <= warn ? '#ffb74d' : '#4caf50';
}
function _certRel(ts) {
  if (!ts) return '—';
  const d = Math.floor(Date.now()/1000) - ts;
  if (d < 60)    return 'just now';
  if (d < 3600)  return Math.floor(d/60) + 'm ago';
  if (d < 86400) return Math.floor(d/3600) + 'h ago';
  return Math.floor(d/86400) + 'd ago';
}

function _renderCerts(data) {
  const grid = document.getElementById('vv-au-cert-grid');
  const ts   = document.getElementById('vv-au-cert-ts');
  const cfg  = document.getElementById('vv-au-cert-cfg');
  const warn = data.warn_days || 30, crit = data.crit_days || 7;

  ts.textContent  = data.checked_at ? 'Last checked: ' + _certRel(data.checked_at) : 'Not yet checked';
  cfg.textContent = `Warn: ${warn}d · Crit: ${crit}d`;

  const domains = data.domains || [];
  if (!domains.length) {
    grid.innerHTML = '
No domains configured — add HOST*_CERT_MONITOR_DOMAINS to host.conf
';
    return;
  }

  grid.innerHTML = domains.map(d => {
    const s      = d.status || 'UNKN';
    const days   = d.days;
    const col    = _certDayColor(days, warn, crit);
    const barPct = days != null ? Math.min(Math.round(days/90*100), 100) : 0;
    const expStr = d.expires ? 'Expires ' + d.expires : (s === 'UNKN' ? 'Not yet checked' : '');
    return `

${_esc(d.domain)}

${days != null ? days : '—'}

${days != null ? 'days remaining' : ''}

      ${_certBadgeTxt(s)}

${_esc(expStr)}

      ${days != null ? `<div class="vv-au-cert-bar"><div class="vv-au-cert-fill" style="width:${barPct}%;background:${col};"></div></div>` : ''}

`;
  }).join('');
}

function _loadCerts() {
  const grid = document.getElementById('vv-au-cert-grid');
  grid.innerHTML = '
Loading…
';
  fetch(CERT_API)
    .then(r => r.json())
    .then(d => { if (d.ok) _renderCerts(d); })
    .catch(() => { grid.innerHTML = '
Failed to load cert data
'; });
}

document.getElementById('vv-au-cert-run').addEventListener('click', function() {
  const btn = this;
  const log = document.getElementById('vv-au-cert-log');
  btn.disabled = true; btn.textContent = 'Checking…';
  log.style.display = 'none'; log.textContent = '';
  const fd = new FormData(); fd.append('action', 'run');
  fetch(CERT_API, { method: 'POST', body: fd })
    .then(r => r.json())
    .then(d => {
      btn.disabled = false; btn.textContent = 'Run now';
      if (d.data) _renderCerts(d.data);
      if (d.output && d.output.length) {
        log.style.display = 'block';
        log.textContent = d.output.join('\n').replace(/\x1b\[[0-9;]*m/g, '');
      }
    })
    .catch(() => { btn.disabled = false; btn.textContent = 'Run now'; });
});


List API files


api_test.php
arrs.php
auth.php
board.php
cert.php
checklist.php
clearlock.php
conf_toggle.php
confform.php
config.php
create_api_key.php
docker.php
docker_action.php
dryrun.php
fallback.php
flag_toggle.php
log.php
manual_sync.php
media.php
monitor.php
monitor_fast.php
monitor_remote.php
movescript.php
partnership.php
partnership_ping.php
partnership_settings.php
rawconf.php
readscript.php
recent.php
reorderarray.php
rsync.php
rsync_profiles.php
rsync_standalone.php
rsync_win_arrays.php
run.php
savefolders.php
scheduler.php
script.php
scriptinfo.php
settings.php
setup.php
snapshot.php
status.php
stop.php
storage.php
system.php
watchdog.php


1	<?php
2	header('Content-Type: application/json');
3	require_once dirname(__DIR__) . '/include/config.php';
4
5	$action = ($_SERVER['REQUEST_METHOD'] === 'POST')
6	    ? trim($_POST['action'] ?? '')
7	    : trim($_GET['action'] ?? '');
8
9	$cacheFile = STATE_DIR . '/cert_status.json';
10
11	// ── Read configured domains (without running checks) ─────────────────────────
12	if ($action === 'domains') {
13	    $hostId   = vv_detect_host();
14	    $hostIdUp = strtoupper($hostId);
15	    $confRaw  = ($hostId !== 'unknown') ? vv_read_conf_raw($hostId . '.conf') : '';
16	    $master   = vv_read_conf_raw('master.conf');
17
18	    // Extract CERT_WARN_DAYS / CERT_CRIT_DAYS from master
19	    preg_match('/^\s*CERT_WARN_DAYS\s*=\s*(\d+)/m', $master, $w);
20	    preg_match('/^\s*CERT_CRIT_DAYS\s*=\s*(\d+)/m', $master, $c);
21
22	    // Extract domains array from host conf
23	    $domains = [];
24	    if (preg_match('/' . $hostIdUp . '_CERT_MONITOR_DOMAINS\s*=\s*\(([^)]*)\)/s', $confRaw, $dm)) {
25	        preg_match_all('/"([^"]+)"/', $dm[1], $dd);
26	        $domains = $dd[1] ?? [];
27	    }
28
29	    echo json_encode([
30	        'ok'        => true,
31	        'host_id'   => $hostId,
32	        'domains'   => $domains,
33	        'warn_days' => (int)($w[1] ?? 30),
34	        'crit_days' => (int)($c[1] ?? 7),
35	    ]);
36	    exit;
37	}
38
39	// ── Run cert_monitor.sh now ───────────────────────────────────────────────────
40	if ($action === 'run') {
41	    $script = SCRIPTS_DIR . '/Monitors/cert_monitor.sh';
42	    if (!file_exists($script)) {
43	        echo json_encode(['ok' => false, 'error' => 'cert_monitor.sh not found']);
44	        exit;
45	    }
46	    set_time_limit(180);
47	    exec('bash ' . escapeshellarg($script) . ' 2>&1', $out, $rc);
48	    // Read freshly written cache
49	    $data = file_exists($cacheFile)
50	        ? (json_decode(file_get_contents($cacheFile), true) ?: null)
51	        : null;
52	    echo json_encode([
53	        'ok'     => true,
54	        'data'   => $data,
55	        'output' => array_slice(array_filter(array_map('trim', $out)), 0, 30),
56	        'rc'     => $rc,
57	    ]);
58	    exit;
59	}
60
61	// ── Default: return cached status ─────────────────────────────────────────────
62	if (!file_exists($cacheFile)) {
63	    // No cache yet — return configured domains so UI can show them unchecked
64	    $hostId   = vv_detect_host();
65	    $hostIdUp = strtoupper($hostId);
66	    $confRaw  = ($hostId !== 'unknown') ? vv_read_conf_raw($hostId . '.conf') : '';
67	    $master   = vv_read_conf_raw('master.conf');
68
69	    preg_match('/^\s*CERT_WARN_DAYS\s*=\s*(\d+)/m', $master, $w);
70	    preg_match('/^\s*CERT_CRIT_DAYS\s*=\s*(\d+)/m', $master, $c);
71	    $domains = [];
72	    if (preg_match('/' . $hostIdUp . '_CERT_MONITOR_DOMAINS\s*=\s*\(([^)]*)\)/s', $confRaw, $dm)) {
73	        preg_match_all('/"([^"]+)"/', $dm[1], $dd);
74	        foreach ($dd[1] ?? [] as $d) {
75	            $domains[] = ['domain' => $d, 'status' => 'UNKN', 'days' => null, 'expires' => ''];
76	        }
77	    }
78
79	    echo json_encode([
80	        'ok'         => true,
81	        'checked_at' => null,
82	        'host'       => $hostId !== 'unknown' ? strtoupper($hostId) : null,
83	        'warn_days'  => (int)($w[1] ?? 30),
84	        'crit_days'  => (int)($c[1] ?? 7),
85	        'domains'    => $domains,
86	    ]);
87	    exit;
88	}
89
90	$data = json_decode(file_get_contents($cacheFile), true) ?: [];
91	echo json_encode(array_merge(['ok' => true], $data));
92

<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';

$action = ($_SERVER['REQUEST_METHOD'] === 'POST')
    ? trim($_POST['action'] ?? '')
    : trim($_GET['action'] ?? '');

$cacheFile = STATE_DIR . '/cert_status.json';

// ── Read configured domains (without running checks) ─────────────────────────
if ($action === 'domains') {
    $hostId   = vv_detect_host();
    $hostIdUp = strtoupper($hostId);
    $confRaw  = ($hostId !== 'unknown') ? vv_read_conf_raw($hostId . '.conf') : '';
    $master   = vv_read_conf_raw('master.conf');

    // Extract CERT_WARN_DAYS / CERT_CRIT_DAYS from master
    preg_match('/^\s*CERT_WARN_DAYS\s*=\s*(\d+)/m', $master, $w);
    preg_match('/^\s*CERT_CRIT_DAYS\s*=\s*(\d+)/m', $master, $c);

    // Extract domains array from host conf
    $domains = [];
    if (preg_match('/' . $hostIdUp . '_CERT_MONITOR_DOMAINS\s*=\s*\(([^)]*)\)/s', $confRaw, $dm)) {
        preg_match_all('/"([^"]+)"/', $dm[1], $dd);
        $domains = $dd[1] ?? [];
    }

    echo json_encode([
        'ok'        => true,
        'host_id'   => $hostId,
        'domains'   => $domains,
        'warn_days' => (int)($w[1] ?? 30),
        'crit_days' => (int)($c[1] ?? 7),
    ]);
    exit;
}

// ── Run cert_monitor.sh now ───────────────────────────────────────────────────
if ($action === 'run') {
    $script = SCRIPTS_DIR . '/Monitors/cert_monitor.sh';
    if (!file_exists($script)) {
        echo json_encode(['ok' => false, 'error' => 'cert_monitor.sh not found']);
        exit;
    }
    set_time_limit(180);
    exec('bash ' . escapeshellarg($script) . ' 2>&1', $out, $rc);
    // Read freshly written cache
    $data = file_exists($cacheFile)
        ? (json_decode(file_get_contents($cacheFile), true) ?: null)
        : null;
    echo json_encode([
        'ok'     => true,
        'data'   => $data,
        'output' => array_slice(array_filter(array_map('trim', $out)), 0, 30),
        'rc'     => $rc,
    ]);
    exit;
}

// ── Default: return cached status ─────────────────────────────────────────────
if (!file_exists($cacheFile)) {
    // No cache yet — return configured domains so UI can show them unchecked
    $hostId   = vv_detect_host();
    $hostIdUp = strtoupper($hostId);
    $confRaw  = ($hostId !== 'unknown') ? vv_read_conf_raw($hostId . '.conf') : '';
    $master   = vv_read_conf_raw('master.conf');

    preg_match('/^\s*CERT_WARN_DAYS\s*=\s*(\d+)/m', $master, $w);
    preg_match('/^\s*CERT_CRIT_DAYS\s*=\s*(\d+)/m', $master, $c);
    $domains = [];
    if (preg_match('/' . $hostIdUp . '_CERT_MONITOR_DOMAINS\s*=\s*\(([^)]*)\)/s', $confRaw, $dm)) {
        preg_match_all('/"([^"]+)"/', $dm[1], $dd);
        foreach ($dd[1] ?? [] as $d) {
            $domains[] = ['domain' => $d, 'status' => 'UNKN', 'days' => null, 'expires' => ''];
        }
    }

    echo json_encode([
        'ok'         => true,
        'checked_at' => null,
        'host'       => $hostId !== 'unknown' ? strtoupper($hostId) : null,
        'warn_days'  => (int)($w[1] ?? 30),
        'crit_days'  => (int)($c[1] ?? 7),
        'domains'    => $domains,
    ]);
    exit;
}

$data = json_decode(file_get_contents($cacheFile), true) ?: [];
echo json_encode(array_merge(['ok' => true], $data));


grep -n "npm_certs\|case\|action" /boot/config/plugins/varaverk/Plugin/unraid/api/auth.php | head -50

Find npm_certs action in auth API


6:    $action = $_GET['action'] ?? '';
7:    $result = match ($action) {
9:        'npm_certs'      => ['ok' => true, 'certs' => vv_npm_list_certs()],
13:        default          => ['ok' => false, 'error' => 'Unknown action: ' . $action],
25:$action = trim($_POST['action'] ?? '');
27:$result = match ($action) {
44:    default                   => ['ok' => false, 'error' => 'Unknown action: ' . $action],


# Check what NPM returns for certificates - hit the live NPM API
php -r "
require '/boot/config/plugins/varaverk/Plugin/unraid/include/auth.php';
\$certs = vv_npm_list_certs();
if (empty(\$certs)) { echo 'No certs or error'; exit; }
echo json_encode(\$certs[0] ?? [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
echo '\n\n--- Keys in first cert ---\n';
echo implode(', ', array_keys(\$certs[0] ?? []));
" 2>&1

Inspect live NPM certificate object structure


{
    "id": 43,
    "created_on": "2025-11-11 19:22:21",
    "modified_on": "2026-03-12 04:21:07",
    "owner_user_id": 1,
    "provider": "letsencrypt",
    "nice_name": "2.gmer4lfe.com",
    "domain_names": [
        "2.gmer4lfe.com"
    ],
    "expires_on": "2026-06-10 07:22:34",
    "meta": {
        "letsencrypt_email": "failedproxy@gmail.com",
        "letsencrypt_agree": true,
        "dns_challenge": false
    }
}\n\n--- Keys in first cert ---\nid, created_on, modified_on, owner_user_id, provider, nice_name, domain_names, expires_on, meta


Good — `expires_on` is right there in the NPM cert object. The plan: add a `?action=npm` endpoint to `cert.php` that pulls certs live from NPM, computes days remaining, and sorts soonest-first. Then retarget the certs tab to use it instead of cert_monitor.sh.


115	/* ── Certs panel ─────────────────────────────────────────────────────────── */
116	.vv-au-cert-grid { display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:10px; }
117	.vv-au-cert-days { font-size:28px;font-weight:700;line-height:1;margin:6px 0 2px; }
118	.vv-au-cert-bar  { height:3px;border-radius:2px;background:#1a1a1a;overflow:hidden;margin-top:8px; }
119	.vv-au-cert-fill { height:100%;border-radius:2px;transition:width .3s; }
120	</style>
121
122	<?php
123	require_once __DIR__ . '/../include/auth.php';
124	$isOwner = vv_is_owner();

/* ── Certs panel ─────────────────────────────────────────────────────────── */
.vv-au-cert-grid { display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:10px; }
.vv-au-cert-days { font-size:28px;font-weight:700;line-height:1;margin:6px 0 2px; }
.vv-au-cert-bar  { height:3px;border-radius:2px;background:#1a1a1a;overflow:hidden;margin-top:8px; }
.vv-au-cert-fill { height:100%;border-radius:2px;transition:width .3s; }
</style>

<?php
require_once __DIR__ . '/../include/auth.php';
$isOwner = vv_is_owner();


Now I'll make all the changes in parallel:


Edit

<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';

<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
require_once dirname(__DIR__) . '/include/auth.php';


The file /boot/config/plugins/varaverk/Plugin/unraid/api/cert.php has been updated successfully. (file state is current in your context — no need to Read it back)

<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
require_once dirname(__DIR__) . '/include/auth.php';

<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';

$action = ($_SERVER['REQUEST_METHOD'] === 'POST')
    ? trim($_POST['action'] ?? '')
    : trim($_GET['action'] ?? '');

$cacheFile = STATE_DIR . '/cert_status.json';

// ── Read configured domains (without running checks) ─────────────────────────
if ($action === 'domains') {
    $hostId   = vv_detect_host();
    $hostIdUp = strtoupper($hostId);
    $confRaw  = ($hostId !== 'unknown') ? vv_read_conf_raw($hostId . '.conf') : '';
    $master   = vv_read_conf_raw('master.conf');

    // Extract CERT_WARN_DAYS / CERT_CRIT_DAYS from master
    preg_match('/^\s*CERT_WARN_DAYS\s*=\s*(\d+)/m', $master, $w);
    preg_match('/^\s*CERT_CRIT_DAYS\s*=\s*(\d+)/m', $master, $c);

    // Extract domains array from host conf
    $domains = [];
    if (preg_match('/' . $hostIdUp . '_CERT_MONITOR_DOMAINS\s*=\s*\(([^)]*)\)/s', $confRaw, $dm)) {
        preg_match_all('/"([^"]+)"/', $dm[1], $dd);
        $domains = $dd[1] ?? [];
    }

    echo json_encode([
        'ok'        => true,
        'host_id'   => $hostId,
        'domains'   => $domains,
        'warn_days' => (int)($w[1] ?? 30),
        'crit_days' => (int)($c[1] ?? 7),
    ]);
    exit;
}

// ── Run cert_monitor.sh now ───────────────────────────────────────────────────
if ($action === 'run') {
    $script = SCRIPTS_DIR . '/Monitors/cert_monitor.sh';
    if (!file_exists($script)) {
        echo json_encode(['ok' => false, 'error' => 'cert_monitor.sh not found']);
        exit;
    }
    set_time_limit(180);
    exec('bash ' . escapeshellarg($script) . ' 2>&1', $out, $rc);
    // Read freshly written cache
    $data = file_exists($cacheFile)
        ? (json_decode(file_get_contents($cacheFile), true) ?: null)
        : null;
    echo json_encode([
        'ok'     => true,
        'data'   => $data,
        'output' => array_slice(array_filter(array_map('trim', $out)), 0, 30),
        'rc'     => $rc,
    ]);
    exit;
}

// ── Default: return cached status ─────────────────────────────────────────────
if (!file_exists($cacheFile)) {
    // No cache yet — return configured domains so UI can show them unchecked
    $hostId   = vv_detect_host();
    $hostIdUp = strtoupper($hostId);
    $confRaw  = ($hostId !== 'unknown') ? vv_read_conf_raw($hostId . '.conf') : '';
    $master   = vv_read_conf_raw('master.conf');

    preg_match('/^\s*CERT_WARN_DAYS\s*=\s*(\d+)/m', $master, $w);
    preg_match('/^\s*CERT_CRIT_DAYS\s*=\s*(\d+)/m', $master, $c);
    $domains = [];
    if (preg_match('/' . $hostIdUp . '_CERT_MONITOR_DOMAINS\s*=\s*\(([^)]*)\)/s', $confRaw, $dm)) {
        preg_match_all('/"([^"]+)"/', $dm[1], $dd);
        foreach ($dd[1] ?? [] as $d) {
            $domains[] = ['domain' => $d, 'status' => 'UNKN', 'days' => null, 'expires' => ''];
        }
    }

    echo json_encode([
        'ok'         => true,
        'checked_at' => null,
        'host'       => $hostId !== 'unknown' ? strtoupper($hostId) : null,
        'warn_days'  => (int)($w[1] ?? 30),
        'crit_days'  => (int)($c[1] ?? 7),
        'domains'    => $domains,
    ]);
    exit;
}

$data = json_decode(file_get_contents($cacheFile), true) ?: [];
echo json_encode(array_merge(['ok' => true], $data));

+require_once dirname(__DIR__) . '/include/auth.php';


// ── Read configured domains (without running checks) ─────────────────────────

// ── Live NPM cert list ────────────────────────────────────────────────────────
if ($action === 'npm') {
    $raw = vv_npm_req('GET', '/api/nginx/certificates');
    if (!is_array($raw) || isset($raw['_err']))
        die(json_encode(['ok' => false, 'error' => $raw['_err'] ?? 'NPM request failed']));

    $master = vv_read_conf_raw('master.conf');
    preg_match('/^\s*CERT_WARN_DAYS\s*=\s*(\d+)/m', $master, $w);
    preg_match('/^\s*CERT_CRIT_DAYS\s*=\s*(\d+)/m', $master, $c);
    $warn = (int)($w[1] ?? 30);
    $crit = (int)($c[1] ?? 7);
    $now  = time();

    $certs = [];
    foreach ($raw as $cert) {
        $exp    = !empty($cert['expires_on']) ? strtotime($cert['expires_on']) : false;
        $days   = $exp !== false ? (int)(($exp - $now) / 86400) : null;
        $status = $days === null ? 'UNKN'
                : ($days < 0    ? 'CRIT'
                : ($days <= $crit ? 'CRIT'
                : ($days <= $warn ? 'WARN' : 'OK')));
        $certs[] = [
            'id'          => $cert['id'],
            'nice_name'   => $cert['nice_name'] ?? implode(', ', $cert['domain_names'] ?? []),
            'domain_names'=> $cert['domain_names'] ?? [],
            'provider'    => $cert['provider'] ?? 'unknown',
            'expires'     => !empty($cert['expires_on']) ? substr($cert['expires_on'], 0, 10) : '',
            'days'        => $days,
            'status'      => $status,
        ];
    }
    usort($certs, fn($a, $b) => ($a['days'] ?? PHP_INT_MAX) <=> ($b['days'] ?? PHP_INT_MAX));

    echo json_encode(['ok' => true, 'certs' => $certs, 'warn_days' => $warn, 'crit_days' => $crit]);
    exit;
}

// ── Read configured domains (without running checks) ─────────────────────────


// ── Live NPM cert list ────────────────────────────────────────────────────────
if ($action === 'npm') {
    $raw = vv_npm_req('GET', '/api/nginx/certificates');
    if (!is_array($raw) || isset($raw['_err']))
        die(json_encode(['ok' => false, 'error' => $raw['_err'] ?? 'NPM request failed']));

    $master = vv_read_conf_raw('master.conf');
    preg_match('/^\s*CERT_WARN_DAYS\s*=\s*(\d+)/m', $master, $w);
    preg_match('/^\s*CERT_CRIT_DAYS\s*=\s*(\d+)/m', $master, $c);
    $warn = (int)($w[1] ?? 30);
    $crit = (int)($c[1] ?? 7);
    $now  = time();

    $certs = [];
    foreach ($raw as $cert) {
        $exp    = !empty($cert['expires_on']) ? strtotime($cert['expires_on']) : false;
        $days   = $exp !== false ? (int)(($exp - $now) / 86400) : null;
        $status = $days === null ? 'UNKN'
                : ($days < 0    ? 'CRIT'
                : ($days <= $crit ? 'CRIT'
                : ($days <= $warn ? 'WARN' : 'OK')));
        $certs[] = [
            'id'          => $cert['id'],
            'nice_name'   => $cert['nice_name'] ?? implode(', ', $cert['domain_names'] ?? []),
            'domain_names'=> $cert['domain_names'] ?? [],
            'provider'    => $cert['provider'] ?? 'unknown',
            'expires'     => !empty($cert['expires_on']) ? substr($cert['expires_on'], 0, 10) : '',
            'days'        => $days,
            'status'      => $status,
        ];
    }
    usort($certs, fn($a, $b) => ($a['days'] ?? PHP_INT_MAX) <=> ($b['days'] ?? PHP_INT_MAX));

    echo json_encode(['ok' => true, 'certs' => $certs, 'warn_days' => $warn, 'crit_days' => $crit]);
    exit;
}

// ── Read configured domains (without running checks) ─────────────────────────

<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
require_once dirname(__DIR__) . '/include/auth.php';

$action = ($_SERVER['REQUEST_METHOD'] === 'POST')
    ? trim($_POST['action'] ?? '')
    : trim($_GET['action'] ?? '');

$cacheFile = STATE_DIR . '/cert_status.json';

// ── Read configured domains (without running checks) ─────────────────────────
if ($action === 'domains') {
    $hostId   = vv_detect_host();
    $hostIdUp = strtoupper($hostId);
    $confRaw  = ($hostId !== 'unknown') ? vv_read_conf_raw($hostId . '.conf') : '';
    $master   = vv_read_conf_raw('master.conf');

    // Extract CERT_WARN_DAYS / CERT_CRIT_DAYS from master
    preg_match('/^\s*CERT_WARN_DAYS\s*=\s*(\d+)/m', $master, $w);
    preg_match('/^\s*CERT_CRIT_DAYS\s*=\s*(\d+)/m', $master, $c);

    // Extract domains array from host conf
    $domains = [];
    if (preg_match('/' . $hostIdUp . '_CERT_MONITOR_DOMAINS\s*=\s*\(([^)]*)\)/s', $confRaw, $dm)) {
        preg_match_all('/"([^"]+)"/', $dm[1], $dd);
        $domains = $dd[1] ?? [];
    }

    echo json_encode([
        'ok'        => true,
        'host_id'   => $hostId,
        'domains'   => $domains,
        'warn_days' => (int)($w[1] ?? 30),
        'crit_days' => (int)($c[1] ?? 7),
    ]);
    exit;
}

// ── Run cert_monitor.sh now ───────────────────────────────────────────────────
if ($action === 'run') {
    $script = SCRIPTS_DIR . '/Monitors/cert_monitor.sh';
    if (!file_exists($script)) {
        echo json_encode(['ok' => false, 'error' => 'cert_monitor.sh not found']);
        exit;
    }
    set_time_limit(180);
    exec('bash ' . escapeshellarg($script) . ' 2>&1', $out, $rc);
    // Read freshly written cache
    $data = file_exists($cacheFile)
        ? (json_decode(file_get_contents($cacheFile), true) ?: null)
        : null;
    echo json_encode([
        'ok'     => true,
        'data'   => $data,
        'output' => array_slice(array_filter(array_map('trim', $out)), 0, 30),
        'rc'     => $rc,
    ]);
    exit;
}

// ── Default: return cached status ─────────────────────────────────────────────
if (!file_exists($cacheFile)) {
    // No cache yet — return configured domains so UI can show them unchecked
    $hostId   = vv_detect_host();
    $hostIdUp = strtoupper($hostId);
    $confRaw  = ($hostId !== 'unknown') ? vv_read_conf_raw($hostId . '.conf') : '';
    $master   = vv_read_conf_raw('master.conf');

    preg_match('/^\s*CERT_WARN_DAYS\s*=\s*(\d+)/m', $master, $w);
    preg_match('/^\s*CERT_CRIT_DAYS\s*=\s*(\d+)/m', $master, $c);
    $domains = [];
    if (preg_match('/' . $hostIdUp . '_CERT_MONITOR_DOMAINS\s*=\s*\(([^)]*)\)/s', $confRaw, $dm)) {
        preg_match_all('/"([^"]+)"/', $dm[1], $dd);
        foreach ($dd[1] ?? [] as $d) {
            $domains[] = ['domain' => $d, 'status' => 'UNKN', 'days' => null, 'expires' => ''];
        }
    }

    echo json_encode([
        'ok'         => true,
        'checked_at' => null,
        'host'       => $hostId !== 'unknown' ? strtoupper($hostId) : null,
        'warn_days'  => (int)($w[1] ?? 30),
        'crit_days'  => (int)($c[1] ?? 7),
        'domains'    => $domains,
    ]);
    exit;
}

$data = json_decode(file_get_contents($cacheFile), true) ?: [];
echo json_encode(array_merge(['ok' => true], $data));

+// ── Live NPM cert list ────────────────────────────────────────────────────────

+    $raw = vv_npm_req('GET', '/api/nginx/certificates');

+    if (!is_array($raw) || isset($raw['_err']))

+        die(json_encode(['ok' => false, 'error' => $raw['_err'] ?? 'NPM request failed']));

+    $master = vv_read_conf_raw('master.conf');

+    preg_match('/^\s*CERT_WARN_DAYS\s*=\s*(\d+)/m', $master, $w);

+    preg_match('/^\s*CERT_CRIT_DAYS\s*=\s*(\d+)/m', $master, $c);

+        $exp    = !empty($cert['expires_on']) ? strtotime($cert['expires_on']) : false;

+        $days   = $exp !== false ? (int)(($exp - $now) / 86400) : null;

+        $status = $days === null ? 'UNKN'

+                : ($days <= $crit ? 'CRIT'

+            'nice_name'   => $cert['nice_name'] ?? implode(', ', $cert['domain_names'] ?? []),

+            'domain_names'=> $cert['domain_names'] ?? [],

+            'provider'    => $cert['provider'] ?? 'unknown',

+            'expires'     => !empty($cert['expires_on']) ? substr($cert['expires_on'], 0, 10) : '',

+    usort($certs, fn($a, $b) => ($a['days'] ?? PHP_INT_MAX) <=> ($b['days'] ?? PHP_INT_MAX));

+    echo json_encode(['ok' => true, 'certs' => $certs, 'warn_days' => $warn, 'crit_days' => $crit]);


Now update the HTML and JS in the auth page:


    <button class="vv-au-btn prim" id="vv-au-cert-run">Run now</button>

Loading…

    <button class="vv-au-btn prim" id="vv-au-cert-run">Refresh</button>

Loading…


The file /boot/config/plugins/varaverk/Plugin/unraid/pages/auth.php has been updated successfully. (file state is current in your context — no need to Read it back)

    <button class="vv-au-btn prim" id="vv-au-cert-run">Run now</button>

Loading…

    <button class="vv-au-btn prim" id="vv-au-cert-run">Refresh</button>

Loading…

-    <button class="vv-au-btn prim" id="vv-au-cert-run">Run now</button>

+    <button class="vv-au-btn prim" id="vv-au-cert-run">Refresh</button>

-  <div id="vv-au-cert-log" style="display:none;margin-top:12px;background:#0d0d0d;border:1px solid #1e1e1e;

-       border-radius:4px;padding:10px 12px;font-size:10px;color:#555;font-family:monospace;

-       max-height:140px;overflow-y:auto;white-space:pre-wrap;"></div>


Now replace the JS cert functions:


function _renderCerts(data) {
  const grid = document.getElementById('vv-au-cert-grid');
  const ts   = document.getElementById('vv-au-cert-ts');
  const cfg  = document.getElementById('vv-au-cert-cfg');
  const warn = data.warn_days || 30, crit = data.crit_days || 7;

  ts.textContent  = data.checked_at ? 'Last checked: ' + _certRel(data.checked_at) : 'Not yet checked';
  cfg.textContent = `Warn: ${warn}d · Crit: ${crit}d`;

  const domains = data.domains || [];
  if (!domains.length) {
    grid.innerHTML = '
No domains configured — add HOST*_CERT_MONITOR_DOMAINS to host.conf
';
    return;
  }

  grid.innerHTML = domains.map(d => {
    const s      = d.status || 'UNKN';
    const days   = d.days;
    const col    = _certDayColor(days, warn, crit);
    const barPct = days != null ? Math.min(Math.round(days/90*100), 100) : 0;
    const expStr = d.expires ? 'Expires ' + d.expires : (s === 'UNKN' ? 'Not yet checked' : '');
    return `

${_esc(d.domain)}

${days != null ? days : '—'}

${days != null ? 'days remaining' : ''}

      ${_certBadgeTxt(s)}

${_esc(expStr)}

      ${days != null ? `<div class="vv-au-cert-bar"><div class="vv-au-cert-fill" style="width:${barPct}%;background:${col};"></div></div>` : ''}

`;
  }).join('');
}

function _loadCerts() {
  const grid = document.getElementById('vv-au-cert-grid');
  grid.innerHTML = '
Loading…
';
  fetch(CERT_API)
    .then(r => r.json())
    .then(d => { if (d.ok) _renderCerts(d); })
    .catch(() => { grid.innerHTML = '
Failed to load cert data
'; });
}

document.getElementById('vv-au-cert-run').addEventListener('click', function() {
  const btn = this;
  const log = document.getElementById('vv-au-cert-log');
  btn.disabled = true; btn.textContent = 'Checking…';
  log.style.display = 'none'; log.textContent = '';
  const fd = new FormData(); fd.append('action', 'run');
  fetch(CERT_API, { method: 'POST', body: fd })
    .then(r => r.json())
    .then(d => {
      btn.disabled = false; btn.textContent = 'Run now';
      if (d.data) _renderCerts(d.data);
      if (d.output && d.output.length) {
        log.style.display = 'block';
        log.textContent = d.output.join('\n').replace(/\x1b\[[0-9;]*m/g, '');
      }
    })
    .catch(() => { btn.disabled = false; btn.textContent = 'Run now'; });
});

function _renderNpmCerts(data) {
  const grid = document.getElementById('vv-au-cert-grid');
  const ts   = document.getElementById('vv-au-cert-ts');
  const cfg  = document.getElementById('vv-au-cert-cfg');
  const warn = data.warn_days || 30, crit = data.crit_days || 7;
  const certs = data.certs || [];

  ts.textContent  = certs.length + ' certificate' + (certs.length !== 1 ? 's' : '') + ' · live from NPM';
  cfg.textContent = `Warn: ${warn}d · Crit: ${crit}d`;

  if (!certs.length) {
    grid.innerHTML = '
No certificates found in NPM
';
    return;
  }

  grid.innerHTML = certs.map(c => {
    const s      = c.status || 'UNKN';
    const days   = c.days;
    const col    = _certDayColor(days, warn, crit);
    const barPct = days != null ? Math.min(Math.round(days / 90 * 100), 100) : 0;
    const expStr = c.expires ? 'Expires ' + c.expires : '';
    const extra  = (c.domain_names || []).filter(d => d !== c.nice_name).join(', ');
    return `

${_esc(c.nice_name)}

      ${extra ? `<div style="font-size:9px;color:#444;margin-bottom:2px;word-break:break-all;">${_esc(extra)}</div>` : ''}

${days != null ? days : '—'}

${days != null ? 'days remaining' : ''}

      ${_certBadgeTxt(s)}

${_esc(expStr)}

      ${days != null ? `<div class="vv-au-cert-bar"><div class="vv-au-cert-fill" style="width:${barPct}%;background:${col};"></div></div>` : ''}

`;
  }).join('');
}

function _loadCerts(onDone) {
  const grid = document.getElementById('vv-au-cert-grid');
  grid.innerHTML = '
Loading…
';
  fetch(CERT_API + '?action=npm')
    .then(r => r.json())
    .then(d => {
      if (d.ok) _renderNpmCerts(d);
      else throw new Error(d.error || 'NPM error');
      if (onDone) onDone();
    })
    .catch(e => {
      grid.innerHTML = `<div class="vv-au-empty" style="color:#ef5350">${_esc(e.message || 'Failed to load')}</div>`;
      if (onDone) onDone();
    });
}

document.getElementById('vv-au-cert-run').addEventListener('click', function() {
  const btn = this;
  btn.disabled = true; btn.textContent = 'Loading…';
  _loadCerts(() => { btn.disabled = false; btn.textContent = 'Refresh'; });
});


function _renderCerts(data) {
  const grid = document.getElementById('vv-au-cert-grid');
  const ts   = document.getElementById('vv-au-cert-ts');
  const cfg  = document.getElementById('vv-au-cert-cfg');
  const warn = data.warn_days || 30, crit = data.crit_days || 7;

  ts.textContent  = data.checked_at ? 'Last checked: ' + _certRel(data.checked_at) : 'Not yet checked';
  cfg.textContent = `Warn: ${warn}d · Crit: ${crit}d`;

  const domains = data.domains || [];
  if (!domains.length) {
    grid.innerHTML = '
No domains configured — add HOST*_CERT_MONITOR_DOMAINS to host.conf
';
    return;
  }

  grid.innerHTML = domains.map(d => {
    const s      = d.status || 'UNKN';
    const days   = d.days;
    const col    = _certDayColor(days, warn, crit);
    const barPct = days != null ? Math.min(Math.round(days/90*100), 100) : 0;
    const expStr = d.expires ? 'Expires ' + d.expires : (s === 'UNKN' ? 'Not yet checked' : '');
    return `

${_esc(d.domain)}

${days != null ? days : '—'}

${days != null ? 'days remaining' : ''}

      ${_certBadgeTxt(s)}

${_esc(expStr)}

      ${days != null ? `<div class="vv-au-cert-bar"><div class="vv-au-cert-fill" style="width:${barPct}%;background:${col};"></div></div>` : ''}

`;
  }).join('');
}

function _loadCerts() {
  const grid = document.getElementById('vv-au-cert-grid');
  grid.innerHTML = '
Loading…
';
  fetch(CERT_API)
    .then(r => r.json())
    .then(d => { if (d.ok) _renderCerts(d); })
    .catch(() => { grid.innerHTML = '
Failed to load cert data
'; });
}

document.getElementById('vv-au-cert-run').addEventListener('click', function() {
  const btn = this;
  const log = document.getElementById('vv-au-cert-log');
  btn.disabled = true; btn.textContent = 'Checking…';
  log.style.display = 'none'; log.textContent = '';
  const fd = new FormData(); fd.append('action', 'run');
  fetch(CERT_API, { method: 'POST', body: fd })
    .then(r => r.json())
    .then(d => {
      btn.disabled = false; btn.textContent = 'Run now';
      if (d.data) _renderCerts(d.data);
      if (d.output && d.output.length) {
        log.style.display = 'block';
        log.textContent = d.output.join('\n').replace(/\x1b\[[0-9;]*m/g, '');
      }
    })
    .catch(() => { btn.disabled = false; btn.textContent = 'Run now'; });
});

function _renderNpmCerts(data) {
  const grid = document.getElementById('vv-au-cert-grid');
  const ts   = document.getElementById('vv-au-cert-ts');
  const cfg  = document.getElementById('vv-au-cert-cfg');
  const warn = data.warn_days || 30, crit = data.crit_days || 7;
  const certs = data.certs || [];

  ts.textContent  = certs.length + ' certificate' + (certs.length !== 1 ? 's' : '') + ' · live from NPM';
  cfg.textContent = `Warn: ${warn}d · Crit: ${crit}d`;

  if (!certs.length) {
    grid.innerHTML = '
No certificates found in NPM
';
    return;
  }

  grid.innerHTML = certs.map(c => {
    const s      = c.status || 'UNKN';
    const days   = c.days;
    const col    = _certDayColor(days, warn, crit);
    const barPct = days != null ? Math.min(Math.round(days / 90 * 100), 100) : 0;
    const expStr = c.expires ? 'Expires ' + c.expires : '';
    const extra  = (c.domain_names || []).filter(d => d !== c.nice_name).join(', ');
    return `

${_esc(c.nice_name)}

      ${extra ? `<div style="font-size:9px;color:#444;margin-bottom:2px;word-break:break-all;">${_esc(extra)}</div>` : ''}

${days != null ? days : '—'}

${days != null ? 'days remaining' : ''}

      ${_certBadgeTxt(s)}

${_esc(expStr)}

      ${days != null ? `<div class="vv-au-cert-bar"><div class="vv-au-cert-fill" style="width:${barPct}%;background:${col};"></div></div>` : ''}

`;
  }).join('');
}

function _loadCerts(onDone) {
  const grid = document.getElementById('vv-au-cert-grid');
  grid.innerHTML = '
Loading…
';
  fetch(CERT_API + '?action=npm')
    .then(r => r.json())
    .then(d => {
      if (d.ok) _renderNpmCerts(d);
      else throw new Error(d.error || 'NPM error');
      if (onDone) onDone();
    })
    .catch(e => {
      grid.innerHTML = `<div class="vv-au-empty" style="color:#ef5350">${_esc(e.message || 'Failed to load')}</div>`;
      if (onDone) onDone();
    });
}

document.getElementById('vv-au-cert-run').addEventListener('click', function() {
  const btn = this;
  btn.disabled = true; btn.textContent = 'Loading…';
  _loadCerts(() => { btn.disabled = false; btn.textContent = 'Refresh'; });
});

-  ts.textContent  = data.checked_at ? 'Last checked: ' + _certRel(data.checked_at) : 'Not yet checked';

+  ts.textContent  = certs.length + ' certificate' + (certs.length !== 1 ? 's' : '') + ' · live from NPM';

-    grid.innerHTML = '
No domains configured — add HOST*_CERT_MONITOR_DOMAINS to host.conf
';

+    grid.innerHTML = '
No certificates found in NPM
';

-    const barPct = days != null ? Math.min(Math.round(days/90*100), 100) : 0;

-    const expStr = d.expires ? 'Expires ' + d.expires : (s === 'UNKN' ? 'Not yet checked' : '');

+    const barPct = days != null ? Math.min(Math.round(days / 90 * 100), 100) : 0;

+    const expStr = c.expires ? 'Expires ' + c.expires : '';

+    const extra  = (c.domain_names || []).filter(d => d !== c.nice_name).join(', ');

return `<div class="vv-au-card" style="padding:12px 14px;">

+      ${extra ? `<div style="font-size:9px;color:#444;margin-bottom:2px;word-break:break-all;">${_esc(extra)}</div>` : ''}

-    .then(d => { if (d.ok) _renderCerts(d); })

-    .catch(() => { grid.innerHTML = '
Failed to load cert data
'; });

+      else throw new Error(d.error || 'NPM error');

+      grid.innerHTML = `<div class="vv-au-empty" style="color:#ef5350">${_esc(e.message || 'Failed to load')}</div>`;

-  const log = document.getElementById('vv-au-cert-log');

-  btn.disabled = true; btn.textContent = 'Checking…';

-  log.style.display = 'none'; log.textContent = '';

-  const fd = new FormData(); fd.append('action', 'run');

-  fetch(CERT_API, { method: 'POST', body: fd })

-      btn.disabled = false; btn.textContent = 'Run now';

-      if (d.data) _renderCerts(d.data);

-      if (d.output && d.output.length) {

-        log.textContent = d.output.join('\n').replace(/\x1b\[[0-9;]*m/g, '');

-    .catch(() => { btn.disabled = false; btn.textContent = 'Run now'; });

+  btn.disabled = true; btn.textContent = 'Loading…';

+  _loadCerts(() => { btn.disabled = false; btn.textContent = 'Refresh'; });

// ── Boot ──────────────────────────────────────────────────────────────────────


Now test the new endpoint live:


Bash

php /boot/config/plugins/varaverk/Plugin/unraid/api/cert.php <<< '' 2>&1; echo "---"; php -r "
\$_GET['action'] = 'npm';
ob_start();
include '/boot/config/plugins/varaverk/Plugin/unraid/api/cert.php';
\$out = ob_get_clean();
\$d = json_decode(\$out, true);
if (!\$d) { echo 'Invalid JSON: ' . \$out; exit; }
echo 'ok=' . (\$d['ok'] ? 'true' : 'false') . ', count=' . count(\$d['certs'] ?? []) . PHP_EOL;
foreach (\$d['certs'] ?? [] as \$c)
    printf('  %-35s %3d days  %s%s%s', \$c['nice_name'], \$c['days'] ?? -999, \$c['status'], \$c['expires'] ? '  ' . \$c['expires'] : '', PHP_EOL);
" 2>&1

Test the new npm cert action


Working perfectly. 49 certs pulled live from NPM, sorted soonest-first. You've got some that need attention:

- `jfinrequest`, `testing.gmer4lfe.us` — expired 124 days ago
- `codee.gmer4lfe.us` — expired 10 days ago
- Several `.com` certs at **3 days** (critical — need renewal ASAP)

The certs tab now loads everything automatically from NPM on open, sorted by urgency, with CRIT/WARN/OK badges and expiry bars. No conf entries to maintain. Hit **Refresh** to re-pull live at any time.


Error loading watchdog data — check API


php /boot/config/plugins/varaverk/Plugin/unraid/api/watchdog.php 2>&1 | head -50

Hit watchdog API directly to see the error


API is working fine. The error is in the frontend. Let me find where that message is displayed and what it's checking:


grep -rn "Error loading watchdog\|check API\|watchdog" /boot/config/plugins/varaverk/Plugin/unraid/pages/ | grep -v ".php~" | head -40

Find the error message and watchdog page


/boot/config/plugins/varaverk/Plugin/unraid/pages/watchdog.php:140:               : apiOnly ? '#4a7a9b'    // blue-grey: API-only, no watchdog state
/boot/config/plugins/varaverk/Plugin/unraid/pages/watchdog.php:170:// ── Docker watchdog state card ────────────────────────────────────────────────
/boot/config/plugins/varaverk/Plugin/unraid/pages/watchdog.php:329:// ── Storage watchdog card ─────────────────────────────────────────────────────
/boot/config/plugins/varaverk/Plugin/unraid/pages/watchdog.php:406:// ── Network watchdog card ─────────────────────────────────────────────────────
/boot/config/plugins/varaverk/Plugin/unraid/pages/watchdog.php:448:  // Per-host: pressure alert (if active) then all 5 watchdog cards in one equal-spaced row
/boot/config/plugins/varaverk/Plugin/unraid/pages/watchdog.php:476:  fetch('/plugins/varaverk/api/watchdog.php')
/boot/config/plugins/varaverk/Plugin/unraid/pages/watchdog.php:481:        '
Error loading watchdog data — check API
';
/boot/config/plugins/varaverk/Plugin/unraid/pages/fallback.php:238:        '
Error loading fallback data — check API
';
/boot/config/plugins/varaverk/Plugin/unraid/pages/arrs.php:397:        '
Error loading arrs data — check API
';
/boot/config/plugins/varaverk/Plugin/unraid/pages/monitor.php:150:

/boot/config/plugins/varaverk/Plugin/unraid/pages/monitor.php:157:
Loading...

/boot/config/plugins/varaverk/Plugin/unraid/pages/monitor.php:905:      // ── CPU title (core count + temp — watchdog data only comes from full poll) ──
/boot/config/plugins/varaverk/Plugin/unraid/pages/monitor.php:908:        const _cpuTemp  = d.watchdog?.stability?.cpu_temp ?? null;
/boot/config/plugins/varaverk/Plugin/unraid/pages/monitor.php:1040:        const wd  = d.watchdog ?? {};
/boot/config/plugins/varaverk/Plugin/unraid/pages/monitor.php:1041:        const el  = document.getElementById('vv-watchdog-body');
/boot/config/plugins/varaverk/Plugin/unraid/pages/monitor.php:1118:        // ── Stability strikes (system watchdog) ──────────────────────────────
/boot/config/plugins/varaverk/Plugin/unraid/pages/monitor.php:1129:        // ── Storage watchdog strikes ─────────────────────────────────────────
/boot/config/plugins/varaverk/Plugin/unraid/pages/monitor.php:1131:          html += `<div style="font-size:10px;color:#555;margin-top:${stabNames.length?6:0}px;margin-bottom:3px;text-transform:uppercase;letter-spacing:.05em;">Storage watchdog</div>`;
/boot/config/plugins/varaverk/Plugin/unraid/pages/monitor.php:1174:        const wdCard = document.getElementById('vv-watchdog-card');
/boot/config/plugins/varaverk/Plugin/unraid/pages/scheduler.php:620:The watchdog tier runs every 15 minutes as a single-pass chain: resource_watchdog frees memory pressure first, docker_watchdog restarts down containers into that headroom, system_watchdog checks storage/WebGUI/network, stability_watchdog reboots as a last resort. The order matters — restarting containers before freeing memory just restarts them into the same pressure.


470	        hour:'2-digit', minute:'2-digit', second:'2-digit'})
471	    : '';
472	  document.getElementById('vv-wd-ts').textContent = ts ? 'Updated: ' + ts : '';
473	}
474
475	function vvWdLoad() {
476	  fetch('/plugins/varaverk/api/watchdog.php')
477	    .then(r => r.json())
478	    .then(_render)
479	    .catch(() => {
480	      document.getElementById('vv-wd-grid').innerHTML =
481	        '
Error loading watchdog data — check API
';
482	    });
483	}
484
485	vvWdLoad();
486	setInterval(vvWdLoad, 30000);
487
488	})();
489	</script>
490

hour:'2-digit', minute:'2-digit', second:'2-digit'})
    : '';
  document.getElementById('vv-wd-ts').textContent = ts ? 'Updated: ' + ts : '';
}

function vvWdLoad() {
  fetch('/plugins/varaverk/api/watchdog.php')
    .then(r => r.json())
    .then(_render)
    .catch(() => {
      document.getElementById('vv-wd-grid').innerHTML =
        '
Error loading watchdog data — check API
';
    });
}

vvWdLoad();
setInterval(vvWdLoad, 30000);

})();
</script>


The `.catch()` swallows both network errors and JS exceptions from `_render`. Let me see what `_render` expects vs what the API actually returns:


1
43
44

45	  Watchdog
46
47

48
49

50
Loading…

51

52
53

  Watchdog

Loading…