Files
OpenFlux/deploy/phpbox/phpbox.php
acac97b07d phpbox: the node survives any host and keeps the tunnel up across generations (#143)
* 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>
2026-10-01 11:28:07 +03:00

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;
}