mirror of
https://github.com/p1neappleXpress/OpenFlux.git
synced 2026-10-19 05:59:59 +08:00
* phpbox: survive any host's limits and keep streams alive across handovers Found on a local emulation of a free host (Apache + PHP-FPM, 60 s CPU per request, the host's disabled functions, a gateway that gives up on the request): - disabled set_time_limit/ignore_user_abort/getmypid/getenv/ini_set ended the node at once under PHP 8 (a disabled function is undefined): guarded everywhere; ping lists what the host took away - time budget per generation on both clocks (CPU on Linux, wall on Windows/macOS, RLIMIT_CPU), handover early when CPU runs short, and learning: a generation that died early leaves its age in host.json and the next ones plan below it; on a host not yet proven, handovers start at 40 s and grow as generations end well - cups: the carrier slept 18 ms per message inside the loop (a 64 KB frame ~0.4 s), so under a download new connections timed out and acks waited; now a paced outbox in the mux: control frames first, streams in turn, whole packets per message, at most ~72 KB/s (the server drops a member that sends faster) - two generations in one room: each now ignores the other's messages (and its own echoes, before decoding them), and messages carry whole packets, so a packet cut across messages is never mixed with another member's bytes; the Go cupsonline receiver keeps one reassembly buffer per member - a successor takes streams only after its link stayed up 8 s (a Mail.ru document drops new connections right after they join, and streams handed over then died) - a repeated OPEN is answered again instead of redialled; the client asks again after 4 s without an answer - the page restarts a node that ended without Stop while it is open - --mode=stream without --inbound is SOCKS5 on every OS again (macOS took utun) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * phpbox: send to cups only as fast as the server confirms; release rebuilds an existing tag - the cups server shows every member's message back to it; our own coming back is its confirmation. At most 6 unconfirmed messages: past that the server is behind, and the client's OPENs and its pings queued behind our echoes (close 3012 'no pong' under a download). The link's close code and reason go to the log. - release.yml: a moved tag replaces the release's assets and notes, as the apps do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * phpbox: count the cups server's echoes reliably; learn a host limit only from two deaths - echoes were matched with a regex on the raw line that never matched the server's JSON, so the window waited out its 3 s timeout each time (~6 KB/s under load): a substring test on the member uuid instead; a live room confirms 10 of 10 - one sudden end (a host restart) no longer teaches a limit that shortens every run for a week: two ends at about the same age do Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * phpbox: hand over early and consistently; never reach for a longer run than the host allows The emulated hosts exposed the real cause of the node dying: the 'hold idle to prove a longer run' recorded a lifetime (107s) longer than a host's hidden wall-clock kill (90s), so later generations set their handover past that limit and were killed before they could start a successor - the chain broke with no generation left to carry the lesson. - a generation now hands over at ~2/3 of the known limit, never later than 45s, even on a host that looks generous: a free host's real limit is unknown until a generation dies by it, and that death must happen after a successor exists. Deaths only lower it. - removed the idle hold and the lifetime-growth; a learned wall/CPU limit still lowers the handover, and a wall limit is believed only after two deaths at a similar age - while a successor shares the room, the predecessor caps its reading (OVERLAP_RATE): the document server copies our download to the successor too, and at full speed the client's OPENs to the successor waited behind it Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * CHANGELOG: the node-survival and stream-proxy fixes Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * CHANGELOG: fold the node-survival fixes into 0.3.0 (rebuilt in place) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: p1neappleXpress <a@a.a> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
156 lines
6.7 KiB
PHP
156 lines
6.7 KiB
PHP
<?php
|
|
if (is_file(__DIR__ . '/config.php')) { require_once __DIR__ . '/config.php'; } // token of a deployed node
|
|
/**
|
|
* phpbox.php - stream-mux exit for the OpenFlux client on plain PHP hosting
|
|
* (including free shared hosting that cannot run a binary, hold a process,
|
|
* or listen on a socket). See transport/phpbox in the core for the client.
|
|
*
|
|
* NOT a packet exit and NOT an open proxy: the client speaks a tiny stream
|
|
* mux (OPEN host:port / DATA / CLOSE) over two half-duplex HTTP channels,
|
|
* because such hosts buffer request bodies but stream responses:
|
|
*
|
|
* down (GET, long-poll) - THE session process: holds the dst sockets,
|
|
* stream_select()s them, and streams dst->client frames in the response.
|
|
* It also drains the upstream bus each loop and applies OPEN/DATA/CLOSE.
|
|
* up (POST, short) - appends client->dst frames to the per-session bus
|
|
* (a file; no MySQL and no persistent process needed), returns at once.
|
|
*
|
|
* A session id is minted per down-connection, so a reconnect after the host
|
|
* caps the request (RUN_CAP) starts clean; MTProto and short HTTPS tolerate
|
|
* it. Guards: a shared token, only ports 80/443, and no private/loopback
|
|
* targets. v0 carries frames in the clear - the OpenFlux client should wrap
|
|
* the carrier with the core's encryption before real use.
|
|
*
|
|
* Setup: upload next to a domain, set PHPBOX_TOKEN in the environment (or
|
|
* edit the constant), point the client's phpbox transport at its URL.
|
|
*
|
|
* Frame: type(1) | stream_id(4 BE) | len(4 BE) | payload(len)
|
|
* 1 OPEN "host:port" 2 DATA 3 CLOSE 4 OPEN_OK 5 OPEN_ERR reason
|
|
*/
|
|
|
|
$TOKEN = (function_exists('getenv') ? getenv('PHPBOX_TOKEN') : false) ?: (defined('PHPBOX_TOKEN') ? PHPBOX_TOKEN : 'CHANGE-ME'); // putenv is disabled on some free hosts: config.php also define()s it
|
|
$BUS_DIR = (function_exists('getenv') ? getenv('PHPBOX_BUS') : false) ?: (sys_get_temp_dir() . '/phpbox-bus');
|
|
const RUN_CAP = 140; // seconds a down-poll holds before returning (client reconnects)
|
|
const DIAL_TO = 6;
|
|
|
|
const OPEN = 1, DATA = 2, CLOSE = 3, OPEN_OK = 4, OPEN_ERR = 5;
|
|
|
|
if (!hash_equals($TOKEN, (string)($_GET['k'] ?? ''))) { http_response_code(404); exit("no\n"); }
|
|
$sess = preg_replace('/[^a-zA-Z0-9]/', '', $_GET['s'] ?? '');
|
|
if ($sess === '') { http_response_code(400); exit("no session\n"); }
|
|
@mkdir($BUS_DIR, 0700, true);
|
|
$busUp = $BUS_DIR . "/$sess.up";
|
|
|
|
switch ($_GET['r'] ?? '') {
|
|
case 'up': up_ingest($busUp); break;
|
|
case 'down': down_pump($busUp); break;
|
|
default: http_response_code(400); exit("bad role\n");
|
|
}
|
|
|
|
// ---- upstream: append the POSTed frames to the bus, return now -----------
|
|
function up_ingest(string $busUp): void
|
|
{
|
|
$body = file_get_contents('php://input');
|
|
$fp = fopen($busUp, 'ab');
|
|
if ($fp) { flock($fp, LOCK_EX); fwrite($fp, $body); flock($fp, LOCK_UN); fclose($fp); }
|
|
header('Content-Type: text/plain');
|
|
echo 'ok ' . strlen($body) . "\n";
|
|
}
|
|
|
|
// ---- downstream: the session. Hold sockets, pump both ways ---------------
|
|
function down_pump(string $busUp): void
|
|
{
|
|
if (function_exists('set_time_limit')) { @set_time_limit(0); }
|
|
if (function_exists('ignore_user_abort')) { ignore_user_abort(false); } // client gone => end the session
|
|
header('Content-Type: application/octet-stream');
|
|
header('X-Accel-Buffering: no'); // ask LiteSpeed/nginx not to buffer
|
|
header('Content-Encoding: none');
|
|
while (ob_get_level() > 0) { ob_end_flush(); }
|
|
echo str_repeat(' ', 4096) . "\n"; // pad past any fixed output buffer
|
|
@flush();
|
|
@touch($busUp);
|
|
|
|
$socks = []; // stream_id => resource
|
|
$upfp = fopen($busUp, 'rb');
|
|
$inbuf = '';
|
|
$start = microtime(true);
|
|
|
|
while (microtime(true) - $start < RUN_CAP) {
|
|
$chunk = stream_get_contents($upfp);
|
|
if ($chunk !== false && $chunk !== '') { $inbuf .= $chunk; }
|
|
while (($f = take_frame($inbuf)) !== null) {
|
|
[$type, $sid, $payload] = $f;
|
|
if ($type === OPEN) {
|
|
[$host, $port] = array_pad(explode(':', $payload, 2), 2, '');
|
|
if (!in_array((int)$port, [80, 443], true)) { emit(OPEN_ERR, $sid, 'port'); continue; }
|
|
if (is_private_host($host)) { emit(OPEN_ERR, $sid, 'blocked'); continue; }
|
|
$s = @stream_socket_client("tcp://$host:$port", $e, $es, DIAL_TO, STREAM_CLIENT_CONNECT);
|
|
if ($s) { stream_set_blocking($s, false); $socks[$sid] = $s; emit(OPEN_OK, $sid, ''); }
|
|
else { emit(OPEN_ERR, $sid, (string)$es); }
|
|
} elseif ($type === DATA && isset($socks[$sid])) {
|
|
@fwrite($socks[$sid], $payload);
|
|
} elseif ($type === CLOSE && isset($socks[$sid])) {
|
|
@fclose($socks[$sid]); unset($socks[$sid]);
|
|
}
|
|
}
|
|
|
|
if ($socks) {
|
|
$r = $socks; $w = $ex = null;
|
|
if (@stream_select($r, $w, $ex, 0, 200000) > 0) {
|
|
foreach ($r as $s) {
|
|
$sid = array_search($s, $socks, true);
|
|
$d = @fread($s, 32768);
|
|
if ($d === '' || $d === false) {
|
|
if (feof($s)) { emit(CLOSE, $sid, ''); @fclose($s); unset($socks[$sid]); }
|
|
} else {
|
|
emit(DATA, $sid, $d);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
usleep(100000);
|
|
}
|
|
if (connection_aborted()) { break; }
|
|
}
|
|
foreach ($socks as $s) { @fclose($s); }
|
|
}
|
|
|
|
function emit(int $type, int $sid, string $payload): void
|
|
{
|
|
echo pack('CNN', $type, $sid, strlen($payload)) . $payload;
|
|
@flush();
|
|
}
|
|
|
|
/** Pull one whole frame off the front of $buf, or null if incomplete. */
|
|
function take_frame(string &$buf): ?array
|
|
{
|
|
if (strlen($buf) < 9) { return null; }
|
|
$h = unpack('Ctype/Nsid/Nlen', substr($buf, 0, 9));
|
|
if (strlen($buf) < 9 + $h['len']) { return null; }
|
|
$payload = substr($buf, 9, $h['len']);
|
|
$buf = substr($buf, 9 + $h['len']);
|
|
return [$h['type'], $h['sid'], $payload];
|
|
}
|
|
|
|
/** Refuse loopback / private / reserved targets (no SSRF into the host LAN). */
|
|
function is_private_host(string $host): bool
|
|
{
|
|
if (function_exists('getenv') && getenv('PHPBOX_ALLOW_PRIVATE') === '1') { return false; } // local testing only
|
|
$ips = [];
|
|
if (filter_var($host, FILTER_VALIDATE_IP)) {
|
|
$ips[] = $host;
|
|
} else {
|
|
foreach (@dns_get_record($host, DNS_A + DNS_AAAA) ?: [] as $rec) {
|
|
$ips[] = $rec['ip'] ?? $rec['ipv6'] ?? null;
|
|
}
|
|
if (!array_filter($ips)) { $ips = [gethostbyname($host)]; }
|
|
}
|
|
foreach (array_filter($ips) as $ip) {
|
|
if (!filter_var($ip, FILTER_VALIDATE_IP,
|
|
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|