Auth page: pure-PHP Authelia ACL parser, NPM-sourced certs tab, watchdog JS fixes, rsync settings layout
- Replace python3/PyYAML Authelia ACL parser with pure PHP (no deps available on Unraid) - Cert tab now pulls live from NPM API instead of cert_monitor.sh — auto-discovers all managed certs sorted by urgency - Watchdog page: add missing GB constant and _fmtBytes/_relTime functions that were causing silent render failure - Rsync settings card: pin to far-right 3 columns (grid-column:6/-1), toggle grid narrowed to 2 columns - Add CLAUDE.md project context file on /boot for session persistence across reboots - claude_startup.sh: symlink CLAUDE.md into /root on array start
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
# 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.
|
||||
@@ -1,6 +1,7 @@
|
||||
<?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'] ?? '')
|
||||
@@ -8,6 +9,43 @@ $action = ($_SERVER['REQUEST_METHOD'] === 'POST')
|
||||
|
||||
$cacheFile = STATE_DIR . '/cert_status.json';
|
||||
|
||||
// ── 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) ─────────────────────────
|
||||
if ($action === 'domains') {
|
||||
$hostId = vv_detect_host();
|
||||
|
||||
+178
-45
@@ -31,7 +31,6 @@ function vv_npm_token(): string {
|
||||
$resp = vv_npm_raw('POST', '/api/tokens', [
|
||||
'identity' => $conf['npm_user'],
|
||||
'secret' => $conf['npm_pass'],
|
||||
'expiry' => '1d',
|
||||
], '', $conf);
|
||||
$token = $resp['token'] ?? '';
|
||||
if ($token) {
|
||||
@@ -146,15 +145,15 @@ function vv_lldap_gql(string $query, array $variables = []): array {
|
||||
}
|
||||
|
||||
function vv_lldap_list_users(): array {
|
||||
$r = vv_lldap_gql('query { listUsers { id displayName email creationDate groups { id displayName } } }');
|
||||
$r = vv_lldap_gql('query { users { id displayName email creationDate groups { id displayName } } }');
|
||||
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Query failed'];
|
||||
return ['ok' => true, 'users' => $r['data']['listUsers'] ?? []];
|
||||
return ['ok' => true, 'users' => $r['data']['users'] ?? []];
|
||||
}
|
||||
|
||||
function vv_lldap_list_groups(): array {
|
||||
$r = vv_lldap_gql('query { listGroups { id displayName users { id displayName } } }');
|
||||
$r = vv_lldap_gql('query { groups { id displayName users { id displayName } } }');
|
||||
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Query failed'];
|
||||
return ['ok' => true, 'groups' => $r['data']['listGroups'] ?? []];
|
||||
return ['ok' => true, 'groups' => $r['data']['groups'] ?? []];
|
||||
}
|
||||
|
||||
function vv_lldap_create_user(string $id, string $email, string $displayName, string $password): array {
|
||||
@@ -186,12 +185,24 @@ function vv_lldap_delete_user(string $id): array {
|
||||
}
|
||||
|
||||
function vv_lldap_set_password(string $userId, string $password): array {
|
||||
$r = vv_lldap_gql(
|
||||
'mutation ChangePassword($userId: String!, $password: String!) { changeUserPassword(userId: $userId, password: $password) }',
|
||||
['userId' => $userId, 'password' => $password]
|
||||
);
|
||||
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Password change failed'];
|
||||
return ['ok' => true];
|
||||
$conf = vv_auth_conf();
|
||||
$token = vv_lldap_token();
|
||||
if (!$token) return ['ok' => false, 'error' => 'lldap auth failed'];
|
||||
|
||||
$ch = curl_init($conf['lldap_url'] . '/auth/admin/resetPassword');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => json_encode(['userId' => $userId, 'password' => $password]),
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $token],
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
if ($code >= 200 && $code < 300) return ['ok' => true];
|
||||
$err = json_decode($body ?: '{}', true)['message'] ?? "HTTP $code";
|
||||
return ['ok' => false, 'error' => $err];
|
||||
}
|
||||
|
||||
function vv_lldap_create_group(string $name): array {
|
||||
@@ -237,19 +248,105 @@ function vv_authelia_read_rules(): array {
|
||||
$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);
|
||||
$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)
|
||||
$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 {
|
||||
@@ -257,29 +354,65 @@ function vv_authelia_write_rules(array $rules, string $defaultPolicy): array {
|
||||
$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);
|
||||
$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']; }
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -214,14 +214,11 @@ $isOwner = vv_is_owner();
|
||||
<div class="vv-au-panel" id="vv-au-panel-certs">
|
||||
<div class="vv-au-sec-bar">
|
||||
<span class="vv-au-sec-title" id="vv-au-cert-ts"></span>
|
||||
<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>
|
||||
<div class="vv-au-cert-grid" id="vv-au-cert-grid">
|
||||
<div class="vv-au-loading">Loading…</div>
|
||||
</div>
|
||||
<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>
|
||||
<div id="vv-au-cert-cfg" style="margin-top:10px;font-size:10px;color:#3a3a3a;"></div>
|
||||
</div>
|
||||
|
||||
@@ -954,29 +951,31 @@ function _certRel(ts) {
|
||||
return Math.floor(d/86400) + 'd ago';
|
||||
}
|
||||
|
||||
function _renderCerts(data) {
|
||||
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 = data.checked_at ? 'Last checked: ' + _certRel(data.checked_at) : 'Not yet checked';
|
||||
ts.textContent = certs.length + ' certificate' + (certs.length !== 1 ? 's' : '') + ' · live from NPM';
|
||||
cfg.textContent = `Warn: ${warn}d · Crit: ${crit}d`;
|
||||
|
||||
const domains = data.domains || [];
|
||||
if (!domains.length) {
|
||||
grid.innerHTML = '<div class="vv-au-empty">No domains configured — add HOST*_CERT_MONITOR_DOMAINS to host.conf</div>';
|
||||
if (!certs.length) {
|
||||
grid.innerHTML = '<div class="vv-au-empty">No certificates found in NPM</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = domains.map(d => {
|
||||
const s = d.status || 'UNKN';
|
||||
const days = d.days;
|
||||
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 = d.expires ? 'Expires ' + d.expires : (s === 'UNKN' ? 'Not yet checked' : '');
|
||||
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;">
|
||||
<div class="vv-au-domain">${_esc(d.domain)}</div>
|
||||
<div class="vv-au-domain">${_esc(c.nice_name)}</div>
|
||||
${extra ? `<div style="font-size:9px;color:#444;margin-bottom:2px;word-break:break-all;">${_esc(extra)}</div>` : ''}
|
||||
<div class="vv-au-cert-days" style="color:${col}">${days != null ? days : '—'}</div>
|
||||
<div style="font-size:9px;color:#444;margin-bottom:6px;">${days != null ? 'days remaining' : ''}</div>
|
||||
<span class="vv-au-badge ${_certBadgeCls(s)}">${_certBadgeTxt(s)}</span>
|
||||
@@ -986,32 +985,26 @@ function _renderCerts(data) {
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function _loadCerts() {
|
||||
function _loadCerts(onDone) {
|
||||
const grid = document.getElementById('vv-au-cert-grid');
|
||||
grid.innerHTML = '<div class="vv-au-loading">Loading…</div>';
|
||||
fetch(CERT_API)
|
||||
fetch(CERT_API + '?action=npm')
|
||||
.then(r => r.json())
|
||||
.then(d => { if (d.ok) _renderCerts(d); })
|
||||
.catch(() => { grid.innerHTML = '<div class="vv-au-empty" style="color:#ef5350">Failed to load cert data</div>'; });
|
||||
.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;
|
||||
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'; });
|
||||
btn.disabled = true; btn.textContent = 'Loading…';
|
||||
_loadCerts(() => { btn.disabled = false; btn.textContent = 'Refresh'; });
|
||||
});
|
||||
|
||||
// ── Boot ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -44,8 +44,7 @@
|
||||
.vv-ry-run-st { text-align:right; }
|
||||
|
||||
/* Settings */
|
||||
.vv-ry-set-grid { display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin-top:6px; }
|
||||
@media (max-width:900px) { .vv-ry-set-grid { grid-template-columns:repeat(2,1fr); } }
|
||||
.vv-ry-set-grid { display:grid;grid-template-columns:repeat(2,1fr);gap:8px;margin-top:6px; }
|
||||
.vv-ry-toggle { display:inline-flex;align-items:center;gap:8px;cursor:pointer;user-select:none; }
|
||||
.vv-ry-toggle-track { width:32px;height:18px;border-radius:9px;background:#222;border:1px solid #333;position:relative;transition:background .15s,border-color .15s;flex-shrink:0; }
|
||||
.vv-ry-toggle-track.on { background:#1a3a1a;border-color:#2d5a2d; }
|
||||
@@ -534,15 +533,6 @@ const PROF_COLORS = [
|
||||
'#4dd0e1','#a5d6a7','#ff8a65','#90caf9','#f48fb1',
|
||||
];
|
||||
|
||||
function _rel(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 ' + Math.floor((d % 3600) / 60) + 'm ago';
|
||||
return Math.floor(d / 86400) + 'd ago';
|
||||
}
|
||||
|
||||
function _dur(s) {
|
||||
if (!s) return '—';
|
||||
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
|
||||
@@ -551,14 +541,6 @@ function _dur(s) {
|
||||
return sec + 's';
|
||||
}
|
||||
|
||||
function _fmtBytes(b) {
|
||||
if (!b) return '';
|
||||
const G = 1073741824, M = 1048576;
|
||||
if (b >= G) return (b / G).toFixed(1) + ' GB';
|
||||
if (b >= M) return (b / M).toFixed(0) + ' MB';
|
||||
return (b / 1024).toFixed(0) + ' KB';
|
||||
}
|
||||
|
||||
// ── Status + active card ──────────────────────────────────────────────────────
|
||||
function _statusCard(data) {
|
||||
const en = data.enabled;
|
||||
@@ -609,7 +591,7 @@ function _lastSyncCard(data) {
|
||||
<span class="vv-ry-lbl">${WIN_META[k]?.label || k}</span>
|
||||
<span class="vv-ry-val">
|
||||
<span style="${cls}">${e.status}</span>
|
||||
<span style="color:#444;margin-left:6px;">${_rel(e.ts)}</span>
|
||||
<span style="color:#444;margin-left:6px;">${_relTime(e.ts)}</span>
|
||||
${e.duration ? `<span style="color:#3a3a3a;margin-left:4px;">${_dur(e.duration)}</span>` : ''}
|
||||
</span>
|
||||
</div>`;
|
||||
@@ -639,7 +621,7 @@ function _windowsRow(data) {
|
||||
if (ls && ls.ts) {
|
||||
const okCol = ls.status === 'success' ? '#4caf50' : '#ef5350';
|
||||
statusHtml = `<div class="vv-ry-win-stat" style="color:${okCol};">${ls.status}</div>
|
||||
<div class="vv-ry-win-dur">${_rel(ls.ts)}${ls.duration ? ' · ' + _dur(ls.duration) : ''}</div>`;
|
||||
<div class="vv-ry-win-dur">${_relTime(ls.ts)}${ls.duration ? ' · ' + _dur(ls.duration) : ''}</div>`;
|
||||
}
|
||||
|
||||
const expandable = !!(scripts.length || shares.length);
|
||||
@@ -755,7 +737,7 @@ function _settingsSection(data) {
|
||||
|
||||
const mbit = s.bw_limit ? ' (~' + (s.bw_limit / 125).toFixed(0) + ' Mbit/s)' : '';
|
||||
|
||||
return `<div class="vv-ry-card" style="grid-column:1/-1;">
|
||||
return `<div class="vv-ry-card" style="grid-column:6/-1;">
|
||||
<div class="vv-ry-sec" style="margin-bottom:10px;">Settings</div>
|
||||
|
||||
<div style="margin-bottom:12px;">
|
||||
|
||||
@@ -55,19 +55,20 @@
|
||||
|
||||
const GB = 1073741824;
|
||||
|
||||
function _relTime(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 ' + Math.floor((d % 3600) / 60) + 'm ago';
|
||||
return Math.floor(d / 86400) + 'd ago';
|
||||
function _fmtBytes(b) {
|
||||
if (!b) return '0';
|
||||
if (b >= GB) return (b / GB).toFixed(1) + 'G';
|
||||
if (b >= 1048576) return (b / 1048576).toFixed(0) + 'M';
|
||||
return (b / 1024).toFixed(0) + 'K';
|
||||
}
|
||||
|
||||
function _fmtBytes(b) {
|
||||
if (b >= GB) return (b / GB).toFixed(1) + ' GB';
|
||||
if (b >= 1048576) return (b / 1048576).toFixed(0) + ' MB';
|
||||
return (b / 1024).toFixed(0) + ' KB';
|
||||
function _relTime(ts) {
|
||||
if (!ts) return '—';
|
||||
const s = Math.floor(Date.now() / 1000) - ts;
|
||||
if (s < 60) return 'just now';
|
||||
if (s < 3600) return Math.floor(s / 60) + 'm ago';
|
||||
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
|
||||
return Math.floor(s / 86400) + 'd ago';
|
||||
}
|
||||
|
||||
function _dur(s) {
|
||||
@@ -493,7 +494,10 @@ function vvWdLoad() {
|
||||
fetch('/plugins/varaverk/api/watchdog.php')
|
||||
.then(r => r.json())
|
||||
.then(_render)
|
||||
.catch(() => {});
|
||||
.catch(() => {
|
||||
document.getElementById('vv-wd-grid').innerHTML =
|
||||
'<div style="grid-column:1/-1;color:#555;font-size:12px;padding:24px 0;text-align:center;">Error loading watchdog data — check API</div>';
|
||||
});
|
||||
}
|
||||
|
||||
vvWdLoad();
|
||||
|
||||
@@ -87,6 +87,17 @@ _log ".claude → $CLAUDE_DATA"
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user