API / Security: - Add api/_helpers.php: shared send_security_headers(), verify_origin(), get_hmac_secret(), check_rate_limit(), read_json_locked(), write_json_locked() - shorten.php: remove Access-Control-Allow-Origin:*, restrict to same-origin, rate-limit 20 req/h per IP, atomic JSON read+lock, HMAC secret from file - verify.php: rate-limit GET (30/min) and POST (10/h) per IP, atomic lock, prevent overwriting existing proofs, origin check on POST - node.php: fix rate limit from 1000 to 60 req/min, add security headers, origin check - check-short.php: add security headers, re-derive signature server-side - s.php: use file-based HMAC secret via get_hmac_secret(), hash_equals() for timing-safe comparison Service Worker: - sw.js: navigation requests (mode=navigate) never served from cache; network-first with offline fallback to prevent stale invoice state Documentation (honest claims): - README: tagline "No backend" -> "No tracking"; new Architecture table listing exactly what server sees for each feature; Security Model section - index.html: meta description and footer updated from "No Backend" to "Minimal Backend" - i18n.js footer: already updated in previous commit
138 lines
3.8 KiB
PHP
138 lines
3.8 KiB
PHP
<?php
|
|
require_once __DIR__ . '/_helpers.php';
|
|
|
|
/**
|
|
* Monero Daemon RPC Proxy
|
|
* Forwards allowed RPC requests to Monero nodes, bypassing CORS.
|
|
* The private view key NEVER passes through this proxy.
|
|
*/
|
|
|
|
header('Content-Type: application/json');
|
|
send_security_headers();
|
|
verify_origin();
|
|
|
|
// Only POST
|
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
http_response_code(405);
|
|
echo json_encode(['error' => 'Method not allowed']);
|
|
exit;
|
|
}
|
|
|
|
// Nodes in priority order
|
|
$NODES = [
|
|
'http://node.xmr.rocks:18089',
|
|
'http://node.community.rino.io:18081',
|
|
'http://node.sethforprivacy.com:18089',
|
|
'http://xmr-node.cakewallet.com:18081',
|
|
];
|
|
|
|
// Allowed RPC methods (whitelist)
|
|
$ALLOWED_JSON_RPC = ['get_info', 'get_block', 'get_block_header_by_height'];
|
|
$ALLOWED_HTTP = ['get_transaction_pool', 'gettransactions', 'get_transaction_pool_hashes.bin'];
|
|
|
|
// Rate limiting (simple file-based, 60 requests/minute per IP)
|
|
$RATE_DIR = __DIR__ . '/../data/rate/';
|
|
if (!is_dir($RATE_DIR)) @mkdir($RATE_DIR, 0755, true);
|
|
|
|
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
|
$rateFile = $RATE_DIR . md5($ip) . '.json';
|
|
$now = time();
|
|
$rateData = [];
|
|
|
|
if (file_exists($rateFile)) {
|
|
$rateData = json_decode(file_get_contents($rateFile), true) ?: [];
|
|
// Clean old entries
|
|
$rateData = array_filter($rateData, fn($t) => $t > $now - 60);
|
|
}
|
|
|
|
if (count($rateData) >= 60) {
|
|
http_response_code(429);
|
|
echo json_encode(['error' => 'Rate limit exceeded']);
|
|
exit;
|
|
}
|
|
|
|
$rateData[] = $now;
|
|
file_put_contents($rateFile, json_encode($rateData));
|
|
|
|
// Parse request
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
if (!$input || !isset($input['method'])) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Missing method']);
|
|
exit;
|
|
}
|
|
|
|
$method = $input['method'];
|
|
$params = $input['params'] ?? [];
|
|
|
|
// Determine endpoint type
|
|
$isJsonRpc = in_array($method, $ALLOWED_JSON_RPC);
|
|
$isHttp = in_array($method, $ALLOWED_HTTP);
|
|
|
|
if (!$isJsonRpc && !$isHttp) {
|
|
http_response_code(403);
|
|
echo json_encode(['error' => 'Method not allowed: ' . $method]);
|
|
exit;
|
|
}
|
|
|
|
// Cache last working node
|
|
$cacheFile = __DIR__ . '/../data/node_cache.json';
|
|
$cachedNode = null;
|
|
if (file_exists($cacheFile)) {
|
|
$cache = json_decode(file_get_contents($cacheFile), true);
|
|
if ($cache && ($cache['time'] ?? 0) > $now - 300) {
|
|
$cachedNode = $cache['node'];
|
|
}
|
|
}
|
|
|
|
// Order nodes: cached first
|
|
$orderedNodes = $NODES;
|
|
if ($cachedNode && in_array($cachedNode, $NODES)) {
|
|
$orderedNodes = array_merge([$cachedNode], array_filter($NODES, fn($n) => $n !== $cachedNode));
|
|
}
|
|
|
|
// Try nodes
|
|
$lastError = '';
|
|
foreach ($orderedNodes as $node) {
|
|
if ($isJsonRpc) {
|
|
$url = $node . '/json_rpc';
|
|
$body = json_encode([
|
|
'jsonrpc' => '2.0',
|
|
'id' => '0',
|
|
'method' => $method,
|
|
'params' => (object)$params
|
|
]);
|
|
} else {
|
|
$url = $node . '/' . $method;
|
|
$body = json_encode((object)$params);
|
|
}
|
|
|
|
$ch = curl_init($url);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_POST => true,
|
|
CURLOPT_POSTFIELDS => $body,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
|
CURLOPT_TIMEOUT => 15,
|
|
CURLOPT_CONNECTTIMEOUT => 5,
|
|
]);
|
|
|
|
$response = curl_exec($ch);
|
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
$curlError = curl_error($ch);
|
|
curl_close($ch);
|
|
|
|
if ($response !== false && $httpCode >= 200 && $httpCode < 300) {
|
|
// Cache this working node
|
|
file_put_contents($cacheFile, json_encode(['node' => $node, 'time' => $now]));
|
|
echo $response;
|
|
exit;
|
|
}
|
|
|
|
$lastError = $curlError ?: "HTTP $httpCode";
|
|
}
|
|
|
|
// All nodes failed
|
|
http_response_code(502);
|
|
echo json_encode(['error' => 'All nodes unreachable', 'detail' => $lastError]);
|