mirror of
https://github.com/p1neappleXpress/OpenFlux.git
synced 2026-10-20 06:11:02 +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>
764 lines
38 KiB
PHP
764 lines
38 KiB
PHP
<?php
|
|
// The page + control side of a phpbox exit: one controller every exit file
|
|
// (cupsexit.php, mailruexit.php, ...) hands its Carrier factory to.
|
|
//
|
|
// node.php?k=TOKEN&url=TARGET browser -> the status page (starts the node for you)
|
|
// anything else (a pinger, curl) -> runs the node, as before
|
|
// ...&a=run | ui | status | log | stop explicit actions (JSON for status/log/stop)
|
|
//
|
|
// "Already running" is decided from a heartbeat the running node writes every
|
|
// second (plus an flock), so a second open - or a pinger - never starts a
|
|
// second node on the same target; it just reports the first one.
|
|
//
|
|
// Self-renewing tunnel (&chain=1, what the page asks for): a request lives only
|
|
// ~cap seconds on these hosts, so before it ends a node starts its successor -
|
|
// a request to its own host, which passes the host's bot check because it comes
|
|
// from the host itself - and they hand over:
|
|
// 1. the successor joins and reports "serving";
|
|
// 2. the old generation stops taking NEW streams ("draining") and keeps
|
|
// serving the ones it has until they end or its own cap;
|
|
// 3. while both are up, a new stream goes to whichever creates its marker
|
|
// directory first (mkdir is atomic), so no stream is served twice.
|
|
// Only one generation ever takes new streams; a stop (the page, &a=stop) ends
|
|
// the whole chain and keeps a dying generation from spawning another.
|
|
//
|
|
// Links are NOT parsed here. The page parses openflux:// links in the browser
|
|
// with the core's own parser (WebAssembly), so there is one parser for every
|
|
// client; this file only ever sees the carrier's own address (?url=).
|
|
|
|
require_once __DIR__ . '/util.php';
|
|
require_once __DIR__ . '/mux.php';
|
|
|
|
/** An append-only ring log, one JSON object per line: {"t":ms,"l":level,"m":message}. */
|
|
final class PhpboxLog
|
|
{
|
|
const MAX_BYTES = 262144;
|
|
|
|
public function __construct(private string $file) {}
|
|
|
|
public function write(string $level, string $msg): void
|
|
{
|
|
$line = json_encode(['t' => (int)(microtime(true) * 1000), 'l' => $level, 'm' => $msg], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
|
|
@file_put_contents($this->file, $line, FILE_APPEND | LOCK_EX);
|
|
if (mt_rand(1, 50) === 1) {
|
|
$this->trim();
|
|
}
|
|
}
|
|
|
|
private function trim(): void
|
|
{
|
|
if (@filesize($this->file) <= self::MAX_BYTES) {
|
|
return;
|
|
}
|
|
$keep = @file_get_contents($this->file, false, null, (int)(self::MAX_BYTES / 2));
|
|
if ($keep === false) {
|
|
return;
|
|
}
|
|
$nl = strpos($keep, "\n");
|
|
@file_put_contents($this->file, $nl === false ? '' : substr($keep, $nl + 1), LOCK_EX);
|
|
}
|
|
|
|
public function clear(): void
|
|
{
|
|
@file_put_contents($this->file, '', LOCK_EX);
|
|
}
|
|
|
|
/** Lines after byte offset $since. Offsets restart at 0 when the ring was trimmed. */
|
|
public function tail(int $since): array
|
|
{
|
|
$size = (int)@filesize($this->file);
|
|
$reset = $since > $size;
|
|
if ($reset) {
|
|
$since = 0;
|
|
}
|
|
$lines = [];
|
|
$next = $since;
|
|
if ($size > $since) {
|
|
$chunk = (string)@file_get_contents($this->file, false, null, $since, 65536);
|
|
$end = strrpos($chunk, "\n"); // only whole lines; a half-written one waits
|
|
if ($end !== false) {
|
|
$chunk = substr($chunk, 0, $end + 1);
|
|
$next = $since + strlen($chunk);
|
|
foreach (explode("\n", rtrim($chunk, "\n")) as $l) {
|
|
$o = json_decode($l, true);
|
|
if (is_array($o)) {
|
|
$lines[] = $o;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return ['lines' => $lines, 'next' => $next, 'reset' => $reset];
|
|
}
|
|
}
|
|
|
|
/** Heartbeat file of one node (per carrier+target). */
|
|
final class PhpboxState
|
|
{
|
|
const STALE = 15; // a node that has not beaten for this long is gone
|
|
|
|
public function __construct(private string $file) {}
|
|
|
|
public function read(): array
|
|
{
|
|
$o = json_decode((string)@file_get_contents($this->file), true);
|
|
return is_array($o) ? $o : [];
|
|
}
|
|
|
|
public function write(array $s): void
|
|
{
|
|
$tmp = $this->file . '.' . PhpboxUtil::pid();
|
|
if (@file_put_contents($tmp, json_encode($s)) !== false) {
|
|
@rename($tmp, $this->file);
|
|
}
|
|
}
|
|
|
|
public static function alive(array $s): bool
|
|
{
|
|
return in_array($s['phase'] ?? '', ['connecting', 'serving', 'draining'], true)
|
|
&& (time() - (int)($s['beat'] ?? 0)) < self::STALE;
|
|
}
|
|
|
|
/** Alive and still taking new streams (not draining). */
|
|
public static function accepting(array $s): bool
|
|
{
|
|
return self::alive($s) && ($s['phase'] ?? '') !== 'draining';
|
|
}
|
|
|
|
/** All generations of one node that have a state file: gen => state. */
|
|
public static function generations(string $dir, string $key): array
|
|
{
|
|
$out = [];
|
|
foreach (glob("$dir/$key.g*.json") ?: [] as $f) {
|
|
if (preg_match('/\.g(\d+)\.json$/', $f, $m)) {
|
|
$o = json_decode((string)@file_get_contents($f), true);
|
|
if (is_array($o)) { $out[(int)$m[1]] = $o; }
|
|
}
|
|
}
|
|
ksort($out);
|
|
return $out;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* How long one request may run on this host, on both clocks, so a generation hands over before the host ends it.
|
|
*
|
|
* Hosts end a PHP request in different ways: max_execution_time counts CPU time on Linux but wall time on Windows
|
|
* and macOS; some add a wall-clock kill outside PHP (php-fpm's request_terminate_timeout, a watchdog) or an RLIMIT_CPU.
|
|
* What PHP can see is used up front; what it cannot see is learned: a generation that died before its plan leaves its
|
|
* age and CPU behind, and the next ones plan around it (host.json in the state folder, shared by every node).
|
|
*/
|
|
final class PhpboxBudget
|
|
{
|
|
const LEARN_TTL = 7 * 86400; // a host's limits can change; forget what was learned after a week
|
|
const MIN_WALL = 30; // shorter than this nothing useful fits: never plan below it
|
|
|
|
const SAFE_HANDOVER = 45; // a generation hands over this early even on a host that looks generous: a free host's
|
|
// real limit is unknown until one generation dies by it, and that death must happen
|
|
// AFTER a successor was started, or the chain cannot carry the lesson forward. So the
|
|
// first generations always hand over well under any plausible limit; deaths only lower it.
|
|
|
|
public int $wall; // seconds of wall time a limit (known or default) would end this request at
|
|
public float $cpu = 0.0; // CPU seconds this request may use (0 = no limit known)
|
|
public array $why = []; // where the numbers came from, for the log
|
|
private float $cpu0;
|
|
|
|
public function __construct(int $cap, string $dir)
|
|
{
|
|
$this->cpu0 = PhpboxNode::cpuSeconds();
|
|
$this->wall = $cap;
|
|
$ini = function_exists('ini_get') ? (int)ini_get('max_execution_time') : 0; // 0 once set_time_limit(0) worked
|
|
if ($ini > 0) {
|
|
if (in_array(PHP_OS_FAMILY, ['Windows', 'Darwin'], true)) { // there the timer is a wall clock
|
|
$this->capWall($ini - 5, "max_execution_time {$ini}s (wall clock on " . PHP_OS_FAMILY . ')');
|
|
} else {
|
|
$this->capCpu($ini, "max_execution_time {$ini}s (CPU)");
|
|
}
|
|
}
|
|
if (function_exists('posix_getrlimit')) {
|
|
$rl = @posix_getrlimit();
|
|
if (is_array($rl) && is_numeric($rl['soft cpu'] ?? null) && (int)$rl['soft cpu'] > 0) {
|
|
$this->capCpu((int)$rl['soft cpu'] - PhpboxNode::cpuSeconds(), "RLIMIT_CPU {$rl['soft cpu']}s");
|
|
}
|
|
}
|
|
$h = self::learned($dir);
|
|
if (!empty($h['wall'])) { $this->capWall((int)$h['wall'] - 10, "a generation died at {$h['wall']}s"); }
|
|
if (!empty($h['cpu'])) { $this->capCpu((float)$h['cpu'], "a generation died after {$h['cpu']}s of CPU"); }
|
|
}
|
|
|
|
private function capWall(int $s, string $why): void
|
|
{
|
|
$s = max(self::MIN_WALL, $s);
|
|
if ($s < $this->wall) { $this->wall = $s; $this->why[] = $why; }
|
|
}
|
|
|
|
private function capCpu(float $s, string $why): void
|
|
{
|
|
if ($s > 0 && ($this->cpu === 0.0 || $s < $this->cpu)) { $this->cpu = round($s, 1); $this->why[] = $why; }
|
|
}
|
|
|
|
/** CPU seconds this request has used. */
|
|
public function cpuUsed(): float
|
|
{
|
|
return round(PhpboxNode::cpuSeconds() - $this->cpu0, 2);
|
|
}
|
|
|
|
/** Share of the CPU budget used (0 when there is none). */
|
|
public function cpuShare(): float
|
|
{
|
|
return $this->cpu > 0 ? $this->cpuUsed() / $this->cpu : 0.0;
|
|
}
|
|
|
|
/**
|
|
* When a generation starts its successor: early and fixed, so that even on a host whose limit we have never seen a
|
|
* generation hands over long before that limit and starts the next one. Lowered only when a known limit is close.
|
|
* Not grown toward the limit: a generation that handed over at this point never observed the host would allow more,
|
|
* so there is nothing to safely grow from, and reaching for a longer run is exactly what broke the chain.
|
|
*/
|
|
public function spawnAt(): int
|
|
{
|
|
return max(4, min(self::SAFE_HANDOVER, (int)($this->wall * 0.65))); // ~2/3 of the limit, never later than SAFE_HANDOVER
|
|
}
|
|
|
|
public function describe(): string
|
|
{
|
|
return "hand over at {$this->spawnAt()}s; limit " . ($this->cpu > 0 ? "CPU {$this->cpu}s" : "{$this->wall}s wall")
|
|
. ($this->why ? ' (' . implode('; ', $this->why) . ')' : '');
|
|
}
|
|
|
|
// ---- learning ----------------------------------------------------------
|
|
|
|
public static function learned(string $dir): array
|
|
{
|
|
$h = json_decode((string)@file_get_contents("$dir/host.json"), true);
|
|
if (!is_array($h) || time() - (int)($h['at'] ?? 0) > self::LEARN_TTL) { return []; }
|
|
return $h;
|
|
}
|
|
|
|
/**
|
|
* Look at the generations before this one: one that ended without saying so (killed outright: its heartbeat just
|
|
* stops), or that PHP ended for its time limit, before the plan it started with, tells how long requests live here.
|
|
* Returns what was learned now, for the log.
|
|
*/
|
|
public static function learn(string $dir, array $gens): ?string
|
|
{
|
|
$h = self::learned($dir);
|
|
$said = null;
|
|
foreach ($gens as $g) {
|
|
$phase = $g['phase'] ?? '';
|
|
$reason = (string)($g['reason'] ?? '');
|
|
$silent = time() - (int)($g['beat'] ?? 0) >= PhpboxState::STALE; // its heartbeat just stopped
|
|
$died = (in_array($phase, ['connecting', 'serving', 'draining', 'holding'], true) && $silent)
|
|
|| str_starts_with($reason, 'died: Maximum execution time') || $reason === 'process ended';
|
|
if (!$died) { continue; }
|
|
$age = (int)(($g['ended'] ?? $g['beat'] ?? 0) - ($g['started'] ?? 0));
|
|
$planned = (int)($g['cap'] ?? 0);
|
|
if ($age <= 0 || $age >= $planned - 3) { continue; } // it lived its plan: nothing to learn
|
|
$cpu = (float)($g['cpu'] ?? 0);
|
|
$cpuB = (float)($g['budget']['cpu'] ?? 0);
|
|
if ($cpu > 0 && $cpu >= 0.75 * $age) { // busy all along: the CPU ran out
|
|
$c = max(5.0, round($cpu * 0.9, 1));
|
|
if (empty($h['cpu']) || $c < $h['cpu']) { $h['cpu'] = $c; $said = "the host ends a request after about {$cpu}s of CPU"; }
|
|
} elseif ($cpuB <= 0 || $cpu < 0.7 * $cpuB) { // not the CPU we knew of: a clock did it
|
|
// Believed only when it happens twice at about the same age: one sudden end can be the host
|
|
// restarting, and a limit learned from that would shorten every run for a week.
|
|
$key = ($g['gen'] ?? 0) . '@' . ($g['started'] ?? 0);
|
|
$deaths = array_filter($h['deaths'] ?? [], fn($d) => time() - (int)$d['at'] < self::LEARN_TTL);
|
|
if (!in_array($key, array_column($deaths, 'id'), true)) {
|
|
foreach ($deaths as $d) {
|
|
if (abs((int)$d['age'] - $age) <= 15 && (empty($h['wall']) || min($age, (int)$d['age']) < $h['wall'])) {
|
|
$h['wall'] = max(self::MIN_WALL, min($age, (int)$d['age']));
|
|
$said = "the host ends a request after about {$h['wall']}s";
|
|
}
|
|
}
|
|
$deaths[] = ['id' => $key, 'age' => $age, 'at' => time()];
|
|
$h['deaths'] = array_slice(array_values($deaths), -6);
|
|
$said = $said ?? '';
|
|
}
|
|
}
|
|
}
|
|
if ($said !== null) {
|
|
$h['at'] = time();
|
|
@file_put_contents("$dir/host.json", json_encode($h));
|
|
}
|
|
return $said === '' ? null : $said; // '' = a death noted, nothing believed yet
|
|
}
|
|
}
|
|
|
|
final class PhpboxNode
|
|
{
|
|
const VERSION = '0.4';
|
|
const CHAIN_CAP = 240; // lifetime of one generation in chain mode (plain mode: the exit's own cap, 140)
|
|
const MAX_DRAIN = 60; // seconds a generation keeps serving its streams after handing over
|
|
// A successor takes streams only once its link has stayed up this long: a Mail.ru document closes the first
|
|
// connections right after they join, and streams handed over then were lost with the link.
|
|
const SETTLE = 8;
|
|
const OVERLAP_RATE = 131072; // bytes/s a generation sends while its successor shares the room
|
|
|
|
/**
|
|
* @param string $carrier 'cupsonline' | 'mailru' - the transport type a link must carry to fit this exit
|
|
* @param string $title shown in the page
|
|
* @param callable $target fn(array $get): string - the carrier's own address from the request ('' if none)
|
|
* @param callable $factory fn(string $target): Carrier
|
|
*/
|
|
public function __construct(
|
|
private string $carrier,
|
|
private string $title,
|
|
private $target,
|
|
private $factory,
|
|
private int $cap = 140,
|
|
) {}
|
|
|
|
public function handle(): void
|
|
{
|
|
error_reporting(E_ALL & ~E_DEPRECATED);
|
|
$token = PhpboxUtil::env('PHPBOX_TOKEN') ?: (defined('PHPBOX_TOKEN') ? PHPBOX_TOKEN : 'CHANGE-ME'); // putenv is disabled on some free hosts: config.php also define()s it
|
|
if (!hash_equals($token, (string)($_GET['k'] ?? ''))) {
|
|
http_response_code(404);
|
|
header('Content-Type: text/plain; charset=utf-8');
|
|
exit("no\n");
|
|
}
|
|
$target = trim((string)($this->target)($_GET));
|
|
$action = (string)($_GET['a'] ?? '');
|
|
if ($action === '') {
|
|
$action = $this->wantsPage() ? 'ui' : 'run';
|
|
}
|
|
|
|
if ($action === 'wasm') { $this->serveAsset('share.wasm.gz', 'application/wasm', true); }
|
|
if ($action === 'ping') { $this->json($this->ping()); } // what a deploy step asks: is this the node, and can this host run it?
|
|
if ($action === 'ui') { $this->servePage($target); }
|
|
|
|
if ($target === '') {
|
|
$this->json(['error' => 'need_target'], 400);
|
|
}
|
|
$key = substr(sha1($this->carrier . '|' . $target), 0, 12);
|
|
$dir = PhpboxUtil::stateDir();
|
|
$log = new PhpboxLog("$dir/$key.log");
|
|
|
|
switch ($action) {
|
|
case 'status':
|
|
$this->json($this->status($dir, $key));
|
|
case 'log':
|
|
$this->json($log->tail((int)($_GET['since'] ?? 0)));
|
|
case 'stop':
|
|
$alive = array_filter(PhpboxState::generations($dir, $key), fn($g) => PhpboxState::alive($g));
|
|
if ($alive) {
|
|
@touch("$dir/$key.stop"); // every generation sees it; none spawns another
|
|
$log->write('info', 'stop requested from the page');
|
|
}
|
|
$this->json(['ok' => true, 'was_running' => (bool)$alive]);
|
|
case 'run':
|
|
$this->run($target, $key, $dir, $log);
|
|
exit;
|
|
default:
|
|
$this->json(['error' => 'unknown_action'], 400);
|
|
}
|
|
}
|
|
|
|
// ---- run ------------------------------------------------------------
|
|
|
|
private function run(string $target, string $key, string $dir, PhpboxLog $log): void
|
|
{
|
|
header('Content-Type: text/plain; charset=utf-8');
|
|
header('Cache-Control: no-store');
|
|
header('X-Accel-Buffering: no');
|
|
if (function_exists('ini_set')) { @ini_set('zlib.output_compression', '0'); }
|
|
PhpboxUtil::keepRunning(); // a dropped tab or a proxy 504 must not stop the node (where the host lets us ask)
|
|
while (ob_get_level() > 0) { ob_end_flush(); }
|
|
|
|
$chain = !empty($_GET['chain']);
|
|
$succ = !empty($_GET['succ']); // started by the previous generation, not by a person
|
|
$from = (int)($_GET['from'] ?? 0);
|
|
$cap = $chain ? self::CHAIN_CAP : $this->cap;
|
|
if (isset($_GET['cap'])) {
|
|
$cap = max(PhpboxUtil::testMode() ? 6 : 30, min(900, (int)$_GET['cap']));
|
|
}
|
|
$gens = PhpboxState::generations($dir, $key);
|
|
$learnedNow = PhpboxBudget::learn($dir, $gens); // a generation before us died early: plan around it
|
|
$budget = new PhpboxBudget($cap, $dir);
|
|
$cap = $budget->wall;
|
|
$spawnAt = $chain ? $budget->spawnAt() : $cap - min(self::MAX_DRAIN, intdiv($cap, 3)); // when this generation starts its successor (or earlier: CPU)
|
|
if (!$succ) {
|
|
$accepting = array_filter($gens, fn($g) => PhpboxState::accepting($g));
|
|
if ($accepting) {
|
|
$g = max(array_keys($accepting));
|
|
$log->write('info', "another open asked to start the node: already running (gen $g)");
|
|
echo "already running\n";
|
|
return;
|
|
}
|
|
@unlink("$dir/$key.stop"); // a person starting it again clears an earlier stop
|
|
} elseif (is_file("$dir/$key.stop")) {
|
|
$log->write('info', 'successor not started: stop was requested');
|
|
echo "stopped\n";
|
|
return;
|
|
}
|
|
$gen = $succ ? $from + 1 : ($gens ? max(array_keys($gens)) : 0) + 1;
|
|
if ($succ && isset($gens[$gen]) && PhpboxState::alive($gens[$gen])) {
|
|
echo "already running\n"; // a duplicate successor request
|
|
return;
|
|
}
|
|
$lock = @fopen("$dir/$key.g$gen.lock", 'c');
|
|
if ($lock && !flock($lock, LOCK_EX | LOCK_NB)) {
|
|
echo "already running\n"; // another request holds this generation but has not beaten yet
|
|
return;
|
|
}
|
|
foreach ($gens as $old => $_) { // forget generations long gone
|
|
if ($old < $gen - 4) { @unlink("$dir/$key.g$old.json"); @unlink("$dir/$key.g$old.lock"); }
|
|
}
|
|
|
|
$state = new PhpboxState("$dir/$key.g$gen.json");
|
|
$started = time();
|
|
$sensitive = !empty($_GET['sensitive']);
|
|
$s = [
|
|
'carrier' => $this->carrier, 'pid' => PhpboxUtil::pid(), 'gen' => $gen, 'phase' => 'connecting',
|
|
'started' => $started, 'beat' => $started, 'cap' => $cap, 'elapsed' => 0,
|
|
'chain' => $chain, 'spawn_at' => $spawnAt, 'from' => $succ ? $from : null,
|
|
'streams' => 0, 'opened' => 0, 'failed' => 0, 'up' => 0, 'down' => 0,
|
|
'sensitive' => $sensitive, 'php' => PHP_VERSION, 'version' => self::VERSION,
|
|
'cpu' => 0, 'budget' => ['wall' => $budget->wall, 'cpu' => $budget->cpu],
|
|
];
|
|
$state->write($s);
|
|
$log->write('info', "node gen $gen starting on {$this->carrier}" . ($succ ? " (successor of gen $from)" : '')
|
|
. ' (phpbox ' . self::VERSION . ', php ' . PHP_VERSION . ')');
|
|
if ($learnedNow !== null) { $log->write('warn', "learned: $learnedNow; generations hand over earlier from now on"); }
|
|
$log->write('info', 'time budget: ' . $budget->describe());
|
|
|
|
// Whatever a carrier echoes is also a log line (and still goes to the response).
|
|
// The response goes nowhere useful: a successor's request was closed by the generation that started it, and
|
|
// a person's tab or a proxy's 504 closes the first one. On hosts where ignore_user_abort does not hold, PHP
|
|
// stops the script at its next write to a closed connection - which silently ended every successor right
|
|
// after "joined". So a successor writes nothing, and a first run only its opening lines.
|
|
$quietAt = time() + 3;
|
|
ob_start(function (string $buf) use ($log, $succ, $quietAt) {
|
|
foreach (preg_split('/\R/', $buf) as $l) {
|
|
if (trim($l) !== '') { $log->write('info', trim($l)); }
|
|
}
|
|
return ($succ || time() > $quietAt) ? '' : $buf;
|
|
}, 1);
|
|
|
|
$ownMine = "$dir/$key.own.$gen"; // markers for streams while a successor is coming up
|
|
$ownPrev = "$dir/$key.own.$from"; // ... and while we are the successor
|
|
$rmOwn = function (string $d) {
|
|
foreach (glob("$d/*") ?: [] as $f) { @rmdir($f); }
|
|
@rmdir($d);
|
|
};
|
|
$ended = false;
|
|
$finish = function (string $why, string $lvl = 'info') use (&$s, $state, $log, &$ended, $rmOwn, $ownMine, $budget) {
|
|
if ($ended) { return; }
|
|
$ended = true;
|
|
$s['phase'] = 'idle';
|
|
$s['ended'] = time();
|
|
$s['elapsed'] = time() - $s['started'];
|
|
$s['cpu'] = $budget->cpuUsed();
|
|
$s['reason'] = $why;
|
|
$state->write($s);
|
|
$rmOwn($ownMine);
|
|
$log->write($lvl, "node gen {$s['gen']} ended: $why");
|
|
};
|
|
register_shutdown_function(function () use (&$finish) { // CPU limit, fatal error
|
|
$e = error_get_last();
|
|
$finish($e ? 'died: ' . $e['message'] : 'process ended', $e ? 'error' : 'info');
|
|
});
|
|
|
|
$carrier = ($this->factory)($target);
|
|
if (!$carrier->connect()) {
|
|
$finish('could not join (see the lines above)', 'error');
|
|
echo "connect failed\n";
|
|
return;
|
|
}
|
|
|
|
$s['phase'] = $succ ? 'connecting' : 'serving'; // a successor serves once its link has settled (onTick)
|
|
$s['beat'] = time();
|
|
if (method_exists($carrier, 'member')) { $s['member'] = $carrier->member(); } // the others ignore what we send
|
|
$state->write($s);
|
|
$log->write('info', "joined; serving" . ($chain ? ", handing over to a successor at {$spawnAt}s" : '') . " up to {$cap}s"
|
|
. ($sensitive ? ' (destinations are logged)' : ' (destinations are hidden: add &sensitive=1 to log them)'));
|
|
echo "{$this->carrier} exit ready; serving up to {$cap}s\n";
|
|
|
|
$mux = new Mux($carrier);
|
|
$mux->accepting = !$succ;
|
|
$settled = !$succ; $upSince = time(); $seenReconnects = 0;
|
|
$mux->sensitive = $sensitive;
|
|
if (isset($_GET['win'])) { $w = max(0, min(4194304, (int)$_GET['win'])); $mux->streamWindow = $w; $mux->totalWindow = $w * 2; } // 0 = no flow control
|
|
if (isset($_GET['idle'])) { $mux->idleTimeout = max(0, min(3600, (int)$_GET['idle'])); }
|
|
if (isset($_GET['chunk'])) { $mux->readChunk = max(2048, min(262144, (int)$_GET['chunk'])); }
|
|
$mux->log = fn(string $lvl, string $m) => $log->write($lvl, $m);
|
|
|
|
// Handover bookkeeping (chain mode).
|
|
$asSucc = $succ; // the previous generation may still take the same OPENs
|
|
$asPred = false; // we spawned a successor that may take them too
|
|
$spawned = false; $lastSpawn = 0; $spawnTries = 0; $frozen = false; $endWhy = '';
|
|
$predState = $succ ? new PhpboxState("$dir/$key.g$from.json") : null;
|
|
$succState = new PhpboxState("$dir/$key." . 'g' . ($gen + 1) . '.json');
|
|
if ($asSucc) { @mkdir($ownPrev, 0700, true); }
|
|
$mux->claim = function (int $sid) use (&$asSucc, &$asPred, $ownPrev, $ownMine): bool {
|
|
// mkdir is atomic: the first generation to create the marker owns the stream. Recursive, because the
|
|
// previous generation removes its marker directory when it ends, up to a second before we notice.
|
|
if ($asSucc && !@mkdir("$ownPrev/$sid", 0700, true)) { return false; }
|
|
if ($asPred && !@mkdir("$ownMine/$sid", 0700, true)) { return false; }
|
|
return true;
|
|
};
|
|
|
|
$mux->onTick = function (Mux $m) use (&$s, $state, $started, $dir, $key, $chain, $spawnAt, $target, $cap, $sensitive, $gen,
|
|
$log, &$asSucc, &$asPred, &$spawned, &$lastSpawn, &$spawnTries, &$frozen, &$endWhy,
|
|
$predState, $succState, $ownMine, $budget, $carrier, &$settled, &$upSince, &$seenReconnects) {
|
|
$s['beat'] = time();
|
|
$s['elapsed'] = time() - $started;
|
|
$s['streams'] = $m->activeStreams();
|
|
$s['opened'] = $m->stats['opened'];
|
|
$s['failed'] = $m->stats['failed'];
|
|
$s['up'] = $m->stats['up'];
|
|
$s['down'] = $m->stats['down'];
|
|
$s['cpu'] = $budget->cpuUsed();
|
|
if (method_exists($carrier, 'member')) { $s['member'] = $carrier->member(); } // a reconnect joins anew
|
|
$state->write($s);
|
|
if (method_exists($carrier, 'ignoreUsers') && $s['elapsed'] % 2 === 0) {
|
|
$others = []; // the other generations in this room: not our client
|
|
foreach (PhpboxState::generations($dir, $key) as $g => $o) {
|
|
if ($g !== $gen && !empty($o['member'])) { $others[] = $o['member']; }
|
|
}
|
|
$carrier->ignoreUsers($others);
|
|
}
|
|
if (!$settled) {
|
|
$rc = $m->stats['reconnects'] ?? 0;
|
|
if ($rc !== $seenReconnects && !PhpboxUtil::testMode()) {
|
|
$seenReconnects = $rc;
|
|
$upSince = time(); // the link dropped and joined again: start counting anew
|
|
}
|
|
if (time() - $upSince >= (PhpboxUtil::testMode() ? 1 : self::SETTLE)) {
|
|
$settled = true;
|
|
$m->accepting = true;
|
|
$s['phase'] = 'serving';
|
|
$state->write($s);
|
|
$log->write('info', 'link steady: taking new streams');
|
|
}
|
|
}
|
|
if (file_exists("$dir/$key.stop")) { // left in place: every generation and any spawn sees it
|
|
$endWhy = 'stopped';
|
|
return false;
|
|
}
|
|
if ($asSucc && !PhpboxState::accepting($predState->read())) {
|
|
$asSucc = false; // the previous generation no longer takes streams: no contest
|
|
$log->write('debug', 'previous generation stopped taking new streams');
|
|
}
|
|
if ($budget->cpuShare() >= 0.85) { // end on our terms, not the host's: clients get CLOSE now
|
|
$endWhy = 'cpu';
|
|
return false;
|
|
}
|
|
if ($chain && !$frozen) {
|
|
$due = $s['elapsed'] >= $spawnAt || $budget->cpuShare() >= 0.55;
|
|
if ($due && time() - $lastSpawn >= 15) {
|
|
$lastSpawn = time();
|
|
@mkdir($ownMine, 0700, true);
|
|
$asPred = true; // from now on new streams are claimed, not assumed
|
|
if ($this->spawn($target, $gen, $cap, $sensitive, $log, $spawnTries++)) {
|
|
$spawned = true;
|
|
$log->write('info', "started gen " . ($gen + 1) . ' to take over');
|
|
} else {
|
|
$log->write('warn', 'could not start the successor; retrying');
|
|
}
|
|
}
|
|
if ($spawned) {
|
|
$n = $succState->read();
|
|
if (PhpboxState::alive($n) && ($n['phase'] ?? '') === 'serving') {
|
|
$m->accepting = false; // the successor takes every new stream from here
|
|
$frozen = true;
|
|
$asPred = false;
|
|
$s['phase'] = 'draining';
|
|
$state->write($s);
|
|
$log->write('info', 'handed over to gen ' . ($gen + 1) . "; draining {$m->activeStreams()} stream(s)");
|
|
}
|
|
}
|
|
}
|
|
// While a successor is in the room, go easy on it: it receives everything we send (see Mux::$readRate).
|
|
$m->readRate = ($spawned || $frozen) ? self::OVERLAP_RATE : 0;
|
|
if ($frozen && $m->activeStreams() === 0) {
|
|
$endWhy = 'handed';
|
|
return false;
|
|
}
|
|
return true;
|
|
};
|
|
$why = $mux->run($cap);
|
|
if ($endWhy === 'stopped') { $finish('stopped from the page'); }
|
|
elseif ($endWhy === 'handed') { $finish('handed over to gen ' . ($gen + 1) . ' (its streams ended)'); }
|
|
elseif ($endWhy === 'cpu' && $frozen) { $finish('handed over to gen ' . ($gen + 1) . "; the CPU budget ({$budget->cpu}s) cut the streams still open"); }
|
|
elseif ($endWhy === 'cpu' && $chain) { $finish("chain broken: the CPU budget ({$budget->cpu}s) ran out before a successor was serving", 'error'); }
|
|
elseif ($endWhy === 'cpu') { $finish("ended before the host's CPU limit ({$budget->cpu}s)"); }
|
|
elseif ($frozen) { $finish('handed over to gen ' . ($gen + 1) . "; {$cap}s cap cut the streams still open"); }
|
|
elseif ($chain) { $finish("chain broken: no successor was serving before the {$cap}s cap", 'error'); }
|
|
else { $finish("reached the {$cap}s cap"); }
|
|
echo "exit done\n";
|
|
}
|
|
|
|
/** Start the next generation: a request to our own host, which the host's bot check lets through. */
|
|
private function spawn(string $target, int $gen, int $cap, bool $sensitive, PhpboxLog $log, int $attempt = 0): bool
|
|
{
|
|
$hostport = (string)($_SERVER['HTTP_HOST'] ?? '');
|
|
if ($hostport === '') { return false; }
|
|
[$h, $p] = array_pad(explode(':', $hostport, 2), 2, null);
|
|
$tls = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
|
|| ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https' || ($_SERVER['SERVER_PORT'] ?? '') === '443';
|
|
if ($attempt % 2 === 1) { $tls = !$tls; } // a retry tries the other scheme (hosts that redirect http to https)
|
|
$port = (int)($p ?: ($tls ? 443 : 80));
|
|
$path = (string)strtok((string)($_SERVER['REQUEST_URI'] ?? '/'), '?');
|
|
$qs = http_build_query([
|
|
'k' => (string)$_GET['k'], 'a' => 'run', 'url' => $target, 'succ' => 1, 'from' => $gen,
|
|
'chain' => 1, 'cap' => $cap, 'sensitive' => $sensitive ? 1 : 0,
|
|
]);
|
|
$fp = @fsockopen(($tls ? 'ssl://' : '') . $h, $port, $en, $es, 10);
|
|
if (!$fp) {
|
|
$log->write('warn', "self-request to " . ($tls ? 'https' : 'http') . "://$h:$port failed: $en $es");
|
|
return false;
|
|
}
|
|
fwrite($fp, "GET $path?$qs HTTP/1.1\r\nHost: $hostport\r\nUser-Agent: phpbox-chain\r\nAccept: text/plain\r\nConnection: close\r\n\r\n");
|
|
// A running successor stays silent, so no answer is what success looks like. An answer within a moment is a
|
|
// redirect, the host's bot check or an error page - say so instead of waiting for a generation that never comes.
|
|
$r = [$fp]; $w = $e = null;
|
|
if (@stream_select($r, $w, $e, 0, 300000)) {
|
|
$head = (string)@fread($fp, 1024);
|
|
$line = strtok($head, "\r\n");
|
|
$ok = preg_match('#^HTTP/\S+ 200#', (string)$line) && stripos($head, 'slowAES') === false;
|
|
if (!$ok) {
|
|
$log->write('warn', 'self-request to ' . ($tls ? 'https' : 'http') . "://$h:$port was answered: "
|
|
. ($line !== false && $line !== '' ? $line : 'nothing usable') . (stripos($head, 'slowAES') !== false ? ' (the host\'s browser check)' : ''));
|
|
fclose($fp);
|
|
return false;
|
|
}
|
|
}
|
|
fclose($fp); // the successor outlives this socket and writes nothing to it
|
|
return true;
|
|
}
|
|
|
|
/** CPU seconds this process has used (user + system): what a host's max_execution_time counts. */
|
|
public static function cpuSeconds(): float
|
|
{
|
|
if (!function_exists('getrusage')) { return 0.0; }
|
|
$u = @getrusage();
|
|
if (!is_array($u)) { return 0.0; }
|
|
return round(($u['ru_utime.tv_sec'] ?? 0) + ($u['ru_utime.tv_usec'] ?? 0) / 1e6
|
|
+ ($u['ru_stime.tv_sec'] ?? 0) + ($u['ru_stime.tv_usec'] ?? 0) / 1e6, 2);
|
|
}
|
|
|
|
// ---- ping -----------------------------------------------------------
|
|
|
|
/** Facts about this host for the installer: what the node needs, and whether the host has it. */
|
|
private function ping(): array
|
|
{
|
|
$dir = PhpboxUtil::stateDir();
|
|
$needs = [
|
|
'fsockopen' => function_exists('fsockopen'),
|
|
'stream_select' => function_exists('stream_select'),
|
|
'stream_socket_client' => function_exists('stream_socket_client'),
|
|
'usleep' => function_exists('usleep'),
|
|
'openssl' => extension_loaded('openssl'),
|
|
'json' => function_exists('json_encode'),
|
|
];
|
|
return [
|
|
'phpbox' => self::VERSION,
|
|
'carrier' => $this->carrier,
|
|
'php' => PHP_VERSION,
|
|
'sapi' => PHP_SAPI,
|
|
'needs' => $needs,
|
|
'missing' => array_keys(array_filter($needs, fn($ok) => !$ok)),
|
|
// The node runs without these, but the host has taken them away: worth knowing when it misbehaves.
|
|
'disabled' => array_values(array_filter(['ignore_user_abort', 'set_time_limit', 'getrusage', 'getmypid', 'getenv', 'ini_set'],
|
|
fn($f) => !function_exists($f))),
|
|
'cpu_limit' => function_exists('ini_get') ? (int)ini_get('max_execution_time') : null,
|
|
'state_dir' => is_dir($dir) && is_writable($dir),
|
|
'parser' => is_file(dirname(__DIR__) . '/assets/share.wasm.gz'),
|
|
'time' => time(),
|
|
];
|
|
}
|
|
|
|
// ---- status ---------------------------------------------------------
|
|
|
|
private function status(string $dir, string $key): array
|
|
{
|
|
$gens = PhpboxState::generations($dir, $key);
|
|
$alive = array_filter($gens, fn($g) => PhpboxState::alive($g));
|
|
$accepting = array_filter($alive, fn($g) => PhpboxState::accepting($g));
|
|
// The state shown: the generation taking streams, else one that is winding down, else the last one that ran.
|
|
$serving = array_filter($accepting, fn($g) => ($g['phase'] ?? '') === 'serving');
|
|
$cur = $serving ? $serving[max(array_keys($serving))]
|
|
: ($accepting ? $accepting[max(array_keys($accepting))]
|
|
: ($alive ? $alive[max(array_keys($alive))] : ($gens ? $gens[max(array_keys($gens))] : [])));
|
|
if (PhpboxState::alive($cur)) {
|
|
$cur['elapsed'] = max((int)($cur['elapsed'] ?? 0), time() - (int)$cur['started']);
|
|
}
|
|
foreach (['streams', 'opened', 'failed', 'up', 'down'] as $f) { // totals across overlapping generations
|
|
$cur[$f] = array_sum(array_map(fn($g) => (int)($g[$f] ?? 0), $alive ?: [$cur]));
|
|
}
|
|
$draining = count($alive) - count($accepting);
|
|
return [
|
|
'running' => (bool)$accepting,
|
|
'draining' => $draining,
|
|
'chain' => !empty($cur['chain']),
|
|
'next_in' => ($accepting && !empty($cur['chain'])) ? max(0, (int)$cur['spawn_at'] - (int)$cur['elapsed']) : null,
|
|
'stopping' => $alive && is_file("$dir/$key.stop"),
|
|
'state' => $cur,
|
|
'age' => isset($cur['beat']) ? time() - (int)$cur['beat'] : null,
|
|
'now' => time(),
|
|
];
|
|
}
|
|
|
|
// ---- page -----------------------------------------------------------
|
|
|
|
/** A browser navigating here wants the page; a pinger or curl wants the node to run. */
|
|
private function wantsPage(): bool
|
|
{
|
|
if (!empty($_GET['headless'])) { return false; }
|
|
$accept = (string)($_SERVER['HTTP_ACCEPT'] ?? '');
|
|
$mode = (string)($_SERVER['HTTP_SEC_FETCH_MODE'] ?? '');
|
|
if ($mode !== '' && $mode !== 'navigate') { return false; }
|
|
return stripos($accept, 'text/html') !== false;
|
|
}
|
|
|
|
private function servePage(string $target): void
|
|
{
|
|
require_once __DIR__ . '/ui.php';
|
|
$assets = dirname(__DIR__) . '/assets';
|
|
header('Content-Type: text/html; charset=utf-8');
|
|
header('Cache-Control: no-store');
|
|
header('X-Robots-Tag: noindex');
|
|
$self = strtok((string)($_SERVER['REQUEST_URI'] ?? ''), '?');
|
|
echo PhpboxUi::render([
|
|
'carrier' => $this->carrier,
|
|
'title' => $this->title,
|
|
'target' => $target,
|
|
'k' => (string)$_GET['k'],
|
|
'self' => $self,
|
|
'auto' => !isset($_GET['auto']) || $_GET['auto'] !== '0',
|
|
'sensitive' => !empty($_GET['sensitive']),
|
|
'cap' => $this->cap,
|
|
'version' => self::VERSION,
|
|
'php' => PHP_VERSION,
|
|
'hasWasm' => is_file("$assets/share.wasm.gz"),
|
|
'wasmExec' => (string)@file_get_contents("$assets/wasm_exec.js"),
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
private function serveAsset(string $name, string $type, bool $gzip): void
|
|
{
|
|
$f = dirname(__DIR__) . '/assets/' . $name;
|
|
if (!is_file($f)) {
|
|
http_response_code(404);
|
|
exit;
|
|
}
|
|
header('Content-Type: ' . $type);
|
|
if ($gzip) { header('Content-Encoding: gzip'); }
|
|
header('Cache-Control: private, max-age=86400');
|
|
header('Content-Length: ' . filesize($f));
|
|
readfile($f);
|
|
exit;
|
|
}
|
|
|
|
private function json(array $o, int $code = 200): void
|
|
{
|
|
http_response_code($code);
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
header('Cache-Control: no-store');
|
|
echo json_encode($o, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
exit;
|
|
}
|
|
}
|