Files
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

145 lines
5.1 KiB
PHP

<?php
// Shared helpers for the phpbox exits (cups, mailru, ...).
final class PhpboxUtil
{
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36';
/** One HTTP request with a shared cookie jar file. Returns [body, status]. */
public static function http(string $url, string $cookieFile, string $method, ?string $body, array $headers): array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_USERAGENT => self::UA,
CURLOPT_COOKIEJAR => $cookieFile,
CURLOPT_COOKIEFILE => $cookieFile,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => $headers,
]);
if ($method === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$out = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
return [$out !== false ? $out : '', $code];
}
public static function scrape(string $re, string $html): string
{
return preg_match($re, $html, $m) ? $m[1] : '';
}
/** Netscape cookie-jar value lookup by name. */
public static function cookie(string $file, string $name): string
{
foreach (@file($file) ?: [] as $line) {
$p = explode("\t", trim($line));
if (count($p) === 7 && $p[5] === $name) {
return $p[6];
}
}
return '';
}
public static function cookieHeader(string $file): string
{
$out = [];
foreach (@file($file) ?: [] as $line) {
$c = explode("\t", trim($line));
if (count($c) === 7) {
$out[] = $c[5] . '=' . $c[6];
}
}
return implode('; ', $out);
}
public static function originOf(string $u): string
{
$p = parse_url($u);
return ($p['scheme'] ?? 'https') . '://' . ($p['host'] ?? '');
}
/** Host -> every address it has (IPv4 first), looked up once per run; [] when it does not resolve. */
public static function resolveAll(string $host): array
{
static $cache = [];
if (!isset($cache[$host])) {
if (filter_var($host, FILTER_VALIDATE_IP)) {
$cache[$host] = [$host];
} else {
$ips = @gethostbynamel($host) ?: [];
$cache[$host] = array_values(array_filter($ips, fn($ip) => filter_var($ip, FILTER_VALIDATE_IP)));
}
}
return $cache[$host];
}
/** Host -> its first address ('' when it does not resolve). */
public static function resolve(string $host): string
{
return self::resolveAll($host)[0] ?? '';
}
/** Local testing only: lifts the private-address and port guards. Never set on a real host. */
public static function testMode(): bool
{
return self::env('PHPBOX_ALLOW_PRIVATE') === '1' || (defined('PHPBOX_ALLOW_PRIVATE') && PHPBOX_ALLOW_PRIVATE === '1');
}
/** Refuse loopback / private / reserved targets (no SSRF into the host LAN): any address that is one blocks the host. */
public static function isPrivate(string $host): bool
{
if (self::testMode()) {
return false;
}
$ips = self::resolveAll($host);
if (!$ips) {
return true;
}
foreach ($ips as $ip) {
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
return true;
}
}
return false;
}
/** Where run state and logs live: a writable dir that is not web-served when the host has one. */
/** getenv() where the host has it: a disabled function is gone in PHP 8, and calling it is a fatal error. */
public static function env(string $name)
{
return function_exists('getenv') ? getenv($name) : false;
}
/** This process's id, or a random stand-in where getmypid() is disabled. */
public static function pid(): int
{
static $pid = null;
if ($pid === null) { $pid = function_exists('getmypid') ? (int)getmypid() : random_int(100000, 999999); }
return $pid;
}
/**
* Ask the host to keep running when the request's connection is gone, and to lift the time limit, where those
* calls exist. Hosts disable them; in PHP 8 a disabled function is undefined and calling it ends the script.
*/
public static function keepRunning(bool $afterClose = true): void
{
if (function_exists('ignore_user_abort')) { @ignore_user_abort($afterClose); }
if (function_exists('set_time_limit')) { @set_time_limit(0); }
}
public static function stateDir(): string
{
$base = (is_dir('/home/tmp') && is_writable('/home/tmp')) ? '/home/tmp' : sys_get_temp_dir();
$dir = $base . '/phpbox-state';
if (!is_dir($dir)) {
@mkdir($dir, 0700, true);
}
return $dir;
}
}