Mesh chat store: append-replicated log over the existing SSH trust
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||||
// PURPOSE
|
||||
// Accept one mesh message from a partner and store it. Invoked over SSH by the sending node's
|
||||
// vv_nc_deliver(), with the record on stdin.
|
||||
//
|
||||
// OPERATIONAL MODEL
|
||||
// php node_chat_receive.php --channel=<id> 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);
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||||
// PURPOSE
|
||||
// The Partnership tab's mesh chat: read a channel, post to it, forget a message on this
|
||||
// machine, and mark a channel read.
|
||||
//
|
||||
// REQUEST
|
||||
// GET channels + this host's id + unread counts
|
||||
// GET ?channel=<id> that channel's messages
|
||||
// POST action=send channel=<id> text=… [color=#rrggbb] [font=mono|sans|serif]
|
||||
// POST action=delete channel=<id> id=<msgid> local only
|
||||
// POST action=read channel=<id> 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']);
|
||||
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||||
// PURPOSE
|
||||
// The mesh's message store. A place for the people who run these servers to talk to each
|
||||
// other — "mine is down for a week", "who has seen this error", "the key you wanted is X" —
|
||||
// in the one window that already knows which machines are in the partnership.
|
||||
//
|
||||
// OPERATIONAL MODEL
|
||||
// Every node keeps its own copy. Sending is: append here, then push a copy to each recipient
|
||||
// over the SSH trust the onboard already established. There is no server and no election —
|
||||
// the store is a log, and a log replicated by append is the shape that survives a partner
|
||||
// being switched off.
|
||||
//
|
||||
// data/db/node_chat/global.jsonl every HOST* in master.conf
|
||||
// data/db/node_chat/dm-host1-host3.jsonl one pair, names sorted so both sides agree
|
||||
// data/db/node_chat/.spool/<host>/*.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;
|
||||
}
|
||||
Reference in New Issue
Block a user