Document the PHP api layer and fix what documenting it exposed
Writing down what each endpoint actually guarantees made the places it didn't obvious — shell arguments reaching a crontab or a bash -c unescaped, master.conf written without tmp+rename, and conf edits that could be saved without ever being parsed.
This commit is contained in:
@@ -1,4 +1,129 @@
|
||||
<?php
|
||||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||||
// PURPOSE
|
||||
// Ad-hoc rsync between hosts. Picks a partner, browses both filesystems to choose a source
|
||||
// and destination, runs a transfer in the background, streams its output, and cancels it —
|
||||
// the manual sync panel on the rsync tab.
|
||||
//
|
||||
// OPERATIONAL MODEL
|
||||
// Entirely outside the profile system. The scheduled tiers sync configured shares with
|
||||
// configured profiles; this is for the one-off move that does not belong in a conf file —
|
||||
// seeding a new host, recovering a share, copying something once. Nothing here is recorded,
|
||||
// scheduled, or repeated.
|
||||
//
|
||||
// Six actions on one URL, in the order the panel uses them: hosts, browse, browse_local,
|
||||
// run, poll, stop. run returns a token immediately and the transfer continues detached; poll
|
||||
// reads its log until a sentinel appears; stop kills it.
|
||||
//
|
||||
// Progress is a log file in /tmp keyed by token, terminated by a __DONE__ sentinel the
|
||||
// wrapper appends after rsync exits. That sentinel is what lets poll distinguish "still
|
||||
// running" from "finished" without inspecting a process — and the pid file is the backstop
|
||||
// for the case where rsync died without reaching it.
|
||||
//
|
||||
// Both filesystems are browsed the same way, one directory at a time. The remote side runs
|
||||
// the same find over SSH that the local side runs directly, so the two panes behave
|
||||
// identically.
|
||||
//
|
||||
// DESIGN PRINCIPLES
|
||||
// Source is always local, destination always remote.
|
||||
// The direction is fixed. A tool that could pull as well as push would need twice the
|
||||
// path validation and would make "which side am I about to overwrite" a question the
|
||||
// user has to answer correctly every time.
|
||||
//
|
||||
// Trailing slashes are normalised onto both paths.
|
||||
// rsync's most consequential piece of syntax is whether the source ends in a slash. It
|
||||
// is appended unconditionally here so the transfer always means "the contents of", never
|
||||
// "the directory itself nested inside".
|
||||
//
|
||||
// One manual sync at a time, host-wide.
|
||||
// Enforced by scanning for any live pid file, not per token. Two concurrent ad-hoc
|
||||
// transfers would compete for the same bandwidth the scheduled tiers are also using.
|
||||
//
|
||||
// An inconclusive remote check proceeds; a definite failure stops.
|
||||
// A destination that reports 'missing' is refused. An SSH check that answers neither
|
||||
// 'ok' nor 'missing' — Tailscale still coming up, for instance — is allowed through,
|
||||
// because rsync will fail cleanly and visibly in the log if the path really is wrong.
|
||||
//
|
||||
// OPERATIONAL SAFEGUARDS
|
||||
// System paths are refused as both source and destination.
|
||||
// An explicit blocklist — /, /proc, /sys, /dev, /run, /etc, /bin, /sbin, /usr, /lib,
|
||||
// /lib64, /boot/EFI, /tmp — compared after trailing-slash normalisation so /etc and
|
||||
// /etc/ are the same answer. This is the guard against a blank or truncated field
|
||||
// collapsing into a path that would sync the operating system over the network.
|
||||
//
|
||||
// Both paths must be absolute, traversal-free, and free of shell metacharacters.
|
||||
// Leading slash required, '..' rejected, and null bytes, newlines, backticks and $
|
||||
// rejected outright — then escapeshellarg() on top.
|
||||
//
|
||||
// rsync flags cannot become shell syntax.
|
||||
// Control characters are rejected and every remaining token is escaped individually.
|
||||
// The previous blocklist stripped metacharacters but not newlines, and these flags are
|
||||
// spliced into a `bash -c` script — a newline would have started a second command
|
||||
// inside it. Escaping each token is right about every character; a blocklist has to be.
|
||||
//
|
||||
// The bandwidth limit is cast, not filtered.
|
||||
// (int) with a max(0, …) floor, so it can only ever be a non-negative number.
|
||||
//
|
||||
// The remote user is reduced to a safe character set before it is used.
|
||||
// preg_replace strips everything outside [a-z0-9_.-], with 'root' as the fallback when
|
||||
// nothing survives.
|
||||
//
|
||||
// The local source is confirmed to exist before anything is launched.
|
||||
//
|
||||
// Tokens are random and fixed-length.
|
||||
// bin2hex(random_bytes(8)) produces the token; poll and stop both reduce the submitted
|
||||
// value to hex and require exactly 16 characters, so neither can name a file outside
|
||||
// the /tmp/vv_ms_ namespace. A transfer's log is readable by anyone who can guess its
|
||||
// token, so the token is not guessable.
|
||||
//
|
||||
// Stop escalates and then cleans up.
|
||||
// SIGTERM, 400ms, SIGKILL — rsync should be given the chance to finish its current file
|
||||
// and close its connection. The cancellation is then written into the log with the
|
||||
// sentinel, so a poll already in flight terminates cleanly instead of hanging.
|
||||
//
|
||||
// Poll detects a crashed transfer as well as a finished one.
|
||||
// A missing sentinel with a dead pid is reported done with an explicit note, so a
|
||||
// transfer killed by OOM or a reboot does not leave the panel polling forever.
|
||||
//
|
||||
// SSH is non-interactive and time-boxed.
|
||||
// BatchMode=yes and ConnectTimeout=10 on the transfer, 8s and 4s on the browse and ping
|
||||
// calls — nothing here can sit waiting for a password or a dead host.
|
||||
//
|
||||
// Log files are removed on the terminal poll, so a completed transfer does not leave its
|
||||
// output in /tmp indefinitely. /tmp is tmpfs, so an abandoned one clears at reboot anyway.
|
||||
//
|
||||
// Accepted exposure: browse can list any directory on either host.
|
||||
// Directory names only — no file contents, no file names. Guarded by the Unraid WebGUI
|
||||
// session; see the CSRF note in README-unraid.md.
|
||||
//
|
||||
// REQUEST
|
||||
// GET ?action=hosts partners, Tailscale state, key presence
|
||||
// GET ?action=browse&host=<slot>&path=/dir remote directory listing over SSH
|
||||
// GET ?action=browse_local&path=/dir local directory listing
|
||||
// POST action=run local=/src host=<slot> remote_path=/dst
|
||||
// [user=root] [bw_limit=<KB/s>] [use_key=0|1] [flags=<rsync flags>]
|
||||
// GET ?action=poll&token=<hex16> output so far, and whether it finished
|
||||
// POST action=stop token=<hex16> cancel a running transfer
|
||||
//
|
||||
// RESPONSE
|
||||
// hosts {"ok":true,"hosts":[{slot,id,hostname,online,ip}],"has_key":bool,"ssh_key":"…"}
|
||||
// browse {"ok":true,"path","dirs":[…],"parent":…} max 200 entries
|
||||
// run {"ok":true,"token":"<hex16>"}
|
||||
// poll {"ok":true,"output":"…","done":bool,"started":bool}
|
||||
// stop {"ok":true}
|
||||
// {"ok":false,"error":"Invalid path"|"Missing: …"|"Refusing to sync from system path: …"
|
||||
// |"Local source does not exist: …"|"Another manual sync is already
|
||||
// running — stop it first."|"Remote destination does not exist: …"
|
||||
// |"SSH connection failed to …"|"Invalid flags"|"Invalid token"
|
||||
// |"Unknown action"}
|
||||
//
|
||||
// DEPENDS ON
|
||||
// include/config.php vv_detect_host(), vv_read_conf_raw(), vv_conf_vars(),
|
||||
// vv_resolve_tailscale_ip()
|
||||
// include/arrs.php vv_arr_known_hosts(), vv_arr_scalar()
|
||||
// include/partnership.php vv_pt_ts_peers(), vv_pt_ssh()
|
||||
// /tmp/vv_ms_<token>.log|.pid per-transfer state
|
||||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||||
header('Content-Type: application/json');
|
||||
header('Cache-Control: no-store, no-cache');
|
||||
require_once dirname(__DIR__) . '/include/config.php';
|
||||
@@ -104,8 +229,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'run') {
|
||||
$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';
|
||||
// Control characters are rejected outright — a newline would start a second command
|
||||
// inside the bash -c script this is spliced into. What survives is split on whitespace
|
||||
// and escaped per token below, so the shell never parses any of it as syntax.
|
||||
$rawFlags = trim($_POST['flags'] ?? '');
|
||||
if ($rawFlags !== '' && preg_match('/[\x00-\x1f\x7f]/', $rawFlags)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Invalid flags']); exit;
|
||||
}
|
||||
$flagList = $rawFlags !== ''
|
||||
? preg_split('/\s+/', $rawFlags, -1, PREG_SPLIT_NO_EMPTY)
|
||||
: ['-av', '--stats'];
|
||||
|
||||
foreach (['local' => $local, 'host' => $slot, 'remote_path' => $remotePath] as $f => $v) {
|
||||
if (!$v) { echo json_encode(['ok' => false, 'error' => 'Missing: ' . $f]); exit; }
|
||||
@@ -170,7 +303,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'run') {
|
||||
} else {
|
||||
$sshOpts = 'ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10';
|
||||
}
|
||||
if ($bwLimit) $flags .= ' --bwlimit=' . (int)$bwLimit;
|
||||
if ($bwLimit) $flagList[] = '--bwlimit=' . (int)$bwLimit;
|
||||
$flags = implode(' ', array_map('escapeshellarg', $flagList));
|
||||
|
||||
$src = escapeshellarg(rtrim($local, '/') . '/');
|
||||
$dst = escapeshellarg($user . '@' . $t['ip'] . ':' . rtrim($remotePath, '/') . '/');
|
||||
|
||||
Reference in New Issue
Block a user