$host, 'ssh_key' => $sshKey]; } function _ms_target(string $slot, string $sshKey): array { $vars = vv_conf_vars(); $hostname = $vars[strtoupper($slot)] ?? ''; if (!$hostname) return ['ok' => false, 'error' => 'Unknown host: ' . $slot]; if (!$sshKey || !file_exists($sshKey)) return ['ok' => false, 'error' => 'SSH key not configured on this host']; $ip = vv_resolve_tailscale_ip($hostname); if (!$ip) return ['ok' => false, 'error' => 'Cannot reach ' . $hostname . ' via Tailscale']; return ['ok' => true, 'ip' => $ip, 'hostname' => $hostname]; } $action = trim($_GET['action'] ?? $_POST['action'] ?? ''); // ── hosts ───────────────────────────────────────────────────────────────────── if ($action === 'hosts') { ['host' => $current, 'ssh_key' => $sshKey] = _ms_caller(); $all = vv_arr_known_hosts(); $peers = vv_pt_ts_peers(); $out = []; foreach ($all as $slot => $hostname) { if ($slot === $current) continue; $label = strtolower($hostname); $ts = $peers[$label] ?? []; $out[] = [ 'slot' => $slot, 'id' => strtoupper($slot), 'hostname' => $hostname, 'online' => $ts['online'] ?? null, 'ip' => $ts['ip'] ?? null, ]; } echo json_encode(['ok' => true, 'hosts' => $out, 'has_key' => !empty($sshKey) && file_exists($sshKey), 'ssh_key' => $sshKey]); exit; } // ── browse remote (SSH) ─────────────────────────────────────────────────────── if ($action === 'browse') { $slot = trim($_GET['host'] ?? ''); $path = trim($_GET['path'] ?? '/mnt/user'); if (!preg_match('#^/[^\0]*$#', $path) || str_contains($path, '..')) { echo json_encode(['ok' => false, 'error' => 'Invalid path']); exit; } ['ssh_key' => $sshKey] = _ms_caller(); $t = _ms_target($slot, $sshKey); if (!$t['ok']) { echo json_encode($t); exit; } $clean = rtrim($path, '/') ?: '/'; $out = vv_pt_ssh($t['ip'], $sshKey, 'find ' . escapeshellarg($clean) . ' -maxdepth 1 -mindepth 1 -type d 2>/dev/null', 8); $dirs = array_values(array_filter(array_map('trim', explode("\n", $out)))); sort($dirs); $dirs = array_slice($dirs, 0, 200); // If empty, do a quick SSH echo to distinguish "no dirs" from "SSH failed" if (empty($dirs)) { $ping = trim(vv_pt_ssh($t['ip'], $sshKey, 'echo ok', 4)); if ($ping !== 'ok') { echo json_encode(['ok' => false, 'error' => 'SSH connection failed to ' . $t['hostname']]); exit; } } $parent = ($clean !== '/') ? (dirname($clean) ?: '/') : null; echo json_encode(['ok' => true, 'path' => $clean, 'dirs' => $dirs, 'parent' => $parent]); exit; } // ── browse local ────────────────────────────────────────────────────────────── if ($action === 'browse_local') { $path = trim($_GET['path'] ?? '/mnt/user'); if (!preg_match('#^/[^\0]*$#', $path) || str_contains($path, '..')) { echo json_encode(['ok' => false, 'error' => 'Invalid path']); exit; } $clean = rtrim($path, '/') ?: '/'; if (!is_dir($clean)) { echo json_encode(['ok' => false, 'error' => 'Not a directory: ' . $clean]); exit; } $out = shell_exec('find ' . escapeshellarg($clean) . ' -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sort | head -200') ?: ''; $dirs = array_values(array_filter(array_map('trim', explode("\n", $out)))); $parent = ($clean !== '/') ? (dirname($clean) ?: '/') : null; echo json_encode(['ok' => true, 'path' => $clean, 'dirs' => $dirs, 'parent' => $parent]); exit; } // ── run (background, returns token) ────────────────────────────────────────── if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'run') { $local = trim($_POST['local'] ?? ''); $slot = trim($_POST['host'] ?? ''); $remotePath = trim($_POST['remote_path'] ?? ''); $user = preg_replace('/[^a-z0-9_.-]/i', '', trim($_POST['user'] ?? 'root')) ?: 'root'; $bwLimit = max(0, (int)($_POST['bw_limit'] ?? 0)); $useKey = ($_POST['use_key'] ?? '1') !== '0'; $rawFlags = trim($_POST['flags'] ?? ''); $flags = $rawFlags !== '' ? preg_replace('/[`$!|&;><(){}\[\]\\\\]/', '', $rawFlags) : '-av --stats'; foreach (['local' => $local, 'host' => $slot, 'remote_path' => $remotePath] as $f => $v) { if (!$v) { echo json_encode(['ok' => false, 'error' => 'Missing: ' . $f]); exit; } } foreach ([$local, $remotePath] as $p) { if (!str_starts_with($p, '/') || str_contains($p, '..') || preg_match('/[\x00\n\r`$]/', $p)) { echo json_encode(['ok' => false, 'error' => 'Invalid path: ' . $p]); exit; } } // ── Safeguards ──────────────────────────────────────────────────────────── // Block syncing from/to dangerous system paths $blocked = ['/', '/proc', '/sys', '/dev', '/run', '/etc', '/bin', '/sbin', '/usr', '/lib', '/lib64', '/boot/EFI', '/tmp']; foreach ($blocked as $b) { if (rtrim($local, '/') === $b) { echo json_encode(['ok' => false, 'error' => 'Refusing to sync from system path: ' . $b]); exit; } if (rtrim($remotePath, '/') === $b) { echo json_encode(['ok' => false, 'error' => 'Refusing to sync to system path: ' . $b]); exit; } } // Local source must exist if (!file_exists($local)) { echo json_encode(['ok' => false, 'error' => 'Local source does not exist: ' . $local]); exit; } // Prevent concurrent manual syncs foreach (glob('/tmp/vv_ms_*.pid') ?: [] as $pf) { $pid = (int)trim(@file_get_contents($pf) ?: '0'); if ($pid > 0 && file_exists('/proc/' . $pid)) { echo json_encode(['ok' => false, 'error' => 'Another manual sync is already running — stop it first.']); exit; } @unlink($pf); // stale } ['ssh_key' => $sshKey] = _ms_caller(); $t = _ms_target($slot, $sshKey); if (!$t['ok']) { echo json_encode($t); exit; } // Remote destination must exist $destCheck = trim(vv_pt_ssh($t['ip'], $sshKey, 'test -d ' . escapeshellarg($remotePath) . ' && echo ok || echo missing', 5)); if ($destCheck === 'missing') { echo json_encode(['ok' => false, 'error' => 'Remote destination does not exist: ' . $remotePath]); exit; } if ($destCheck !== 'ok') { // SSH check inconclusive — log warning but proceed; rsync will fail cleanly if needed // (e.g. Tailscale not yet up, rsync error will surface in output) } // ── Build and launch ────────────────────────────────────────────────────── $token = bin2hex(random_bytes(8)); $logFile = '/tmp/vv_ms_' . $token . '.log'; $pidFile = '/tmp/vv_ms_' . $token . '.pid'; if ($useKey && $sshKey && file_exists($sshKey)) { $sshOpts = 'ssh -i ' . escapeshellarg($sshKey) . ' -o StrictHostKeyChecking=no -o BatchMode=yes -o ConnectTimeout=10'; } else { $sshOpts = 'ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10'; } if ($bwLimit) $flags .= ' --bwlimit=' . (int)$bwLimit; $src = escapeshellarg(rtrim($local, '/') . '/'); $dst = escapeshellarg($user . '@' . $t['ip'] . ':' . rtrim($remotePath, '/') . '/'); // Start rsync in background, capture its PID for stop support $inner = "rsync $flags -e " . escapeshellarg($sshOpts) . " $src $dst >> " . escapeshellarg($logFile) . " 2>&1 &" . " RSYNC_PID=\$!;" . " echo \$RSYNC_PID > " . escapeshellarg($pidFile) . ";" . " wait \$RSYNC_PID;" . " echo __DONE__ >> " . escapeshellarg($logFile) . ";" . " rm -f " . escapeshellarg($pidFile); shell_exec('nohup bash -c ' . escapeshellarg($inner) . ' &>/dev/null &'); echo json_encode(['ok' => true, 'token' => $token]); exit; } // ── stop ────────────────────────────────────────────────────────────────────── if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'stop') { $token = preg_replace('/[^a-f0-9]/', '', trim($_POST['token'] ?? '')); if (!$token || strlen($token) !== 16) { echo json_encode(['ok' => false, 'error' => 'Invalid token']); exit; } $pidFile = '/tmp/vv_ms_' . $token . '.pid'; $logFile = '/tmp/vv_ms_' . $token . '.log'; $pid = (int)trim(@file_get_contents($pidFile) ?: '0'); if ($pid > 0 && file_exists('/proc/' . $pid)) { shell_exec('kill -TERM ' . $pid . ' 2>/dev/null'); usleep(400000); // 400ms grace if (file_exists('/proc/' . $pid)) shell_exec('kill -KILL ' . $pid . ' 2>/dev/null'); } @unlink($pidFile); @file_put_contents($logFile, "\n\n--- Cancelled by user ---\n__DONE__\n", FILE_APPEND); echo json_encode(['ok' => true]); exit; } // ── poll ────────────────────────────────────────────────────────────────────── if ($action === 'poll') { $token = preg_replace('/[^a-f0-9]/', '', trim($_GET['token'] ?? '')); if (!$token || strlen($token) !== 16) { echo json_encode(['ok' => false, 'error' => 'Invalid token']); exit; } $logFile = '/tmp/vv_ms_' . $token . '.log'; $pidFile = '/tmp/vv_ms_' . $token . '.pid'; if (!file_exists($logFile)) { echo json_encode(['ok' => true, 'output' => '', 'done' => false, 'started' => false]); exit; } $content = file_get_contents($logFile) ?: ''; $done = str_contains($content, '__DONE__'); if ($done) { $content = str_replace(['__DONE__', "\n\n\n"], ['', "\n\n"], $content); @unlink($logFile); @unlink($pidFile); } // Check if rsync process is actually alive (catches crashes without __DONE__) $pid = (int)trim(@file_get_contents($pidFile) ?: '0'); if (!$done && $pid > 0 && !file_exists('/proc/' . $pid)) { $done = true; $content .= "\n\n--- Process ended unexpectedly ---"; @unlink($pidFile); } echo json_encode(['ok' => true, 'output' => $content, 'done' => $done, 'started' => true]); exit; } echo json_encode(['ok' => false, 'error' => 'Unknown action']);