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,7 +1,69 @@
|
||||
<?php
|
||||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||||
// PURPOSE
|
||||
// Conf save endpoint. Writes the full text of one configuration file back to
|
||||
// Configurations/, for the settings tab's raw editor.
|
||||
//
|
||||
// OPERATIONAL MODEL
|
||||
// Whole-file replacement, not a patch. The editor sends back everything it was given, so a
|
||||
// save is a straight overwrite. There is no merge, no per-key update, and no attempt to
|
||||
// reconcile with a concurrent edit — last writer wins, which is correct for a single-admin
|
||||
// plugin and far more predictable than a partial merge of a bash file.
|
||||
//
|
||||
// DESIGN PRINCIPLES
|
||||
// Which files exist is decided by the host, never by the request.
|
||||
// vv_get_conf_files() returns the allowlist for this host — master.conf plus its own
|
||||
// host conf on HOST1, its own conf alone elsewhere. The filename is checked for exact
|
||||
// membership in that list. This is what keeps sparse checkout honest: HOST2 cannot be
|
||||
// asked to write host1.conf, because host1.conf is not in its list.
|
||||
//
|
||||
// Raw is the point.
|
||||
// The structured editor is confform.php. This endpoint exists for the cases that one
|
||||
// cannot express — new keys, comments, array literals, bulk edits — so it deliberately
|
||||
// does not parse, reformat, or normalise what it is given.
|
||||
//
|
||||
// OPERATIONAL SAFEGUARDS
|
||||
// POST only. Refused before the allowlist is even consulted, so a link or a prefetch can
|
||||
// never reach the write path.
|
||||
//
|
||||
// Exact allowlist membership, not a pattern.
|
||||
// in_array() against vv_get_conf_files() — not a regex, not a prefix test, 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 content is syntax-checked before it can replace a working file.
|
||||
// Conf files are sourced by every script in the system. A stray quote saved here would
|
||||
// break load_config.sh, and with it every orchestrator, watchdog and fallback path — on
|
||||
// a machine whose whole purpose is running unattended. `bash -n` on a private temp copy
|
||||
// is the difference between a rejected save and a silent, total outage, so a file that
|
||||
// does not parse is refused and the previous version is left untouched.
|
||||
//
|
||||
// The temp copy is created with tempnam() and always removed.
|
||||
// The candidate is never written next to the real conf and never under a predictable
|
||||
// name, so a failed validation cannot leave a stray file for a script to source.
|
||||
//
|
||||
// The real write is atomic.
|
||||
// vv_write_conf_raw() writes .vv.tmp and rename()s, so a script sourcing the conf
|
||||
// during the save reads either the old file or the new one, never a half-written one.
|
||||
//
|
||||
// REQUEST
|
||||
// POST file=<allowed conf name> content=<full file text>
|
||||
//
|
||||
// RESPONSE
|
||||
// {"ok":true,"error":null}
|
||||
// {"ok":false,"error":"POST only"|"File not permitted"|"Syntax error: …"|"Failed to write file"}
|
||||
//
|
||||
// DEPENDS ON
|
||||
// include/config.php vv_get_conf_files(), vv_write_conf_raw(), CONF_DIR
|
||||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||||
header('Content-Type: application/json');
|
||||
require_once dirname(__DIR__) . '/include/config.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['ok' => false, 'error' => 'POST only']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$file = trim($_POST['file'] ?? '');
|
||||
$content = $_POST['content'] ?? '';
|
||||
|
||||
@@ -12,5 +74,23 @@ if (!$file || !in_array($file, $allowed)) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Every script sources these. A syntax error here takes the whole system down, so the
|
||||
// candidate is parsed before it is allowed to replace a working file.
|
||||
$check = tempnam(sys_get_temp_dir(), 'vvconf');
|
||||
if ($check !== false) {
|
||||
file_put_contents($check, $content);
|
||||
$out = []; $rc = 0;
|
||||
exec('bash -n ' . escapeshellarg($check) . ' 2>&1', $out, $rc);
|
||||
@unlink($check);
|
||||
if ($rc !== 0) {
|
||||
$msg = implode(' ', array_filter(array_map('trim', $out)));
|
||||
echo json_encode([
|
||||
'ok' => false,
|
||||
'error' => 'Syntax error: ' . str_replace($check, $file, $msg ?: 'conf does not parse'),
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$ok = vv_write_conf_raw($file, $content);
|
||||
echo json_encode(['ok' => $ok, 'error' => $ok ? null : 'Failed to write file']);
|
||||
|
||||
Reference in New Issue
Block a user