diff --git a/Plugin/unraid/Tools/api_cache_writer.sh b/Plugin/unraid/Tools/api_cache_writer.sh index d890cc1..a25217c 100755 --- a/Plugin/unraid/Tools/api_cache_writer.sh +++ b/Plugin/unraid/Tools/api_cache_writer.sh @@ -74,3 +74,7 @@ php "$SCRIPT_DIR/api_cache_writer.php" # Failure is ignored on purpose: a missed sample costs resolution in one window, and this job's # actual purpose is the WebGUI cache. php "$SCRIPT_DIR/mesh_traffic_sample.php" >/dev/null 2>&1 || true + +# Retry any mesh chat that could not be delivered when it was sent — a partner being asleep is +# the normal case for the message "my server is going down". +php "$SCRIPT_DIR/node_chat_receive.php" --flush >/dev/null 2>&1 || true diff --git a/Plugin/unraid/Tools/node_chat_receive.php b/Plugin/unraid/Tools/node_chat_receive.php new file mode 100644 index 0000000..49b8fdf --- /dev/null +++ b/Plugin/unraid/Tools/node_chat_receive.php @@ -0,0 +1,69 @@ + record on stdin, one JSON object +// php node_chat_receive.php --flush retry this host's own undelivered spool +// +// Exit 0 means stored. The sender treats anything else as undelivered and spools for retry, so +// a non-zero exit here is a message that will arrive later rather than one that is lost. +// +// OPERATIONAL SAFEGUARDS +// Reached only over SSH with a key this mesh installed, so the caller already has root. This +// file therefore validates shape, not authority — there is no privilege here to protect that +// the transport has not already granted. +// +// The channel is resolved against this host's own membership. A name that is not a channel +// this machine belongs to is refused, so the argument cannot address a path outside the store. +// +// Storage is append-and-trim through vv_nc_append(), which is idempotent on message id — a +// retry of something that already landed is a no-op rather than a duplicate. +// +// DEPENDS ON +// include/node_chat.php vv_nc_append(), vv_nc_channel(), vv_nc_flush_spool() +// ═══════════════════════════════════════════════════════════════════════════════════════════════ +require_once dirname(__DIR__) . '/include/node_chat.php'; + +$args = $argv ?? []; + +if (in_array('--flush', $args, true)) { + $n = vv_nc_flush_spool(); + if ($n) echo "delivered $n queued message(s)\n"; + exit(0); +} + +$channel = ''; +foreach ($args as $a) { + if (str_starts_with($a, '--channel=')) $channel = substr($a, 10); +} +if ($channel === '' || !vv_nc_channel($channel)) { + fwrite(STDERR, "unknown channel\n"); + exit(2); +} + +$raw = stream_get_contents(STDIN); +$msg = json_decode((string)$raw, true); +if (!is_array($msg) || empty($msg['id']) || !isset($msg['text'])) { + fwrite(STDERR, "malformed message\n"); + exit(2); +} + +// Rebuilt rather than stored as sent: a record is only ever the fields this store understands, +// so a sender running newer code cannot write keys this one will later hand to a page. +$clean = [ + 'id' => preg_replace('/[^a-f0-9]/i', '', (string)$msg['id']), + 'ts' => (int)($msg['ts'] ?? time()), + 'from' => preg_replace('/[^a-z0-9]/i', '', strtolower((string)($msg['from'] ?? ''))), + 'kind' => in_array($msg['kind'] ?? 'msg', ['msg', 'question', 'notice'], true) ? $msg['kind'] : 'msg', + 'text' => mb_substr((string)$msg['text'], 0, 4000), + 'style' => [ + 'color' => preg_match('/^#[0-9a-f]{6}$/i', $msg['style']['color'] ?? '') ? $msg['style']['color'] : '', + 'font' => in_array($msg['style']['font'] ?? '', ['mono','sans','serif'], true) ? $msg['style']['font'] : '', + ], +]; +if ($clean['id'] === '' || $clean['from'] === '') { fwrite(STDERR, "malformed message\n"); exit(2); } + +exit(vv_nc_append($channel, $clean) ? 0 : 1); diff --git a/Plugin/unraid/api/node_chat.php b/Plugin/unraid/api/node_chat.php new file mode 100644 index 0000000..17f3c70 --- /dev/null +++ b/Plugin/unraid/api/node_chat.php @@ -0,0 +1,112 @@ + that channel's messages +// POST action=send channel= text=… [color=#rrggbb] [font=mono|sans|serif] +// POST action=delete channel= id= local only +// POST action=read channel= mark seen up to now +// +// DESIGN PRINCIPLES +// Read marks are local and per channel. "Unread" is a fact about this operator at this +// machine, not something to replicate — the partner has their own idea of what they have seen. +// +// OPERATIONAL SAFEGUARDS +// POST for every mutation, so Unraid's CSRF guard applies. See README-unraid.md. +// +// Channel ids are resolved against vv_nc_channels() inside the store layer, so a request +// cannot name a file this host is not a member of. +// +// Colour and font are validated to a hex triplet and a three-item list before they are stored, +// because they are interpolated into a style attribute when rendered. +// +// Sending reports the record even when no partner could be reached. The message is written +// locally and spooled for retry; saying "failed" over something that is stored and queued +// would be the wrong claim. +// +// DEPENDS ON +// include/node_chat.php +// ═══════════════════════════════════════════════════════════════════════════════════════════════ +header('Content-Type: application/json'); +require_once dirname(__DIR__) . '/include/node_chat.php'; + +define('VV_NC_READ_DB', VV_NC_DIR . '/.read.json'); + +function vv_nc_read_marks(): array { + $j = @json_decode((string)@file_get_contents(VV_NC_READ_DB), true); + return is_array($j) ? $j : []; +} +function vv_nc_set_read(string $ch, int $ts): void { + $m = vv_nc_read_marks(); + $m[$ch] = $ts; + @mkdir(VV_NC_DIR, 0755, true); + @file_put_contents(VV_NC_READ_DB, json_encode($m), LOCK_EX); +} + +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $marks = vv_nc_read_marks(); + $ch = $_GET['channel'] ?? ''; + + if ($ch !== '') { + if (!vv_nc_channel($ch)) { echo json_encode(['ok' => false, 'error' => 'Unknown channel']); exit; } + echo json_encode([ + 'ok' => true, + 'me' => vv_nc_me(), + 'channel' => $ch, + 'messages' => vv_nc_read($ch, 200), + 'last_read'=> (int)($marks[$ch] ?? 0), + ]); + exit; + } + + $chans = []; + foreach (vv_nc_channels() as $c) { + $since = (int)($marks[$c['id']] ?? 0); + $unread = 0; + foreach (vv_nc_read($c['id'], 200) as $m) { + if ((int)($m['ts'] ?? 0) > $since && ($m['from'] ?? '') !== vv_nc_me()) $unread++; + } + $c['unread'] = $unread; + $chans[] = $c; + } + echo json_encode(['ok' => true, 'me' => vv_nc_me(), 'channels' => $chans]); + exit; +} + +$action = $_POST['action'] ?? ''; +$ch = $_POST['channel'] ?? ''; +if (!vv_nc_channel($ch)) { echo json_encode(['ok' => false, 'error' => 'Unknown channel']); exit; } + +if ($action === 'send') { + $text = (string)($_POST['text'] ?? ''); + if (trim($text) === '') { echo json_encode(['ok' => false, 'error' => 'Nothing to send']); exit; } + $msg = vv_nc_send($ch, $text, [ + 'color' => (string)($_POST['color'] ?? ''), + 'font' => (string)($_POST['font'] ?? ''), + ]); + if (!$msg) { echo json_encode(['ok' => false, 'error' => 'Could not store message']); exit; } + // Queued is not failed — say which, so a partner being asleep reads as pending rather than + // as an error the operator should act on. + $queued = count(glob(VV_NC_SPOOL . '/*/' . $msg['id'] . '.json') ?: []); + echo json_encode(['ok' => true, 'msg' => $msg, 'queued' => $queued]); + exit; +} + +if ($action === 'delete') { + $id = preg_replace('/[^a-f0-9]/i', '', (string)($_POST['id'] ?? '')); + if ($id === '') { echo json_encode(['ok' => false, 'error' => 'No message id']); exit; } + echo json_encode(['ok' => vv_nc_delete_local($ch, $id)]); + exit; +} + +if ($action === 'read') { + vv_nc_set_read($ch, time()); + echo json_encode(['ok' => true]); + exit; +} + +echo json_encode(['ok' => false, 'error' => 'Unknown action']); diff --git a/Plugin/unraid/include/node_chat.php b/Plugin/unraid/include/node_chat.php new file mode 100644 index 0000000..05af794 --- /dev/null +++ b/Plugin/unraid/include/node_chat.php @@ -0,0 +1,217 @@ +/*.json undelivered, retried by the minute job +// +// DESIGN PRINCIPLES +// Never two hosts. +// Channels are derived from vv_known_hosts(), so a third machine gets a global channel +// that includes it and a DM channel with each existing node, with no code change. The +// pair name is sorted rather than "mine-theirs" precisely so both ends compute the same +// filename without having to agree on who spoke first. +// +// Delete is local, and that is the feature. +// Removing a message everywhere needs tombstones, ordering and a story for a node that +// was offline when the delete happened. Removing it from this machine is one file write. +// The UI says "delete here" so nobody expects otherwise. +// +// Sender identity comes from the transport, not the payload's word for it. +// The record carries `from`, but anything writing to this store already had root over +// SSH — the trust boundary is the key, and a machine that holds it can say anything +// regardless of what this file validates. +// +// OPERATIONAL SAFEGUARDS +// Messages are stored PLAINTEXT on every recipient. Tailscale encrypts the wire; the file on +// disk is readable by anyone with the box. The card says so, because the first thing anyone +// wants to send a partner is a credential. +// +// Channel names are derived, never taken from the caller — a name is rebuilt from known host +// ids, so no request can address a path outside the store. +// +// Retention is per channel and enforced on write, so a busy channel cannot fill the flash. +// +// Delivery failures spool rather than throw. A partner that is asleep is the normal case for +// the message "my server is going down". +// +// EXPORTS +// vv_nc_channels() the channels this host participates in +// vv_nc_read() messages for one channel, newest last +// vv_nc_append() store one record locally +// vv_nc_send() store + deliver to the channel's other members +// vv_nc_delete_local() forget one message on this machine +// vv_nc_flush_spool() retry undelivered +// ═══════════════════════════════════════════════════════════════════════════════════════════════ +require_once __DIR__ . '/config.php'; + +define('VV_NC_DIR', DATA_DIR . '/db/node_chat'); +define('VV_NC_SPOOL', VV_NC_DIR . '/.spool'); +define('VV_NC_KEEP', 300); // messages retained per channel, per node + +function vv_nc_me(): string { return strtolower(vv_detect_host()); } + +// host1/host2/... → the channels this host can see. Global plus one DM per other node. +function vv_nc_channels(): array { + $me = vv_nc_me(); + $hosts = array_keys(vv_known_hosts()); + $out = [['id' => 'global', 'label' => 'Global', 'members' => $hosts]]; + foreach ($hosts as $h) { + if ($h === $me) continue; + $pair = [$me, $h]; + sort($pair); + $out[] = ['id' => 'dm-' . implode('-', $pair), 'label' => strtoupper($h), 'members' => $pair]; + } + return $out; +} + +// Rebuilt from the known channel list rather than trusted — the caller names a channel, and the +// only names that resolve are ones this host is actually a member of. +function vv_nc_channel(string $id): ?array { + foreach (vv_nc_channels() as $c) if ($c['id'] === $id) return $c; + return null; +} + +function vv_nc_path(string $channelId): string { + return VV_NC_DIR . '/' . $channelId . '.jsonl'; +} + +function vv_nc_read(string $channelId, int $limit = 200): array { + $p = vv_nc_path($channelId); + if (!is_file($p)) return []; + $lines = @file($p, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; + if (count($lines) > $limit) $lines = array_slice($lines, -$limit); + $out = []; + foreach ($lines as $l) { + $j = json_decode($l, true); + if (is_array($j) && !empty($j['id'])) $out[] = $j; + } + return $out; +} + +function vv_nc_append(string $channelId, array $msg): bool { + if (!vv_nc_channel($channelId)) return false; + @mkdir(VV_NC_DIR, 0755, true); + $p = vv_nc_path($channelId); + + // Idempotent on id: a spool retry that actually landed the first time must not double-post. + foreach (vv_nc_read($channelId, VV_NC_KEEP) as $m) { + if (($m['id'] ?? '') === ($msg['id'] ?? '')) return true; + } + if (@file_put_contents($p, json_encode($msg, JSON_UNESCAPED_SLASHES) . "\n", + FILE_APPEND | LOCK_EX) === false) return false; + + $lines = @file($p, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; + if (count($lines) > VV_NC_KEEP) { + $tmp = $p . '.tmp'; + if (@file_put_contents($tmp, implode("\n", array_slice($lines, -VV_NC_KEEP)) . "\n") !== false) + @rename($tmp, $p); + } + return true; +} + +function vv_nc_delete_local(string $channelId, string $msgId): bool { + $p = vv_nc_path($channelId); + if (!vv_nc_channel($channelId) || !is_file($p)) return false; + $keep = []; + foreach (@file($p, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $l) { + $j = json_decode($l, true); + if (is_array($j) && ($j['id'] ?? '') === $msgId) continue; + $keep[] = $l; + } + $tmp = $p . '.tmp'; + if (@file_put_contents($tmp, $keep ? implode("\n", $keep) . "\n" : '') === false) return false; + return @rename($tmp, $p); +} + +// Deliver one record to one host. Returns false on any failure so the caller can spool. +function vv_nc_deliver(string $host, string $channelId, array $msg): bool { + $vars = vv_conf_vars(); + $me = strtoupper(vv_nc_me()); + $sshKey = $vars[$me . '_SSH_KEY'] ?? ''; + if (!$sshKey || !is_file($sshKey)) return false; + + $hostname = $vars[strtoupper($host)] ?? ''; + if ($hostname === '') return false; + $ip = vv_resolve_tailscale_ip($hostname); + if (!$ip) return false; + + // The receiving script lives wherever that host installed Varaverk, which is not necessarily + // where this one did — read from the partner's varaverk.cfg, which stays on flash whatever + // layout it uses. Assuming our own path is the bug that broke Step 1b on an appdata mirror. + $sshBase = 'ssh -i ' . escapeshellarg($sshKey) + . ' -o ConnectTimeout=8 -o BatchMode=yes -o StrictHostKeyChecking=no root@' . escapeshellarg($ip); + $cfg = trim((string)shell_exec($sshBase + . ' ' . escapeshellarg('grep -oP \'(?<=SCRIPTS_DIR=")[^"]+\' /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null'))); + $sd = $cfg !== '' ? $cfg : '/boot/config/plugins/varaverk'; + $remote = $sd . '/Plugin/unraid/Tools/node_chat_receive.php'; + + $cmd = $sshBase . ' ' . escapeshellarg('[ -f ' . $remote . ' ] || exit 127; php ' . $remote + . ' --channel=' . escapeshellarg($channelId)); + $desc = [0 => ['pipe','r'], 1 => ['pipe','w'], 2 => ['pipe','w']]; + $pr = @proc_open($cmd, $desc, $pipes); + if (!is_resource($pr)) return false; + fwrite($pipes[0], json_encode($msg, JSON_UNESCAPED_SLASHES)); + fclose($pipes[0]); + stream_get_contents($pipes[1]); fclose($pipes[1]); + stream_get_contents($pipes[2]); fclose($pipes[2]); + return proc_close($pr) === 0; +} + +function vv_nc_spool(string $host, string $channelId, array $msg): void { + $dir = VV_NC_SPOOL . '/' . $host; + @mkdir($dir, 0755, true); + @file_put_contents($dir . '/' . $msg['id'] . '.json', + json_encode(['channel' => $channelId, 'msg' => $msg], JSON_UNESCAPED_SLASHES)); +} + +// Store locally, then deliver to everyone else in the channel. Always returns the record: a +// message that reached no one is still a message this operator wrote and must be able to see. +function vv_nc_send(string $channelId, string $text, array $style = []): ?array { + $ch = vv_nc_channel($channelId); + if (!$ch || trim($text) === '') return null; + + $msg = [ + 'id' => bin2hex(random_bytes(8)), + 'ts' => time(), + 'from' => vv_nc_me(), + // Reserved from day one so a board or an announcement is a filter later rather than a + // migration. Everything written today is a message. + 'kind' => 'msg', + 'text' => mb_substr(trim($text), 0, 4000), + 'style' => [ + 'color' => preg_match('/^#[0-9a-f]{6}$/i', $style['color'] ?? '') ? $style['color'] : '', + 'font' => in_array($style['font'] ?? '', ['mono', 'sans', 'serif'], true) ? $style['font'] : '', + ], + ]; + vv_nc_append($channelId, $msg); + + foreach ($ch['members'] as $h) { + if ($h === vv_nc_me()) continue; + if (!vv_nc_deliver($h, $channelId, $msg)) vv_nc_spool($h, $channelId, $msg); + } + return $msg; +} + +function vv_nc_flush_spool(): int { + $sent = 0; + foreach (glob(VV_NC_SPOOL . '/*', GLOB_ONLYDIR) ?: [] as $dir) { + $host = basename($dir); + foreach (glob($dir . '/*.json') ?: [] as $f) { + $j = json_decode((string)@file_get_contents($f), true); + if (!is_array($j) || empty($j['msg'])) { @unlink($f); continue; } + if (vv_nc_deliver($host, (string)$j['channel'], $j['msg'])) { @unlink($f); $sent++; } + } + } + return $sent; +}