Add Rsync page, upgrade monitor rsync card, partnership overhaul, API key periodic check, docker watchdog manual-stop detection
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
header('Cache-Control: no-store, no-cache');
|
||||
require_once dirname(__DIR__) . '/include/config.php';
|
||||
require_once dirname(__DIR__) . '/include/arrs.php';
|
||||
require_once dirname(__DIR__) . '/include/partnership.php'; // vv_pt_ts_peers, vv_pt_ssh
|
||||
|
||||
function _ms_caller(): array {
|
||||
$host = vv_detect_host();
|
||||
$id = strtoupper($host);
|
||||
$raw = vv_read_conf_raw($host . '.conf');
|
||||
$sshKey = vv_arr_scalar($raw, $id . '_SSH_KEY');
|
||||
return ['host' => $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']);
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once dirname(__DIR__) . '/include/config.php';
|
||||
require_once dirname(__DIR__) . '/include/monitor.php';
|
||||
|
||||
$base = vv_rsync_status();
|
||||
$vars = vv_conf_vars();
|
||||
|
||||
$base['windows']['fallback'] = ($vars['FALLBACK_RSYNC_ENABLED'] ?? 'true') !== 'false';
|
||||
|
||||
// Bandwidth history — last 30 days
|
||||
$bwLog = DATA_DIR . '/bandwidth_history.db';
|
||||
$warnGb = (float)($vars['BANDWIDTH_WARN_GB'] ?? 50);
|
||||
$cutoff = date('Y-m-d', strtotime('-30 days'));
|
||||
$history = [];
|
||||
|
||||
if (file_exists($bwLog)) {
|
||||
foreach (file($bwLog, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
|
||||
$p = explode('|', $line);
|
||||
if (count($p) < 5) continue;
|
||||
if ($p[0] < $cutoff) continue;
|
||||
$history[] = [
|
||||
'date' => $p[0],
|
||||
'time' => $p[1],
|
||||
'profile' => $p[2],
|
||||
'duration' => (int)$p[3],
|
||||
'status' => trim($p[4]),
|
||||
'bytes' => isset($p[5]) ? (int)$p[5] : 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'enabled' => $base['enabled'],
|
||||
'windows' => $base['windows'],
|
||||
'active' => $base['active'],
|
||||
'last_sync' => $base['last_sync'],
|
||||
'bw_history' => $history,
|
||||
'bw_warn_gb' => $warnGb,
|
||||
'settings' => [
|
||||
'bw_limit' => (int)($vars['BW_LIMIT'] ?? 0),
|
||||
'retry_count' => (int)($vars['RETRY_COUNT'] ?? 3),
|
||||
'sleep' => (int)($vars['SLEEP'] ?? 300),
|
||||
'bw_warn_gb' => (float)($vars['BANDWIDTH_WARN_GB'] ?? 50),
|
||||
],
|
||||
'ts' => time(),
|
||||
]);
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
header('Cache-Control: no-store, no-cache');
|
||||
require_once dirname(__DIR__) . '/include/config.php';
|
||||
require_once dirname(__DIR__) . '/include/confform.php';
|
||||
|
||||
// All PROFILE_* assoc arrays and their UI field keys
|
||||
const RP_ARRAYS = [
|
||||
'PROFILE_RSYNC_OPTS' => 'rsync_opts',
|
||||
'PROFILE_BW_LIMIT' => 'bw_limit',
|
||||
'PROFILE_RETRY_COUNT' => 'retry_count',
|
||||
'PROFILE_SLEEP' => 'sleep',
|
||||
'PROFILE_CRITICAL_CONTAINER_NAMES' => 'critical_containers',
|
||||
'PROFILE_DELAYED_CONTAINERS' => 'delayed_containers',
|
||||
'PROFILE_CONTAINER_DELAY' => 'container_delay',
|
||||
'PROFILE_EXCLUDE_DIRS' => 'exclude_dirs',
|
||||
'PROFILE_REMOTE_RESTART_CONTAINERS' => 'remote_restart',
|
||||
];
|
||||
|
||||
// Parse [key]="value" or [key]=bare entries from an assoc_array body
|
||||
function _rp_parse_assoc(string $body): array {
|
||||
$result = [];
|
||||
preg_match_all('/\[([^\]]+)\]\s*=\s*(?:"([^"]*)"|([^\s#\n]*))/', $body, $m, PREG_SET_ORDER);
|
||||
foreach ($m as $match) {
|
||||
$key = $match[1];
|
||||
$val = $match[2] !== '' ? $match[2] : ($match[3] ?? '');
|
||||
$result[$key] = $val;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Rebuild assoc_array body from entries map
|
||||
function _rp_build_assoc(array $entries): string {
|
||||
$lines = [];
|
||||
foreach ($entries as $k => $v) {
|
||||
// Quote if empty, has spaces or special shell chars
|
||||
if ($v === '' || preg_match('/[\s\$\!\[\]\(\)\|\'`\\\\]/', $v)) {
|
||||
$lines[] = ' [' . $k . ']="' . str_replace('"', '\\"', $v) . '"';
|
||||
} else {
|
||||
$lines[] = ' [' . $k . ']=' . $v;
|
||||
}
|
||||
}
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
// Read all PROFILE_* arrays from master.conf → [profile_name => [field => value]]
|
||||
function _rp_read_all(): array {
|
||||
$raw = vv_read_conf_raw('master.conf');
|
||||
$result = [];
|
||||
foreach (RP_ARRAYS as $varName => $fieldKey) {
|
||||
if (!preg_match('/declare\s+-A\s+' . preg_quote($varName, '/') . '\s*=\s*\((.*?)\)/s', $raw, $m)) continue;
|
||||
foreach (_rp_parse_assoc($m[1]) as $profile => $value) {
|
||||
$result[$profile][$fieldKey] = $value;
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
$action = trim($_GET['action'] ?? $_POST['action'] ?? '');
|
||||
|
||||
// ── list ──────────────────────────────────────────────────────────────────────
|
||||
if ($action === 'list') {
|
||||
echo json_encode(['ok' => true, 'profiles' => _rp_read_all()]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── save (create or update) ───────────────────────────────────────────────────
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'save') {
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
if (!$name || !preg_match('/^[a-zA-Z0-9_\-]+$/', $name)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Invalid profile name — use letters, numbers, hyphens, underscores']); exit;
|
||||
}
|
||||
|
||||
$raw = vv_read_conf_raw('master.conf');
|
||||
$changes = [];
|
||||
|
||||
foreach (RP_ARRAYS as $varName => $fieldKey) {
|
||||
$value = trim($_POST[$fieldKey] ?? '');
|
||||
// Find and parse current array body
|
||||
if (!preg_match('/declare\s+-A\s+' . preg_quote($varName, '/') . '\s*=\s*\((.*?)\)/s', $raw, $m)) continue;
|
||||
$entries = _rp_parse_assoc($m[1]);
|
||||
$entries[$name] = $value;
|
||||
$changes[] = [
|
||||
'file' => 'master.conf',
|
||||
'key' => $varName,
|
||||
'type' => 'assoc_array',
|
||||
'value' => _rp_build_assoc($entries),
|
||||
];
|
||||
}
|
||||
|
||||
if (!$changes) { echo json_encode(['ok' => false, 'error' => 'No profile arrays found in master.conf']); exit; }
|
||||
|
||||
$results = vv_conf_write_changes($changes);
|
||||
$ok = !in_array(false, $results, true);
|
||||
if ($ok) { vv_push_master_conf(); vv_push_setup_state(); }
|
||||
echo json_encode(['ok' => $ok, 'results' => $results]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── delete ────────────────────────────────────────────────────────────────────
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'delete') {
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
if (!$name || !preg_match('/^[a-zA-Z0-9_\-]+$/', $name)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Invalid profile name']); exit;
|
||||
}
|
||||
|
||||
$raw = vv_read_conf_raw('master.conf');
|
||||
$changes = [];
|
||||
|
||||
foreach (RP_ARRAYS as $varName => $fieldKey) {
|
||||
if (!preg_match('/declare\s+-A\s+' . preg_quote($varName, '/') . '\s*=\s*\((.*?)\)/s', $raw, $m)) continue;
|
||||
$entries = _rp_parse_assoc($m[1]);
|
||||
if (!array_key_exists($name, $entries)) continue;
|
||||
unset($entries[$name]);
|
||||
$changes[] = [
|
||||
'file' => 'master.conf',
|
||||
'key' => $varName,
|
||||
'type' => 'assoc_array',
|
||||
'value' => _rp_build_assoc($entries),
|
||||
];
|
||||
}
|
||||
|
||||
if (!$changes) { echo json_encode(['ok' => false, 'error' => 'Profile not found']); exit; }
|
||||
|
||||
$results = vv_conf_write_changes($changes);
|
||||
$ok = !in_array(false, $results, true);
|
||||
if ($ok) { vv_push_master_conf(); vv_push_setup_state(); }
|
||||
echo json_encode(['ok' => $ok]);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(['ok' => false, 'error' => 'Unknown action']);
|
||||
Reference in New Issue
Block a user