Eleven call sites wrote master.conf with tmp+rename and nothing else — no backup, no parse check, no audit — including the two toggles the UI uses most and the raw editor that installs a whole hand-edited file.
123 lines
6.7 KiB
PHP
123 lines
6.7 KiB
PHP
<?php
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
// PURPOSE
|
|
// Raw conf read and write. Serves the full text of one configuration file and saves it
|
|
// back, and — when the file is master.conf — distributes the result to every partner host.
|
|
//
|
|
// OPERATIONAL MODEL
|
|
// Read and write on one URL, split on method, sharing one allowlist. GET also returns the
|
|
// allowlist itself, so the editor can populate its file picker from the same authority that
|
|
// will later authorise the save. The two can therefore never disagree about what this host
|
|
// is permitted to edit.
|
|
//
|
|
// master.conf is shared; host*.conf is not. A master.conf save is followed by a push to
|
|
// every partner, because a threshold or toggle that differs between hosts produces
|
|
// behaviour neither side expects. A host conf is local by definition and is never pushed.
|
|
//
|
|
// DESIGN PRINCIPLES
|
|
// The allowlist encodes sparse checkout.
|
|
// vv_get_conf_files() returns master.conf plus its own host conf on HOST1, and its own
|
|
// conf alone elsewhere. That is the same split git enforces at checkout — HOST2 has no
|
|
// host1.conf to read, and this endpoint will not name one either.
|
|
//
|
|
// master.conf defaults on read, nothing defaults on write.
|
|
// GET with no file returns master.conf, because that is what the editor opens to. POST
|
|
// has no default: a save must name its target explicitly.
|
|
//
|
|
// Push results travel with the response.
|
|
// Per-host outcomes are returned rather than logged, so the page can show that a partner
|
|
// did not receive the change instead of leaving the two hosts quietly divergent.
|
|
//
|
|
// OPERATIONAL SAFEGUARDS
|
|
// Exact allowlist membership with strict comparison, on both paths.
|
|
// in_array(..., true) against vv_get_conf_files() — not a pattern, not basename(). A
|
|
// filename that is not literally one of the permitted strings is rejected, which makes
|
|
// traversal and absolute paths unreachable rather than merely filtered. The redundant
|
|
// '..' check is kept as a second, explicit statement of intent.
|
|
//
|
|
// The content is syntax-checked before it can replace a working file.
|
|
// Conf files are sourced by every script in the system, and master.conf is pushed from
|
|
// here to every partner — so a stray quote saved through this endpoint would not just
|
|
// break this host's orchestrators, watchdogs and fallback, it would distribute that
|
|
// break across the mesh. `bash -n` on a private temp copy is checked first, and a file
|
|
// that does not parse is refused with the previous version left untouched.
|
|
//
|
|
// The temp copy is created with tempnam() and always removed, so a rejected save cannot
|
|
// leave a stray file beside the real conf for a script to source.
|
|
//
|
|
// The write goes through the one guarded conf path.
|
|
// vv_conf_edit() takes an exclusive lock, copies the previous file into CONF_BACKUP_DIR,
|
|
// re-checks the syntax, installs via .vv.tmp + rename() so a concurrent reader sees the
|
|
// old file or the new one but never a half-written one, then sources the installed file
|
|
// to prove it still loads and restores the backup if it does not. That backup matters
|
|
// more here than anywhere else: this endpoint replaces a whole hand-edited file, and the
|
|
// confs are gitignored, so before it existed a bad paste had nothing to go back to.
|
|
//
|
|
// The push only happens after a confirmed write.
|
|
// Guarded on $written, so a failed save cannot distribute a stale or partly-written
|
|
// master.conf. The push is also a no-op on a host with no SSH key, which is what stops a
|
|
// partner from overwriting the owner's file.
|
|
//
|
|
// Unknown methods are refused explicitly, so a PUT or DELETE cannot fall through the two
|
|
// handled blocks into an empty 200.
|
|
//
|
|
// REQUEST
|
|
// GET ?file=<allowed conf name> defaults to master.conf
|
|
// POST file=<allowed conf name> content=<full file text>
|
|
//
|
|
// RESPONSE
|
|
// GET {"ok":true,"content":"…","file":"…","allowed":["…"]}
|
|
// POST {"ok":true,"push":[{"host","ok","ready","error"}, …]}
|
|
// push is empty for host confs and on hosts with no partners
|
|
// {"ok":false,"error":"Not allowed"|"Syntax error: …"|"Method not allowed"}
|
|
//
|
|
// DEPENDS ON
|
|
// include/config.php vv_get_conf_files(), vv_read_conf_raw(),
|
|
// vv_push_master_conf(), vv_push_setup_state()
|
|
// include/confform.php vv_conf_syntax_error(), vv_conf_edit()
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
header('Content-Type: application/json');
|
|
require_once dirname(__DIR__) . '/include/config.php';
|
|
require_once dirname(__DIR__) . '/include/confform.php';
|
|
|
|
$allowed = vv_get_conf_files();
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|
$file = trim($_GET['file'] ?? 'master.conf');
|
|
if (!in_array($file, $allowed, true) || str_contains($file, '..')) {
|
|
echo json_encode(['ok' => false, 'error' => 'Not allowed']);
|
|
exit;
|
|
}
|
|
echo json_encode(['ok' => true, 'content' => vv_read_conf_raw($file), 'file' => $file, 'allowed' => $allowed]);
|
|
exit;
|
|
}
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
$file = trim($_POST['file'] ?? '');
|
|
$content = $_POST['content'] ?? '';
|
|
if (!in_array($file, $allowed, true) || str_contains($file, '..')) {
|
|
echo json_encode(['ok' => false, 'error' => 'Not allowed']);
|
|
exit;
|
|
}
|
|
// Every script sources these, and master.conf is pushed to every partner from here — a
|
|
// syntax error saved through this endpoint would propagate the outage across the mesh.
|
|
// Checked here as well as inside vv_conf_edit() so the editor can show bash's own complaint;
|
|
// the write path only knows whether to proceed, not what to tell the person typing.
|
|
$syntax = vv_conf_syntax_error($content, $file);
|
|
if ($syntax !== null) {
|
|
echo json_encode(['ok' => false, 'error' => 'Syntax error: ' . $syntax, 'push' => []]);
|
|
exit;
|
|
}
|
|
|
|
$written = vv_conf_edit($file, fn() => $content, [], ['whole-file']);
|
|
$push = [];
|
|
if ($written && $file === 'master.conf') {
|
|
$push = vv_push_master_conf();
|
|
vv_push_setup_state();
|
|
}
|
|
echo json_encode(['ok' => $written, 'push' => $push]);
|
|
exit;
|
|
}
|
|
|
|
echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
|