The .plg hardcoded the flash path and runs every boot, so an appdata node served a stale copy that never receives pulls — fixes appeared to do nothing, indefinitely.
285 lines
15 KiB
PHP
285 lines
15 KiB
PHP
<?php
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
// PURPOSE
|
|
// Setup checklist. Evaluates what this host still needs before it is fully configured —
|
|
// identity, conf, API and SSH keys, service discovery, media keys, and partnership state —
|
|
// and names the action that fixes each gap.
|
|
//
|
|
// OPERATIONAL MODEL
|
|
// Derived, never stored. Every item is computed from the conf files and the setup state on
|
|
// each request, so the checklist cannot go stale or disagree with reality. There is no
|
|
// "completed" flag anyone could tick.
|
|
//
|
|
// The item list is not fixed. Emby and Jellyfin checks appear only once their container is
|
|
// configured; the master.conf pull appears only on a partner host; partnership appears only
|
|
// once a HOST2 is named. A checklist that listed everything would tell a single-host
|
|
// install it was permanently incomplete.
|
|
//
|
|
// Items carry an action name, not a URL. The page maps create_key, ssh_setup, run_populate,
|
|
// pull_master and onboard onto the right endpoint or instruction — so this file describes
|
|
// what is wrong, and the UI owns how to fix it.
|
|
//
|
|
// DESIGN PRINCIPLES
|
|
// Each check answers the narrowest useful question.
|
|
// The SSH item tests that the configured path exists on disk, not merely that a path is
|
|
// set — a path set to a file that was never generated is the actual failure mode, and
|
|
// the detail text distinguishes the two cases.
|
|
//
|
|
// Auto-populate is satisfied by any one service.
|
|
// The check passes on the first arr key or media container found. Requiring all of them
|
|
// would leave the item permanently red on a host that legitimately runs only some.
|
|
//
|
|
// Partnership reports its phases separately.
|
|
// Phase 1 done with phase 2 outstanding is its own message, because the fix is to go
|
|
// finish onboarding on the other host — not to re-run anything here.
|
|
//
|
|
// State keys are read case-insensitively.
|
|
// Both HOST2_PHASE1_DONE and host2_phase1_done are accepted, because the state file has
|
|
// been written by both the shell layer and the PHP layer over its life.
|
|
//
|
|
// complete is derived from the items, not tracked.
|
|
// A single strict in_array(false, …) over the item results, so the summary can never
|
|
// disagree with the list it summarises.
|
|
//
|
|
// OPERATIONAL SAFEGUARDS
|
|
// Read-only. This endpoint diagnoses and never fixes — every remedy is a separate,
|
|
// explicitly invoked action. That separation is what makes it safe to poll.
|
|
//
|
|
// No input at all. There are no parameters, so there is nothing to validate and no way to
|
|
// ask about a host other than this one.
|
|
//
|
|
// An unidentified host degrades to a report rather than an error.
|
|
// vv_detect_host() returning 'unknown' is handled at every use — the host conf is not
|
|
// read, and the identity and host_conf items say so explicitly. That is the exact state
|
|
// a fresh install is in, and it is the checklist's job to describe it.
|
|
//
|
|
// Every conf read is defaulted.
|
|
// vv_read_conf_raw() returns empty for a missing file and every scalar lookup is
|
|
// trimmed with a ?? fallback, so a partial or absent conf yields items marked not-ok
|
|
// rather than a fatal that would blank the whole panel.
|
|
//
|
|
// Key presence is reported, key values never are.
|
|
// The API, SSH, Emby and Jellyfin items report only whether a value is set — and for
|
|
// SSH, the basename of the path. No credential is returned.
|
|
//
|
|
// Missing is reported as missing, never as fine.
|
|
// Every item defaults to ok:false and is only set true by a positive test. A check that
|
|
// cannot run reports the gap it could not rule out.
|
|
//
|
|
// REQUEST
|
|
// GET, no parameters
|
|
//
|
|
// RESPONSE
|
|
// {"ok":true,"complete":bool,"host_id":"host<n>|unknown",
|
|
// "items":[{"id","label","ok","detail","action"?}, …]}
|
|
// action is present and non-null only when there is a remedy the UI can invoke.
|
|
//
|
|
// DEPENDS ON
|
|
// include/config.php vv_detect_host(), vv_read_conf_raw(), vv_parse_conf_scalar(),
|
|
// vv_setup_state_read(), CONF_DIR
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
header('Content-Type: application/json');
|
|
require_once dirname(__DIR__) . '/include/config.php';
|
|
|
|
$hostId = vv_detect_host();
|
|
$hostIdUp = strtoupper($hostId);
|
|
$master = vv_read_conf_raw('master.conf');
|
|
$confRaw = ($hostId !== 'unknown') ? vv_read_conf_raw($hostId . '.conf') : '';
|
|
|
|
$items = [];
|
|
|
|
// ── Identity ──────────────────────────────────────────────────────────────────
|
|
preg_match('/^\s*HOST1\s*=\s*"([^"]*)"/m', $master, $m1);
|
|
$host1 = trim($m1[1] ?? '');
|
|
// "Server identity" is this server's, not HOST1's. It was hardcoded to read master.conf's HOST1
|
|
// and print "HOST1: <name>", so on the mirror the row that answers "which host am I" confidently
|
|
// named the other machine — and reported ok purely because the owner's slot was filled in, which
|
|
// says nothing about whether this host resolved to a slot at all.
|
|
$myName = '';
|
|
if ($hostId !== 'unknown') {
|
|
preg_match('/^\s*' . preg_quote($hostIdUp, '/') . '\s*=\s*"([^"]*)"/m', $master, $mSelf);
|
|
$myName = trim($mSelf[1] ?? '');
|
|
}
|
|
$items[] = [
|
|
'id' => 'identity',
|
|
'label' => 'Server identity',
|
|
'ok' => $hostId !== 'unknown' && !empty($myName),
|
|
'detail' => ($hostId !== 'unknown' && $myName)
|
|
? "$hostIdUp: $myName"
|
|
: 'This host does not match any HOST* entry in master.conf',
|
|
];
|
|
|
|
// ── Host conf ─────────────────────────────────────────────────────────────────
|
|
$confExists = $hostId !== 'unknown' && file_exists(CONF_DIR . '/' . $hostId . '.conf');
|
|
$items[] = [
|
|
'id' => 'host_conf',
|
|
'label' => 'Host configuration',
|
|
'ok' => $confExists,
|
|
'detail' => $confExists
|
|
? "$hostId.conf present"
|
|
: ($hostId === 'unknown' ? 'Server not yet identified' : "$hostId.conf missing"),
|
|
];
|
|
|
|
// ── Unraid API key ─────────────────────────────────────────────────────────────
|
|
$apiKey = trim(vv_parse_conf_scalar($confRaw, $hostIdUp . '_UNRAID_API_KEY'));
|
|
$items[] = [
|
|
'id' => 'api_key',
|
|
'label' => 'Unraid API key',
|
|
'ok' => !empty($apiKey),
|
|
'detail' => $apiKey ? 'Key present' : 'Not set',
|
|
'action' => $apiKey ? null : 'create_key',
|
|
];
|
|
|
|
// ── SSH key ────────────────────────────────────────────────────────────────────
|
|
$sshPath = trim(vv_parse_conf_scalar($confRaw, $hostIdUp . '_SSH_KEY'));
|
|
$sshOk = $sshPath && file_exists($sshPath);
|
|
$items[] = [
|
|
'id' => 'ssh_key',
|
|
'label' => 'SSH key',
|
|
'ok' => $sshOk,
|
|
'detail' => $sshOk
|
|
? basename($sshPath)
|
|
: ($sshPath ? "Path set but file missing: $sshPath" : 'No key path in host.conf'),
|
|
'action' => $sshOk ? null : 'ssh_setup',
|
|
];
|
|
|
|
// ── Auto-populate (any service key or container detected) ──────────────────────
|
|
$populated = false;
|
|
foreach (['_RADARR_API_KEY','_SONARR_API_KEY','_LIDARR_API_KEY','_EMBY_CONTAINER','_JELLYFIN_CONTAINER'] as $f) {
|
|
if (trim(vv_parse_conf_scalar($confRaw, $hostIdUp . $f)) !== '') {
|
|
$populated = true;
|
|
break;
|
|
}
|
|
}
|
|
$items[] = [
|
|
'id' => 'populated',
|
|
'label' => 'Auto-populate',
|
|
'ok' => $populated,
|
|
'detail' => $populated ? 'Services detected in host.conf' : 'No services detected yet',
|
|
'action' => $populated ? null : 'run_populate',
|
|
];
|
|
|
|
// ── Emby API key ───────────────────────────────────────────────────────────────
|
|
$embyContainer = trim(vv_parse_conf_scalar($confRaw, $hostIdUp . '_EMBY_CONTAINER'));
|
|
if (!empty($embyContainer)) {
|
|
$embyKey = trim(vv_parse_conf_scalar($confRaw, $hostIdUp . '_EMBY_API_KEY'));
|
|
$items[] = [
|
|
'id' => 'emby_key',
|
|
'label' => 'Emby API key',
|
|
'ok' => !empty($embyKey),
|
|
'detail' => $embyKey
|
|
? 'Key present'
|
|
: 'Not set — Emby Dashboard → API Keys → + New Key',
|
|
];
|
|
}
|
|
|
|
// ── Jellyfin API key ───────────────────────────────────────────────────────────
|
|
$jfContainer = trim(vv_parse_conf_scalar($confRaw, $hostIdUp . '_JELLYFIN_CONTAINER'));
|
|
if (!empty($jfContainer)) {
|
|
$jfKey = trim(vv_parse_conf_scalar($confRaw, $hostIdUp . '_JELLYFIN_API_KEY'));
|
|
$items[] = [
|
|
'id' => 'jellyfin_key',
|
|
'label' => 'Jellyfin API key',
|
|
'ok' => !empty($jfKey),
|
|
'detail' => $jfKey
|
|
? 'Key present'
|
|
: 'Not set — Jellyfin Dashboard → Administration → API Keys',
|
|
];
|
|
}
|
|
|
|
// ── master.conf delivery (partner servers only) ───────────────────────────────────────────────
|
|
//
|
|
// The question is whether this host HAS the owner's master.conf, not whether it went and
|
|
// fetched one. There are two ways it arrives and only one of them used to count:
|
|
//
|
|
// pull — this host ran 'Pull from HOST1', which sets master_conf_pulled in the setup state.
|
|
// push — HOST1 sent it during Phase 1 of onboarding, before this host even had Varaverk
|
|
// installed. Nothing on this side runs, so no flag is written here.
|
|
//
|
|
// Keying solely off the pull flag meant a node whose conf had been seeded by Phase 1 —
|
|
// populated, correct, naming both hosts — was told "Not yet pulled from HOST1" and offered a
|
|
// button to fetch what it already had.
|
|
//
|
|
// The push leaves its own evidence: Phase 1 ends by writing <THIS_HOST>_PHASE1_DONE into the
|
|
// owner's setup state and pushing that file here, so the flag can only be present on this
|
|
// machine because the owner completed a push AT this machine. That is the discriminator.
|
|
//
|
|
// A populated HOST1 line is deliberately NOT the test. The wizard's manual partner path writes
|
|
// HOST1 and HOST2 into the LOCAL template conf from what the operator typed, which would make
|
|
// a template look delivered and hide the one button that fixes it.
|
|
if ($hostId !== 'host1' && $hostId !== 'unknown') {
|
|
$state = vv_setup_state_read();
|
|
$pulled = !empty($state['master_conf_pulled']);
|
|
// Both spellings, for the same reason the partnership item below accepts both.
|
|
$pushed = !empty($state[$hostIdUp . '_PHASE1_DONE']) || !empty($state[$hostId . '_phase1_done']);
|
|
$items[] = [
|
|
'id' => 'master_conf',
|
|
'label' => 'master.conf',
|
|
'ok' => $pulled || $pushed,
|
|
'detail' => $pulled
|
|
? "Pulled from $host1"
|
|
: ($pushed
|
|
? "Seeded by $host1 — identity read from it"
|
|
: ($host1 ? "Not yet pulled from $host1" : 'HOST1 hostname not set in master.conf')),
|
|
'action' => (!$pulled && !$pushed && $host1) ? 'pull_master' : null,
|
|
];
|
|
}
|
|
|
|
// ── Partnership (only if a partner is configured) ──────────────────────────────
|
|
preg_match('/^\s*HOST2\s*=\s*"([^"]*)"/m', $master, $m2);
|
|
$host2 = trim($m2[1] ?? '');
|
|
if (!empty($host2)) {
|
|
$state = vv_setup_state_read();
|
|
$p1done = !empty($state['HOST2_PHASE1_DONE']) || !empty($state['host2_phase1_done']);
|
|
$p2done = !empty($state['HOST2_PHASE2_DONE']) || !empty($state['host2_phase2_done']);
|
|
$items[] = [
|
|
'id' => 'partnership',
|
|
'label' => 'Partnership',
|
|
'ok' => $p1done && $p2done,
|
|
'detail' => ($p1done && $p2done)
|
|
? "Active with $host2"
|
|
: ($p1done ? "Phase 1 done — waiting for HOST2 to complete" : "Not started — run partnership_onboard.sh"),
|
|
'action' => (!$p1done) ? 'onboard' : null,
|
|
];
|
|
}
|
|
|
|
// ── Blocking vs deferrable ────────────────────────────────────────────────────
|
|
// Setup used to be complete only when every item was green, which made a media-server API key a
|
|
// gate on finishing onboarding. A mirror that runs no Emby, or runs one the operator has not got
|
|
// round to configuring, could never reach "complete" — and the checklist is what tells them
|
|
// whether they are done.
|
|
//
|
|
// Blocking items are the ones the mesh genuinely cannot work without: who this host is, its conf,
|
|
// its SSH key, the owner's master.conf, and the partnership itself. Everything else improves the
|
|
// install without being load-bearing, and can be dismissed with a recorded decision.
|
|
//
|
|
// Deferring is per item and reversible, stored in the setup state so it survives a reload. The
|
|
// item still shows — amber, "deferred" — rather than disappearing, because a dismissed item is a
|
|
// decision to revisit, not a thing that stopped being true.
|
|
$deferrable = ['api_key' => true, 'populated' => true, 'emby_key' => true, 'jellyfin_key' => true];
|
|
$state = vv_setup_state_read();
|
|
|
|
foreach ($items as &$item) {
|
|
$canDefer = !empty($deferrable[$item['id']]);
|
|
$item['blocking'] = !$canDefer;
|
|
$item['deferred'] = $canDefer && !$item['ok']
|
|
&& !empty($state['DEFER_' . strtoupper($item['id'])]);
|
|
if ($item['deferred']) {
|
|
$item['detail'] = ($item['detail'] ?? '') . ' — deferred';
|
|
$item['action'] = 'undefer';
|
|
} elseif ($canDefer && !$item['ok']) {
|
|
// Keep the item's real action as the primary; the UI offers defer alongside it.
|
|
$item['can_defer'] = true;
|
|
}
|
|
}
|
|
unset($item);
|
|
|
|
// Complete when every blocking item is green and every deferrable one is green or dismissed.
|
|
$allOk = true;
|
|
foreach ($items as $i) {
|
|
if ($i['ok']) continue;
|
|
if ($i['blocking'] || empty($i['deferred'])) { $allOk = false; break; }
|
|
}
|
|
|
|
echo json_encode(['ok' => true, 'complete' => $allOk, 'host_id' => $hostId, 'items' => $items]);
|