Rebuild the Fallback tab around what the daemon is actually doing, not what its last state file said

This commit is contained in:
Gmer4Lfe
2026-08-22 00:44:35 -04:00
parent 5924aae1c0
commit da15cc7531
3 changed files with 518 additions and 36 deletions
+128
View File
@@ -0,0 +1,128 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Start and stop the fallback daemon, its dry-run preview, and the failover test harness, on
// this host or on a partner. The Fallback tab's only write surface.
//
// OPERATIONAL MODEL
// Every action shells out to the script that already owns the operation — fallback.sh --stop
// and fallback_test.sh --stop — rather than signalling PIDs from PHP. Those two know things
// this layer must not have to: which lock holds the process, how long to wait, and, for the
// test, that SIGKILL must never be used because only its EXIT trap removes the iptables rule
// it installed.
//
// DESIGN PRINCIPLES
// The scripts own stopping; this endpoint owns routing.
// Duplicating the escalation logic here would put a second, divergent implementation of
// "how to stop fallback safely" in a language that cannot run its traps.
//
// Start is dry-run only.
// The live daemon is started by array_started.sh at array start, which is the one context
// where the tier delays and the state file mean what they say. A button that launched a
// live failover monitor mid-session — against a partner mid-maintenance, from a page
// someone was reading — is not a button this page should have. Previewing is safe and is
// what the page is for.
//
// OPERATIONAL SAFEGUARDS
// POST only. Unraid's CSRF token injector is jQuery-only, so a native fetch() GET would fail
// silently anyway; making these POST means a link or prefetch cannot stop a daemon.
//
// host is matched against the configured host list, never used as a path or a shell word.
// The slot resolves to a hostname from conf, then to a Tailscale IP through the same
// unambiguous-prefix resolver the rest of the mesh uses. A value that does not name a
// configured host is refused before anything runs.
//
// setsid, not nohup+&, for the dry run.
// A backgrounded child stays in php-fpm's process group, and the group kill that ends a
// request takes it with it. setsid detaches it into its own session so it survives the
// response — the same fix the Scheduler's Stop button needed.
//
// Output is captured and returned, not discarded.
// These scripts report refusals in words — "did not exit within 30s", "NOT force-killing"
// — and a boolean would throw away the only explanation the operator gets.
//
// REQUEST
// POST action=stop|stop_test|start_dry|clear_lock host=<slot>
//
// RESPONSE
// {"ok":true,"output":string} action ran; output is the script's own report
// {"ok":false,"error":string} bad method, unknown action, or unresolvable host
//
// DEPENDS ON
// include/fallback.php vv_pt_peer_lookup(), vv_pt_ts_peers(), vv_pt_ssh()
// Fallback/fallback.sh --stop
// Fallback/fallback_test.sh --stop
// ═══════════════════════════════════════════════════════════════════════════════════════════════
require_once dirname(__DIR__) . '/include/fallback.php';
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['ok' => false, 'error' => 'POST only']);
exit;
}
$action = (string)($_POST['action'] ?? '');
$slot = strtolower((string)($_POST['host'] ?? ''));
$allowed = ['stop', 'stop_test', 'start_dry', 'clear_lock'];
if (!in_array($action, $allowed, true)) {
echo json_encode(['ok' => false, 'error' => 'Unknown action']);
exit;
}
$hosts = vv_fb_known_hosts();
if (!isset($hosts[$slot])) {
echo json_encode(['ok' => false, 'error' => 'Unknown host']);
exit;
}
$isMe = ($slot === vv_detect_host());
$scripts = rtrim(SCRIPTS_DIR, '/');
// The command, as the script that owns the operation would be invoked by hand.
$cmds = [
'stop' => 'bash ' . escapeshellarg("$scripts/Fallback/fallback.sh") . ' --stop 2>&1',
'stop_test' => 'bash ' . escapeshellarg("$scripts/Fallback/fallback_test.sh") . ' --stop 2>&1',
// setsid so it outlives this request; own log so the page can show what the preview said.
'start_dry' => 'setsid bash ' . escapeshellarg("$scripts/Fallback/fallback.sh")
. ' --dry-run --log > /tmp/varaverk/fallback_dryrun.log 2>&1 < /dev/null & echo started',
'clear_lock' => 'rm -f /tmp/unraid_locks/fallback.lock /tmp/unraid_locks/fallback_test.lock && echo cleared',
];
if ($isMe) {
@mkdir('/tmp/varaverk', 0755, true);
$out = (string)shell_exec($cmds[$action]);
echo json_encode(['ok' => true, 'output' => trim($out)]);
exit;
}
// Remote: same command, same script, over the SSH this file's neighbours already use.
$tsPeers = vv_pt_ts_peers();
$ts = vv_pt_peer_lookup($tsPeers, $hosts[$slot]);
$ip = $ts['ip'] ?? null;
$myId = strtoupper(vv_detect_host());
$sshKey = vv_fb_scalar(vv_read_conf_raw(vv_detect_host() . '.conf'), $myId . '_SSH_KEY');
if (!$ip || !$sshKey) {
echo json_encode(['ok' => false, 'error' => 'Partner not resolvable — no Tailscale IP or no SSH key']);
exit;
}
// The remote's SCRIPTS_DIR is not this host's: appdata mode on one side and flash on the other
// is the normal case on this mesh, so ask the partner where it keeps them.
$remoteDir = trim((string)vv_pt_ssh($ip, $sshKey,
'sed -n \'s/^SCRIPTS_DIR="\(.*\)"$/\1/p\' /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null'));
if ($remoteDir === '') $remoteDir = '/boot/config/plugins/varaverk';
$remoteCmds = [
'stop' => "bash '$remoteDir/Fallback/fallback.sh' --stop 2>&1",
'stop_test' => "bash '$remoteDir/Fallback/fallback_test.sh' --stop 2>&1",
'start_dry' => "mkdir -p /tmp/varaverk; setsid bash '$remoteDir/Fallback/fallback.sh'"
. " --dry-run --log > /tmp/varaverk/fallback_dryrun.log 2>&1 < /dev/null & echo started",
'clear_lock' => 'rm -f /tmp/unraid_locks/fallback.lock /tmp/unraid_locks/fallback_test.lock && echo cleared',
];
$out = vv_pt_ssh($ip, $sshKey, $remoteCmds[$action]);
echo json_encode(['ok' => true, 'output' => trim((string)$out)]);