* 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>
phpbox — a stream-mux exit on plain PHP hosting
phpbox.php turns a plain PHP host (including free shared hosting that
cannot run a binary, keep a long-lived process, or listen on a socket) into a
limited OpenFlux exit. The client side is the transport/phpbox package.
It is meant for reconnect-tolerant, mostly-:443 traffic — Telegram (MTProto)
and short HTTPS requests — not as a general full-tunnel VPN exit.
How it works
PHP hosts buffer the request body but stream the response, so one full-duplex tunnel rides two half-duplex HTTP channels:
- down (
GET …?r=down): a long-poll that is the session. It holds the destination sockets,stream_select()s them, and streamsdst→clientframes back in the response body. Each loop it also drains the upstream bus and appliesOPEN/DATA/CLOSE. - up (
POST …?r=up): the client'sOPEN/DATA/CLOSEframes, appended to a per-session bus file for the down process to pick up. Returns at once.
Frame: type(1) | stream_id(4 BE) | len(4 BE) | payload, where type is
1 OPEN "host:port", 2 DATA, 3 CLOSE, 4 OPEN_OK, 5 OPEN_ERR reason.
A new session id is minted per down-connection. When the host caps the
request (RUN_CAP, ~140 s) the streams end and the client reconnects; MTProto
and short HTTPS tolerate it.
Setup
- Upload
phpbox.phpnext to a domain on the host. - Set
PHPBOX_TOKENin the environment (or edit the constant) to a secret. - Point the client's phpbox transport at the file's URL with that token.
Guards: a shared token, only ports 80/443, and no private/loopback targets.
Limits (measured on free hosting)
- No binary, no daemon, no listening socket: the exit exists only for the life
of each request.
RUN_CAP≈ 140 s per down-poll, then a reconnect. - Outbound is filtered to
:80/:443; other ports are refused. - Throughput is modest (HTTP framing + the host's CPU/hit limits). Treat it as a free backup channel, not a fast path.
v0 caveat — no encryption yet
v0 carries frames in the clear, so the host and anything on-path see the destinations and traffic (the app's own TLS/MTProto still protects content). Wrap the carrier with the core's encryption before any real use.
cupsexit.php — the cups path (for the censored client)
When the client can only reach cups.online (not the PHP host directly), use
cupsexit.php instead of phpbox.php. It joins the same cups room as the
client and relays the stream mux there:
client (--mode=stream, --transport=cupsonline, --url <room>)
-> cups.online room -> cupsexit.php -> dst
cupsexit.php is a cups participant (same auth + Centrifuge WS + cursor
encoding as transport/cupsonline) plus the mux demux and fsockopen(dst).
It carries no IP packets and runs no gVisor.
Run (open in a browser so the host's bot check passes; keep re-opening or point a pinger at it — one request serves ~140 s):
https://<host>/cupsexit.php?k=PHPBOX_TOKEN&url=<full cups room URL>
Offline framing self-test (no network): php cupsexit.php selftest.
mailruexit.php — the Mail.ru path
mailruexit.php is the sibling of cupsexit.php for Mail.ru's cloud document
editor (docs.datacloudmail.ru), matching transport/mailru: one WebSocket
message carries exactly one packet (one mux frame), base64 in the "cursor"
field, no extra length framing. Same guards and shape as cups.
client (--mode=stream, --transport=mailru, --url <public doc link>)
-> Mail.ru doc -> mailruexit.php -> dst
Run: https://<host>/mailruexit.php?k=PHPBOX_TOKEN&url=<public doc link>
Offline self-test: php mailruexit.php selftest.
Shared code (lib/)
Both exits share lib/: mux.php (the carrier-agnostic stream mux — the
Carrier interface, OPEN/DATA/CLOSE demux, dst dialing with the port/private
guards, the select loop), ws.php (a minimal WebSocket client), and
util.php (HTTP with a cookie jar, HTML scraping, the private-target guard).
Each exit implements one Carrier: CupsCarrier (Centrifuge + cursor
integers + 2-byte chunk/packet framing) and MailruCarrier (Socket.IO +
base64 in the cursor field). Adding another editor = one more Carrier.
The page (v0.3)
Open an exit's URL in a browser and you get a status page, not a wall of
text: the node's state (running / run number / time left / streams / bytes),
a live log with info and debug lines, Start and Stop, and a link box.
- Already running? The running node writes a heartbeat every second (and holds a lock). Opening the page again, or a pinger hitting the URL, attaches to it and says so; it never starts a second node on the same target.
- Who gets what. A browser navigation gets the page, which starts the node
unless one already runs (
&auto=0to only look). Anything else (a pinger,curl) runs the node directly, exactly as before;&a=runforces it,&headless=1forces it from a browser. Other actions:a=status,a=log,a=stop(JSON). - Links. Paste an
openflux://link into the box: it is parsed in the browser by the core's own parser (share, compiled to WebAssembly), shown field by field with the reason codes worded by the page, and drawn as a QR. The secret stays in the browser. The page picks the transport that fits this exit (cupsonlineormailru) and starts the node on it. Raw room and document addresses still work, and so does the old?url=/?room=. - Debug log. Kept on the host in a ring file (trimmed when full).
Destination hosts are hidden (
*:443) like the core's--sensitive; tick the box on the page, or add&sensitive=1, to log them.
Build and upload:
deploy/phpbox/build-bundle.sh # -> phpbox_bundle/ (token in config.php; needs Go for the parser)
./upload_bundle.sh # asks for the FTP password; nothing is stored
NO_WASM=1 skips the parser build: addresses work, openflux:// links then
show a note instead of a parse. Tests: php test/mux_test.php,
test/node_test.sh, php cupsexit.php selftest, php mailruexit.php selftest.
Free-host facts this code relies on (measured on InfinityFree): sleep,
set_time_limit, putenv, proc_open and socket_* are disabled, so the
code uses usleep, stream_select and define(); the 60 s limit counts CPU
time, not wall time.
Keeping the tunnel up (chain mode, v0.4)
A request lives only a few minutes on these hosts (the 60 s limit counts CPU
time, so a waiting node is nearly free, but the host ends the request after a
while). With &chain=1 (the page's "keep the tunnel up continuously" box, on
by default) a node starts its successor before it ends:
- at
cap - 60 s(cap 240 s) the running generation asks its own host, which lets the request through the bot check, to start generation N+1; - N+1 joins the room, reports "serving", and takes every new stream;
- N stops taking new streams ("draining"), keeps serving the ones it has (they end by themselves, or at its cap), and exits when none are left.
While both are up a new stream goes to whichever generation creates its marker
directory first (mkdir is atomic), so no stream is ever served twice. A stop
(Stop on the page or &a=stop) leaves a marker that ends every generation
and keeps the dying one from spawning another. A pinger that hits the URL
while the chain is alive just attaches ("already running"); if the chain ever
breaks (the host refused the self-request) the next open or ping starts it again.
Streams that are still open when the last generation of their chain ends are cut with a CLOSE, and the client reconnects; MTProto and short HTTPS tolerate it.
Client logs
--mode=stream obeys the same levels as the packet modes: -d prints one line
per mux frame ([STREAM] -> 526 bytes - stream 7 DATA, OPEN host:443,
<- ... OPEN_OK), -dd adds the operational lines, -ddd hexdumps DATA
payloads. On the PHP side the page's log has info and debug lines;
destinations are hidden (*:443) unless &sensitive=1.
Tuning (query parameters of a run)
&cap= seconds one generation lives (30-900), &chunk= bytes read from a
destination per frame (2048-262144, default 65536; bigger frames cost fewer carrier messages),
&win= flow-control window per stream in bytes (default 262144, 2x that over all streams; 0 = never honour a client's request for windows), &idle= seconds before an idle stream is closed (0 = never), &sensitive=1,
&chain=1.
Flow control
A saturated carrier (a document server that takes a few hundred KB/s) queues
whatever it is given, without limit, and every frame waits behind it: four
parallel downloads made new TLS handshakes starve for minutes. So a client that
understands acks says so in OPEN (host:port + \0fc), the exit answers
OPEN_OK fc, and from then on each side keeps at most a window (256 KB per
stream, 512 KB over all) of DATA unacknowledged; the other side acks what it
consumed (ACK frame 6, payload = running total, uint32). Measured on a
simulated shared queue with four bulk downloads running: a handshake-sized
round trip takes 0.5 s with windows and never completes within 6 s without.
Old clients and old exits never say fc, and then nothing changes.