API keys: Varaverk_HOST1/HOST2 named keys, cross-host SSH setup, keys in master.conf for direct GraphQL access

This commit is contained in:
Gmer4Lfe
2026-06-03 18:34:19 -04:00
parent a55a3afad5
commit 7178be73a5
3 changed files with 273 additions and 117 deletions
+38 -17
View File
@@ -95,35 +95,56 @@ if ($action === 'detect' && $_SERVER['REQUEST_METHOD'] === 'POST') {
exit;
}
// ── Unraid API key status ─────────────────────────────────────────────────────
// ── Unraid API key status — all hosts ────────────────────────────────────────
if ($action === 'api_status') {
require_once dirname(__DIR__) . '/include/unraid_api.php';
$data = vv_api_data();
$status = vv_api_get_status();
$vars = vv_conf_vars();
$myHost = vv_detect_host();
$keyVar = strtoupper($myHost) . '_UNRAID_API_KEY';
$key = $vars[$keyVar] ?? '';
$localStatus = vv_api_get_status();
$vars = vv_conf_vars();
$myHost = vv_detect_host();
$myId = strtoupper($myHost);
// Collect status for every configured host
$hosts = [];
foreach ($vars as $k => $v) {
if (!preg_match('/^HOST(\d+)$/', $k, $m) || !$v) continue;
$id = 'HOST' . $m[1];
$keyVar = $id . '_UNRAID_API_KEY';
$key = $vars[$keyVar] ?? '';
$isLocal = ($id === $myId);
$hosts[] = [
'host_id' => $id,
'hostname' => $v,
'is_local' => $isLocal,
'key_var' => $keyVar,
'key_present' => !empty($key),
'key_preview' => $key ? substr($key, 0, 8) . '...' . substr($key, -4) : null,
'api_ok' => $isLocal
? (!$localStatus['key_missing'] && $localStatus['available'])
: !empty($key),
];
}
usort($hosts, fn($a,$b) => strcmp($a['host_id'], $b['host_id']));
echo json_encode([
'ok' => true,
'key_present' => !empty($key),
'key_preview' => $key ? substr($key, 0, 8) . '...' . substr($key, -4) : null,
'api_ok' => !empty($key) && !$status['key_missing'] && $status['available'],
'key_missing' => $status['key_missing'],
'fallbacks' => $status['fallbacks'],
'ok' => true,
'my_id' => $myId,
'hosts' => $hosts,
'fallbacks' => $localStatus['fallbacks'],
]);
exit;
}
// ── Renew Unraid API key ──────────────────────────────────────────────────────
if ($action === 'renew_apikey' && $_SERVER['REQUEST_METHOD'] === 'POST') {
// ── Setup/renew API keys (local + all partners via SSH) ───────────────────────
if ($action === 'setup_apikeys' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$script = SCRIPTS_DIR . '/unRAID_Essentials/unraid_api_key_renew.sh';
if (!file_exists($script)) {
echo json_encode(['ok' => false, 'error' => 'unraid_api_key_renew.sh not found']); exit;
}
set_time_limit(30);
$allHosts = ($_POST['all_hosts'] ?? '0') === '1';
$flags = $allHosts ? ' --all-hosts' : '';
set_time_limit(60);
$output = []; $exit = 0;
exec('bash ' . escapeshellarg($script) . ' 2>&1', $output, $exit);
exec('bash ' . escapeshellarg($script) . $flags . ' 2>&1', $output, $exit);
echo json_encode(['ok' => $exit === 0, 'exit' => $exit, 'output' => implode("\n", $output)]);
exit;
}
+67 -44
View File
@@ -145,28 +145,23 @@ $_apiPreview = $_apiKey ? substr($_apiKey, 0, 8) . '...' . substr($_apiKey, -4)
<!-- Unraid API Key card -->
<div class="vv-set-card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<span class="vv-set-hdr" style="margin-bottom:0;">Unraid API Key</span>
<span id="vv-api-badge" class="vv-set-badge"><?= $_apiKey ? 'present' : 'missing' ?></span>
<span class="vv-set-hdr" style="margin-bottom:0;">Unraid API Keys</span>
<span id="vv-api-badge" class="vv-set-badge"></span>
</div>
<div id="vv-api-info" style="font-size:11px;color:#3a3a3a;margin-bottom:12px;">
<?php if ($_apiPreview): ?>
Key: <code style="color:#4a6a4a;font-size:10px;"><?= htmlspecialchars($_apiPreview) ?></code>
&nbsp;·&nbsp; Var: <code style="color:#3a3a3a;font-size:10px;"><?= htmlspecialchars($_apiKeyVar) ?></code>
<?php else: ?>
No key found in <?= htmlspecialchars($_myHost) ?>.conf — click Renew to register one.
<?php endif; ?>
</div>
<div id="vv-api-hosts" style="margin-bottom:12px;"></div>
<div style="font-size:11px;color:#444;margin-bottom:12px;line-height:1.6;">
The Unraid API key is ephemeral — it clears on OS updates and service restarts.
<code style="font-size:10px;color:#4a7a4a;background:#080808;padding:1px 4px;border-radius:2px;">unraid_api_key_renew.sh</code>
runs at array start to auto-recover, but if the monitor page shows API errors, renew here.
Each host registers a key named <code style="font-size:10px;color:#4a7a4a;background:#080808;padding:1px 4px;border-radius:2px;">Varaverk_HOST1</code>,
<code style="font-size:10px;color:#4a7a4a;background:#080808;padding:1px 4px;border-radius:2px;">Varaverk_HOST2</code>, etc.
Keys are stored in <code style="font-size:10px;color:#4a7a4a;background:#080808;padding:1px 4px;border-radius:2px;">master.conf</code>
so every host can query every other host's GraphQL API directly — no SSH needed for remote monitoring.
</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<button class="vv-set-btn" onclick="vvApiCheck()" id="vv-api-check-btn">Check status</button>
<button class="vv-set-btn primary" onclick="vvApiRenew()" id="vv-api-renew-btn">Renew key</button>
<button class="vv-set-btn" onclick="vvApiCheck()" id="vv-api-check-btn">Check status</button>
<button class="vv-set-btn" onclick="vvApiRenew(false)" id="vv-api-local-btn">Renew local</button>
<button class="vv-set-btn primary" onclick="vvApiRenew(true)" id="vv-api-all-btn">Setup all host keys</button>
<span id="vv-api-fb" style="font-size:11px;color:#444;"></span>
</div>
<pre class="vv-set-out" id="vv-api-out"></pre>
@@ -337,46 +332,74 @@ function vvNtfSaveWebhook() {
.catch(() => { btn.disabled = false; btn.textContent = 'Save'; fb.style.color='#ef5350'; fb.textContent='Request failed'; });
}
// ── Unraid API Key ───────────────────────────────────────────────────────────
// ── Unraid API Keys ───────────────────────────────────────────────────────────
function vvApiCheck() {
const btn = document.getElementById('vv-api-check-btn');
const fb = document.getElementById('vv-api-fb');
const badge= document.getElementById('vv-api-badge');
btn.disabled = true; btn.textContent = 'Checking…'; fb.textContent = '';
const btn = document.getElementById('vv-api-check-btn');
const badge = document.getElementById('vv-api-badge');
const hosts = document.getElementById('vv-api-hosts');
btn.disabled = true; btn.textContent = 'Checking…';
fetch('/plugins/varaverk/api/storage.php?action=api_status&_=' + Date.now())
.then(r => r.json())
.then(d => {
btn.disabled = false; btn.textContent = 'Check status';
if (!d.ok) { fb.style.color='#ef5350'; fb.textContent='Request failed'; return; }
badge.textContent = d.api_ok ? 'ok' : d.key_missing ? 'missing' : 'error';
badge.className = 'vv-set-badge ' + (d.api_ok ? 'internal' : 'flash');
fb.style.color = d.api_ok ? '#4caf50' : '#ef5350';
fb.textContent = d.api_ok ? 'API responding ✓'
: d.key_missing ? 'Key missing from conf — click Renew'
: 'API unreachable — ' + (d.fallbacks?.length ? d.fallbacks.join(', ') + ' using fallback' : 'check Unraid API service');
btn.disabled = false; btn.textContent = 'Check status';
if (!d.ok) return;
const allOk = d.hosts.every(h => h.api_ok);
const anyMissing = d.hosts.some(h => !h.key_present);
badge.textContent = allOk ? 'all ok' : anyMissing ? 'keys missing' : 'partial';
badge.className = 'vv-set-badge ' + (allOk ? 'internal' : 'flash');
if (hosts) hosts.innerHTML = d.hosts.map(h => {
const local = h.is_local ? ' <span style="color:#2a3a2a;font-size:9px;">local</span>' : '';
const keyBit = h.key_present
? `<code style="font-size:9px;color:#4a6a4a;">${h.key_preview}</code>`
: `<span style="color:#8b2a2a;font-size:10px;">not set</span>`;
const dot = h.api_ok ? '#4caf50' : '#555';
const name = `Varaverk_${h.host_id}`;
return `<div style="display:flex;align-items:baseline;gap:8px;padding:3px 0;border-bottom:1px solid #111;">
<span style="width:6px;height:6px;border-radius:50%;background:${dot};flex-shrink:0;margin-top:3px;display:inline-block;"></span>
<span style="font-size:11px;color:#666;width:60px;flex-shrink:0;">${h.host_id}${local}</span>
<code style="font-size:9px;color:#2a3a2a;flex-shrink:0;">${name}</code>
<span style="flex:1;text-align:right;">${keyBit}</span>
</div>`;
}).join('');
})
.catch(() => { btn.disabled = false; btn.textContent = 'Check status'; fb.style.color='#ef5350'; fb.textContent='Request failed'; });
.catch(() => { btn.disabled = false; btn.textContent = 'Check status'; });
}
function vvApiRenew() {
const btn = document.getElementById('vv-api-renew-btn');
const out = document.getElementById('vv-api-out');
const fb = document.getElementById('vv-api-fb');
const badge= document.getElementById('vv-api-badge');
btn.disabled = true; btn.textContent = 'Renewing…'; fb.textContent = '';
out.textContent = ''; out.style.display = '';
const fd = new FormData(); fd.append('action', 'renew_apikey');
function vvApiRenew(allHosts) {
const localBtn = document.getElementById('vv-api-local-btn');
const allBtn = document.getElementById('vv-api-all-btn');
const out = document.getElementById('vv-api-out');
const fb = document.getElementById('vv-api-fb');
const badge = document.getElementById('vv-api-badge');
const activeBtn = allHosts ? allBtn : localBtn;
[localBtn, allBtn].forEach(b => { if(b) b.disabled = true; });
activeBtn.textContent = allHosts ? 'Setting up…' : 'Renewing…';
fb.textContent = ''; out.textContent = ''; out.style.display = '';
const fd = new FormData();
fd.append('action', 'setup_apikeys');
fd.append('all_hosts', allHosts ? '1' : '0');
fetch('/plugins/varaverk/api/storage.php', { method: 'POST', body: fd })
.then(r => r.json())
.then(d => {
btn.disabled = false; btn.textContent = 'Renew key';
out.textContent = d.output || '(no output)';
out.scrollTop = out.scrollHeight;
badge.textContent = d.ok ? 'ok' : 'error';
badge.className = 'vv-set-badge ' + (d.ok ? 'internal' : 'flash');
localBtn.disabled = false; localBtn.textContent = 'Renew local';
allBtn.disabled = false; allBtn.textContent = 'Setup all host keys';
out.textContent = d.output || '(no output)';
out.scrollTop = out.scrollHeight;
fb.style.color = d.ok ? '#4caf50' : '#ef5350';
fb.textContent = d.ok ? 'Key renewed ✓' : 'Renewal failed — see output';
fb.textContent = d.ok ? '✓ Done' : 'Failed — see output';
if (d.ok) { badge.textContent = ''; vvApiCheck(); }
})
.catch(() => { btn.disabled = false; btn.textContent = 'Renew key'; fb.style.color='#ef5350'; fb.textContent='Request failed'; });
.catch(() => {
localBtn.disabled = false; localBtn.textContent = 'Renew local';
allBtn.disabled = false; allBtn.textContent = 'Setup all host keys';
fb.style.color = '#ef5350'; fb.textContent = 'Request failed';
});
}
// Auto-check on load
vvApiCheck();
</script>
+168 -56
View File
@@ -5,26 +5,31 @@
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Creates/overwrites the Varaverk API key in the unraid-api service registry at
# array start. The registry is ephemeral — OS updates and service restarts clear
# it. This script re-registers the key every boot so Varaverk's enhanced
# monitoring self-heals without manual intervention.
# Creates/syncs the Varaverk API key in the Unraid registry. The registry is
# ephemeral — OS updates and service restarts clear it. This script re-registers
# every boot so monitoring self-heals without manual intervention.
#
# Also updates HOST*_UNRAID_API_KEY in the local host conf so the partnership
# page always reflects the live key value.
# Each host's key is named "Varaverk_HOST1", "Varaverk_HOST2", etc., and stored
# in master.conf (shared) so all hosts can call each other's GraphQL API directly
# for real-time monitoring without SSH.
#
# --all-hosts also SSHes to each partner, creates their key there, and writes
# all keys into master.conf. Run once from Settings → API Key → Setup
# to fully wire cross-host API access.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# unraid_api_key_renew.sh
# Renew the key. Silent on success.
# Renew local key only — runs at array start, fast.
#
# unraid_api_key_renew.sh --all-hosts
# Renew local key AND SSH to each partner to create/sync their key.
# Writes all keys into master.conf and pushes to partners.
#
# unraid_api_key_renew.sh --dry-run
# Show what would happen — no changes made.
#
# unraid_api_key_renew.sh --log
# Verbose output.
#
# ==============================================================================================
@@ -37,11 +42,20 @@ acquire_lock
detect_hosts
# ──────────────────────────────────────────────────────────────────────────────
CONF_FILE="$SCRIPT_DIR/../Configurations/${MY_ID,,}.conf"
VAR_NAME="${MY_ID}_UNRAID_API_KEY"
# Parse --all-hosts from raw args (parse_args doesn't handle this flag)
ALL_HOSTS=false
for _arg in "$@"; do [[ "$_arg" == "--all-hosts" ]] && ALL_HOSTS=true; done
unset _arg
log "$ICON_GEAR Conf file: $CONF_FILE"
log "$ICON_GEAR Key var: $VAR_NAME"
CONF_FILE="$SCRIPT_DIR/../Configurations/${MY_ID,,}.conf"
MASTER_CONF="$SCRIPT_DIR/../Configurations/master.conf"
VAR_NAME="${MY_ID}_UNRAID_API_KEY"
KEY_NAME="Varaverk_${MY_ID}"
log "$ICON_GEAR Conf file: $CONF_FILE"
log "$ICON_GEAR Key name: $KEY_NAME"
log "$ICON_GEAR Key var: $VAR_NAME"
log "$ICON_GEAR All hosts: $ALL_HOSTS"
if [[ ! -f "$CONF_FILE" ]]; then
error "Conf file not found: $CONF_FILE"
@@ -49,63 +63,161 @@ if [[ ! -f "$CONF_FILE" ]]; then
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would check registry, renew only if key missing"
warn "DRY RUN — would check registry, renew if missing, write to host conf + master.conf"
[[ "$ALL_HOSTS" == true ]] && warn "DRY RUN — would also SSH to all partners and sync their keys"
exit 0
fi
# ── Helper: write a key variable into a conf file ─────────────────────────────
_write_key_to_conf() {
local conf="$1" var="$2" key="$3"
[[ ! -f "$conf" ]] && return 1
if grep -q "^\s*${var}\s*=" "$conf"; then
sed -i "s|^\(\s*${var}\s*=\s*\)\"[^\"]*\"|\1\"${key}\"|" "$conf"
else
echo " ${var}=\"${key}\"" >> "$conf"
fi
}
# ── Helper: push master.conf to all partners ──────────────────────────────────
_push_master() {
php -r "
require_once '/usr/local/emhttp/plugins/varaverk/include/config.php';
require_once '/usr/local/emhttp/plugins/varaverk/include/confform.php';
vv_push_master_conf();
" 2>/dev/null && log "master.conf pushed to partner(s)" || warn "master.conf push failed (partner offline?)"
}
# ──────────────────────────────────────────────────────────────────────────────
# Check if key already exists in the unraid-api registry before creating.
# --overwrite generates a new key value every time, invalidating the old one.
# Only renew if the registry has lost it.
log "Checking unraid-api registry for existing Varaverk key..."
EXISTING=$(timeout 5 /usr/local/sbin/unraid-api apikey --name "Varaverk" --json </dev/null 2>/dev/null)
# Step 1: Local key — check registry, create if missing, sync to conf
# ──────────────────────────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_GEAR Local key ($KEY_NAME) ━━━"
EXISTING=$(timeout 5 /usr/local/sbin/unraid-api apikey --name "$KEY_NAME" --json </dev/null 2>/dev/null)
KEY=$(echo "$EXISTING" | jq -r '.key // empty' 2>/dev/null)
if [[ -n "$KEY" ]]; then
PREVIEW="${KEY:0:8}...${KEY: -4}"
# Always sync registry key → conf — prevents stale key mismatch after reboot/update
CONF_KEY=$(grep "^\s*${VAR_NAME}\s*=" "$CONF_FILE" 2>/dev/null | \
sed 's/.*="\(.*\)".*/\1/' | tr -d '[:space:]')
if [[ "$CONF_KEY" == "$KEY" ]]; then
CONF_KEY=$(grep "^\s*${VAR_NAME}\s*=" "$CONF_FILE" 2>/dev/null | sed 's/.*="\(.*\)".*/\1/' | tr -d '[:space:]')
MASTER_KEY=$(grep "^\s*${VAR_NAME}\s*=" "$MASTER_CONF" 2>/dev/null | sed 's/.*="\(.*\)".*/\1/' | tr -d '[:space:]')
if [[ "$CONF_KEY" == "$KEY" && "$MASTER_KEY" == "$KEY" ]]; then
echo "API key valid ✅ — $VAR_NAME = $PREVIEW"
log "Key in sync — no update needed"
exit 0
fi
log "Registry key differs from conf — syncing..."
if grep -q "^\s*${VAR_NAME}\s*=" "$CONF_FILE"; then
sed -i "s|^\(\s*${VAR_NAME}\s*=\s*\)\"[^\"]*\"|\1\"${KEY}\"|" "$CONF_FILE"
log "Key in sync across host conf + master.conf"
else
echo " ${VAR_NAME}=\"${KEY}\"" >> "$CONF_FILE"
log "Syncing key to conf files..."
_write_key_to_conf "$CONF_FILE" "$VAR_NAME" "$KEY"
_write_key_to_conf "$MASTER_CONF" "$VAR_NAME" "$KEY"
_push_master
warn "API key synced ✅ — $VAR_NAME = $PREVIEW"
fi
warn "API key synced to conf ✅ — $VAR_NAME = $PREVIEW"
exit 0
fi
else
log "Key not found in registry — creating $KEY_NAME..."
RAW=$(timeout 10 /usr/local/sbin/unraid-api apikey \
--name "$KEY_NAME" --create --overwrite \
--description "Varaverk plugin" --roles ADMIN --json </dev/null 2>&1)
log "Key not found in registry — creating new key..."
KEY=$(echo "$RAW" | jq -r '.key // empty' 2>/dev/null)
if [[ -z "$KEY" ]]; then
error "unraid-api returned no key: ${RAW:0:200}"
exit 1
fi
RAW=$(timeout 10 /usr/local/sbin/unraid-api apikey \
--name "Varaverk" --create --overwrite \
--description "Varaverk plugin" --roles ADMIN --json </dev/null 2>&1)
if [[ -z "$RAW" ]]; then
error "unraid-api returned no output"
exit 1
fi
KEY=$(echo "$RAW" | jq -r '.key // empty' 2>/dev/null)
if [[ -z "$KEY" ]]; then
error "No key in unraid-api response: ${RAW:0:200}"
exit 1
_write_key_to_conf "$CONF_FILE" "$VAR_NAME" "$KEY"
_write_key_to_conf "$MASTER_CONF" "$VAR_NAME" "$KEY"
_push_master
PREVIEW="${KEY:0:8}...${KEY: -4}"
warn "API key created ✅ — $VAR_NAME = $PREVIEW"
fi
# ──────────────────────────────────────────────────────────────────────────────
if grep -q "^\s*${VAR_NAME}\s*=" "$CONF_FILE"; then
sed -i "s|^\(\s*${VAR_NAME}\s*=\s*\)\"[^\"]*\"|\1\"${KEY}\"|" "$CONF_FILE"
else
# Field missing from conf — append it
echo " ${VAR_NAME}=\"${KEY}\"" >> "$CONF_FILE"
# Step 2 (--all-hosts): SSH to each partner, create their key, write to master.conf
# ──────────────────────────────────────────────────────────────────────────────
[[ "$ALL_HOSTS" != true ]] && exit 0
echo ""
echo "━━━ $ICON_SYNC Partner keys ━━━"
PARTNER_OK=0
PARTNER_FAIL=0
for host_var in HOST1 HOST2 HOST3 HOST4 HOST5 HOST6 HOST7 HOST8; do
[[ "$host_var" == "$MY_ID" ]] && continue
hostname="${!host_var:-}"
[[ -z "$hostname" ]] && continue
r_var_name="${host_var}_UNRAID_API_KEY"
r_key_name="Varaverk_${host_var}"
r_conf_path="/boot/config/plugins/varaverk/Configurations/${host_var,,}.conf"
echo " $host_var ($hostname)…"
REMOTE_IP=$(resolve_tailscale_ip "$hostname")
if [[ -z "$REMOTE_IP" ]]; then
warn " $host_var: cannot resolve Tailscale IP — skipping"
(( PARTNER_FAIL++ ))
continue
fi
# SSH: check for existing key, create if missing, return the key value
REMOTE_KEY=$(ssh -i "$SSH_KEY" \
-o ConnectTimeout=10 \
-o StrictHostKeyChecking=no \
-o BatchMode=yes \
"root@${REMOTE_IP}" "
EXISTING=\$(timeout 5 /usr/local/sbin/unraid-api apikey --name '${r_key_name}' --json </dev/null 2>/dev/null)
KEY=\$(echo \"\$EXISTING\" | jq -r '.key // empty' 2>/dev/null)
if [[ -n \"\$KEY\" ]]; then
echo \"\$KEY\"
else
timeout 10 /usr/local/sbin/unraid-api apikey \\
--name '${r_key_name}' --create --overwrite \\
--description 'Varaverk plugin' --roles ADMIN --json </dev/null 2>/dev/null \\
| jq -r '.key // empty' 2>/dev/null
fi
" 2>/dev/null | tr -d '[:space:]')
if [[ -z "$REMOTE_KEY" ]]; then
warn " $host_var: could not get key from $hostname — skipping"
(( PARTNER_FAIL++ ))
continue
fi
R_PREVIEW="${REMOTE_KEY:0:8}...${REMOTE_KEY: -4}"
# Write remote key to master.conf locally
_write_key_to_conf "$MASTER_CONF" "$r_var_name" "$REMOTE_KEY"
# Also write to remote's host*.conf so they have it locally
ssh -i "$SSH_KEY" \
-o ConnectTimeout=10 \
-o StrictHostKeyChecking=no \
-o BatchMode=yes \
"root@${REMOTE_IP}" "
CONF='${r_conf_path}'
if [[ -f \"\$CONF\" ]]; then
if grep -q '^\s*${r_var_name}\s*=' \"\$CONF\"; then
sed -i \"s|^\(\s*${r_var_name}\s*=\s*\)\\\"[^\\\"]*\\\"|\1\\\"${REMOTE_KEY}\\\"|\" \"\$CONF\"
else
echo ' ${r_var_name}=\\\"${REMOTE_KEY}\\\"' >> \"\$CONF\"
fi
fi
" 2>/dev/null
echo " $host_var: $r_key_name = $R_PREVIEW"
(( PARTNER_OK++ ))
done
# Push master.conf with all updated keys to all partners
if (( PARTNER_OK > 0 )); then
echo ""
echo " Pushing master.conf with all keys…"
_push_master
fi
PREVIEW="${KEY:0:8}...${KEY: -4}"
log "Writing new key to: $CONF_FILE"
warn "API key renewed $VAR_NAME = $PREVIEW (registry had lost it)"
echo ""
echo "━━━━━ $ICON_SUMMARY Key Setup Summary ━━━━━"
echo " Local: $VAR_NAME"
echo " Partners: $PARTNER_OK updated · $PARTNER_FAIL failed"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"